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        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    // The native session id lives on line one. A small decoration budget keeps
882    // the common title/model case without turning 1,800 sessions into a
883    // sequential 60 MiB read before the list can render.
884    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
983/// Read only the bounded native envelope needed by discovery. Loading a
984/// sidecar-backed session here used to deserialize the complete byte-lossless
985/// transcript family, making a workspace list proportional to every saved
986/// Supercode transcript on the machine.
987fn 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                // SQLite's CURRENT_TIMESTAMP uses `YYYY-MM-DD HH:MM:SS`.
1268                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
1705/// A workspace filter is satisfiable only by a session whose RECORDED working
1706/// directory is absolute. A relative recorded cwd (OpenCode has shipped
1707/// literal `"."` session rows) carries no information about where the session
1708/// ran; resolving it against the discoverer's own current directory made such
1709/// a session match every workspace discovery happened to run from.
1710fn 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            // A full native-family parse rejects this trailing residue. Header
2076            // discovery must not touch it after it has enough metadata.
2077            "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        // OpenCode has shipped session rows whose `directory` is the literal
2148        // ".". Resolving that against the discoverer's own cwd made the
2149        // session match every workspace discovery ran from — the workspace
2150        // here IS the test process cwd, the exact aliasing that leaked.
2151        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}