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