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