Skip to main content

supercode_interchange/
catalog.rs

1//! Discovery and durable addressing for sessions written by external harnesses.
2//!
3//! [`HarnessCatalog`] is intentionally about persisted state. It does not
4//! claim that the process which wrote a session is still alive or attachable.
5
6use 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/// Extensible identifier for a coding harness.
21#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
22#[serde(transparent)]
23pub struct HarnessId(pub String);
24
25impl HarnessId {
26    /// Claude Code's stable identifier.
27    pub const CLAUDE_CODE: &'static str = "claude-code";
28    /// Codex's stable identifier.
29    pub const CODEX: &'static str = "codex";
30    /// Pi's stable identifier.
31    pub const PI: &'static str = "pi";
32    /// OpenCode's stable identifier.
33    pub const OPENCODE: &'static str = "opencode";
34    /// Grok's stable identifier.
35    pub const GROK: &'static str = "grok";
36    /// Gemini CLI's stable identifier.
37    pub const GEMINI: &'static str = "gemini";
38    /// Goose's stable identifier.
39    pub const GOOSE: &'static str = "goose";
40    /// Supercode's native saved-session store.
41    pub const SUPERCODE: &'static str = "supercode";
42
43    /// Construct an identifier without restricting third-party harness names.
44    pub fn new(value: impl Into<String>) -> Self {
45        Self(value.into())
46    }
47
48    /// Return the identifier as a string slice.
49    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/// Durable storage address for a persisted session.
61#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
62#[serde(tag = "kind", rename_all = "snake_case")]
63pub enum StorageLocator {
64    /// One session stored in one file.
65    File {
66        /// Absolute or caller-resolvable path to the transcript.
67        path: PathBuf,
68    },
69    /// One logical session selected from a SQLite store.
70    Sqlite {
71        /// Path to the SQLite database.
72        path: PathBuf,
73        /// Harness-native stable selector, currently an OpenCode session id.
74        selector: String,
75    },
76}
77
78impl StorageLocator {
79    /// Return the underlying file or database path.
80    pub fn path(&self) -> &Path {
81        match self {
82            Self::File { path } | Self::Sqlite { path, .. } => path,
83        }
84    }
85}
86
87/// Stable identity for a persisted harness session.
88#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
89pub struct SessionLocator {
90    /// Harness which owns the storage format.
91    pub harness: HarnessId,
92    /// Harness-native session identity.
93    pub session_id: String,
94    /// Exact storage address needed to load the session again.
95    pub storage: StorageLocator,
96}
97
98/// Lightweight metadata returned by catalog discovery.
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100pub struct SessionDescriptor {
101    /// Durable address accepted by [`HarnessCatalog::load`] and
102    /// [`HarnessCatalog::follow`].
103    pub locator: SessionLocator,
104    /// Working directory recorded by the harness.
105    pub cwd: Option<PathBuf>,
106    /// Harness-provided title, when cheaply available.
107    pub title: Option<String>,
108    /// Oldest-first bounded conversation messages for a fallback topic when
109    /// the harness does not publish a useful title. These are read only for
110    /// the returned page and interpreted by the same presentation projection
111    /// as an opened conversation.
112    #[serde(default, skip_serializing_if = "Vec::is_empty")]
113    pub preview_candidates: Vec<SessionPreviewCandidate>,
114    /// Newest-first bounded conversation messages for compact list previews.
115    /// These are read only for the returned page, never for the entire
116    /// catalog, and are interpreted by the same presentation projection as
117    /// an opened conversation.
118    #[serde(default, skip_serializing_if = "Vec::is_empty")]
119    pub latest_message_candidates: Vec<SessionPreviewCandidate>,
120    /// Last update time as Unix epoch milliseconds.
121    pub updated_at_ms: Option<u64>,
122    /// Harness message-record count, when available without loading the session.
123    pub message_count: Option<usize>,
124    /// Model recorded in lightweight session metadata.
125    pub model: Option<String>,
126}
127
128/// One bounded normalized conversation-message candidate for list projection.
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130pub struct SessionPreviewCandidate {
131    /// Canonical conversation role. Older clients may assume `user` when this
132    /// field is absent from an older server.
133    pub role: String,
134    /// Canonical text content.
135    pub content: String,
136    /// Canonical provenance used by the normal conversation visibility rules.
137    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
138    pub metadata: HashMap<String, String>,
139}
140
141/// One stable newest-first discovery page.
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143pub struct DiscoveryPage {
144    /// Sessions in this page.
145    pub sessions: Vec<SessionDescriptor>,
146    /// Opaque cursor for the next page, or `None` at the end.
147    pub next_cursor: Option<String>,
148}
149
150/// Configurable session roots for the built-in harnesses.
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
152#[serde(default)]
153pub struct HarnessHomes {
154    /// Directory containing Claude Code project session directories.
155    pub claude_code: PathBuf,
156    /// Directory containing Codex rollout sessions.
157    pub codex: PathBuf,
158    /// Directory containing Pi project session directories.
159    pub pi: PathBuf,
160    /// OpenCode data root, or an explicit `opencode*.db` path.
161    pub opencode: PathBuf,
162    /// Grok session root containing percent-encoded workspace directories.
163    pub grok: PathBuf,
164    /// Gemini CLI configuration root containing `projects.json` and `tmp/`.
165    pub gemini: PathBuf,
166    /// Goose `sessions.db`, or a directory containing it.
167    pub goose: PathBuf,
168    /// Supercode's native saved-session directory.
169    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/// Filters and roots used for one catalog scan.
252#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
253#[serde(default)]
254pub struct DiscoveryQuery {
255    /// Only return sessions whose recorded working directory is this path.
256    pub workspace: Option<PathBuf>,
257    /// Harnesses to scan. Empty means all built-ins.
258    pub harnesses: Vec<HarnessId>,
259    /// Storage roots to scan.
260    pub homes: HarnessHomes,
261    /// Case-insensitive search over harness, id, title, workspace, and model.
262    pub query: Option<String>,
263    /// Opaque cursor returned by a prior [`HarnessCatalog::discover_page`].
264    pub cursor: Option<String>,
265    /// Maximum number of results after newest-first sorting.
266    pub limit: Option<usize>,
267    /// Include oldest-first bounded topic candidates for harnesses whose
268    /// native store does not publish a useful title. Off by default because
269    /// topics are stable and list clients can retain them across refreshes.
270    pub include_topic_candidates: bool,
271}
272
273/// Read-only entry point for discovering, loading, and following persisted
274/// harness sessions.
275#[derive(Debug, Default, Clone, Copy)]
276pub struct HarnessCatalog;
277
278impl HarnessCatalog {
279    /// Construct a catalog. It holds no cache or global mutable state.
280    pub fn new() -> Self {
281        Self
282    }
283
284    /// Discover sessions using lightweight headers/indexes rather than full
285    /// transcript normalization. Malformed or concurrently-created entries
286    /// are skipped without aborting the rest of the scan.
287    pub fn discover(&self, query: &DiscoveryQuery) -> Result<Vec<SessionDescriptor>> {
288        Ok(self.discover_page(query)?.sessions)
289    }
290
291    /// Discover one stable page and return the cursor for its successor.
292    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    /// Refresh one file-backed descriptor without rescanning its native store.
420    ///
421    /// This is the incremental counterpart to [`Self::discover_page`]: a
422    /// filesystem notification is only an invalidation hint, so callers
423    /// re-read the durable file and derive the complete current descriptor.
424    /// `None` means the path disappeared or no longer contains a recognizable
425    /// session. SQLite-backed harnesses retain their indexed discovery path.
426    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            // Harnesses append the header and first turn non-atomically. A
446            // transiently incomplete new file is not a service error; the
447            // next native event or reconciliation pass will retry it.
448            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    /// Load the complete normalized session named by a durable locator.
483    pub fn load(&self, locator: &SessionLocator) -> Result<Session> {
484        self.load_with_fidelity(locator, Fidelity::ByteLossless)
485    }
486
487    /// [`Self::load`] at a declared fidelity.
488    ///
489    /// Read-only surfaces (a session mirror, `follow`) pass
490    /// [`Fidelity::Semantic`] so a compacted transcript renders instead of
491    /// erroring; every continuation/transfer/export caller keeps the strict
492    /// default. See [`Session::load_with_fidelity`].
493    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    /// Load the selected parent transcript without recursively attaching
517    /// Claude Code child sessions. This is the bounded frontend-view seam;
518    /// lossless operations continue to use [`Self::load_with_fidelity`].
519    #[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    /// Load bounded parent-only human-visible history. Codex compaction
544    /// changes resumable context but does not erase earlier visible turns.
545    #[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    /// Open a passive change-triggered follower for a durable locator.
582    pub fn follow(&self, locator: &SessionLocator) -> Result<SessionFollower> {
583        self.follow_with_fidelity(locator, Fidelity::ByteLossless)
584    }
585
586    /// [`Self::follow`] at a declared fidelity — see [`Self::load_with_fidelity`].
587    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    /// Follow a read-only view with explicit child-tree and history bounds.
596    #[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        match harness {
746            HarnessId::CLAUDE_CODE => {
747                fill_string(&mut result.session_id, value.get("sessionId"));
748                fill_path(&mut result.cwd, value.get("cwd"));
749                fill_string(
750                    &mut result.model,
751                    value.get("message").and_then(|v| v.get("model")),
752                );
753            }
754            HarnessId::CODEX => {
755                let payload = value.get("payload").unwrap_or(&Value::Null);
756                if value.get("type").and_then(Value::as_str) == Some("session_meta") {
757                    fill_string(&mut result.session_id, payload.get("id"));
758                    fill_path(&mut result.cwd, payload.get("cwd"));
759                    fill_string(&mut result.title, payload.get("thread_name"));
760                    fill_string(&mut result.title, payload.get("title"));
761                    if result.title.is_none() {
762                        result.title = payload
763                            .pointer("/source/subagent/thread_spawn/agent_path")
764                            .and_then(Value::as_str)
765                            .and_then(|path| path.rsplit('/').find(|part| !part.is_empty()))
766                            .map(humanize_topic);
767                    }
768                }
769                if value.get("type").and_then(Value::as_str) == Some("turn_context") {
770                    fill_path(&mut result.cwd, payload.get("cwd"));
771                    fill_string(&mut result.model, payload.get("model"));
772                }
773            }
774            HarnessId::PI => {
775                if value.get("type").and_then(Value::as_str) == Some("session") {
776                    fill_string(&mut result.session_id, value.get("id"));
777                    fill_path(&mut result.cwd, value.get("cwd"));
778                }
779                fill_string(
780                    &mut result.model,
781                    value.get("message").and_then(|v| v.get("model")),
782                );
783            }
784            _ => {}
785        }
786        if result.session_id.is_some() && result.cwd.is_some() && result.model.is_some() {
787            break;
788        }
789    }
790    if result.session_id.is_none() && result.cwd.is_none() {
791        return Err(Error::Other(format!(
792            "{} has no recognizable {harness} session header",
793            path.display()
794        )));
795    }
796    Ok(result)
797}
798
799fn humanize_topic(value: &str) -> String {
800    let text = value.replace(['_', '-'], " ");
801    let mut characters = text.chars();
802    match characters.next() {
803        Some(first) => first.to_uppercase().collect::<String>() + characters.as_str(),
804        None => text,
805    }
806}
807
808fn discover_gemini(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
809    let slug_to_cwd = std::fs::read_to_string(root.join("projects.json"))
810        .ok()
811        .and_then(|text| serde_json::from_str::<Value>(&text).ok())
812        .and_then(|value| value.get("projects").and_then(Value::as_object).cloned())
813        .map(|projects| {
814            projects
815                .into_iter()
816                .filter_map(|(cwd, slug)| Some((slug.as_str()?.to_string(), PathBuf::from(cwd))))
817                .collect::<HashMap<_, _>>()
818        })
819        .unwrap_or_default();
820    let mut files = Vec::new();
821    collect_jsonl(&root.join("tmp"), HarnessId::GEMINI, &mut files);
822    let worker_count = std::thread::available_parallelism()
823        .map(usize::from)
824        .unwrap_or(4)
825        .clamp(1, 8)
826        .min(files.len().max(1));
827    let chunk_size = files.len().max(1).div_ceil(worker_count);
828    let discovered = std::thread::scope(|scope| {
829        files
830            .chunks(chunk_size)
831            .map(|paths| {
832                scope.spawn(|| {
833                    paths
834                        .iter()
835                        .filter_map(|path| gemini_descriptor(path, &slug_to_cwd, workspace))
836                        .collect::<Vec<_>>()
837                })
838            })
839            .collect::<Vec<_>>()
840            .into_iter()
841            .flat_map(|worker| {
842                worker
843                    .join()
844                    .expect("Gemini discovery worker must not panic")
845            })
846            .collect::<Vec<_>>()
847    });
848    found.extend(discovered);
849}
850
851fn gemini_descriptor(
852    path: &Path,
853    slug_to_cwd: &HashMap<String, PathBuf>,
854    workspace: Option<&Path>,
855) -> Option<SessionDescriptor> {
856    if path
857        .parent()
858        .and_then(Path::file_name)
859        .and_then(|name| name.to_str())
860        != Some("chats")
861    {
862        return None;
863    }
864    let slug = path
865        .parent()
866        .and_then(Path::parent)
867        .and_then(Path::file_name)
868        .and_then(|name| name.to_str());
869    let cwd = slug.and_then(|slug| slug_to_cwd.get(slug)).cloned();
870    if workspace.is_some_and(|wanted| {
871        cwd.as_deref()
872            .is_none_or(|actual| !recorded_cwd_matches(actual, wanted))
873    }) {
874        return None;
875    }
876
877    // The native session id lives on line one. A small decoration budget keeps
878    // the common title/model case without turning 1,800 sessions into a
879    // sequential 60 MiB read before the list can render.
880    let file = File::open(path).ok()?;
881    let mut reader = BufReader::new(file.take(64 * 1024));
882    let mut header = String::new();
883    reader.read_line(&mut header).ok()?;
884    let header = serde_json::from_str::<Value>(&header).ok()?;
885    let session_id = header.get("sessionId")?.as_str()?.to_string();
886    let mut model = None;
887    for line in reader
888        .take(4 * 1024)
889        .lines()
890        .map_while(std::result::Result::ok)
891    {
892        let Ok(value) = serde_json::from_str::<Value>(&line) else {
893            continue;
894        };
895        let kind = value.get("type").and_then(Value::as_str);
896        if kind != Some("user") && kind != Some("gemini") {
897            continue;
898        }
899        if model.is_none() {
900            model = value
901                .get("model")
902                .and_then(Value::as_str)
903                .map(str::to_string);
904        }
905        if model.is_some() {
906            break;
907        }
908    }
909    Some(SessionDescriptor {
910        locator: SessionLocator {
911            harness: HarnessId::from(HarnessId::GEMINI),
912            session_id,
913            storage: StorageLocator::File {
914                path: path.to_path_buf(),
915            },
916        },
917        cwd,
918        title: None,
919        preview_candidates: Vec::new(),
920        latest_message_candidates: Vec::new(),
921        updated_at_ms: modified_ms(path),
922        message_count: None,
923        model,
924    })
925}
926
927fn display_text(content: Option<&Value>) -> Option<String> {
928    match content? {
929        Value::String(text) => Some(text.clone()),
930        Value::Array(parts) => Some(
931            parts
932                .iter()
933                .filter_map(|part| part.get("text").and_then(Value::as_str))
934                .collect::<Vec<_>>()
935                .join(" ")
936                .trim()
937                .to_string(),
938        ),
939        _ => None,
940    }
941}
942
943fn discover_supercode(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
944    for info in list_native_store(root) {
945        let path = if info.archived {
946            root.join("archived").join(format!("{}.jsonl", info.name))
947        } else {
948            root.join(format!("{}.jsonl", info.name))
949        };
950        let loaded = workspace
951            .is_some()
952            .then(|| load_native_store_family(&path))
953            .transpose()
954            .ok()
955            .flatten()
956            .flatten();
957        if workspace.is_some_and(|wanted| {
958            loaded
959                .as_ref()
960                .and_then(|session| session.meta.cwd.as_deref())
961                .is_none_or(|cwd| !recorded_cwd_matches(cwd, wanted))
962        }) {
963            continue;
964        }
965        let title = (!info.title.trim().is_empty()).then_some(info.title);
966        let updated_at_ms =
967            modified_ms(&path).or_else(|| modified_ms(&path.with_extension("sidecar.jsonl")));
968        found.push(SessionDescriptor {
969            locator: SessionLocator {
970                harness: HarnessId::from(HarnessId::SUPERCODE),
971                session_id: info.name,
972                storage: StorageLocator::File { path: path.clone() },
973            },
974            cwd: loaded.as_ref().and_then(|session| session.meta.cwd.clone()),
975            title,
976            preview_candidates: Vec::new(),
977            latest_message_candidates: Vec::new(),
978            updated_at_ms,
979            message_count: loaded.as_ref().map(|session| session.messages.len()),
980            model: loaded.and_then(|session| session.meta.model),
981        });
982    }
983}
984
985#[derive(Deserialize)]
986struct NativeStoreInfo {
987    name: String,
988    #[serde(default)]
989    title: String,
990    #[serde(skip)]
991    archived: bool,
992}
993
994fn list_native_store(root: &Path) -> Vec<NativeStoreInfo> {
995    let mut sessions = Vec::new();
996    for archived in [false, true] {
997        let directory = if archived {
998            root.join("archived")
999        } else {
1000            root.to_path_buf()
1001        };
1002        let Ok(entries) = fs::read_dir(directory) else {
1003            continue;
1004        };
1005        for entry in entries.flatten() {
1006            let path = entry.path();
1007            if !path.to_string_lossy().ends_with(".meta.json") {
1008                continue;
1009            }
1010            let Ok(text) = fs::read_to_string(path) else {
1011                continue;
1012            };
1013            let Ok(mut info) = serde_json::from_str::<NativeStoreInfo>(&text) else {
1014                continue;
1015            };
1016            info.archived = archived;
1017            sessions.push(info);
1018        }
1019    }
1020    sessions.sort_by(|left, right| left.name.cmp(&right.name));
1021    sessions
1022}
1023
1024fn discover_grok(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
1025    let Ok(workspaces) = fs::read_dir(root) else {
1026        return;
1027    };
1028    for workspace_entry in workspaces.flatten() {
1029        let encoded = workspace_entry.file_name();
1030        let Some(cwd) = encoded
1031            .to_str()
1032            .and_then(percent_decode_path)
1033            .map(PathBuf::from)
1034        else {
1035            continue;
1036        };
1037        if workspace.is_some_and(|wanted| !recorded_cwd_matches(&cwd, wanted)) {
1038            continue;
1039        }
1040        let Ok(sessions) = fs::read_dir(workspace_entry.path()) else {
1041            continue;
1042        };
1043        for session_entry in sessions.flatten() {
1044            let session_dir = session_entry.path();
1045            if !session_dir.is_dir() {
1046                continue;
1047            }
1048            let transcript = session_dir.join("chat_history.jsonl");
1049            if !transcript.is_file() {
1050                continue;
1051            }
1052            let Some(session_id) = session_dir
1053                .file_name()
1054                .and_then(|name| name.to_str())
1055                .map(str::to_string)
1056            else {
1057                continue;
1058            };
1059            let summary = fs::read_to_string(session_dir.join("summary.json"))
1060                .ok()
1061                .and_then(|text| serde_json::from_str::<Value>(&text).ok());
1062            let title = summary
1063                .as_ref()
1064                .and_then(|value| value.get("generated_title"))
1065                .and_then(Value::as_str)
1066                .filter(|title| !title.is_empty())
1067                .map(str::to_string);
1068            let model = summary
1069                .as_ref()
1070                .and_then(|value| value.get("current_model_id"))
1071                .and_then(Value::as_str)
1072                .map(str::to_string);
1073            let message_count = summary
1074                .as_ref()
1075                .and_then(|value| value.get("num_chat_messages"))
1076                .and_then(Value::as_u64)
1077                .and_then(|count| usize::try_from(count).ok());
1078            let updated_at_ms = summary
1079                .as_ref()
1080                .and_then(|value| value.get("updated_at"))
1081                .and_then(Value::as_str)
1082                .and_then(crate::sidecar::rfc3339_to_ms)
1083                .and_then(|millis| u64::try_from(millis).ok())
1084                .or_else(|| modified_ms(&transcript));
1085            found.push(SessionDescriptor {
1086                locator: SessionLocator {
1087                    harness: HarnessId::from(HarnessId::GROK),
1088                    session_id,
1089                    storage: StorageLocator::File { path: transcript },
1090                },
1091                cwd: Some(cwd.clone()),
1092                title,
1093                preview_candidates: Vec::new(),
1094                latest_message_candidates: Vec::new(),
1095                updated_at_ms,
1096                message_count,
1097                model,
1098            });
1099        }
1100    }
1101}
1102
1103fn discover_opencode(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
1104    let mut dbs = Vec::new();
1105    if root.is_file() {
1106        dbs.push(root.to_path_buf());
1107    } else if let Ok(entries) = fs::read_dir(root) {
1108        dbs.extend(entries.flatten().map(|entry| entry.path()).filter(|path| {
1109            path.file_name()
1110                .and_then(|v| v.to_str())
1111                .is_some_and(|name| name.starts_with("opencode") && name.ends_with(".db"))
1112        }));
1113    }
1114    dbs.sort();
1115    for db in dbs {
1116        let Ok(conn) = Connection::open_with_flags(
1117            &db,
1118            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
1119        ) else {
1120            continue;
1121        };
1122        let has_model = conn.prepare("SELECT model FROM session LIMIT 0").is_ok();
1123        let model_column = if has_model { "s.model" } else { "NULL" };
1124        let query = format!(
1125            "SELECT s.id, s.directory, s.title, s.time_updated, {model_column}, COUNT(m.id) \
1126             FROM session s LEFT JOIN message m ON m.session_id = s.id \
1127             GROUP BY s.id ORDER BY s.time_updated DESC"
1128        );
1129        let Ok(mut stmt) = conn.prepare(&query) else {
1130            continue;
1131        };
1132        let Ok(rows) = stmt.query_map([], |row| {
1133            Ok((
1134                row.get::<_, String>(0)?,
1135                row.get::<_, String>(1)?,
1136                row.get::<_, String>(2)?,
1137                row.get::<_, i64>(3)?,
1138                row.get::<_, Option<String>>(4)?,
1139                row.get::<_, i64>(5)?,
1140            ))
1141        }) else {
1142            continue;
1143        };
1144        for row in rows.flatten() {
1145            let (id, cwd, title, updated, model, messages) = row;
1146            let cwd = PathBuf::from(cwd);
1147            if workspace.is_some_and(|wanted| !recorded_cwd_matches(&cwd, wanted)) {
1148                continue;
1149            }
1150            found.push(SessionDescriptor {
1151                locator: SessionLocator {
1152                    harness: HarnessId::from(HarnessId::OPENCODE),
1153                    session_id: id.clone(),
1154                    storage: StorageLocator::Sqlite {
1155                        path: db.clone(),
1156                        selector: id,
1157                    },
1158                },
1159                cwd: Some(cwd),
1160                title: (!title.is_empty()).then_some(title),
1161                preview_candidates: Vec::new(),
1162                latest_message_candidates: Vec::new(),
1163                updated_at_ms: u64::try_from(updated).ok(),
1164                message_count: usize::try_from(messages).ok(),
1165                model,
1166            });
1167        }
1168    }
1169}
1170
1171fn discover_goose(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
1172    let db = if root.is_file() {
1173        root.to_path_buf()
1174    } else if root.join("sessions.db").is_file() {
1175        root.join("sessions.db")
1176    } else {
1177        root.join("sessions/sessions.db")
1178    };
1179    let Ok(connection) = Connection::open_with_flags(
1180        &db,
1181        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
1182    ) else {
1183        return;
1184    };
1185    let Ok(mut statement) = connection.prepare(
1186        "SELECT s.id, s.working_dir, s.name, s.updated_at, s.model_config_json, \
1187                COUNT(m.id) \
1188         FROM sessions s LEFT JOIN messages m ON m.session_id = s.id \
1189         WHERE s.archived_at IS NULL \
1190         GROUP BY s.id ORDER BY s.updated_at DESC",
1191    ) else {
1192        return;
1193    };
1194    let Ok(rows) = statement.query_map([], |row| {
1195        Ok((
1196            row.get::<_, String>(0)?,
1197            row.get::<_, String>(1)?,
1198            row.get::<_, String>(2)?,
1199            row.get::<_, String>(3)?,
1200            row.get::<_, Option<String>>(4)?,
1201            row.get::<_, i64>(5)?,
1202        ))
1203    }) else {
1204        return;
1205    };
1206    for row in rows.flatten() {
1207        let (id, cwd, title, updated_at, model_config, message_count) = row;
1208        let cwd = PathBuf::from(cwd);
1209        if workspace.is_some_and(|wanted| !recorded_cwd_matches(&cwd, wanted)) {
1210            continue;
1211        }
1212        let model = model_config
1213            .as_deref()
1214            .and_then(|value| serde_json::from_str::<Value>(value).ok())
1215            .and_then(|value| {
1216                value
1217                    .get("model_name")
1218                    .or_else(|| value.get("modelName"))
1219                    .and_then(Value::as_str)
1220                    .map(str::to_string)
1221            });
1222        let updated_at_ms = crate::sidecar::rfc3339_to_ms(&updated_at)
1223            .or_else(|| {
1224                // SQLite's CURRENT_TIMESTAMP uses `YYYY-MM-DD HH:MM:SS`.
1225                crate::sidecar::rfc3339_to_ms(&format!("{}Z", updated_at.replace(' ', "T")))
1226            })
1227            .and_then(|value| u64::try_from(value).ok());
1228        found.push(SessionDescriptor {
1229            locator: SessionLocator {
1230                harness: HarnessId::from(HarnessId::GOOSE),
1231                session_id: id.clone(),
1232                storage: StorageLocator::Sqlite {
1233                    path: db.clone(),
1234                    selector: id,
1235                },
1236            },
1237            cwd: Some(cwd),
1238            title: (!title.trim().is_empty()).then_some(title),
1239            preview_candidates: Vec::new(),
1240            latest_message_candidates: Vec::new(),
1241            updated_at_ms,
1242            message_count: usize::try_from(message_count).ok(),
1243            model,
1244        });
1245    }
1246}
1247
1248const LATEST_PREVIEW_CANDIDATES: usize = 8;
1249const TOPIC_PREVIEW_HEAD_BYTES: u64 = 512 * 1024;
1250const LATEST_PREVIEW_TAIL_BYTES: u64 = 512 * 1024;
1251const LATEST_PREVIEW_MAX_BYTES: u64 = 4 * 1024 * 1024;
1252
1253fn topic_message_candidates(locator: &SessionLocator) -> Result<Vec<SessionPreviewCandidate>> {
1254    match &locator.storage {
1255        StorageLocator::File { path }
1256            if matches!(
1257                locator.harness.as_str(),
1258                HarnessId::CLAUDE_CODE | HarnessId::CODEX
1259            ) =>
1260        {
1261            topic_file_message_candidates(path, locator.harness.as_str())
1262        }
1263        _ => Ok(Vec::new()),
1264    }
1265}
1266
1267fn codex_history_topics(
1268    sessions_root: &Path,
1269    sessions: &[SessionDescriptor],
1270) -> Result<HashMap<String, Vec<SessionPreviewCandidate>>> {
1271    let wanted: HashSet<&str> = sessions
1272        .iter()
1273        .filter(|descriptor| descriptor.locator.harness.as_str() == HarnessId::CODEX)
1274        .map(|descriptor| descriptor.locator.session_id.as_str())
1275        .collect();
1276    if wanted.is_empty() {
1277        return Ok(HashMap::new());
1278    }
1279    let Some(root) = sessions_root.parent() else {
1280        return Ok(HashMap::new());
1281    };
1282    let file = match File::open(root.join("history.jsonl")) {
1283        Ok(file) => file,
1284        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(HashMap::new()),
1285        Err(error) => return Err(error.into()),
1286    };
1287    let mut topics = HashMap::new();
1288    for line in BufReader::new(file).lines() {
1289        let Ok(value) = serde_json::from_str::<Value>(&line?) else {
1290            continue;
1291        };
1292        let Some(session_id) = value.get("session_id").and_then(Value::as_str) else {
1293            continue;
1294        };
1295        if !wanted.contains(session_id) || topics.contains_key(session_id) {
1296            continue;
1297        }
1298        let mut candidates = Vec::new();
1299        push_message_candidate(&mut candidates, "user", value.get("text"), HashMap::new());
1300        if !candidates.is_empty() {
1301            topics.insert(session_id.to_string(), candidates);
1302            if topics.len() == wanted.len() {
1303                break;
1304            }
1305        }
1306    }
1307    Ok(topics)
1308}
1309
1310fn latest_message_candidates(locator: &SessionLocator) -> Result<Vec<SessionPreviewCandidate>> {
1311    match &locator.storage {
1312        StorageLocator::File { path } => {
1313            latest_file_message_candidates(path, locator.harness.as_str())
1314        }
1315        StorageLocator::Sqlite { path, selector }
1316            if locator.harness.as_str() == HarnessId::OPENCODE =>
1317        {
1318            latest_opencode_message_candidates(path, selector)
1319        }
1320        StorageLocator::Sqlite { path, selector }
1321            if locator.harness.as_str() == HarnessId::GOOSE =>
1322        {
1323            latest_goose_message_candidates(path, selector)
1324        }
1325        StorageLocator::Sqlite { .. } => Ok(Vec::new()),
1326    }
1327}
1328
1329fn topic_file_message_candidates(
1330    path: &Path,
1331    harness: &str,
1332) -> Result<Vec<SessionPreviewCandidate>> {
1333    let mut file = File::open(path)?;
1334    let mut bytes = Vec::with_capacity(TOPIC_PREVIEW_HEAD_BYTES as usize);
1335    file.by_ref()
1336        .take(TOPIC_PREVIEW_HEAD_BYTES)
1337        .read_to_end(&mut bytes)?;
1338    if file.metadata()?.len() > TOPIC_PREVIEW_HEAD_BYTES {
1339        if let Some(newline) = bytes.iter().rposition(|byte| *byte == b'\n') {
1340            bytes.truncate(newline);
1341        }
1342    }
1343    let text = String::from_utf8(bytes).map_err(|_| {
1344        Error::Other(format!(
1345            "{} contains non-UTF-8 data in its topic-preview window",
1346            path.display()
1347        ))
1348    })?;
1349    let mut candidates = Vec::new();
1350    for line in text.lines() {
1351        let Ok(value) = serde_json::from_str::<Value>(line) else {
1352            continue;
1353        };
1354        push_topic_message_candidate(&mut candidates, harness, &value);
1355        if candidates.len() >= LATEST_PREVIEW_CANDIDATES {
1356            break;
1357        }
1358    }
1359    Ok(candidates)
1360}
1361
1362fn latest_file_message_candidates(
1363    path: &Path,
1364    harness: &str,
1365) -> Result<Vec<SessionPreviewCandidate>> {
1366    let mut candidates =
1367        latest_file_message_candidates_with_limit(path, harness, LATEST_PREVIEW_TAIL_BYTES)?;
1368    if candidates.is_empty() {
1369        candidates =
1370            latest_file_message_candidates_with_limit(path, harness, LATEST_PREVIEW_MAX_BYTES)?;
1371    }
1372    Ok(candidates)
1373}
1374
1375fn latest_file_message_candidates_with_limit(
1376    path: &Path,
1377    harness: &str,
1378    byte_limit: u64,
1379) -> Result<Vec<SessionPreviewCandidate>> {
1380    let mut file = File::open(path)?;
1381    let file_len = file.metadata()?.len();
1382    let start = file_len.saturating_sub(byte_limit);
1383    file.seek(SeekFrom::Start(start))?;
1384    let mut bytes = Vec::with_capacity((file_len - start) as usize);
1385    file.read_to_end(&mut bytes)?;
1386    if start > 0 {
1387        if let Some(newline) = bytes.iter().position(|byte| *byte == b'\n') {
1388            bytes.drain(..=newline);
1389        } else {
1390            return Ok(Vec::new());
1391        }
1392    }
1393    let text = String::from_utf8(bytes).map_err(|_| {
1394        Error::Other(format!(
1395            "{} contains non-UTF-8 data in its list-preview window",
1396            path.display()
1397        ))
1398    })?;
1399    let mut candidates = Vec::new();
1400    for line in text.lines().rev() {
1401        let Ok(value) = serde_json::from_str::<Value>(line) else {
1402            continue;
1403        };
1404        let (role, content, metadata) = match harness {
1405            HarnessId::CLAUDE_CODE => {
1406                let role = value.get("type").and_then(Value::as_str);
1407                if !matches!(role, Some("user" | "assistant")) {
1408                    continue;
1409                }
1410                let metadata = if role == Some("user") {
1411                    crate::session::claude_user_provenance(&value)
1412                        .into_iter()
1413                        .collect()
1414                } else {
1415                    HashMap::new()
1416                };
1417                (
1418                    role.unwrap_or_default(),
1419                    value
1420                        .get("message")
1421                        .and_then(|message| message.get("content")),
1422                    metadata,
1423                )
1424            }
1425            HarnessId::CODEX => {
1426                let payload = value.get("payload").unwrap_or(&Value::Null);
1427                if value.get("type").and_then(Value::as_str) != Some("response_item")
1428                    || payload.get("type").and_then(Value::as_str) != Some("message")
1429                {
1430                    continue;
1431                }
1432                let Some(role @ ("user" | "assistant")) =
1433                    payload.get("role").and_then(Value::as_str)
1434                else {
1435                    continue;
1436                };
1437                (role, payload.get("content"), HashMap::new())
1438            }
1439            HarnessId::PI => {
1440                if value.get("type").and_then(Value::as_str) != Some("message") {
1441                    continue;
1442                }
1443                let message = value.get("message").unwrap_or(&Value::Null);
1444                let Some(role @ ("user" | "assistant")) =
1445                    message.get("role").and_then(Value::as_str)
1446                else {
1447                    continue;
1448                };
1449                (role, message.get("content"), HashMap::new())
1450            }
1451            HarnessId::GEMINI => {
1452                let Some(kind @ ("user" | "gemini")) = value.get("type").and_then(Value::as_str)
1453                else {
1454                    continue;
1455                };
1456                (
1457                    if kind == "gemini" {
1458                        "assistant"
1459                    } else {
1460                        "user"
1461                    },
1462                    value.get("content"),
1463                    HashMap::new(),
1464                )
1465            }
1466            HarnessId::GROK => {
1467                let Some(role @ ("user" | "assistant")) = value.get("type").and_then(Value::as_str)
1468                else {
1469                    continue;
1470                };
1471                (role, value.get("content"), HashMap::new())
1472            }
1473            HarnessId::SUPERCODE => {
1474                let Some(role @ ("user" | "assistant")) = value.get("role").and_then(Value::as_str)
1475                else {
1476                    continue;
1477                };
1478                (role, value.get("content"), HashMap::new())
1479            }
1480            _ => continue,
1481        };
1482        let mut metadata = metadata;
1483        if matches!(harness, HarnessId::CLAUDE_CODE | HarnessId::CODEX) {
1484            if let Some(timestamp) = value.get("timestamp").and_then(Value::as_str) {
1485                metadata.insert("timestamp".to_string(), timestamp.to_string());
1486            }
1487        }
1488        push_message_candidate(&mut candidates, role, content, metadata);
1489        if candidates.len() >= LATEST_PREVIEW_CANDIDATES {
1490            break;
1491        }
1492    }
1493    Ok(candidates)
1494}
1495
1496fn push_topic_message_candidate(
1497    candidates: &mut Vec<SessionPreviewCandidate>,
1498    harness: &str,
1499    value: &Value,
1500) {
1501    let (role, content, metadata) = match harness {
1502        HarnessId::CLAUDE_CODE => {
1503            let role = value.get("type").and_then(Value::as_str);
1504            if !matches!(role, Some("user" | "assistant")) {
1505                return;
1506            }
1507            let metadata = if role == Some("user") {
1508                crate::session::claude_user_provenance(value)
1509                    .into_iter()
1510                    .collect()
1511            } else {
1512                HashMap::new()
1513            };
1514            (
1515                role.unwrap_or_default(),
1516                value
1517                    .get("message")
1518                    .and_then(|message| message.get("content")),
1519                metadata,
1520            )
1521        }
1522        HarnessId::CODEX => {
1523            let payload = value.get("payload").unwrap_or(&Value::Null);
1524            if value.get("type").and_then(Value::as_str) != Some("response_item")
1525                || payload.get("type").and_then(Value::as_str) != Some("message")
1526            {
1527                return;
1528            }
1529            let Some(role @ ("user" | "assistant")) = payload.get("role").and_then(Value::as_str)
1530            else {
1531                return;
1532            };
1533            (role, payload.get("content"), HashMap::new())
1534        }
1535        _ => return,
1536    };
1537    push_message_candidate(candidates, role, content, metadata);
1538}
1539
1540fn latest_opencode_message_candidates(
1541    path: &Path,
1542    session_id: &str,
1543) -> Result<Vec<SessionPreviewCandidate>> {
1544    let connection = Connection::open_with_flags(
1545        path,
1546        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
1547    )
1548    .map_err(|error| Error::Other(format!("failed to open list-preview store: {error}")))?;
1549    let mut statement = connection
1550        .prepare(
1551            "SELECT m.data, p.data FROM message m JOIN part p ON p.message_id = m.id \
1552         WHERE m.session_id = ?1 ORDER BY m.time_created DESC, p.time_created DESC LIMIT 32",
1553        )
1554        .map_err(|error| Error::Other(format!("failed to prepare list-preview query: {error}")))?;
1555    let rows = statement
1556        .query_map([session_id], |row| {
1557            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1558        })
1559        .map_err(|error| Error::Other(format!("failed to read list-preview rows: {error}")))?;
1560    let mut candidates = Vec::new();
1561    for row in rows.flatten() {
1562        let (Ok(message), Ok(part)) = (
1563            serde_json::from_str::<Value>(&row.0),
1564            serde_json::from_str::<Value>(&row.1),
1565        ) else {
1566            continue;
1567        };
1568        let Some(role @ ("user" | "assistant")) = message.get("role").and_then(Value::as_str)
1569        else {
1570            continue;
1571        };
1572        if part.get("type").and_then(Value::as_str) != Some("text") {
1573            continue;
1574        }
1575        push_message_candidate(&mut candidates, role, part.get("text"), HashMap::new());
1576        if candidates.len() >= LATEST_PREVIEW_CANDIDATES {
1577            break;
1578        }
1579    }
1580    Ok(candidates)
1581}
1582
1583fn latest_goose_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 role, content_json FROM messages WHERE session_id = ?1 \
1595         ORDER BY created_timestamp DESC, id DESC LIMIT 16",
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 (role, content) = row;
1606        if !matches!(role.as_str(), "user" | "assistant") {
1607            continue;
1608        }
1609        let Ok(content) = serde_json::from_str::<Value>(&content) else {
1610            continue;
1611        };
1612        push_message_candidate(&mut candidates, &role, Some(&content), HashMap::new());
1613        if candidates.len() >= LATEST_PREVIEW_CANDIDATES {
1614            break;
1615        }
1616    }
1617    Ok(candidates)
1618}
1619
1620fn push_message_candidate(
1621    candidates: &mut Vec<SessionPreviewCandidate>,
1622    role: &str,
1623    content: Option<&Value>,
1624    metadata: HashMap<String, String>,
1625) {
1626    if candidates.len() >= LATEST_PREVIEW_CANDIDATES {
1627        return;
1628    }
1629    let Some(text) = display_text(content).filter(|text| !text.trim().is_empty()) else {
1630        return;
1631    };
1632    const MAX_CHARS: usize = 4_096;
1633    candidates.push(SessionPreviewCandidate {
1634        role: role.to_string(),
1635        content: text.chars().take(MAX_CHARS).collect(),
1636        metadata,
1637    });
1638}
1639
1640fn fill_string(target: &mut Option<String>, value: Option<&Value>) {
1641    if target.is_none() {
1642        *target = value.and_then(Value::as_str).map(str::to_owned);
1643    }
1644}
1645
1646fn fill_path(target: &mut Option<PathBuf>, value: Option<&Value>) {
1647    if target.is_none() {
1648        *target = value.and_then(Value::as_str).map(PathBuf::from);
1649    }
1650}
1651
1652fn modified_ms(path: &Path) -> Option<u64> {
1653    fs::metadata(path)
1654        .ok()?
1655        .modified()
1656        .ok()?
1657        .duration_since(UNIX_EPOCH)
1658        .ok()
1659        .and_then(|duration| u64::try_from(duration.as_millis()).ok())
1660}
1661
1662/// A workspace filter is satisfiable only by a session whose RECORDED working
1663/// directory is absolute. A relative recorded cwd (OpenCode has shipped
1664/// literal `"."` session rows) carries no information about where the session
1665/// ran; resolving it against the discoverer's own current directory made such
1666/// a session match every workspace discovery happened to run from.
1667fn recorded_cwd_matches(recorded: &Path, wanted: &Path) -> bool {
1668    recorded.is_absolute() && same_path(recorded, wanted)
1669}
1670
1671fn same_path(left: &Path, right: &Path) -> bool {
1672    match (fs::canonicalize(left), fs::canonicalize(right)) {
1673        (Ok(left), Ok(right)) => left == right,
1674        _ => normalize_path(left) == normalize_path(right),
1675    }
1676}
1677
1678fn normalize_path(path: &Path) -> PathBuf {
1679    let absolute = if path.is_absolute() {
1680        path.to_path_buf()
1681    } else {
1682        std::env::current_dir()
1683            .unwrap_or_else(|_| PathBuf::from("."))
1684            .join(path)
1685    };
1686    let mut normalized = PathBuf::new();
1687    for component in absolute.components() {
1688        match component {
1689            Component::CurDir => {}
1690            Component::ParentDir => {
1691                normalized.pop();
1692            }
1693            other => normalized.push(other.as_os_str()),
1694        }
1695    }
1696    normalized
1697}
1698
1699#[cfg(test)]
1700mod tests {
1701    use super::*;
1702    use std::time::{SystemTime, UNIX_EPOCH};
1703
1704    fn temp_dir(label: &str) -> PathBuf {
1705        let nonce = SystemTime::now()
1706            .duration_since(UNIX_EPOCH)
1707            .unwrap()
1708            .as_nanos();
1709        let path = std::env::temp_dir().join(format!(
1710            "supercode-catalog-{label}-{}-{nonce}",
1711            std::process::id()
1712        ));
1713        fs::create_dir_all(&path).unwrap();
1714        path
1715    }
1716
1717    #[test]
1718    fn locator_json_round_trip_preserves_sqlite_selector() {
1719        let locator = SessionLocator {
1720            harness: HarnessId::from(HarnessId::OPENCODE),
1721            session_id: "ses_123".into(),
1722            storage: StorageLocator::Sqlite {
1723                path: PathBuf::from("/tmp/opencode-dev.db"),
1724                selector: "ses_123".into(),
1725            },
1726        };
1727        let encoded = serde_json::to_string(&locator).unwrap();
1728        assert_eq!(
1729            serde_json::from_str::<SessionLocator>(&encoded).unwrap(),
1730            locator
1731        );
1732    }
1733
1734    #[test]
1735    fn discovers_filters_loads_and_follows_three_jsonl_harnesses() {
1736        let root = temp_dir("jsonl");
1737        let workspace = root.join("workspace");
1738        let other = root.join("other");
1739        fs::create_dir_all(&workspace).unwrap();
1740        fs::create_dir_all(&other).unwrap();
1741
1742        let claude = root.join("claude");
1743        let codex = root.join("codex");
1744        let pi = root.join("pi");
1745        fs::create_dir_all(&claude).unwrap();
1746        fs::create_dir_all(&codex).unwrap();
1747        fs::create_dir_all(&pi).unwrap();
1748        fs::write(
1749            claude.join("claude.jsonl"),
1750            format!(
1751                "{{\"type\":\"user\",\"sessionId\":\"cc-1\",\"cwd\":{},\"timestamp\":\"2026-01-01T00:00:01Z\",\"message\":{{\"role\":\"user\",\"content\":\"hi\"}}}}\n",
1752                serde_json::to_string(&workspace.to_string_lossy()).unwrap()
1753            ),
1754        )
1755        .unwrap();
1756        fs::write(
1757            codex.join("rollout.jsonl"),
1758            format!(
1759                "{{\"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",
1760                serde_json::to_string(&workspace.to_string_lossy()).unwrap()
1761            ),
1762        )
1763        .unwrap();
1764        fs::write(
1765            pi.join("pi.jsonl"),
1766            format!(
1767                "{{\"type\":\"session\",\"version\":3,\"id\":\"pi-1\",\"timestamp\":\"2026-01-01T00:00:00Z\",\"cwd\":{}}}\n{{\"type\":\"message\",\"message\":{{\"role\":\"user\",\"content\":\"inspect pi\"}}}}\n",
1768                serde_json::to_string(&workspace.to_string_lossy()).unwrap()
1769            ),
1770        )
1771        .unwrap();
1772        fs::write(
1773            pi.join("unrelated.jsonl"),
1774            format!(
1775                "{{\"type\":\"session\",\"version\":3,\"id\":\"pi-2\",\"timestamp\":\"2026-01-01T00:00:00Z\",\"cwd\":{}}}\n",
1776                serde_json::to_string(&other.to_string_lossy()).unwrap()
1777            ),
1778        )
1779        .unwrap();
1780        fs::write(claude.join("partial.jsonl"), "{truncated").unwrap();
1781
1782        let query = DiscoveryQuery {
1783            workspace: Some(workspace),
1784            homes: HarnessHomes {
1785                claude_code: claude,
1786                codex,
1787                pi,
1788                opencode: root.join("missing-opencode"),
1789                grok: root.join("missing-grok"),
1790                gemini: root.join("missing-gemini"),
1791                goose: root.join("missing-goose"),
1792                supercode: root.join("missing-supercode"),
1793            },
1794            ..DiscoveryQuery::default()
1795        };
1796        let catalog = HarnessCatalog::new();
1797        let found = catalog.discover(&query).unwrap();
1798        assert_eq!(found.len(), 3);
1799        assert_eq!(
1800            found
1801                .iter()
1802                .map(|item| item.locator.harness.as_str())
1803                .collect::<HashSet<_>>(),
1804            HashSet::from([HarnessId::CLAUDE_CODE, HarnessId::CODEX, HarnessId::PI])
1805        );
1806        for descriptor in found {
1807            assert!(descriptor.preview_candidates.is_empty());
1808            assert_eq!(descriptor.latest_message_candidates.len(), 1);
1809            assert_eq!(descriptor.latest_message_candidates[0].role, "user");
1810            if descriptor.locator.harness.as_str() == HarnessId::CLAUDE_CODE {
1811                assert_eq!(
1812                    descriptor.latest_message_candidates[0]
1813                        .metadata
1814                        .get("timestamp")
1815                        .map(String::as_str),
1816                    Some("2026-01-01T00:00:01Z")
1817                );
1818            } else if descriptor.locator.harness.as_str() == HarnessId::CODEX {
1819                assert_eq!(
1820                    descriptor.latest_message_candidates[0]
1821                        .metadata
1822                        .get("timestamp")
1823                        .map(String::as_str),
1824                    Some("2026-01-01T00:00:02Z")
1825                );
1826            }
1827            let loaded = catalog.load(&descriptor.locator).unwrap();
1828            assert_eq!(
1829                loaded.meta.session_id.as_deref(),
1830                Some(descriptor.locator.session_id.as_str())
1831            );
1832            let mut follower = catalog.follow(&descriptor.locator).unwrap();
1833            assert!(matches!(
1834                follower.poll().unwrap(),
1835                Some(crate::SessionWatchEvent::SessionSnapshot { .. })
1836            ));
1837        }
1838        fs::remove_dir_all(root).ok();
1839    }
1840
1841    #[test]
1842    fn discovers_loads_and_follows_opencode_sqlite() {
1843        let db = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1844            .join("../harness/tests/fixtures/opencode_fixture/opencode.db");
1845        let catalog = HarnessCatalog::new();
1846        let found = catalog
1847            .discover(&DiscoveryQuery {
1848                harnesses: vec![HarnessId::from(HarnessId::OPENCODE)],
1849                homes: HarnessHomes {
1850                    opencode: db,
1851                    ..HarnessHomes::default()
1852                },
1853                ..DiscoveryQuery::default()
1854            })
1855            .unwrap();
1856        assert!(!found.is_empty());
1857        for descriptor in found {
1858            assert_eq!(descriptor.locator.harness.as_str(), HarnessId::OPENCODE);
1859            assert_eq!(
1860                catalog.load(&descriptor.locator).unwrap().meta.session_id,
1861                Some(descriptor.locator.session_id.clone())
1862            );
1863            assert!(catalog.follow(&descriptor.locator).is_ok());
1864        }
1865    }
1866
1867    #[test]
1868    fn discovers_loads_and_follows_gemini_conversation_records() {
1869        let root = temp_dir("gemini");
1870        let workspace = root.join("workspace");
1871        let chats = root.join("gemini/tmp/demo/chats");
1872        fs::create_dir_all(&workspace).unwrap();
1873        fs::create_dir_all(&chats).unwrap();
1874        fs::write(
1875            root.join("gemini/projects.json"),
1876            serde_json::json!({
1877                "projects": {workspace.to_string_lossy(): "demo"}
1878            })
1879            .to_string(),
1880        )
1881        .unwrap();
1882        let transcript = chats.join("gemini-id.jsonl");
1883        fs::write(
1884            &transcript,
1885            include_str!("../../harness/tests/fixtures/gemini_session.jsonl"),
1886        )
1887        .unwrap();
1888
1889        let catalog = HarnessCatalog::new();
1890        let found = catalog
1891            .discover(&DiscoveryQuery {
1892                harnesses: vec![HarnessId::from(HarnessId::GEMINI)],
1893                homes: HarnessHomes {
1894                    gemini: root.join("gemini"),
1895                    ..HarnessHomes::default()
1896                },
1897                workspace: Some(workspace.clone()),
1898                ..DiscoveryQuery::default()
1899            })
1900            .unwrap();
1901
1902        assert_eq!(found.len(), 1);
1903        assert_eq!(found[0].cwd.as_deref(), Some(workspace.as_path()));
1904        assert_eq!(found[0].message_count, None);
1905        assert_eq!(found[0].model.as_deref(), Some("gemini-2.5-pro"));
1906        assert_eq!(found[0].title, None);
1907        assert!(found[0].preview_candidates.is_empty());
1908        assert_eq!(found[0].latest_message_candidates.len(), 3);
1909        assert_eq!(
1910            found[0].latest_message_candidates[0].content,
1911            "Fixture inspected."
1912        );
1913        let loaded = catalog.load(&found[0].locator).unwrap();
1914        assert_eq!(
1915            loaded.meta.session_id.as_deref(),
1916            Some("11111111-1111-4111-8111-111111111111")
1917        );
1918        assert_eq!(loaded.messages.len(), 4);
1919        assert!(matches!(
1920            catalog.follow(&found[0].locator).unwrap().poll().unwrap(),
1921            Some(crate::SessionWatchEvent::SessionSnapshot { .. })
1922        ));
1923        fs::remove_dir_all(root).ok();
1924    }
1925
1926    #[test]
1927    fn discovers_native_store_and_pages_search_results() {
1928        let root = temp_dir("supercode");
1929        let store_root = root.join("sessions");
1930        fs::create_dir_all(&store_root).unwrap();
1931        for (name, title) in [
1932            ("alpha", "Alpha planning"),
1933            ("beta", "Beta implementation"),
1934            ("gamma", "Gamma review"),
1935        ] {
1936            fs::write(
1937                store_root.join(format!("{name}.jsonl")),
1938                format!("{{\"role\":\"user\",\"content\":\"{title}\"}}\n"),
1939            )
1940            .unwrap();
1941            fs::write(
1942                store_root.join(format!("{name}.meta.json")),
1943                serde_json::json!({"name": name, "title": title}).to_string(),
1944            )
1945            .unwrap();
1946        }
1947        let catalog = HarnessCatalog::new();
1948        let base = DiscoveryQuery {
1949            harnesses: vec![HarnessId::from(HarnessId::SUPERCODE)],
1950            homes: HarnessHomes {
1951                supercode: store_root,
1952                ..HarnessHomes::default()
1953            },
1954            limit: Some(1),
1955            ..DiscoveryQuery::default()
1956        };
1957
1958        let first = catalog.discover_page(&base).unwrap();
1959        assert_eq!(first.sessions.len(), 1);
1960        assert!(first.next_cursor.is_some());
1961        let second = catalog
1962            .discover_page(&DiscoveryQuery {
1963                cursor: first.next_cursor,
1964                ..base.clone()
1965            })
1966            .unwrap();
1967        assert_eq!(second.sessions.len(), 1);
1968        assert_ne!(
1969            first.sessions[0].locator.session_id,
1970            second.sessions[0].locator.session_id
1971        );
1972        let search = catalog
1973            .discover_page(&DiscoveryQuery {
1974                limit: None,
1975                query: Some("implementation".into()),
1976                ..base
1977            })
1978            .unwrap();
1979        assert_eq!(search.sessions.len(), 1);
1980        assert_eq!(search.sessions[0].locator.session_id, "beta");
1981        assert_eq!(search.sessions[0].message_count, None);
1982        assert_eq!(
1983            catalog
1984                .load(&search.sessions[0].locator)
1985                .unwrap()
1986                .messages
1987                .len(),
1988            1
1989        );
1990        fs::remove_dir_all(root).ok();
1991    }
1992
1993    #[test]
1994    fn discovers_current_opencode_schema_without_a_session_model_column() {
1995        let root = temp_dir("opencode-current");
1996        let db = root.join("opencode.db");
1997        let conn = Connection::open(&db).unwrap();
1998        conn.execute_batch(
1999            "CREATE TABLE session (
2000                id TEXT PRIMARY KEY,
2001                directory TEXT NOT NULL,
2002                title TEXT NOT NULL,
2003                time_updated INTEGER NOT NULL
2004             );
2005             CREATE TABLE message (
2006                id TEXT PRIMARY KEY,
2007                session_id TEXT NOT NULL
2008             );
2009             INSERT INTO session VALUES ('ses_current', '/tmp/work', 'Current', 42);
2010             INSERT INTO message VALUES ('msg_current', 'ses_current');",
2011        )
2012        .unwrap();
2013        drop(conn);
2014
2015        let found = HarnessCatalog::new()
2016            .discover(&DiscoveryQuery {
2017                harnesses: vec![HarnessId::from(HarnessId::OPENCODE)],
2018                homes: HarnessHomes {
2019                    opencode: db,
2020                    ..HarnessHomes::default()
2021                },
2022                ..DiscoveryQuery::default()
2023            })
2024            .unwrap();
2025
2026        assert_eq!(found.len(), 1);
2027        assert_eq!(found[0].locator.session_id, "ses_current");
2028        assert_eq!(found[0].message_count, Some(1));
2029        assert_eq!(found[0].model, None);
2030        fs::remove_dir_all(root).ok();
2031    }
2032
2033    #[test]
2034    fn workspace_filter_never_matches_a_relative_recorded_cwd() {
2035        // OpenCode has shipped session rows whose `directory` is the literal
2036        // ".". Resolving that against the discoverer's own cwd made the
2037        // session match every workspace discovery ran from — the workspace
2038        // here IS the test process cwd, the exact aliasing that leaked.
2039        let root = temp_dir("opencode-relative-cwd");
2040        let db = root.join("opencode.db");
2041        let conn = Connection::open(&db).unwrap();
2042        let here = std::env::current_dir().unwrap();
2043        conn.execute_batch(&format!(
2044            "CREATE TABLE session (
2045                id TEXT PRIMARY KEY,
2046                directory TEXT NOT NULL,
2047                title TEXT NOT NULL,
2048                time_updated INTEGER NOT NULL
2049             );
2050             CREATE TABLE message (
2051                id TEXT PRIMARY KEY,
2052                session_id TEXT NOT NULL
2053             );
2054             INSERT INTO session VALUES ('ses_relative', '.', 'Ghost', 41);
2055             INSERT INTO session VALUES ('ses_here', '{}', 'Real', 42);",
2056            here.display()
2057        ))
2058        .unwrap();
2059        drop(conn);
2060
2061        let found = HarnessCatalog::new()
2062            .discover(&DiscoveryQuery {
2063                workspace: Some(here),
2064                harnesses: vec![HarnessId::from(HarnessId::OPENCODE)],
2065                homes: HarnessHomes {
2066                    opencode: db,
2067                    ..HarnessHomes::default()
2068                },
2069                ..DiscoveryQuery::default()
2070            })
2071            .unwrap();
2072
2073        assert_eq!(found.len(), 1);
2074        assert_eq!(found[0].locator.session_id, "ses_here");
2075        fs::remove_dir_all(root).ok();
2076    }
2077}