1use std::collections::{HashMap, HashSet};
7use std::fs::{self, File};
8use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
113 pub preview_candidates: Vec<SessionPreviewCandidate>,
114 #[serde(default, skip_serializing_if = "Vec::is_empty")]
119 pub latest_message_candidates: Vec<SessionPreviewCandidate>,
120 pub updated_at_ms: Option<u64>,
122 pub message_count: Option<usize>,
124 pub model: Option<String>,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130pub struct SessionPreviewCandidate {
131 pub role: String,
134 pub content: String,
136 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
138 pub metadata: HashMap<String, String>,
139}
140
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143pub struct DiscoveryPage {
144 pub sessions: Vec<SessionDescriptor>,
146 pub next_cursor: Option<String>,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
152#[serde(default)]
153pub struct HarnessHomes {
154 pub claude_code: PathBuf,
156 pub codex: PathBuf,
158 pub pi: PathBuf,
160 pub opencode: PathBuf,
162 pub grok: PathBuf,
164 pub gemini: PathBuf,
166 pub goose: PathBuf,
168 pub supercode: PathBuf,
170}
171
172impl Default for HarnessHomes {
173 fn default() -> Self {
174 let home = std::env::var_os("HOME")
175 .map(PathBuf::from)
176 .unwrap_or_else(|| PathBuf::from("."));
177 let claude_root = std::env::var_os("CLAUDE_CONFIG_DIR")
178 .map(PathBuf::from)
179 .unwrap_or_else(|| home.join(".claude"));
180 let codex_root = std::env::var_os("CODEX_HOME")
181 .map(PathBuf::from)
182 .unwrap_or_else(|| home.join(".codex"));
183 let pi = std::env::var_os("PI_CODING_AGENT_SESSION_DIR")
184 .map(PathBuf::from)
185 .unwrap_or_else(|| {
186 std::env::var_os("PI_CODING_AGENT_DIR")
187 .map(PathBuf::from)
188 .unwrap_or_else(|| home.join(".pi/agent"))
189 .join("sessions")
190 });
191 let opencode = std::env::var_os("OPENCODE_DB")
192 .map(PathBuf::from)
193 .unwrap_or_else(|| {
194 std::env::var_os("XDG_DATA_HOME")
195 .map(PathBuf::from)
196 .unwrap_or_else(|| home.join(".local/share"))
197 .join("opencode")
198 });
199 let grok = std::env::var_os("GROK_HOME")
200 .map(PathBuf::from)
201 .unwrap_or_else(|| home.join(".grok"))
202 .join("sessions");
203 let gemini = std::env::var_os("GEMINI_CLI_HOME")
204 .map(PathBuf::from)
205 .unwrap_or_else(|| home.join(".gemini"));
206 let goose = std::env::var_os("GOOSE_PATH_ROOT")
207 .map(PathBuf::from)
208 .map(|root| root.join("data/sessions/sessions.db"))
209 .unwrap_or_else(|| {
210 #[cfg(target_os = "macos")]
211 {
212 home.join("Library/Application Support/Block/goose/sessions/sessions.db")
213 }
214 #[cfg(target_os = "windows")]
215 {
216 std::env::var_os("APPDATA")
217 .map(PathBuf::from)
218 .unwrap_or_else(|| home.join("AppData/Roaming"))
219 .join("Block/goose/sessions/sessions.db")
220 }
221 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
222 {
223 std::env::var_os("XDG_DATA_HOME")
224 .map(PathBuf::from)
225 .unwrap_or_else(|| home.join(".local/share"))
226 .join("goose/sessions/sessions.db")
227 }
228 });
229 let supercode = std::env::var_os("SUPERCODE_HOME")
230 .map(PathBuf::from)
231 .unwrap_or_else(|| {
232 std::env::var_os("XDG_CONFIG_HOME")
233 .map(PathBuf::from)
234 .unwrap_or_else(|| home.join(".config"))
235 .join("supercode")
236 })
237 .join("sessions");
238 Self {
239 claude_code: claude_root.join("projects"),
240 codex: codex_root.join("sessions"),
241 gemini,
242 goose,
243 supercode,
244 pi,
245 opencode,
246 grok,
247 }
248 }
249}
250
251#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
253#[serde(default)]
254pub struct DiscoveryQuery {
255 pub workspace: Option<PathBuf>,
257 pub harnesses: Vec<HarnessId>,
259 pub homes: HarnessHomes,
261 pub query: Option<String>,
263 pub cursor: Option<String>,
265 pub limit: Option<usize>,
267 pub include_topic_candidates: bool,
271}
272
273#[derive(Debug, Default, Clone, Copy)]
276pub struct HarnessCatalog;
277
278impl HarnessCatalog {
279 pub fn new() -> Self {
281 Self
282 }
283
284 pub fn discover(&self, query: &DiscoveryQuery) -> Result<Vec<SessionDescriptor>> {
288 Ok(self.discover_page(query)?.sessions)
289 }
290
291 pub fn discover_page(&self, query: &DiscoveryQuery) -> Result<DiscoveryPage> {
293 let selected: HashSet<&str> = if query.harnesses.is_empty() {
294 [
295 HarnessId::CLAUDE_CODE,
296 HarnessId::CODEX,
297 HarnessId::PI,
298 HarnessId::OPENCODE,
299 HarnessId::GROK,
300 HarnessId::GEMINI,
301 HarnessId::GOOSE,
302 HarnessId::SUPERCODE,
303 ]
304 .into_iter()
305 .collect()
306 } else {
307 query.harnesses.iter().map(HarnessId::as_str).collect()
308 };
309 let mut found = Vec::new();
310 if selected.contains(HarnessId::CLAUDE_CODE) {
311 discover_jsonl(
312 &query.homes.claude_code,
313 HarnessId::CLAUDE_CODE,
314 query.workspace.as_deref(),
315 &mut found,
316 );
317 }
318 if selected.contains(HarnessId::CODEX) {
319 discover_jsonl(
320 &query.homes.codex,
321 HarnessId::CODEX,
322 query.workspace.as_deref(),
323 &mut found,
324 );
325 }
326 if selected.contains(HarnessId::PI) {
327 discover_jsonl(
328 &query.homes.pi,
329 HarnessId::PI,
330 query.workspace.as_deref(),
331 &mut found,
332 );
333 }
334 if selected.contains(HarnessId::OPENCODE) {
335 discover_opencode(
336 &query.homes.opencode,
337 query.workspace.as_deref(),
338 &mut found,
339 );
340 }
341 if selected.contains(HarnessId::GROK) {
342 discover_grok(&query.homes.grok, query.workspace.as_deref(), &mut found);
343 }
344 if selected.contains(HarnessId::GEMINI) {
345 discover_gemini(&query.homes.gemini, query.workspace.as_deref(), &mut found);
346 }
347 if selected.contains(HarnessId::GOOSE) {
348 discover_goose(&query.homes.goose, query.workspace.as_deref(), &mut found);
349 }
350 if selected.contains(HarnessId::SUPERCODE) {
351 discover_supercode(
352 &query.homes.supercode,
353 query.workspace.as_deref(),
354 &mut found,
355 );
356 }
357 found.sort_by(|a, b| {
358 b.updated_at_ms
359 .cmp(&a.updated_at_ms)
360 .then_with(|| a.locator.harness.cmp(&b.locator.harness))
361 .then_with(|| a.locator.session_id.cmp(&b.locator.session_id))
362 });
363 if let Some(search) = query
364 .query
365 .as_deref()
366 .map(str::trim)
367 .filter(|q| !q.is_empty())
368 {
369 let search = search.to_lowercase();
370 found.retain(|descriptor| descriptor_matches(descriptor, &search));
371 }
372 let start = match query.cursor.as_deref() {
373 Some(cursor) => {
374 let key = decode_cursor(cursor)?;
375 found
376 .iter()
377 .position(|descriptor| descriptor_cursor_key(descriptor) == key)
378 .map(|index| index + 1)
379 .ok_or_else(|| Error::Other("discovery cursor is stale or invalid".into()))?
380 }
381 None => 0,
382 };
383 let end = query
384 .limit
385 .map(|limit| start.saturating_add(limit).min(found.len()))
386 .unwrap_or(found.len());
387 let mut sessions = found[start.min(found.len())..end].to_vec();
388 let codex_topics = if query.include_topic_candidates {
389 codex_history_topics(&query.homes.codex, &sessions).unwrap_or_default()
390 } else {
391 HashMap::new()
392 };
393 for descriptor in &mut sessions {
394 if query.include_topic_candidates {
395 descriptor.preview_candidates =
396 if descriptor.locator.harness.as_str() == HarnessId::CODEX {
397 codex_topics
398 .get(&descriptor.locator.session_id)
399 .cloned()
400 .unwrap_or_else(|| {
401 topic_message_candidates(&descriptor.locator).unwrap_or_default()
402 })
403 } else {
404 topic_message_candidates(&descriptor.locator).unwrap_or_default()
405 };
406 }
407 descriptor.latest_message_candidates =
408 latest_message_candidates(&descriptor.locator).unwrap_or_default();
409 }
410 let next_cursor = (end < found.len())
411 .then(|| sessions.last().map(encode_cursor))
412 .flatten();
413 Ok(DiscoveryPage {
414 sessions,
415 next_cursor,
416 })
417 }
418
419 pub fn refresh_file_descriptor(
427 &self,
428 locator: &SessionLocator,
429 workspace: Option<&Path>,
430 include_topic_candidates: bool,
431 ) -> Result<Option<SessionDescriptor>> {
432 let StorageLocator::File { path } = &locator.storage else {
433 return Ok(None);
434 };
435 if !matches!(
436 locator.harness.as_str(),
437 HarnessId::CLAUDE_CODE | HarnessId::CODEX
438 ) {
439 return Ok(None);
440 }
441 if !path.is_file() {
442 return Ok(None);
443 }
444 let Ok(meta) = read_header(path, locator.harness.as_str()) else {
445 return Ok(None);
449 };
450 if workspace.is_some_and(|wanted| {
451 meta.cwd
452 .as_deref()
453 .is_none_or(|cwd| !recorded_cwd_matches(cwd, wanted))
454 }) {
455 return Ok(None);
456 }
457 let mut descriptor = SessionDescriptor {
458 locator: SessionLocator {
459 harness: locator.harness.clone(),
460 session_id: meta
461 .session_id
462 .unwrap_or_else(|| locator.session_id.clone()),
463 storage: StorageLocator::File { path: path.clone() },
464 },
465 cwd: meta.cwd,
466 title: meta.title,
467 preview_candidates: Vec::new(),
468 latest_message_candidates: Vec::new(),
469 updated_at_ms: modified_ms(path),
470 message_count: None,
471 model: meta.model,
472 };
473 if include_topic_candidates {
474 descriptor.preview_candidates =
475 topic_message_candidates(&descriptor.locator).unwrap_or_default();
476 }
477 descriptor.latest_message_candidates =
478 latest_message_candidates(&descriptor.locator).unwrap_or_default();
479 Ok(Some(descriptor))
480 }
481
482 pub fn load(&self, locator: &SessionLocator) -> Result<Session> {
484 self.load_with_fidelity(locator, Fidelity::ByteLossless)
485 }
486
487 pub fn load_with_fidelity(
494 &self,
495 locator: &SessionLocator,
496 fidelity: Fidelity,
497 ) -> Result<Session> {
498 match &locator.storage {
499 StorageLocator::File { path } => {
500 if let Some(session) = load_native_store_family(path)? {
501 Ok(session)
502 } else {
503 Ok(Session::load_with_fidelity(path, fidelity)?)
504 }
505 }
506 StorageLocator::Sqlite { path, selector } => {
507 if locator.harness.as_str() == HarnessId::GOOSE {
508 Ok(Session::from_goose_sqlite(path, selector)?)
509 } else {
510 Ok(Session::from_opencode_sqlite(path, Some(selector))?)
511 }
512 }
513 }
514 }
515
516 #[doc(hidden)]
520 pub fn load_parent_with_fidelity(
521 &self,
522 locator: &SessionLocator,
523 fidelity: Fidelity,
524 ) -> Result<Session> {
525 match &locator.storage {
526 StorageLocator::File { path } => {
527 if let Some(session) = load_native_store_family(path)? {
528 Ok(session)
529 } else {
530 Ok(Session::load_parent_with_fidelity(path, fidelity)?)
531 }
532 }
533 StorageLocator::Sqlite { path, selector } => {
534 if locator.harness.as_str() == HarnessId::GOOSE {
535 Ok(Session::from_goose_sqlite(path, selector)?)
536 } else {
537 Ok(Session::from_opencode_sqlite(path, Some(selector))?)
538 }
539 }
540 }
541 }
542
543 #[doc(hidden)]
546 pub fn load_display_view(
547 &self,
548 locator: &SessionLocator,
549 fidelity: Fidelity,
550 message_limit: usize,
551 ) -> Result<Session> {
552 match &locator.storage {
553 StorageLocator::File { path } => {
554 if let Some(mut session) = load_native_store_family(path)? {
555 if session.messages.len() > message_limit.max(1) {
556 session
557 .messages
558 .drain(..session.messages.len() - message_limit.max(1));
559 }
560 Ok(session)
561 } else {
562 Ok(Session::load_display_view(path, fidelity, message_limit)?)
563 }
564 }
565 StorageLocator::Sqlite { path, selector } => {
566 let mut session = if locator.harness.as_str() == HarnessId::GOOSE {
567 Session::from_goose_sqlite_display(path, selector, message_limit)?
568 } else {
569 Session::from_opencode_sqlite(path, Some(selector))?
570 };
571 if session.messages.len() > message_limit.max(1) {
572 session
573 .messages
574 .drain(..session.messages.len() - message_limit.max(1));
575 }
576 Ok(session)
577 }
578 }
579 }
580
581 pub fn follow(&self, locator: &SessionLocator) -> Result<SessionFollower> {
583 self.follow_with_fidelity(locator, Fidelity::ByteLossless)
584 }
585
586 pub fn follow_with_fidelity(
588 &self,
589 locator: &SessionLocator,
590 fidelity: Fidelity,
591 ) -> Result<SessionFollower> {
592 SessionFollower::open_locator_with_fidelity(locator, fidelity)
593 }
594
595 #[doc(hidden)]
597 pub fn follow_read_view(
598 &self,
599 locator: &SessionLocator,
600 fidelity: Fidelity,
601 include_subagents: bool,
602 message_limit: Option<usize>,
603 max_message_chars: Option<usize>,
604 display_history: bool,
605 ) -> Result<SessionFollower> {
606 SessionFollower::open_locator_with_view(
607 locator,
608 fidelity,
609 include_subagents,
610 message_limit,
611 max_message_chars,
612 display_history,
613 )
614 }
615}
616
617fn descriptor_matches(descriptor: &SessionDescriptor, search: &str) -> bool {
618 [
619 Some(descriptor.locator.harness.as_str()),
620 Some(descriptor.locator.session_id.as_str()),
621 descriptor.title.as_deref(),
622 descriptor.cwd.as_ref().and_then(|path| path.to_str()),
623 descriptor.model.as_deref(),
624 ]
625 .into_iter()
626 .flatten()
627 .any(|value| value.to_lowercase().contains(search))
628}
629
630fn descriptor_cursor_key(descriptor: &SessionDescriptor) -> (Option<u64>, String, String) {
631 (
632 descriptor.updated_at_ms,
633 descriptor.locator.harness.as_str().to_string(),
634 descriptor.locator.session_id.clone(),
635 )
636}
637
638fn encode_cursor(descriptor: &SessionDescriptor) -> String {
639 let json = serde_json::to_vec(&descriptor_cursor_key(descriptor)).unwrap_or_default();
640 let mut encoded = String::with_capacity(json.len() * 2);
641 for byte in json {
642 use std::fmt::Write;
643 let _ = write!(&mut encoded, "{byte:02x}");
644 }
645 encoded
646}
647
648fn decode_cursor(cursor: &str) -> Result<(Option<u64>, String, String)> {
649 if cursor.len() % 2 != 0 {
650 return Err(Error::Other("discovery cursor is invalid".into()));
651 }
652 let bytes = (0..cursor.len())
653 .step_by(2)
654 .map(|index| u8::from_str_radix(&cursor[index..index + 2], 16))
655 .collect::<std::result::Result<Vec<_>, _>>()
656 .map_err(|_| Error::Other("discovery cursor is invalid".into()))?;
657 serde_json::from_slice(&bytes).map_err(|_| Error::Other("discovery cursor is invalid".into()))
658}
659
660#[derive(Default)]
661struct HeaderMeta {
662 session_id: Option<String>,
663 cwd: Option<PathBuf>,
664 title: Option<String>,
665 model: Option<String>,
666}
667
668fn discover_jsonl(
669 root: &Path,
670 harness: &str,
671 workspace: Option<&Path>,
672 found: &mut Vec<SessionDescriptor>,
673) {
674 let mut files = Vec::new();
675 collect_jsonl(root, harness, &mut files);
676 for path in files {
677 let Ok(meta) = read_header(&path, harness) else {
678 continue;
679 };
680 if workspace.is_some_and(|wanted| {
681 meta.cwd
682 .as_deref()
683 .is_none_or(|cwd| !recorded_cwd_matches(cwd, wanted))
684 }) {
685 continue;
686 }
687 let session_id = meta.session_id.unwrap_or_else(|| {
688 path.file_stem()
689 .and_then(|value| value.to_str())
690 .unwrap_or("unknown")
691 .to_string()
692 });
693 found.push(SessionDescriptor {
694 locator: SessionLocator {
695 harness: HarnessId::new(harness),
696 session_id,
697 storage: StorageLocator::File { path: path.clone() },
698 },
699 cwd: meta.cwd,
700 title: meta.title,
701 preview_candidates: Vec::new(),
702 latest_message_candidates: Vec::new(),
703 updated_at_ms: modified_ms(&path),
704 message_count: None,
705 model: meta.model,
706 });
707 }
708}
709
710fn collect_jsonl(root: &Path, harness: &str, out: &mut Vec<PathBuf>) {
711 let Ok(entries) = fs::read_dir(root) else {
712 return;
713 };
714 for entry in entries.flatten() {
715 let Ok(kind) = entry.file_type() else {
716 continue;
717 };
718 let path = entry.path();
719 if kind.is_dir() {
720 if harness == HarnessId::CLAUDE_CODE
721 && path.file_name().and_then(|v| v.to_str()) == Some("subagents")
722 {
723 continue;
724 }
725 collect_jsonl(&path, harness, out);
726 } else if kind.is_file() && path.extension().and_then(|v| v.to_str()) == Some("jsonl") {
727 out.push(path);
728 }
729 }
730}
731
732fn read_header(path: &Path, harness: &str) -> Result<HeaderMeta> {
733 let file = File::open(path)?;
734 let mut result = HeaderMeta::default();
735 let mut bytes = 0usize;
736 for line in BufReader::new(file).lines().take(32) {
737 let line = line?;
738 bytes += line.len();
739 if bytes > 256 * 1024 {
740 break;
741 }
742 let Ok(value) = serde_json::from_str::<Value>(&line) else {
743 continue;
744 };
745 update_header_meta(&mut result, &value, harness);
746 if result.session_id.is_some() && result.cwd.is_some() && result.model.is_some() {
747 break;
748 }
749 }
750 if result.session_id.is_none() && result.cwd.is_none() {
751 return Err(Error::Other(format!(
752 "{} has no recognizable {harness} session header",
753 path.display()
754 )));
755 }
756 Ok(result)
757}
758
759fn update_header_meta(result: &mut HeaderMeta, value: &Value, harness: &str) {
760 match harness {
761 HarnessId::CLAUDE_CODE => {
762 fill_string(&mut result.session_id, value.get("sessionId"));
763 fill_path(&mut result.cwd, value.get("cwd"));
764 fill_string(
765 &mut result.model,
766 value.get("message").and_then(|v| v.get("model")),
767 );
768 }
769 HarnessId::CODEX => {
770 let payload = value.get("payload").unwrap_or(&Value::Null);
771 if value.get("type").and_then(Value::as_str) == Some("session_meta") {
772 fill_string(&mut result.session_id, payload.get("id"));
773 fill_path(&mut result.cwd, payload.get("cwd"));
774 fill_string(&mut result.title, payload.get("thread_name"));
775 fill_string(&mut result.title, payload.get("title"));
776 if result.title.is_none() {
777 result.title = payload
778 .pointer("/source/subagent/thread_spawn/agent_path")
779 .and_then(Value::as_str)
780 .and_then(|path| path.rsplit('/').find(|part| !part.is_empty()))
781 .map(humanize_topic);
782 }
783 }
784 if value.get("type").and_then(Value::as_str) == Some("turn_context") {
785 fill_path(&mut result.cwd, payload.get("cwd"));
786 fill_string(&mut result.model, payload.get("model"));
787 }
788 }
789 HarnessId::PI => {
790 if value.get("type").and_then(Value::as_str) == Some("session") {
791 fill_string(&mut result.session_id, value.get("id"));
792 fill_path(&mut result.cwd, value.get("cwd"));
793 }
794 fill_string(
795 &mut result.model,
796 value.get("message").and_then(|v| v.get("model")),
797 );
798 }
799 _ => {}
800 }
801}
802
803fn humanize_topic(value: &str) -> String {
804 let text = value.replace(['_', '-'], " ");
805 let mut characters = text.chars();
806 match characters.next() {
807 Some(first) => first.to_uppercase().collect::<String>() + characters.as_str(),
808 None => text,
809 }
810}
811
812fn discover_gemini(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
813 let slug_to_cwd = std::fs::read_to_string(root.join("projects.json"))
814 .ok()
815 .and_then(|text| serde_json::from_str::<Value>(&text).ok())
816 .and_then(|value| value.get("projects").and_then(Value::as_object).cloned())
817 .map(|projects| {
818 projects
819 .into_iter()
820 .filter_map(|(cwd, slug)| Some((slug.as_str()?.to_string(), PathBuf::from(cwd))))
821 .collect::<HashMap<_, _>>()
822 })
823 .unwrap_or_default();
824 let mut files = Vec::new();
825 collect_jsonl(&root.join("tmp"), HarnessId::GEMINI, &mut files);
826 let worker_count = std::thread::available_parallelism()
827 .map(usize::from)
828 .unwrap_or(4)
829 .clamp(1, 8)
830 .min(files.len().max(1));
831 let chunk_size = files.len().max(1).div_ceil(worker_count);
832 let discovered = std::thread::scope(|scope| {
833 files
834 .chunks(chunk_size)
835 .map(|paths| {
836 scope.spawn(|| {
837 paths
838 .iter()
839 .filter_map(|path| gemini_descriptor(path, &slug_to_cwd, workspace))
840 .collect::<Vec<_>>()
841 })
842 })
843 .collect::<Vec<_>>()
844 .into_iter()
845 .flat_map(|worker| {
846 worker
847 .join()
848 .expect("Gemini discovery worker must not panic")
849 })
850 .collect::<Vec<_>>()
851 });
852 found.extend(discovered);
853}
854
855fn gemini_descriptor(
856 path: &Path,
857 slug_to_cwd: &HashMap<String, PathBuf>,
858 workspace: Option<&Path>,
859) -> Option<SessionDescriptor> {
860 if path
861 .parent()
862 .and_then(Path::file_name)
863 .and_then(|name| name.to_str())
864 != Some("chats")
865 {
866 return None;
867 }
868 let slug = path
869 .parent()
870 .and_then(Path::parent)
871 .and_then(Path::file_name)
872 .and_then(|name| name.to_str());
873 let cwd = slug.and_then(|slug| slug_to_cwd.get(slug)).cloned();
874 if workspace.is_some_and(|wanted| {
875 cwd.as_deref()
876 .is_none_or(|actual| !recorded_cwd_matches(actual, wanted))
877 }) {
878 return None;
879 }
880
881 let file = File::open(path).ok()?;
885 let mut reader = BufReader::new(file.take(64 * 1024));
886 let mut header = String::new();
887 reader.read_line(&mut header).ok()?;
888 let header = serde_json::from_str::<Value>(&header).ok()?;
889 let session_id = header.get("sessionId")?.as_str()?.to_string();
890 let mut model = None;
891 for line in reader
892 .take(4 * 1024)
893 .lines()
894 .map_while(std::result::Result::ok)
895 {
896 let Ok(value) = serde_json::from_str::<Value>(&line) else {
897 continue;
898 };
899 let kind = value.get("type").and_then(Value::as_str);
900 if kind != Some("user") && kind != Some("gemini") {
901 continue;
902 }
903 if model.is_none() {
904 model = value
905 .get("model")
906 .and_then(Value::as_str)
907 .map(str::to_string);
908 }
909 if model.is_some() {
910 break;
911 }
912 }
913 Some(SessionDescriptor {
914 locator: SessionLocator {
915 harness: HarnessId::from(HarnessId::GEMINI),
916 session_id,
917 storage: StorageLocator::File {
918 path: path.to_path_buf(),
919 },
920 },
921 cwd,
922 title: None,
923 preview_candidates: Vec::new(),
924 latest_message_candidates: Vec::new(),
925 updated_at_ms: modified_ms(path),
926 message_count: None,
927 model,
928 })
929}
930
931fn display_text(content: Option<&Value>) -> Option<String> {
932 match content? {
933 Value::String(text) => Some(text.clone()),
934 Value::Array(parts) => Some(
935 parts
936 .iter()
937 .filter_map(|part| part.get("text").and_then(Value::as_str))
938 .collect::<Vec<_>>()
939 .join(" ")
940 .trim()
941 .to_string(),
942 ),
943 _ => None,
944 }
945}
946
947fn discover_supercode(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
948 for info in list_native_store(root) {
949 let path = if info.archived {
950 root.join("archived").join(format!("{}.jsonl", info.name))
951 } else {
952 root.join(format!("{}.jsonl", info.name))
953 };
954 let header = read_native_store_header(&path);
955 if workspace.is_some_and(|wanted| {
956 header
957 .as_ref()
958 .and_then(|meta| meta.cwd.as_deref())
959 .is_none_or(|cwd| !recorded_cwd_matches(cwd, wanted))
960 }) {
961 continue;
962 }
963 let title = (!info.title.trim().is_empty()).then_some(info.title);
964 let updated_at_ms =
965 modified_ms(&path).or_else(|| modified_ms(&path.with_extension("sidecar.jsonl")));
966 found.push(SessionDescriptor {
967 locator: SessionLocator {
968 harness: HarnessId::from(HarnessId::SUPERCODE),
969 session_id: info.name,
970 storage: StorageLocator::File { path: path.clone() },
971 },
972 cwd: header.as_ref().and_then(|meta| meta.cwd.clone()),
973 title,
974 preview_candidates: Vec::new(),
975 latest_message_candidates: Vec::new(),
976 updated_at_ms,
977 message_count: None,
978 model: header.and_then(|meta| meta.model),
979 });
980 }
981}
982
983fn read_native_store_header(path: &Path) -> Option<HeaderMeta> {
988 let name = path.file_stem()?.to_str()?;
989 let sidecar = path.with_file_name(format!("{name}.sidecar.jsonl"));
990 let source_path = if sidecar.is_file() {
991 sidecar
992 } else {
993 path.to_path_buf()
994 };
995 let file = File::open(source_path).ok()?;
996 let mut result = HeaderMeta::default();
997 let mut source = None;
998 let mut bytes = 0usize;
999 for line in BufReader::new(file).lines().take(32) {
1000 let line = line.ok()?;
1001 bytes += line.len();
1002 if bytes > 256 * 1024 {
1003 break;
1004 }
1005 let Ok(value) = serde_json::from_str::<Value>(&line) else {
1006 continue;
1007 };
1008 if source.is_none() {
1009 source = value.get("source").and_then(Value::as_str).map(|source| {
1010 if source == "claude_code" {
1011 HarnessId::CLAUDE_CODE.to_string()
1012 } else {
1013 source.to_string()
1014 }
1015 });
1016 fill_string(&mut result.session_id, value.get("session_id"));
1017 }
1018 if let Some(harness) = source.as_deref() {
1019 update_header_meta(&mut result, &value, harness);
1020 }
1021 if result.cwd.is_some() && result.model.is_some() {
1022 break;
1023 }
1024 }
1025 Some(result)
1026}
1027
1028#[derive(Deserialize)]
1029struct NativeStoreInfo {
1030 name: String,
1031 #[serde(default)]
1032 title: String,
1033 #[serde(skip)]
1034 archived: bool,
1035}
1036
1037fn list_native_store(root: &Path) -> Vec<NativeStoreInfo> {
1038 let mut sessions = Vec::new();
1039 for archived in [false, true] {
1040 let directory = if archived {
1041 root.join("archived")
1042 } else {
1043 root.to_path_buf()
1044 };
1045 let Ok(entries) = fs::read_dir(directory) else {
1046 continue;
1047 };
1048 for entry in entries.flatten() {
1049 let path = entry.path();
1050 if !path.to_string_lossy().ends_with(".meta.json") {
1051 continue;
1052 }
1053 let Ok(text) = fs::read_to_string(path) else {
1054 continue;
1055 };
1056 let Ok(mut info) = serde_json::from_str::<NativeStoreInfo>(&text) else {
1057 continue;
1058 };
1059 info.archived = archived;
1060 sessions.push(info);
1061 }
1062 }
1063 sessions.sort_by(|left, right| left.name.cmp(&right.name));
1064 sessions
1065}
1066
1067fn discover_grok(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
1068 let Ok(workspaces) = fs::read_dir(root) else {
1069 return;
1070 };
1071 for workspace_entry in workspaces.flatten() {
1072 let encoded = workspace_entry.file_name();
1073 let Some(cwd) = encoded
1074 .to_str()
1075 .and_then(percent_decode_path)
1076 .map(PathBuf::from)
1077 else {
1078 continue;
1079 };
1080 if workspace.is_some_and(|wanted| !recorded_cwd_matches(&cwd, wanted)) {
1081 continue;
1082 }
1083 let Ok(sessions) = fs::read_dir(workspace_entry.path()) else {
1084 continue;
1085 };
1086 for session_entry in sessions.flatten() {
1087 let session_dir = session_entry.path();
1088 if !session_dir.is_dir() {
1089 continue;
1090 }
1091 let transcript = session_dir.join("chat_history.jsonl");
1092 if !transcript.is_file() {
1093 continue;
1094 }
1095 let Some(session_id) = session_dir
1096 .file_name()
1097 .and_then(|name| name.to_str())
1098 .map(str::to_string)
1099 else {
1100 continue;
1101 };
1102 let summary = fs::read_to_string(session_dir.join("summary.json"))
1103 .ok()
1104 .and_then(|text| serde_json::from_str::<Value>(&text).ok());
1105 let title = summary
1106 .as_ref()
1107 .and_then(|value| value.get("generated_title"))
1108 .and_then(Value::as_str)
1109 .filter(|title| !title.is_empty())
1110 .map(str::to_string);
1111 let model = summary
1112 .as_ref()
1113 .and_then(|value| value.get("current_model_id"))
1114 .and_then(Value::as_str)
1115 .map(str::to_string);
1116 let message_count = summary
1117 .as_ref()
1118 .and_then(|value| value.get("num_chat_messages"))
1119 .and_then(Value::as_u64)
1120 .and_then(|count| usize::try_from(count).ok());
1121 let updated_at_ms = summary
1122 .as_ref()
1123 .and_then(|value| value.get("updated_at"))
1124 .and_then(Value::as_str)
1125 .and_then(crate::sidecar::rfc3339_to_ms)
1126 .and_then(|millis| u64::try_from(millis).ok())
1127 .or_else(|| modified_ms(&transcript));
1128 found.push(SessionDescriptor {
1129 locator: SessionLocator {
1130 harness: HarnessId::from(HarnessId::GROK),
1131 session_id,
1132 storage: StorageLocator::File { path: transcript },
1133 },
1134 cwd: Some(cwd.clone()),
1135 title,
1136 preview_candidates: Vec::new(),
1137 latest_message_candidates: Vec::new(),
1138 updated_at_ms,
1139 message_count,
1140 model,
1141 });
1142 }
1143 }
1144}
1145
1146fn discover_opencode(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
1147 let mut dbs = Vec::new();
1148 if root.is_file() {
1149 dbs.push(root.to_path_buf());
1150 } else if let Ok(entries) = fs::read_dir(root) {
1151 dbs.extend(entries.flatten().map(|entry| entry.path()).filter(|path| {
1152 path.file_name()
1153 .and_then(|v| v.to_str())
1154 .is_some_and(|name| name.starts_with("opencode") && name.ends_with(".db"))
1155 }));
1156 }
1157 dbs.sort();
1158 for db in dbs {
1159 let Ok(conn) = Connection::open_with_flags(
1160 &db,
1161 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
1162 ) else {
1163 continue;
1164 };
1165 let has_model = conn.prepare("SELECT model FROM session LIMIT 0").is_ok();
1166 let model_column = if has_model { "s.model" } else { "NULL" };
1167 let query = format!(
1168 "SELECT s.id, s.directory, s.title, s.time_updated, {model_column}, COUNT(m.id) \
1169 FROM session s LEFT JOIN message m ON m.session_id = s.id \
1170 GROUP BY s.id ORDER BY s.time_updated DESC"
1171 );
1172 let Ok(mut stmt) = conn.prepare(&query) else {
1173 continue;
1174 };
1175 let Ok(rows) = stmt.query_map([], |row| {
1176 Ok((
1177 row.get::<_, String>(0)?,
1178 row.get::<_, String>(1)?,
1179 row.get::<_, String>(2)?,
1180 row.get::<_, i64>(3)?,
1181 row.get::<_, Option<String>>(4)?,
1182 row.get::<_, i64>(5)?,
1183 ))
1184 }) else {
1185 continue;
1186 };
1187 for row in rows.flatten() {
1188 let (id, cwd, title, updated, model, messages) = row;
1189 let cwd = PathBuf::from(cwd);
1190 if workspace.is_some_and(|wanted| !recorded_cwd_matches(&cwd, wanted)) {
1191 continue;
1192 }
1193 found.push(SessionDescriptor {
1194 locator: SessionLocator {
1195 harness: HarnessId::from(HarnessId::OPENCODE),
1196 session_id: id.clone(),
1197 storage: StorageLocator::Sqlite {
1198 path: db.clone(),
1199 selector: id,
1200 },
1201 },
1202 cwd: Some(cwd),
1203 title: (!title.is_empty()).then_some(title),
1204 preview_candidates: Vec::new(),
1205 latest_message_candidates: Vec::new(),
1206 updated_at_ms: u64::try_from(updated).ok(),
1207 message_count: usize::try_from(messages).ok(),
1208 model,
1209 });
1210 }
1211 }
1212}
1213
1214fn discover_goose(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
1215 let db = if root.is_file() {
1216 root.to_path_buf()
1217 } else if root.join("sessions.db").is_file() {
1218 root.join("sessions.db")
1219 } else {
1220 root.join("sessions/sessions.db")
1221 };
1222 let Ok(connection) = Connection::open_with_flags(
1223 &db,
1224 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
1225 ) else {
1226 return;
1227 };
1228 let Ok(mut statement) = connection.prepare(
1229 "SELECT s.id, s.working_dir, s.name, s.updated_at, s.model_config_json, \
1230 COUNT(m.id) \
1231 FROM sessions s LEFT JOIN messages m ON m.session_id = s.id \
1232 WHERE s.archived_at IS NULL \
1233 GROUP BY s.id ORDER BY s.updated_at DESC",
1234 ) else {
1235 return;
1236 };
1237 let Ok(rows) = statement.query_map([], |row| {
1238 Ok((
1239 row.get::<_, String>(0)?,
1240 row.get::<_, String>(1)?,
1241 row.get::<_, String>(2)?,
1242 row.get::<_, String>(3)?,
1243 row.get::<_, Option<String>>(4)?,
1244 row.get::<_, i64>(5)?,
1245 ))
1246 }) else {
1247 return;
1248 };
1249 for row in rows.flatten() {
1250 let (id, cwd, title, updated_at, model_config, message_count) = row;
1251 let cwd = PathBuf::from(cwd);
1252 if workspace.is_some_and(|wanted| !recorded_cwd_matches(&cwd, wanted)) {
1253 continue;
1254 }
1255 let model = model_config
1256 .as_deref()
1257 .and_then(|value| serde_json::from_str::<Value>(value).ok())
1258 .and_then(|value| {
1259 value
1260 .get("model_name")
1261 .or_else(|| value.get("modelName"))
1262 .and_then(Value::as_str)
1263 .map(str::to_string)
1264 });
1265 let updated_at_ms = crate::sidecar::rfc3339_to_ms(&updated_at)
1266 .or_else(|| {
1267 crate::sidecar::rfc3339_to_ms(&format!("{}Z", updated_at.replace(' ', "T")))
1269 })
1270 .and_then(|value| u64::try_from(value).ok());
1271 found.push(SessionDescriptor {
1272 locator: SessionLocator {
1273 harness: HarnessId::from(HarnessId::GOOSE),
1274 session_id: id.clone(),
1275 storage: StorageLocator::Sqlite {
1276 path: db.clone(),
1277 selector: id,
1278 },
1279 },
1280 cwd: Some(cwd),
1281 title: (!title.trim().is_empty()).then_some(title),
1282 preview_candidates: Vec::new(),
1283 latest_message_candidates: Vec::new(),
1284 updated_at_ms,
1285 message_count: usize::try_from(message_count).ok(),
1286 model,
1287 });
1288 }
1289}
1290
1291const LATEST_PREVIEW_CANDIDATES: usize = 8;
1292const TOPIC_PREVIEW_HEAD_BYTES: u64 = 512 * 1024;
1293const LATEST_PREVIEW_TAIL_BYTES: u64 = 512 * 1024;
1294const LATEST_PREVIEW_MAX_BYTES: u64 = 4 * 1024 * 1024;
1295
1296fn topic_message_candidates(locator: &SessionLocator) -> Result<Vec<SessionPreviewCandidate>> {
1297 match &locator.storage {
1298 StorageLocator::File { path }
1299 if matches!(
1300 locator.harness.as_str(),
1301 HarnessId::CLAUDE_CODE | HarnessId::CODEX
1302 ) =>
1303 {
1304 topic_file_message_candidates(path, locator.harness.as_str())
1305 }
1306 _ => Ok(Vec::new()),
1307 }
1308}
1309
1310fn codex_history_topics(
1311 sessions_root: &Path,
1312 sessions: &[SessionDescriptor],
1313) -> Result<HashMap<String, Vec<SessionPreviewCandidate>>> {
1314 let wanted: HashSet<&str> = sessions
1315 .iter()
1316 .filter(|descriptor| descriptor.locator.harness.as_str() == HarnessId::CODEX)
1317 .map(|descriptor| descriptor.locator.session_id.as_str())
1318 .collect();
1319 if wanted.is_empty() {
1320 return Ok(HashMap::new());
1321 }
1322 let Some(root) = sessions_root.parent() else {
1323 return Ok(HashMap::new());
1324 };
1325 let file = match File::open(root.join("history.jsonl")) {
1326 Ok(file) => file,
1327 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(HashMap::new()),
1328 Err(error) => return Err(error.into()),
1329 };
1330 let mut topics = HashMap::new();
1331 for line in BufReader::new(file).lines() {
1332 let Ok(value) = serde_json::from_str::<Value>(&line?) else {
1333 continue;
1334 };
1335 let Some(session_id) = value.get("session_id").and_then(Value::as_str) else {
1336 continue;
1337 };
1338 if !wanted.contains(session_id) || topics.contains_key(session_id) {
1339 continue;
1340 }
1341 let mut candidates = Vec::new();
1342 push_message_candidate(&mut candidates, "user", value.get("text"), HashMap::new());
1343 if !candidates.is_empty() {
1344 topics.insert(session_id.to_string(), candidates);
1345 if topics.len() == wanted.len() {
1346 break;
1347 }
1348 }
1349 }
1350 Ok(topics)
1351}
1352
1353fn latest_message_candidates(locator: &SessionLocator) -> Result<Vec<SessionPreviewCandidate>> {
1354 match &locator.storage {
1355 StorageLocator::File { path } => {
1356 latest_file_message_candidates(path, locator.harness.as_str())
1357 }
1358 StorageLocator::Sqlite { path, selector }
1359 if locator.harness.as_str() == HarnessId::OPENCODE =>
1360 {
1361 latest_opencode_message_candidates(path, selector)
1362 }
1363 StorageLocator::Sqlite { path, selector }
1364 if locator.harness.as_str() == HarnessId::GOOSE =>
1365 {
1366 latest_goose_message_candidates(path, selector)
1367 }
1368 StorageLocator::Sqlite { .. } => Ok(Vec::new()),
1369 }
1370}
1371
1372fn topic_file_message_candidates(
1373 path: &Path,
1374 harness: &str,
1375) -> Result<Vec<SessionPreviewCandidate>> {
1376 let mut file = File::open(path)?;
1377 let mut bytes = Vec::with_capacity(TOPIC_PREVIEW_HEAD_BYTES as usize);
1378 file.by_ref()
1379 .take(TOPIC_PREVIEW_HEAD_BYTES)
1380 .read_to_end(&mut bytes)?;
1381 if file.metadata()?.len() > TOPIC_PREVIEW_HEAD_BYTES {
1382 if let Some(newline) = bytes.iter().rposition(|byte| *byte == b'\n') {
1383 bytes.truncate(newline);
1384 }
1385 }
1386 let text = String::from_utf8(bytes).map_err(|_| {
1387 Error::Other(format!(
1388 "{} contains non-UTF-8 data in its topic-preview window",
1389 path.display()
1390 ))
1391 })?;
1392 let mut candidates = Vec::new();
1393 for line in text.lines() {
1394 let Ok(value) = serde_json::from_str::<Value>(line) else {
1395 continue;
1396 };
1397 push_topic_message_candidate(&mut candidates, harness, &value);
1398 if candidates.len() >= LATEST_PREVIEW_CANDIDATES {
1399 break;
1400 }
1401 }
1402 Ok(candidates)
1403}
1404
1405fn latest_file_message_candidates(
1406 path: &Path,
1407 harness: &str,
1408) -> Result<Vec<SessionPreviewCandidate>> {
1409 let mut candidates =
1410 latest_file_message_candidates_with_limit(path, harness, LATEST_PREVIEW_TAIL_BYTES)?;
1411 if candidates.is_empty() {
1412 candidates =
1413 latest_file_message_candidates_with_limit(path, harness, LATEST_PREVIEW_MAX_BYTES)?;
1414 }
1415 Ok(candidates)
1416}
1417
1418fn latest_file_message_candidates_with_limit(
1419 path: &Path,
1420 harness: &str,
1421 byte_limit: u64,
1422) -> Result<Vec<SessionPreviewCandidate>> {
1423 let mut file = File::open(path)?;
1424 let file_len = file.metadata()?.len();
1425 let start = file_len.saturating_sub(byte_limit);
1426 file.seek(SeekFrom::Start(start))?;
1427 let mut bytes = Vec::with_capacity((file_len - start) as usize);
1428 file.read_to_end(&mut bytes)?;
1429 if start > 0 {
1430 if let Some(newline) = bytes.iter().position(|byte| *byte == b'\n') {
1431 bytes.drain(..=newline);
1432 } else {
1433 return Ok(Vec::new());
1434 }
1435 }
1436 let text = String::from_utf8(bytes).map_err(|_| {
1437 Error::Other(format!(
1438 "{} contains non-UTF-8 data in its list-preview window",
1439 path.display()
1440 ))
1441 })?;
1442 let mut candidates = Vec::new();
1443 for line in text.lines().rev() {
1444 let Ok(value) = serde_json::from_str::<Value>(line) else {
1445 continue;
1446 };
1447 let (role, content, metadata) = match harness {
1448 HarnessId::CLAUDE_CODE => {
1449 let role = value.get("type").and_then(Value::as_str);
1450 if !matches!(role, Some("user" | "assistant")) {
1451 continue;
1452 }
1453 let metadata = if role == Some("user") {
1454 crate::session::claude_user_provenance(&value)
1455 .into_iter()
1456 .collect()
1457 } else {
1458 HashMap::new()
1459 };
1460 (
1461 role.unwrap_or_default(),
1462 value
1463 .get("message")
1464 .and_then(|message| message.get("content")),
1465 metadata,
1466 )
1467 }
1468 HarnessId::CODEX => {
1469 let payload = value.get("payload").unwrap_or(&Value::Null);
1470 if value.get("type").and_then(Value::as_str) != Some("response_item")
1471 || payload.get("type").and_then(Value::as_str) != Some("message")
1472 {
1473 continue;
1474 }
1475 let Some(role @ ("user" | "assistant")) =
1476 payload.get("role").and_then(Value::as_str)
1477 else {
1478 continue;
1479 };
1480 (role, payload.get("content"), HashMap::new())
1481 }
1482 HarnessId::PI => {
1483 if value.get("type").and_then(Value::as_str) != Some("message") {
1484 continue;
1485 }
1486 let message = value.get("message").unwrap_or(&Value::Null);
1487 let Some(role @ ("user" | "assistant")) =
1488 message.get("role").and_then(Value::as_str)
1489 else {
1490 continue;
1491 };
1492 (role, message.get("content"), HashMap::new())
1493 }
1494 HarnessId::GEMINI => {
1495 let Some(kind @ ("user" | "gemini")) = value.get("type").and_then(Value::as_str)
1496 else {
1497 continue;
1498 };
1499 (
1500 if kind == "gemini" {
1501 "assistant"
1502 } else {
1503 "user"
1504 },
1505 value.get("content"),
1506 HashMap::new(),
1507 )
1508 }
1509 HarnessId::GROK => {
1510 let Some(role @ ("user" | "assistant")) = value.get("type").and_then(Value::as_str)
1511 else {
1512 continue;
1513 };
1514 (role, value.get("content"), HashMap::new())
1515 }
1516 HarnessId::SUPERCODE => {
1517 let Some(role @ ("user" | "assistant")) = value.get("role").and_then(Value::as_str)
1518 else {
1519 continue;
1520 };
1521 (role, value.get("content"), HashMap::new())
1522 }
1523 _ => continue,
1524 };
1525 let mut metadata = metadata;
1526 if matches!(harness, HarnessId::CLAUDE_CODE | HarnessId::CODEX) {
1527 if let Some(timestamp) = value.get("timestamp").and_then(Value::as_str) {
1528 metadata.insert("timestamp".to_string(), timestamp.to_string());
1529 }
1530 }
1531 push_message_candidate(&mut candidates, role, content, metadata);
1532 if candidates.len() >= LATEST_PREVIEW_CANDIDATES {
1533 break;
1534 }
1535 }
1536 Ok(candidates)
1537}
1538
1539fn push_topic_message_candidate(
1540 candidates: &mut Vec<SessionPreviewCandidate>,
1541 harness: &str,
1542 value: &Value,
1543) {
1544 let (role, content, metadata) = match harness {
1545 HarnessId::CLAUDE_CODE => {
1546 let role = value.get("type").and_then(Value::as_str);
1547 if !matches!(role, Some("user" | "assistant")) {
1548 return;
1549 }
1550 let metadata = if role == Some("user") {
1551 crate::session::claude_user_provenance(value)
1552 .into_iter()
1553 .collect()
1554 } else {
1555 HashMap::new()
1556 };
1557 (
1558 role.unwrap_or_default(),
1559 value
1560 .get("message")
1561 .and_then(|message| message.get("content")),
1562 metadata,
1563 )
1564 }
1565 HarnessId::CODEX => {
1566 let payload = value.get("payload").unwrap_or(&Value::Null);
1567 if value.get("type").and_then(Value::as_str) != Some("response_item")
1568 || payload.get("type").and_then(Value::as_str) != Some("message")
1569 {
1570 return;
1571 }
1572 let Some(role @ ("user" | "assistant")) = payload.get("role").and_then(Value::as_str)
1573 else {
1574 return;
1575 };
1576 (role, payload.get("content"), HashMap::new())
1577 }
1578 _ => return,
1579 };
1580 push_message_candidate(candidates, role, content, metadata);
1581}
1582
1583fn latest_opencode_message_candidates(
1584 path: &Path,
1585 session_id: &str,
1586) -> Result<Vec<SessionPreviewCandidate>> {
1587 let connection = Connection::open_with_flags(
1588 path,
1589 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
1590 )
1591 .map_err(|error| Error::Other(format!("failed to open list-preview store: {error}")))?;
1592 let mut statement = connection
1593 .prepare(
1594 "SELECT m.data, p.data FROM message m JOIN part p ON p.message_id = m.id \
1595 WHERE m.session_id = ?1 ORDER BY m.time_created DESC, p.time_created DESC LIMIT 32",
1596 )
1597 .map_err(|error| Error::Other(format!("failed to prepare list-preview query: {error}")))?;
1598 let rows = statement
1599 .query_map([session_id], |row| {
1600 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1601 })
1602 .map_err(|error| Error::Other(format!("failed to read list-preview rows: {error}")))?;
1603 let mut candidates = Vec::new();
1604 for row in rows.flatten() {
1605 let (Ok(message), Ok(part)) = (
1606 serde_json::from_str::<Value>(&row.0),
1607 serde_json::from_str::<Value>(&row.1),
1608 ) else {
1609 continue;
1610 };
1611 let Some(role @ ("user" | "assistant")) = message.get("role").and_then(Value::as_str)
1612 else {
1613 continue;
1614 };
1615 if part.get("type").and_then(Value::as_str) != Some("text") {
1616 continue;
1617 }
1618 push_message_candidate(&mut candidates, role, part.get("text"), HashMap::new());
1619 if candidates.len() >= LATEST_PREVIEW_CANDIDATES {
1620 break;
1621 }
1622 }
1623 Ok(candidates)
1624}
1625
1626fn latest_goose_message_candidates(
1627 path: &Path,
1628 session_id: &str,
1629) -> Result<Vec<SessionPreviewCandidate>> {
1630 let connection = Connection::open_with_flags(
1631 path,
1632 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
1633 )
1634 .map_err(|error| Error::Other(format!("failed to open list-preview store: {error}")))?;
1635 let mut statement = connection
1636 .prepare(
1637 "SELECT role, content_json FROM messages WHERE session_id = ?1 \
1638 ORDER BY created_timestamp DESC, id DESC LIMIT 16",
1639 )
1640 .map_err(|error| Error::Other(format!("failed to prepare list-preview query: {error}")))?;
1641 let rows = statement
1642 .query_map([session_id], |row| {
1643 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1644 })
1645 .map_err(|error| Error::Other(format!("failed to read list-preview rows: {error}")))?;
1646 let mut candidates = Vec::new();
1647 for row in rows.flatten() {
1648 let (role, content) = row;
1649 if !matches!(role.as_str(), "user" | "assistant") {
1650 continue;
1651 }
1652 let Ok(content) = serde_json::from_str::<Value>(&content) else {
1653 continue;
1654 };
1655 push_message_candidate(&mut candidates, &role, Some(&content), HashMap::new());
1656 if candidates.len() >= LATEST_PREVIEW_CANDIDATES {
1657 break;
1658 }
1659 }
1660 Ok(candidates)
1661}
1662
1663fn push_message_candidate(
1664 candidates: &mut Vec<SessionPreviewCandidate>,
1665 role: &str,
1666 content: Option<&Value>,
1667 metadata: HashMap<String, String>,
1668) {
1669 if candidates.len() >= LATEST_PREVIEW_CANDIDATES {
1670 return;
1671 }
1672 let Some(text) = display_text(content).filter(|text| !text.trim().is_empty()) else {
1673 return;
1674 };
1675 const MAX_CHARS: usize = 4_096;
1676 candidates.push(SessionPreviewCandidate {
1677 role: role.to_string(),
1678 content: text.chars().take(MAX_CHARS).collect(),
1679 metadata,
1680 });
1681}
1682
1683fn fill_string(target: &mut Option<String>, value: Option<&Value>) {
1684 if target.is_none() {
1685 *target = value.and_then(Value::as_str).map(str::to_owned);
1686 }
1687}
1688
1689fn fill_path(target: &mut Option<PathBuf>, value: Option<&Value>) {
1690 if target.is_none() {
1691 *target = value.and_then(Value::as_str).map(PathBuf::from);
1692 }
1693}
1694
1695fn modified_ms(path: &Path) -> Option<u64> {
1696 fs::metadata(path)
1697 .ok()?
1698 .modified()
1699 .ok()?
1700 .duration_since(UNIX_EPOCH)
1701 .ok()
1702 .and_then(|duration| u64::try_from(duration.as_millis()).ok())
1703}
1704
1705fn recorded_cwd_matches(recorded: &Path, wanted: &Path) -> bool {
1711 recorded.is_absolute() && same_path(recorded, wanted)
1712}
1713
1714fn same_path(left: &Path, right: &Path) -> bool {
1715 match (fs::canonicalize(left), fs::canonicalize(right)) {
1716 (Ok(left), Ok(right)) => left == right,
1717 _ => normalize_path(left) == normalize_path(right),
1718 }
1719}
1720
1721fn normalize_path(path: &Path) -> PathBuf {
1722 let absolute = if path.is_absolute() {
1723 path.to_path_buf()
1724 } else {
1725 std::env::current_dir()
1726 .unwrap_or_else(|_| PathBuf::from("."))
1727 .join(path)
1728 };
1729 let mut normalized = PathBuf::new();
1730 for component in absolute.components() {
1731 match component {
1732 Component::CurDir => {}
1733 Component::ParentDir => {
1734 normalized.pop();
1735 }
1736 other => normalized.push(other.as_os_str()),
1737 }
1738 }
1739 normalized
1740}
1741
1742#[cfg(test)]
1743mod tests {
1744 use super::*;
1745 use std::time::{SystemTime, UNIX_EPOCH};
1746
1747 fn temp_dir(label: &str) -> PathBuf {
1748 let nonce = SystemTime::now()
1749 .duration_since(UNIX_EPOCH)
1750 .unwrap()
1751 .as_nanos();
1752 let path = std::env::temp_dir().join(format!(
1753 "supercode-catalog-{label}-{}-{nonce}",
1754 std::process::id()
1755 ));
1756 fs::create_dir_all(&path).unwrap();
1757 path
1758 }
1759
1760 #[test]
1761 fn locator_json_round_trip_preserves_sqlite_selector() {
1762 let locator = SessionLocator {
1763 harness: HarnessId::from(HarnessId::OPENCODE),
1764 session_id: "ses_123".into(),
1765 storage: StorageLocator::Sqlite {
1766 path: PathBuf::from("/tmp/opencode-dev.db"),
1767 selector: "ses_123".into(),
1768 },
1769 };
1770 let encoded = serde_json::to_string(&locator).unwrap();
1771 assert_eq!(
1772 serde_json::from_str::<SessionLocator>(&encoded).unwrap(),
1773 locator
1774 );
1775 }
1776
1777 #[test]
1778 fn discovers_filters_loads_and_follows_three_jsonl_harnesses() {
1779 let root = temp_dir("jsonl");
1780 let workspace = root.join("workspace");
1781 let other = root.join("other");
1782 fs::create_dir_all(&workspace).unwrap();
1783 fs::create_dir_all(&other).unwrap();
1784
1785 let claude = root.join("claude");
1786 let codex = root.join("codex");
1787 let pi = root.join("pi");
1788 fs::create_dir_all(&claude).unwrap();
1789 fs::create_dir_all(&codex).unwrap();
1790 fs::create_dir_all(&pi).unwrap();
1791 fs::write(
1792 claude.join("claude.jsonl"),
1793 format!(
1794 "{{\"type\":\"user\",\"sessionId\":\"cc-1\",\"cwd\":{},\"timestamp\":\"2026-01-01T00:00:01Z\",\"message\":{{\"role\":\"user\",\"content\":\"hi\"}}}}\n",
1795 serde_json::to_string(&workspace.to_string_lossy()).unwrap()
1796 ),
1797 )
1798 .unwrap();
1799 fs::write(
1800 codex.join("rollout.jsonl"),
1801 format!(
1802 "{{\"timestamp\":\"2026-01-01T00:00:00Z\",\"type\":\"session_meta\",\"payload\":{{\"id\":\"cx-1\",\"cwd\":{}}}}}\n{{\"timestamp\":\"2026-01-01T00:00:02Z\",\"type\":\"response_item\",\"payload\":{{\"type\":\"message\",\"role\":\"user\",\"content\":[{{\"type\":\"input_text\",\"text\":\"inspect codex\"}}]}}}}\n",
1803 serde_json::to_string(&workspace.to_string_lossy()).unwrap()
1804 ),
1805 )
1806 .unwrap();
1807 fs::write(
1808 pi.join("pi.jsonl"),
1809 format!(
1810 "{{\"type\":\"session\",\"version\":3,\"id\":\"pi-1\",\"timestamp\":\"2026-01-01T00:00:00Z\",\"cwd\":{}}}\n{{\"type\":\"message\",\"message\":{{\"role\":\"user\",\"content\":\"inspect pi\"}}}}\n",
1811 serde_json::to_string(&workspace.to_string_lossy()).unwrap()
1812 ),
1813 )
1814 .unwrap();
1815 fs::write(
1816 pi.join("unrelated.jsonl"),
1817 format!(
1818 "{{\"type\":\"session\",\"version\":3,\"id\":\"pi-2\",\"timestamp\":\"2026-01-01T00:00:00Z\",\"cwd\":{}}}\n",
1819 serde_json::to_string(&other.to_string_lossy()).unwrap()
1820 ),
1821 )
1822 .unwrap();
1823 fs::write(claude.join("partial.jsonl"), "{truncated").unwrap();
1824
1825 let query = DiscoveryQuery {
1826 workspace: Some(workspace),
1827 homes: HarnessHomes {
1828 claude_code: claude,
1829 codex,
1830 pi,
1831 opencode: root.join("missing-opencode"),
1832 grok: root.join("missing-grok"),
1833 gemini: root.join("missing-gemini"),
1834 goose: root.join("missing-goose"),
1835 supercode: root.join("missing-supercode"),
1836 },
1837 ..DiscoveryQuery::default()
1838 };
1839 let catalog = HarnessCatalog::new();
1840 let found = catalog.discover(&query).unwrap();
1841 assert_eq!(found.len(), 3);
1842 assert_eq!(
1843 found
1844 .iter()
1845 .map(|item| item.locator.harness.as_str())
1846 .collect::<HashSet<_>>(),
1847 HashSet::from([HarnessId::CLAUDE_CODE, HarnessId::CODEX, HarnessId::PI])
1848 );
1849 for descriptor in found {
1850 assert!(descriptor.preview_candidates.is_empty());
1851 assert_eq!(descriptor.latest_message_candidates.len(), 1);
1852 assert_eq!(descriptor.latest_message_candidates[0].role, "user");
1853 if descriptor.locator.harness.as_str() == HarnessId::CLAUDE_CODE {
1854 assert_eq!(
1855 descriptor.latest_message_candidates[0]
1856 .metadata
1857 .get("timestamp")
1858 .map(String::as_str),
1859 Some("2026-01-01T00:00:01Z")
1860 );
1861 } else if descriptor.locator.harness.as_str() == HarnessId::CODEX {
1862 assert_eq!(
1863 descriptor.latest_message_candidates[0]
1864 .metadata
1865 .get("timestamp")
1866 .map(String::as_str),
1867 Some("2026-01-01T00:00:02Z")
1868 );
1869 }
1870 let loaded = catalog.load(&descriptor.locator).unwrap();
1871 assert_eq!(
1872 loaded.meta.session_id.as_deref(),
1873 Some(descriptor.locator.session_id.as_str())
1874 );
1875 let mut follower = catalog.follow(&descriptor.locator).unwrap();
1876 assert!(matches!(
1877 follower.poll().unwrap(),
1878 Some(crate::SessionWatchEvent::SessionSnapshot { .. })
1879 ));
1880 }
1881 fs::remove_dir_all(root).ok();
1882 }
1883
1884 #[test]
1885 fn discovers_loads_and_follows_opencode_sqlite() {
1886 let db = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1887 .join("../harness/tests/fixtures/opencode_fixture/opencode.db");
1888 let catalog = HarnessCatalog::new();
1889 let found = catalog
1890 .discover(&DiscoveryQuery {
1891 harnesses: vec![HarnessId::from(HarnessId::OPENCODE)],
1892 homes: HarnessHomes {
1893 opencode: db,
1894 ..HarnessHomes::default()
1895 },
1896 ..DiscoveryQuery::default()
1897 })
1898 .unwrap();
1899 assert!(!found.is_empty());
1900 for descriptor in found {
1901 assert_eq!(descriptor.locator.harness.as_str(), HarnessId::OPENCODE);
1902 assert_eq!(
1903 catalog.load(&descriptor.locator).unwrap().meta.session_id,
1904 Some(descriptor.locator.session_id.clone())
1905 );
1906 assert!(catalog.follow(&descriptor.locator).is_ok());
1907 }
1908 }
1909
1910 #[test]
1911 fn discovers_loads_and_follows_gemini_conversation_records() {
1912 let root = temp_dir("gemini");
1913 let workspace = root.join("workspace");
1914 let chats = root.join("gemini/tmp/demo/chats");
1915 fs::create_dir_all(&workspace).unwrap();
1916 fs::create_dir_all(&chats).unwrap();
1917 fs::write(
1918 root.join("gemini/projects.json"),
1919 serde_json::json!({
1920 "projects": {workspace.to_string_lossy(): "demo"}
1921 })
1922 .to_string(),
1923 )
1924 .unwrap();
1925 let transcript = chats.join("gemini-id.jsonl");
1926 fs::write(
1927 &transcript,
1928 include_str!("../../harness/tests/fixtures/gemini_session.jsonl"),
1929 )
1930 .unwrap();
1931
1932 let catalog = HarnessCatalog::new();
1933 let found = catalog
1934 .discover(&DiscoveryQuery {
1935 harnesses: vec![HarnessId::from(HarnessId::GEMINI)],
1936 homes: HarnessHomes {
1937 gemini: root.join("gemini"),
1938 ..HarnessHomes::default()
1939 },
1940 workspace: Some(workspace.clone()),
1941 ..DiscoveryQuery::default()
1942 })
1943 .unwrap();
1944
1945 assert_eq!(found.len(), 1);
1946 assert_eq!(found[0].cwd.as_deref(), Some(workspace.as_path()));
1947 assert_eq!(found[0].message_count, None);
1948 assert_eq!(found[0].model.as_deref(), Some("gemini-2.5-pro"));
1949 assert_eq!(found[0].title, None);
1950 assert!(found[0].preview_candidates.is_empty());
1951 assert_eq!(found[0].latest_message_candidates.len(), 3);
1952 assert_eq!(
1953 found[0].latest_message_candidates[0].content,
1954 "Fixture inspected."
1955 );
1956 let loaded = catalog.load(&found[0].locator).unwrap();
1957 assert_eq!(
1958 loaded.meta.session_id.as_deref(),
1959 Some("11111111-1111-4111-8111-111111111111")
1960 );
1961 assert_eq!(loaded.messages.len(), 4);
1962 assert!(matches!(
1963 catalog.follow(&found[0].locator).unwrap().poll().unwrap(),
1964 Some(crate::SessionWatchEvent::SessionSnapshot { .. })
1965 ));
1966 fs::remove_dir_all(root).ok();
1967 }
1968
1969 #[test]
1970 fn discovers_native_store_and_pages_search_results() {
1971 let root = temp_dir("supercode");
1972 let store_root = root.join("sessions");
1973 fs::create_dir_all(&store_root).unwrap();
1974 for (name, title) in [
1975 ("alpha", "Alpha planning"),
1976 ("beta", "Beta implementation"),
1977 ("gamma", "Gamma review"),
1978 ] {
1979 fs::write(
1980 store_root.join(format!("{name}.jsonl")),
1981 format!("{{\"role\":\"user\",\"content\":\"{title}\"}}\n"),
1982 )
1983 .unwrap();
1984 fs::write(
1985 store_root.join(format!("{name}.meta.json")),
1986 serde_json::json!({"name": name, "title": title}).to_string(),
1987 )
1988 .unwrap();
1989 }
1990 let catalog = HarnessCatalog::new();
1991 let base = DiscoveryQuery {
1992 harnesses: vec![HarnessId::from(HarnessId::SUPERCODE)],
1993 homes: HarnessHomes {
1994 supercode: store_root,
1995 ..HarnessHomes::default()
1996 },
1997 limit: Some(1),
1998 ..DiscoveryQuery::default()
1999 };
2000
2001 let first = catalog.discover_page(&base).unwrap();
2002 assert_eq!(first.sessions.len(), 1);
2003 assert!(first.next_cursor.is_some());
2004 let second = catalog
2005 .discover_page(&DiscoveryQuery {
2006 cursor: first.next_cursor,
2007 ..base.clone()
2008 })
2009 .unwrap();
2010 assert_eq!(second.sessions.len(), 1);
2011 assert_ne!(
2012 first.sessions[0].locator.session_id,
2013 second.sessions[0].locator.session_id
2014 );
2015 let search = catalog
2016 .discover_page(&DiscoveryQuery {
2017 limit: None,
2018 query: Some("implementation".into()),
2019 ..base
2020 })
2021 .unwrap();
2022 assert_eq!(search.sessions.len(), 1);
2023 assert_eq!(search.sessions[0].locator.session_id, "beta");
2024 assert_eq!(search.sessions[0].message_count, None);
2025 assert_eq!(
2026 catalog
2027 .load(&search.sessions[0].locator)
2028 .unwrap()
2029 .messages
2030 .len(),
2031 1
2032 );
2033 fs::remove_dir_all(root).ok();
2034 }
2035
2036 #[test]
2037 fn native_workspace_discovery_reads_bounded_sidecar_headers() {
2038 let root = temp_dir("supercode-bounded-header");
2039 let store_root = root.join("sessions");
2040 let workspace = root.join("project");
2041 fs::create_dir_all(&store_root).unwrap();
2042 fs::create_dir_all(&workspace).unwrap();
2043 let name = "bounded-native";
2044 fs::write(
2045 store_root.join(format!("{name}.meta.json")),
2046 serde_json::json!({"name": name, "title": "Bounded native"}).to_string(),
2047 )
2048 .unwrap();
2049 fs::write(
2050 store_root.join(format!("{name}.jsonl")),
2051 "{\"role\":\"user\",\"content\":\"projected view\"}\n",
2052 )
2053 .unwrap();
2054 let sidecar = [
2055 serde_json::json!({
2056 "supercode_native": 2,
2057 "source": "claude_code",
2058 "session_id": "native-session"
2059 })
2060 .to_string(),
2061 serde_json::json!({
2062 "type": "user",
2063 "sessionId": "native-session",
2064 "cwd": workspace,
2065 "message": {"role": "user", "content": "hello"}
2066 })
2067 .to_string(),
2068 serde_json::json!({
2069 "type": "assistant",
2070 "sessionId": "native-session",
2071 "cwd": workspace,
2072 "message": {"role": "assistant", "model": "claude-sonnet-5", "content": []}
2073 })
2074 .to_string(),
2075 "not-json".into(),
2078 ]
2079 .join("\n");
2080 fs::write(
2081 store_root.join(format!("{name}.sidecar.jsonl")),
2082 format!("{sidecar}\n"),
2083 )
2084 .unwrap();
2085
2086 let found = HarnessCatalog::new()
2087 .discover(&DiscoveryQuery {
2088 workspace: Some(workspace.clone()),
2089 harnesses: vec![HarnessId::from(HarnessId::SUPERCODE)],
2090 homes: HarnessHomes {
2091 supercode: store_root,
2092 ..HarnessHomes::default()
2093 },
2094 ..DiscoveryQuery::default()
2095 })
2096 .unwrap();
2097
2098 assert_eq!(found.len(), 1);
2099 assert_eq!(found[0].cwd.as_deref(), Some(workspace.as_path()));
2100 assert_eq!(found[0].model.as_deref(), Some("claude-sonnet-5"));
2101 assert_eq!(found[0].message_count, None);
2102 fs::remove_dir_all(root).ok();
2103 }
2104
2105 #[test]
2106 fn discovers_current_opencode_schema_without_a_session_model_column() {
2107 let root = temp_dir("opencode-current");
2108 let db = root.join("opencode.db");
2109 let conn = Connection::open(&db).unwrap();
2110 conn.execute_batch(
2111 "CREATE TABLE session (
2112 id TEXT PRIMARY KEY,
2113 directory TEXT NOT NULL,
2114 title TEXT NOT NULL,
2115 time_updated INTEGER NOT NULL
2116 );
2117 CREATE TABLE message (
2118 id TEXT PRIMARY KEY,
2119 session_id TEXT NOT NULL
2120 );
2121 INSERT INTO session VALUES ('ses_current', '/tmp/work', 'Current', 42);
2122 INSERT INTO message VALUES ('msg_current', 'ses_current');",
2123 )
2124 .unwrap();
2125 drop(conn);
2126
2127 let found = HarnessCatalog::new()
2128 .discover(&DiscoveryQuery {
2129 harnesses: vec![HarnessId::from(HarnessId::OPENCODE)],
2130 homes: HarnessHomes {
2131 opencode: db,
2132 ..HarnessHomes::default()
2133 },
2134 ..DiscoveryQuery::default()
2135 })
2136 .unwrap();
2137
2138 assert_eq!(found.len(), 1);
2139 assert_eq!(found[0].locator.session_id, "ses_current");
2140 assert_eq!(found[0].message_count, Some(1));
2141 assert_eq!(found[0].model, None);
2142 fs::remove_dir_all(root).ok();
2143 }
2144
2145 #[test]
2146 fn workspace_filter_never_matches_a_relative_recorded_cwd() {
2147 let root = temp_dir("opencode-relative-cwd");
2152 let db = root.join("opencode.db");
2153 let conn = Connection::open(&db).unwrap();
2154 let here = std::env::current_dir().unwrap();
2155 conn.execute_batch(&format!(
2156 "CREATE TABLE session (
2157 id TEXT PRIMARY KEY,
2158 directory TEXT NOT NULL,
2159 title TEXT NOT NULL,
2160 time_updated INTEGER NOT NULL
2161 );
2162 CREATE TABLE message (
2163 id TEXT PRIMARY KEY,
2164 session_id TEXT NOT NULL
2165 );
2166 INSERT INTO session VALUES ('ses_relative', '.', 'Ghost', 41);
2167 INSERT INTO session VALUES ('ses_here', '{}', 'Real', 42);",
2168 here.display()
2169 ))
2170 .unwrap();
2171 drop(conn);
2172
2173 let found = HarnessCatalog::new()
2174 .discover(&DiscoveryQuery {
2175 workspace: Some(here),
2176 harnesses: vec![HarnessId::from(HarnessId::OPENCODE)],
2177 homes: HarnessHomes {
2178 opencode: db,
2179 ..HarnessHomes::default()
2180 },
2181 ..DiscoveryQuery::default()
2182 })
2183 .unwrap();
2184
2185 assert_eq!(found.len(), 1);
2186 assert_eq!(found[0].locator.session_id, "ses_here");
2187 fs::remove_dir_all(root).ok();
2188 }
2189}