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