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