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::{BTreeSet, 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::ontology::{Binding, OrchestratorBindingRow};
18use crate::session::{
19    hermes_capture_nouns, openclaw_agent_id_from_path, openclaw_capture_header_nouns,
20    percent_decode_path, OrchestrationNouns, SessionMeta, SessionSource,
21};
22use crate::{Error, Fidelity, Result, Session, SessionFollower};
23
24pub use crate::ontology::HarnessId;
25
26/// Durable storage address for a persisted session.
27#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
28#[serde(tag = "kind", rename_all = "snake_case")]
29pub enum StorageLocator {
30    /// One session stored in one file.
31    File {
32        /// Absolute or caller-resolvable path to the transcript.
33        path: PathBuf,
34    },
35    /// One logical session selected from a SQLite store.
36    Sqlite {
37        /// Path to the SQLite database.
38        path: PathBuf,
39        /// Harness-native stable selector, currently an OpenCode session id.
40        selector: String,
41    },
42}
43
44impl StorageLocator {
45    /// Return the underlying file or database path.
46    pub fn path(&self) -> &Path {
47        match self {
48            Self::File { path } | Self::Sqlite { path, .. } => path,
49        }
50    }
51}
52
53/// Stable identity for a persisted harness session.
54#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
55pub struct SessionLocator {
56    /// Harness which owns the storage format.
57    pub harness: HarnessId,
58    /// Harness-native session identity.
59    pub session_id: String,
60    /// Exact storage address needed to load the session again.
61    pub storage: StorageLocator,
62}
63
64/// Lightweight metadata returned by catalog discovery.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct SessionDescriptor {
67    /// Durable address accepted by [`HarnessCatalog::load`] and
68    /// [`HarnessCatalog::follow`].
69    pub locator: SessionLocator,
70    /// Working directory recorded by the harness.
71    pub cwd: Option<PathBuf>,
72    /// Harness-provided title, when cheaply available.
73    pub title: Option<String>,
74    /// Oldest-first bounded conversation messages for a fallback topic when
75    /// the harness does not publish a useful title. These are read only for
76    /// the returned page and interpreted by the same presentation projection
77    /// as an opened conversation.
78    #[serde(default, skip_serializing_if = "Vec::is_empty")]
79    pub preview_candidates: Vec<SessionPreviewCandidate>,
80    /// Newest-first bounded conversation messages for compact list previews.
81    /// These are read only for the returned page, never for the entire
82    /// catalog, and are interpreted by the same presentation projection as
83    /// an opened conversation.
84    #[serde(default, skip_serializing_if = "Vec::is_empty")]
85    pub latest_message_candidates: Vec<SessionPreviewCandidate>,
86    /// Last update time as Unix epoch milliseconds.
87    pub updated_at_ms: Option<u64>,
88    /// Harness message-record count, when available without loading the session.
89    /// STORED message-record count in the native store (cheap line scan) —
90    /// for branched record-tree dialects this can exceed the active-path
91    /// message count a full load renders; `inspect` labels both (SUP-58).
92    pub message_count: Option<usize>,
93    /// Model recorded in lightweight session metadata.
94    pub model: Option<String>,
95    /// Direct parent session for a harness-native child rollout. Ordinary
96    /// conversation lists exclude these children, while tree/fidelity callers
97    /// can request them explicitly without losing the native relationship.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub parent_session_id: Option<String>,
100    /// Number of proven harness-native descendants represented by this root.
101    /// Child identities remain behind the trusted catalog boundary until a
102    /// caller explicitly requests this session family.
103    #[serde(default, skip_serializing_if = "is_zero")]
104    pub child_session_count: usize,
105    /// ORCH-6: the ORCH-3 conversation nouns (`trigger`, `surface`,
106    /// `profile`, `recurrence`, `cross_surface`, `workspace`), flattened onto
107    /// the row so the wire stays additive. Filled by `finalize_nouns` for
108    /// every harness; only Hermes and OpenClaw publish more than the
109    /// `trigger`/`workspace` defaults today.
110    #[serde(flatten)]
111    pub nouns: OrchestrationNouns,
112}
113
114/// One bounded normalized conversation-message candidate for list projection.
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
116pub struct SessionPreviewCandidate {
117    /// Opaque identity of this native message boundary. Consumers may retain
118    /// it to reconcile bounded discovery windows without treating a growing
119    /// preview or a native-store heartbeat as a new conversation message.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub cursor: Option<String>,
122    /// Canonical conversation role. Older clients may assume `user` when this
123    /// field is absent from an older server.
124    pub role: String,
125    /// Canonical text content.
126    pub content: String,
127    /// Canonical provenance used by the normal conversation visibility rules.
128    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
129    pub metadata: HashMap<String, String>,
130}
131
132/// One stable newest-first discovery page.
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134pub struct DiscoveryPage {
135    /// Sessions in this page.
136    pub sessions: Vec<SessionDescriptor>,
137    /// Opaque cursor for the next page, or `None` at the end.
138    pub next_cursor: Option<String>,
139    /// Coverage receipt (UNI-7 / SUP-54): what was asked and what came back,
140    /// so incomplete coverage can never read as complete.
141    #[serde(default)]
142    pub receipt: DiscoveryReceipt,
143}
144
145/// Coverage evidence for one discovery page.
146#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
147#[serde(default)]
148pub struct DiscoveryReceipt {
149    /// The requested lower time bound (epoch ms), when one was given.
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub requested_after_ms: Option<u64>,
152    /// The requested upper time bound (epoch ms), when one was given.
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub requested_before_ms: Option<u64>,
155    /// The requested page limit, when one was given.
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub requested_limit: Option<usize>,
158    /// Oldest `updated_at_ms` among the RETURNED sessions.
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub oldest_returned_ms: Option<u64>,
161    /// Newest `updated_at_ms` among the RETURNED sessions.
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub newest_returned_ms: Option<u64>,
164    /// Sessions in this page.
165    pub returned: usize,
166    /// Sessions matching the query across ALL pages.
167    pub total_matched: usize,
168    /// True when matches beyond this page exist (`next_cursor` is the resume
169    /// point). Explicit so a consumer cannot mistake a capped page for the
170    /// complete result set.
171    pub truncated: bool,
172}
173
174/// Append-aware index of the stable topic records in Codex `history.jsonl`.
175///
176/// Long-lived session-list clients can retain this index and refresh it after
177/// filesystem invalidations. Ordinary appends read and parse only the new
178/// bytes; truncation, replacement, and in-place rewrites rebuild the index so
179/// the result remains identical to a fresh catalog discovery.
180#[derive(Debug)]
181pub struct CodexHistoryTopicIndex {
182    path: PathBuf,
183    fingerprint: Option<CodexHistoryFingerprint>,
184    offset: u64,
185    trailing: Vec<u8>,
186    topics: HashMap<String, Vec<SessionPreviewCandidate>>,
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190struct CodexHistoryFingerprint {
191    len: u64,
192    modified_ns: u128,
193    identity: u128,
194}
195
196impl CodexHistoryTopicIndex {
197    /// Create an empty index for the Codex sessions root from
198    /// [`HarnessHomes::codex`]. Call [`Self::refresh`] before first use.
199    pub fn new(sessions_root: &Path) -> Self {
200        let root = sessions_root.parent().unwrap_or(sessions_root);
201        Self {
202            path: root.join("history.jsonl"),
203            fingerprint: None,
204            offset: 0,
205            trailing: Vec::new(),
206            topics: HashMap::new(),
207        }
208    }
209
210    /// Return the native history file watched by this index.
211    pub fn path(&self) -> &Path {
212        &self.path
213    }
214
215    /// Refresh from durable state and return session ids whose topic changed.
216    ///
217    /// A missing file is a valid empty history. Read errors leave the previous
218    /// successful index intact so a transient filesystem error cannot erase
219    /// topics from a live session list.
220    pub fn refresh(&mut self) -> Result<BTreeSet<String>> {
221        let metadata = match fs::metadata(&self.path) {
222            Ok(metadata) => metadata,
223            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
224                let changed = self.topics.keys().cloned().collect();
225                self.fingerprint = None;
226                self.offset = 0;
227                self.trailing.clear();
228                self.topics.clear();
229                return Ok(changed);
230            }
231            Err(error) => return Err(error.into()),
232        };
233        let fingerprint = codex_history_fingerprint(&metadata)?;
234        if self.fingerprint == Some(fingerprint) {
235            return Ok(BTreeSet::new());
236        }
237
238        let is_append = self.fingerprint.is_some_and(|previous| {
239            previous.identity == fingerprint.identity
240                && previous.len < fingerprint.len
241                && self.offset <= previous.len
242        });
243        if is_append {
244            let mut file = File::open(&self.path)?;
245            file.seek(SeekFrom::Start(self.offset))?;
246            let mut bytes = Vec::with_capacity(
247                usize::try_from(fingerprint.len.saturating_sub(self.offset)).unwrap_or(0),
248            );
249            file.read_to_end(&mut bytes)?;
250            self.offset = file.stream_position()?;
251            let changed = self.ingest(bytes);
252            self.fingerprint = Some(CodexHistoryFingerprint {
253                len: self.offset,
254                ..fingerprint
255            });
256            return Ok(changed);
257        }
258
259        let previous = std::mem::take(&mut self.topics);
260        let mut file = File::open(&self.path)?;
261        let mut bytes = Vec::with_capacity(usize::try_from(fingerprint.len).unwrap_or(0));
262        file.read_to_end(&mut bytes)?;
263        self.offset = file.stream_position()?;
264        self.trailing.clear();
265        self.ingest(bytes);
266        self.fingerprint = Some(CodexHistoryFingerprint {
267            len: self.offset,
268            ..fingerprint
269        });
270        Ok(changed_topic_ids(&previous, &self.topics))
271    }
272
273    fn ingest(&mut self, bytes: Vec<u8>) -> BTreeSet<String> {
274        let mut input = std::mem::take(&mut self.trailing);
275        input.extend(bytes);
276        let complete_len = input
277            .iter()
278            .rposition(|byte| *byte == b'\n')
279            .map_or(0, |index| index + 1);
280        let mut changed = BTreeSet::new();
281        for line in input[..complete_len].split(|byte| *byte == b'\n') {
282            self.ingest_line(line, &mut changed);
283        }
284        self.trailing.extend_from_slice(&input[complete_len..]);
285        if !self.trailing.is_empty() {
286            let trailing = self.trailing.clone();
287            if self.ingest_line(&trailing, &mut changed) {
288                self.trailing.clear();
289            }
290        }
291        changed
292    }
293
294    fn ingest_line(&mut self, line: &[u8], changed: &mut BTreeSet<String>) -> bool {
295        let Ok(value) = serde_json::from_slice::<Value>(line) else {
296            return false;
297        };
298        let Some(session_id) = value.get("session_id").and_then(Value::as_str) else {
299            return true;
300        };
301        if self.topics.contains_key(session_id) {
302            return true;
303        }
304        let mut candidates = Vec::new();
305        push_message_candidate(&mut candidates, "user", value.get("text"), HashMap::new());
306        if !candidates.is_empty() {
307            self.topics.insert(session_id.to_string(), candidates);
308            changed.insert(session_id.to_string());
309        }
310        true
311    }
312}
313
314/// Configurable session roots for the built-in harnesses.
315#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
316#[serde(default)]
317pub struct HarnessHomes {
318    /// Directory containing Claude Code project session directories.
319    pub claude_code: PathBuf,
320    /// Directory containing Codex rollout sessions.
321    pub codex: PathBuf,
322    /// Directory containing Pi project session directories.
323    pub pi: PathBuf,
324    /// OpenCode data root, or an explicit `opencode*.db` path.
325    pub opencode: PathBuf,
326    /// Grok session root containing percent-encoded workspace directories.
327    pub grok: PathBuf,
328    /// Gemini CLI configuration root containing `projects.json` and `tmp/`.
329    pub gemini: PathBuf,
330    /// Goose `sessions.db`, or a directory containing it.
331    pub goose: PathBuf,
332    /// Supercode's native saved-session directory.
333    pub supercode: PathBuf,
334    /// OpenClaw home root (contains `agents/<agentId>/sessions/*.jsonl`,
335    /// openclaw >= 2026.7 — plain pi-v3 dialect files; see
336    /// `SessionSource::OpenClaw`). READ-ONLY discovery (UNI-16).
337    pub openclaw: PathBuf,
338    /// Hermes `state.db` SQLite store path (see `SessionSource::Hermes`).
339    /// READ-ONLY discovery (UNI-15).
340    pub hermes: PathBuf,
341    /// The orchestrator's home FOLDER (`SUPERCODE_ORCHESTRATOR_HOME`, default
342    /// `~/.supercode/orchestrator`). Unlike every other entry this addresses a
343    /// directory, because the orchestrator's serialization IS its folder
344    /// (`docs/ORCHESTRATOR-IR.md` §6): the root is the `default` profile and
345    /// `profiles/<name>/` are the named ones. READ-ONLY (ORC-7).
346    pub orchestrator: PathBuf,
347}
348
349/// Every profile folder under an orchestrator home, in listing order: the
350/// root (the implicit `default` profile) then each `profiles/<name>/`.
351///
352/// One helper, used by every reader that answers `--harness orchestrator`, so
353/// the folder layout of `docs/ORCHESTRATOR-IR.md` §6 is stated once.
354pub fn orchestrator_profile_dirs(root: &Path) -> Vec<(String, PathBuf)> {
355    if !root.is_dir() {
356        return Vec::new();
357    }
358    let mut dirs = vec![("default".to_string(), root.to_path_buf())];
359    if let Ok(entries) = fs::read_dir(root.join("profiles")) {
360        let mut named: Vec<(String, PathBuf)> = entries
361            .flatten()
362            .filter(|entry| entry.path().is_dir())
363            .filter_map(|entry| {
364                entry
365                    .file_name()
366                    .into_string()
367                    .ok()
368                    .map(|name| (name, entry.path()))
369            })
370            // A dot-prefixed folder is never a profile: `profiles/.trash/`
371            // holds the homes `profiles.delete` moved aside (ORC-13), and the
372            // package's own loader skips it for the same reason.
373            .filter(|(name, _)| !name.starts_with('.'))
374            .collect();
375        named.sort();
376        dirs.extend(named);
377    }
378    dirs
379}
380
381impl Default for HarnessHomes {
382    fn default() -> Self {
383        let home = std::env::var_os("HOME")
384            .map(PathBuf::from)
385            .unwrap_or_else(|| PathBuf::from("."));
386        let claude_root = std::env::var_os("CLAUDE_CONFIG_DIR")
387            .map(PathBuf::from)
388            .unwrap_or_else(|| home.join(".claude"));
389        let codex_root = std::env::var_os("CODEX_HOME")
390            .map(PathBuf::from)
391            .unwrap_or_else(|| home.join(".codex"));
392        let pi = std::env::var_os("PI_CODING_AGENT_SESSION_DIR")
393            .map(PathBuf::from)
394            .unwrap_or_else(|| {
395                std::env::var_os("PI_CODING_AGENT_DIR")
396                    .map(PathBuf::from)
397                    .unwrap_or_else(|| home.join(".pi/agent"))
398                    .join("sessions")
399            });
400        let opencode = std::env::var_os("OPENCODE_DB")
401            .map(PathBuf::from)
402            .unwrap_or_else(|| {
403                std::env::var_os("XDG_DATA_HOME")
404                    .map(PathBuf::from)
405                    .unwrap_or_else(|| home.join(".local/share"))
406                    .join("opencode")
407            });
408        let grok = std::env::var_os("GROK_HOME")
409            .map(PathBuf::from)
410            .unwrap_or_else(|| home.join(".grok"))
411            .join("sessions");
412        let gemini = std::env::var_os("GEMINI_CLI_HOME")
413            .map(PathBuf::from)
414            .unwrap_or_else(|| home.join(".gemini"));
415        // Blind-walk finding 2026-08-31: openclaw itself treats
416        // OPENCLAW_HOME as a HOME replacement (state lives at
417        // `$OPENCLAW_HOME/.openclaw`), and names the state dir directly with
418        // OPENCLAW_STATE_DIR. Mirror those semantics exactly so an inherited
419        // environment means the same thing to us and to any openclaw process
420        // we spawn.
421        let openclaw = std::env::var_os("OPENCLAW_STATE_DIR")
422            .map(PathBuf::from)
423            .or_else(|| {
424                std::env::var_os("OPENCLAW_HOME").map(|root| PathBuf::from(root).join(".openclaw"))
425            })
426            .unwrap_or_else(|| home.join(".openclaw"));
427        let orchestrator = std::env::var_os("SUPERCODE_ORCHESTRATOR_HOME")
428            .map(PathBuf::from)
429            .unwrap_or_else(|| home.join(".supercode/orchestrator"));
430        let hermes = std::env::var_os("HERMES_HOME")
431            .map(PathBuf::from)
432            .unwrap_or_else(|| home.join(".hermes"))
433            .join("state.db");
434        let goose = std::env::var_os("GOOSE_PATH_ROOT")
435            .map(PathBuf::from)
436            .map(|root| root.join("data/sessions/sessions.db"))
437            .unwrap_or_else(|| {
438                #[cfg(target_os = "macos")]
439                {
440                    home.join("Library/Application Support/Block/goose/sessions/sessions.db")
441                }
442                #[cfg(target_os = "windows")]
443                {
444                    std::env::var_os("APPDATA")
445                        .map(PathBuf::from)
446                        .unwrap_or_else(|| home.join("AppData/Roaming"))
447                        .join("Block/goose/sessions/sessions.db")
448                }
449                #[cfg(not(any(target_os = "macos", target_os = "windows")))]
450                {
451                    std::env::var_os("XDG_DATA_HOME")
452                        .map(PathBuf::from)
453                        .unwrap_or_else(|| home.join(".local/share"))
454                        .join("goose/sessions/sessions.db")
455                }
456            });
457        let supercode = std::env::var_os("SUPERCODE_HOME")
458            .map(PathBuf::from)
459            .unwrap_or_else(|| {
460                std::env::var_os("XDG_CONFIG_HOME")
461                    .map(PathBuf::from)
462                    .unwrap_or_else(|| home.join(".config"))
463                    .join("supercode")
464            })
465            .join("sessions");
466        Self {
467            claude_code: claude_root.join("projects"),
468            codex: codex_root.join("sessions"),
469            gemini,
470            goose,
471            supercode,
472            openclaw,
473            hermes,
474            orchestrator,
475            pi,
476            opencode,
477            grok,
478        }
479    }
480}
481
482fn codex_history_fingerprint(metadata: &fs::Metadata) -> Result<CodexHistoryFingerprint> {
483    let modified_ns = metadata
484        .modified()?
485        .duration_since(UNIX_EPOCH)
486        .map_err(|error| Error::Other(format!("history timestamp predates Unix epoch: {error}")))?
487        .as_nanos();
488    #[cfg(unix)]
489    let identity = {
490        use std::os::unix::fs::MetadataExt;
491        (u128::from(metadata.dev()) << 64) | u128::from(metadata.ino())
492    };
493    #[cfg(not(unix))]
494    let identity = 0;
495    Ok(CodexHistoryFingerprint {
496        len: metadata.len(),
497        modified_ns,
498        identity,
499    })
500}
501
502fn changed_topic_ids(
503    before: &HashMap<String, Vec<SessionPreviewCandidate>>,
504    after: &HashMap<String, Vec<SessionPreviewCandidate>>,
505) -> BTreeSet<String> {
506    before
507        .keys()
508        .chain(after.keys())
509        .filter(|session_id| before.get(*session_id) != after.get(*session_id))
510        .cloned()
511        .collect()
512}
513
514/// Filters and roots used for one catalog scan.
515#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
516#[serde(default)]
517pub struct DiscoveryQuery {
518    /// Only return sessions whose recorded working directory is this path.
519    pub workspace: Option<PathBuf>,
520    /// Only return sessions whose recorded working directory belongs to the
521    /// same REPOSITORY FAMILY as this path (UNI-7 / SUP-54 decision): the
522    /// family identity is the realpath of the git common directory, so a main
523    /// checkout and all its worktrees join as one family; the origin URL is
524    /// the tie-breaker that also joins separate clones of the same remote. A
525    /// non-repository path degrades to exact-realpath matching. Combines with
526    /// `workspace` as AND when both are set (exact-cwd stays available).
527    pub workspace_family: Option<PathBuf>,
528    /// Only return sessions updated at or after this epoch-ms instant.
529    pub updated_after_ms: Option<u64>,
530    /// Only return sessions updated at or before this epoch-ms instant.
531    pub updated_before_ms: Option<u64>,
532    /// Harnesses to scan. Empty means all built-ins.
533    pub harnesses: Vec<HarnessId>,
534    /// Storage roots to scan.
535    pub homes: HarnessHomes,
536    /// Case-insensitive search over harness, id, title, workspace, and model.
537    pub query: Option<String>,
538    /// Opaque cursor returned by a prior [`HarnessCatalog::discover_page`].
539    pub cursor: Option<String>,
540    /// Maximum number of results after newest-first sorting.
541    pub limit: Option<usize>,
542    /// Include oldest-first bounded topic candidates for harnesses whose
543    /// native store does not publish a useful title. Off by default because
544    /// topics are stable and list clients can retain them across refreshes.
545    pub include_topic_candidates: bool,
546    /// Include harness-native child rollouts such as Codex subagents. Off by
547    /// default because they are parts of a parent conversation, not chats the
548    /// user independently started. Translation/tree callers can opt in.
549    pub include_child_sessions: bool,
550    /// Restrict an explicit child-inclusive discovery to one root and every
551    /// descendant linked to it by native lineage. Applied before pagination.
552    pub root_session_id: Option<String>,
553    /// ORCH-6: only return sessions routed through this config home (Hermes
554    /// `profile_name` / gateway key namespace, OpenClaw agent id). Exact
555    /// match; a session with no profile never matches. `harnesses` is the
556    /// harness filter and needs no second spelling.
557    pub profile: Option<String>,
558}
559
560/// Repository-family identity (UNI-7 / SUP-54 decision): the realpath of the
561/// git common directory joins a main checkout with all its worktrees, and the
562/// origin URL is the tie-breaker that also joins separate clones of the same
563/// remote. Resolved from the filesystem alone (`.git` file/dir + config), no
564/// subprocess, so discovery stays deterministic and sandbox-friendly.
565#[derive(Debug, Clone, PartialEq, Eq)]
566struct RepoFamily {
567    /// Realpath of the git common directory, or the realpath of the queried
568    /// path itself when it is not inside a git repository.
569    identity: PathBuf,
570    /// True when `identity` is a git common directory (not a bare-path
571    /// degradation) — only then may origin tie-breaking apply.
572    is_repository: bool,
573    /// `[remote "origin"] url` from the common directory's config, if any.
574    origin_url: Option<String>,
575}
576
577impl RepoFamily {
578    fn of(path: &Path) -> Self {
579        let start = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
580        let mut current = Some(start.as_path());
581        while let Some(dir) = current {
582            let dot_git = dir.join(".git");
583            if dot_git.is_dir() {
584                let identity = std::fs::canonicalize(&dot_git).unwrap_or_else(|_| dot_git.clone());
585                let origin_url = read_origin_url(&identity);
586                return Self {
587                    identity,
588                    is_repository: true,
589                    origin_url,
590                };
591            }
592            if dot_git.is_file() {
593                // Worktree checkout: `.git` is a one-line pointer file
594                // `gitdir: <main>/.git/worktrees/<name>`; the common directory
595                // is everything before `/worktrees/<name>`.
596                if let Ok(text) = std::fs::read_to_string(&dot_git) {
597                    if let Some(gitdir) = text
598                        .lines()
599                        .find_map(|line| line.trim().strip_prefix("gitdir:"))
600                    {
601                        let gitdir = PathBuf::from(gitdir.trim());
602                        let gitdir = if gitdir.is_absolute() {
603                            gitdir
604                        } else {
605                            dir.join(gitdir)
606                        };
607                        let common = gitdir
608                            .parent()
609                            .filter(|parent| parent.ends_with("worktrees"))
610                            .and_then(Path::parent)
611                            .map(Path::to_path_buf)
612                            .unwrap_or(gitdir);
613                        let identity = std::fs::canonicalize(&common).unwrap_or(common);
614                        let origin_url = read_origin_url(&identity);
615                        return Self {
616                            identity,
617                            is_repository: true,
618                            origin_url,
619                        };
620                    }
621                }
622            }
623            current = dir.parent();
624        }
625        Self {
626            identity: start,
627            is_repository: false,
628            origin_url: None,
629        }
630    }
631
632    /// Two paths join one family when their common directories match, or —
633    /// for genuine repositories only — when both declare the same origin URL
634    /// (the clone tie-breaker). Bare-path degradations never origin-match.
635    fn joins(&self, other: &Self) -> bool {
636        if self.identity == other.identity {
637            return true;
638        }
639        self.is_repository
640            && other.is_repository
641            && matches!((&self.origin_url, &other.origin_url), (Some(a), Some(b)) if a == b)
642    }
643}
644
645/// Minimal git-config scan for `[remote "origin"] url = ...`.
646fn read_origin_url(common_dir: &Path) -> Option<String> {
647    let text = std::fs::read_to_string(common_dir.join("config")).ok()?;
648    let mut in_origin = false;
649    for line in text.lines() {
650        let line = line.trim();
651        if line.starts_with('[') {
652            in_origin = line.starts_with("[remote \"origin\"]");
653            continue;
654        }
655        if in_origin {
656            if let Some(value) = line.strip_prefix("url") {
657                let value = value.trim_start();
658                if let Some(url) = value.strip_prefix('=') {
659                    let url = url.trim();
660                    if !url.is_empty() {
661                        return Some(url.to_string());
662                    }
663                }
664            }
665        }
666    }
667    None
668}
669
670/// Read-only entry point for discovering, loading, and following persisted
671/// harness sessions.
672#[derive(Debug, Default, Clone, Copy)]
673pub struct HarnessCatalog;
674
675impl HarnessCatalog {
676    /// Construct a catalog. It holds no cache or global mutable state.
677    pub fn new() -> Self {
678        Self
679    }
680
681    /// Discover sessions using lightweight headers/indexes rather than full
682    /// transcript normalization. Malformed or concurrently-created entries
683    /// are skipped without aborting the rest of the scan.
684    pub fn discover(&self, query: &DiscoveryQuery) -> Result<Vec<SessionDescriptor>> {
685        Ok(self.discover_page(query)?.sessions)
686    }
687
688    /// Scan file-backed session metadata without pagination or conversation
689    /// previews. Child sessions are retained so a long-lived caller can keep
690    /// an exact in-memory lineage index and project it without rescanning the
691    /// native stores.
692    pub fn discover_raw_index(&self, query: &DiscoveryQuery) -> Vec<SessionDescriptor> {
693        self.scan_descriptors(query, true)
694    }
695
696    /// Project a raw metadata index through the query's lineage, search, and
697    /// pagination rules without reading any transcript content.
698    pub fn project_index(
699        &self,
700        query: &DiscoveryQuery,
701        descriptors: impl IntoIterator<Item = SessionDescriptor>,
702    ) -> Result<Vec<SessionDescriptor>> {
703        let mut found = descriptors.into_iter().collect::<Vec<_>>();
704        project_descriptors(query, &mut found);
705        let (sessions, _) = paginate_descriptors(query, found)?;
706        Ok(sessions)
707    }
708
709    /// Add the bounded topic/latest-message previews used by list clients to
710    /// an already projected metadata page.
711    pub fn enrich_index_page(
712        &self,
713        query: &DiscoveryQuery,
714        mut sessions: Vec<SessionDescriptor>,
715    ) -> Result<Vec<SessionDescriptor>> {
716        enrich_descriptors(query, &mut sessions, None)?;
717        Ok(sessions)
718    }
719
720    /// Add list previews using a retained append-aware Codex history index.
721    ///
722    /// Results are identical to [`Self::enrich_index_page`], while a
723    /// long-lived caller avoids reparsing all of `history.jsonl` for every
724    /// active transcript write.
725    pub fn enrich_index_page_with_codex_history(
726        &self,
727        query: &DiscoveryQuery,
728        mut sessions: Vec<SessionDescriptor>,
729        codex_history: &CodexHistoryTopicIndex,
730    ) -> Result<Vec<SessionDescriptor>> {
731        enrich_descriptors(query, &mut sessions, Some(codex_history))?;
732        Ok(sessions)
733    }
734
735    /// Discover one stable page and return the cursor for its successor.
736    pub fn discover_page(&self, query: &DiscoveryQuery) -> Result<DiscoveryPage> {
737        let mut found = self.scan_descriptors(query, query.include_child_sessions);
738        project_descriptors(query, &mut found);
739        let total_matched = found.len();
740        let (mut sessions, next_cursor) = paginate_descriptors(query, found)?;
741        enrich_descriptors(query, &mut sessions, None)?;
742        let receipt = DiscoveryReceipt {
743            requested_after_ms: query.updated_after_ms,
744            requested_before_ms: query.updated_before_ms,
745            requested_limit: query.limit,
746            oldest_returned_ms: sessions.iter().filter_map(|s| s.updated_at_ms).min(),
747            newest_returned_ms: sessions.iter().filter_map(|s| s.updated_at_ms).max(),
748            returned: sessions.len(),
749            total_matched,
750            truncated: next_cursor.is_some(),
751        };
752        Ok(DiscoveryPage {
753            sessions,
754            next_cursor,
755            receipt,
756        })
757    }
758
759    fn scan_descriptors(
760        &self,
761        query: &DiscoveryQuery,
762        include_child_sessions: bool,
763    ) -> Vec<SessionDescriptor> {
764        let selected: HashSet<&str> = if query.harnesses.is_empty() {
765            [
766                HarnessId::CLAUDE_CODE,
767                HarnessId::CODEX,
768                HarnessId::PI,
769                HarnessId::OPENCODE,
770                HarnessId::GROK,
771                HarnessId::GEMINI,
772                HarnessId::GOOSE,
773                HarnessId::SUPERCODE,
774                HarnessId::OPENCLAW,
775                HarnessId::HERMES,
776                HarnessId::ORCHESTRATOR,
777            ]
778            .into_iter()
779            .collect()
780        } else {
781            query.harnesses.iter().map(HarnessId::as_str).collect()
782        };
783        let mut found = Vec::new();
784        if selected.contains(HarnessId::CLAUDE_CODE) {
785            discover_jsonl(
786                &query.homes.claude_code,
787                HarnessId::CLAUDE_CODE,
788                query.workspace.as_deref(),
789                include_child_sessions,
790                &mut found,
791            );
792        }
793        if selected.contains(HarnessId::CODEX) {
794            discover_jsonl(
795                &query.homes.codex,
796                HarnessId::CODEX,
797                query.workspace.as_deref(),
798                include_child_sessions,
799                &mut found,
800            );
801        }
802        if selected.contains(HarnessId::PI) {
803            discover_jsonl(
804                &query.homes.pi,
805                HarnessId::PI,
806                query.workspace.as_deref(),
807                include_child_sessions,
808                &mut found,
809            );
810        }
811        if selected.contains(HarnessId::OPENCODE) {
812            discover_opencode(
813                &query.homes.opencode,
814                query.workspace.as_deref(),
815                &mut found,
816            );
817        }
818        if selected.contains(HarnessId::GROK) {
819            discover_grok(&query.homes.grok, query.workspace.as_deref(), &mut found);
820        }
821        if selected.contains(HarnessId::GEMINI) {
822            discover_gemini(&query.homes.gemini, query.workspace.as_deref(), &mut found);
823        }
824        if selected.contains(HarnessId::GOOSE) {
825            discover_goose(&query.homes.goose, query.workspace.as_deref(), &mut found);
826        }
827        if selected.contains(HarnessId::OPENCLAW) {
828            discover_openclaw(
829                &query.homes.openclaw,
830                query.workspace.as_deref(),
831                &mut found,
832            );
833        }
834        if selected.contains(HarnessId::HERMES) {
835            discover_hermes(&query.homes.hermes, query.workspace.as_deref(), &mut found);
836        }
837        if selected.contains(HarnessId::ORCHESTRATOR) {
838            discover_orchestrator(
839                &query.homes.orchestrator,
840                query.workspace.as_deref(),
841                &mut found,
842            );
843        }
844        if selected.contains(HarnessId::SUPERCODE) {
845            discover_supercode(
846                &query.homes.supercode,
847                query.workspace.as_deref(),
848                &mut found,
849            );
850        }
851        for descriptor in &mut found {
852            finalize_nouns(descriptor);
853        }
854        found
855    }
856
857    /// Refresh one file-backed descriptor without rescanning its native store.
858    ///
859    /// This is the incremental counterpart to [`Self::discover_page`]: a
860    /// filesystem notification is only an invalidation hint, so callers
861    /// re-read the durable file and derive the complete current descriptor.
862    /// `None` means the path disappeared or no longer contains a recognizable
863    /// session. SQLite-backed harnesses retain their indexed discovery path.
864    pub fn refresh_file_descriptor(
865        &self,
866        locator: &SessionLocator,
867        workspace: Option<&Path>,
868        include_topic_candidates: bool,
869    ) -> Result<Option<SessionDescriptor>> {
870        let Some(mut descriptor) = self.refresh_file_index_descriptor(locator, workspace)? else {
871            return Ok(None);
872        };
873        if include_topic_candidates {
874            descriptor.preview_candidates =
875                topic_message_candidates(&descriptor.locator).unwrap_or_default();
876        }
877        descriptor.latest_message_candidates =
878            latest_message_candidates(&descriptor.locator).unwrap_or_default();
879        Ok(Some(descriptor))
880    }
881
882    /// Refresh only stable list metadata for one file-backed descriptor.
883    /// This avoids reading conversation preview windows for background index
884    /// maintenance.
885    pub fn refresh_file_index_descriptor(
886        &self,
887        locator: &SessionLocator,
888        workspace: Option<&Path>,
889    ) -> Result<Option<SessionDescriptor>> {
890        let StorageLocator::File { path } = &locator.storage else {
891            return Ok(None);
892        };
893        if !matches!(
894            locator.harness.as_str(),
895            HarnessId::CLAUDE_CODE | HarnessId::CODEX
896        ) {
897            return Ok(None);
898        }
899        if !path.is_file() {
900            return Ok(None);
901        }
902        let Ok(meta) = read_header(path, locator.harness.as_str()) else {
903            // Harnesses append the header and first turn non-atomically. A
904            // transiently incomplete new file is not a service error; the
905            // next native event or reconciliation pass will retry it.
906            return Ok(None);
907        };
908        if workspace.is_some_and(|wanted| {
909            meta.cwd
910                .as_deref()
911                .is_none_or(|cwd| !recorded_cwd_matches(cwd, wanted))
912        }) {
913            return Ok(None);
914        }
915        let parent_session_id = meta.parent_session_id.or_else(|| {
916            (locator.harness.as_str() == HarnessId::CLAUDE_CODE)
917                .then(|| claude_subagent_parent_id(path))
918                .flatten()
919        });
920        let descriptor = SessionDescriptor {
921            locator: SessionLocator {
922                harness: locator.harness.clone(),
923                session_id: meta
924                    .session_id
925                    .unwrap_or_else(|| locator.session_id.clone()),
926                storage: StorageLocator::File { path: path.clone() },
927            },
928            cwd: meta.cwd,
929            title: meta.title,
930            preview_candidates: Vec::new(),
931            latest_message_candidates: Vec::new(),
932            updated_at_ms: modified_ms(path),
933            message_count: None,
934            model: meta.model,
935            parent_session_id,
936            child_session_count: 0,
937            nouns: OrchestrationNouns::default(),
938        };
939        Ok(Some(descriptor))
940    }
941
942    /// Load the complete normalized session named by a durable locator.
943    pub fn load(&self, locator: &SessionLocator) -> Result<Session> {
944        self.load_with_fidelity(locator, Fidelity::ByteLossless)
945    }
946
947    /// [`Self::load`] at a declared fidelity.
948    ///
949    /// Read-only surfaces (a session mirror, `follow`) pass
950    /// [`Fidelity::Semantic`] so a compacted transcript renders instead of
951    /// erroring; every continuation/transfer/export caller keeps the strict
952    /// default. See [`Session::load_with_fidelity`].
953    pub fn load_with_fidelity(
954        &self,
955        locator: &SessionLocator,
956        fidelity: Fidelity,
957    ) -> Result<Session> {
958        if let Some(session) = load_hermes_locator(locator) {
959            return session;
960        }
961        match &locator.storage {
962            StorageLocator::File { path } => {
963                if let Some(session) = load_native_store_family(path)? {
964                    Ok(session)
965                } else {
966                    Ok(Session::load_with_fidelity(path, fidelity)?)
967                }
968            }
969            StorageLocator::Sqlite { path, selector } => {
970                if locator.harness.as_str() == HarnessId::GOOSE {
971                    Ok(Session::from_goose_sqlite(path, selector)?)
972                } else {
973                    Ok(Session::from_opencode_sqlite(path, Some(selector))?)
974                }
975            }
976        }
977    }
978
979    /// Load the selected parent transcript without recursively attaching
980    /// Claude Code child sessions. This is the bounded frontend-view seam;
981    /// lossless operations continue to use [`Self::load_with_fidelity`].
982    #[doc(hidden)]
983    pub fn load_parent_with_fidelity(
984        &self,
985        locator: &SessionLocator,
986        fidelity: Fidelity,
987    ) -> Result<Session> {
988        if let Some(session) = load_hermes_locator(locator) {
989            return session;
990        }
991        match &locator.storage {
992            StorageLocator::File { path } => {
993                if let Some(session) = load_native_store_family(path)? {
994                    Ok(session)
995                } else {
996                    Ok(Session::load_parent_with_fidelity(path, fidelity)?)
997                }
998            }
999            StorageLocator::Sqlite { path, selector } => {
1000                if locator.harness.as_str() == HarnessId::GOOSE {
1001                    Ok(Session::from_goose_sqlite(path, selector)?)
1002                } else {
1003                    Ok(Session::from_opencode_sqlite(path, Some(selector))?)
1004                }
1005            }
1006        }
1007    }
1008
1009    /// Load bounded parent-only human-visible history. Codex compaction
1010    /// changes resumable context but does not erase earlier visible turns.
1011    #[doc(hidden)]
1012    pub fn load_display_view(
1013        &self,
1014        locator: &SessionLocator,
1015        fidelity: Fidelity,
1016        message_limit: usize,
1017    ) -> Result<Session> {
1018        if let Some(session) = load_hermes_locator(locator) {
1019            let mut session = session?;
1020            if session.messages.len() > message_limit.max(1) {
1021                session
1022                    .messages
1023                    .drain(..session.messages.len() - message_limit.max(1));
1024            }
1025            return Ok(session);
1026        }
1027        match &locator.storage {
1028            StorageLocator::File { path } => {
1029                if let Some(mut session) = load_native_store_family(path)? {
1030                    if session.messages.len() > message_limit.max(1) {
1031                        session
1032                            .messages
1033                            .drain(..session.messages.len() - message_limit.max(1));
1034                    }
1035                    Ok(session)
1036                } else {
1037                    Ok(Session::load_display_view(path, fidelity, message_limit)?)
1038                }
1039            }
1040            StorageLocator::Sqlite { path, selector } => {
1041                let mut session = if locator.harness.as_str() == HarnessId::GOOSE {
1042                    Session::from_goose_sqlite_display(path, selector, message_limit)?
1043                } else {
1044                    Session::from_opencode_sqlite(path, Some(selector))?
1045                };
1046                if session.messages.len() > message_limit.max(1) {
1047                    session
1048                        .messages
1049                        .drain(..session.messages.len() - message_limit.max(1));
1050                }
1051                Ok(session)
1052            }
1053        }
1054    }
1055
1056    /// Open a passive change-triggered follower for a durable locator.
1057    pub fn follow(&self, locator: &SessionLocator) -> Result<SessionFollower> {
1058        self.follow_with_fidelity(locator, Fidelity::ByteLossless)
1059    }
1060
1061    /// [`Self::follow`] at a declared fidelity — see [`Self::load_with_fidelity`].
1062    pub fn follow_with_fidelity(
1063        &self,
1064        locator: &SessionLocator,
1065        fidelity: Fidelity,
1066    ) -> Result<SessionFollower> {
1067        SessionFollower::open_locator_with_fidelity(locator, fidelity)
1068    }
1069
1070    /// Follow a read-only view with explicit child-tree and history bounds.
1071    #[doc(hidden)]
1072    pub fn follow_read_view(
1073        &self,
1074        locator: &SessionLocator,
1075        fidelity: Fidelity,
1076        include_subagents: bool,
1077        message_limit: Option<usize>,
1078        max_message_chars: Option<usize>,
1079        display_history: bool,
1080    ) -> Result<SessionFollower> {
1081        SessionFollower::open_locator_with_view(
1082            locator,
1083            fidelity,
1084            include_subagents,
1085            message_limit,
1086            max_message_chars,
1087            display_history,
1088        )
1089    }
1090}
1091
1092/// ORCH-6: a Hermes row's durable address is the pair `{state.db, session
1093/// id}` — one store holds every session — so the generic file loader (which
1094/// opens the store's most recent session) would answer with the WRONG
1095/// conversation for every discovered locator but one, and its nouns with it.
1096/// Route by the locator's own session id instead.
1097fn load_hermes_locator(locator: &SessionLocator) -> Option<Result<Session>> {
1098    if locator.harness.as_str() != HarnessId::HERMES {
1099        return None;
1100    }
1101    let StorageLocator::File { path } = &locator.storage else {
1102        return None;
1103    };
1104    Some(Session::from_hermes_sqlite(path, Some(&locator.session_id)))
1105}
1106
1107/// ORCH-6: resolve every discovered row's `trigger` and `workspace` through
1108/// ORCH-3's own derivation. Harness scanners fill only what their native store
1109/// states (`surface`, `profile`, `recurrence`, `cross_surface`, and an explicit
1110/// `trigger` when the source says); this pass rebuilds a `SessionMeta` from
1111/// those facts plus `cwd` and re-reads the nouns off it, so a discovered row
1112/// and a loaded session can never disagree about the same session.
1113fn finalize_nouns(descriptor: &mut SessionDescriptor) {
1114    let mut meta = SessionMeta::new(SessionSource::Native);
1115    meta.cwd = descriptor.cwd.clone();
1116    meta.trigger = descriptor.nouns.trigger;
1117    meta.surface = descriptor.nouns.surface.clone();
1118    meta.profile = descriptor.nouns.profile.clone();
1119    meta.recurrence = descriptor.nouns.recurrence.clone();
1120    meta.cross_surface = descriptor.nouns.cross_surface.clone();
1121    descriptor.nouns = OrchestrationNouns::from_meta(&meta);
1122}
1123
1124fn project_descriptors(query: &DiscoveryQuery, found: &mut Vec<SessionDescriptor>) {
1125    roll_up_session_children(found, query.include_child_sessions);
1126    if let Some(root_session_id) = query.root_session_id.as_deref() {
1127        retain_session_family(found, root_session_id);
1128    }
1129    if let Some(family_path) = query.workspace_family.as_deref() {
1130        let family = RepoFamily::of(family_path);
1131        let mut cache: HashMap<PathBuf, bool> = HashMap::new();
1132        found.retain(|descriptor| {
1133            let Some(cwd) = descriptor.cwd.as_deref() else {
1134                return false;
1135            };
1136            *cache
1137                .entry(cwd.to_path_buf())
1138                .or_insert_with(|| RepoFamily::of(cwd).joins(&family))
1139        });
1140    }
1141    if let Some(profile) = query
1142        .profile
1143        .as_deref()
1144        .map(str::trim)
1145        .filter(|p| !p.is_empty())
1146    {
1147        found.retain(|descriptor| descriptor.nouns.profile.as_deref() == Some(profile));
1148    }
1149    if let Some(after) = query.updated_after_ms {
1150        found.retain(|descriptor| descriptor.updated_at_ms.is_some_and(|at| at >= after));
1151    }
1152    if let Some(before) = query.updated_before_ms {
1153        found.retain(|descriptor| descriptor.updated_at_ms.is_some_and(|at| at <= before));
1154    }
1155    found.sort_by(|a, b| {
1156        b.updated_at_ms
1157            .cmp(&a.updated_at_ms)
1158            .then_with(|| a.locator.harness.cmp(&b.locator.harness))
1159            .then_with(|| a.locator.session_id.cmp(&b.locator.session_id))
1160    });
1161    if let Some(search) = query
1162        .query
1163        .as_deref()
1164        .map(str::trim)
1165        .filter(|query| !query.is_empty())
1166    {
1167        let search = search.to_lowercase();
1168        found.retain(|descriptor| descriptor_matches(descriptor, &search));
1169    }
1170}
1171
1172fn paginate_descriptors(
1173    query: &DiscoveryQuery,
1174    found: Vec<SessionDescriptor>,
1175) -> Result<(Vec<SessionDescriptor>, Option<String>)> {
1176    let start = match query.cursor.as_deref() {
1177        Some(cursor) => {
1178            let key = decode_cursor(cursor)?;
1179            found
1180                .iter()
1181                .position(|descriptor| descriptor_cursor_key(descriptor) == key)
1182                .map(|index| index + 1)
1183                .ok_or_else(|| Error::Other("discovery cursor is stale or invalid".into()))?
1184        }
1185        None => 0,
1186    };
1187    let end = query
1188        .limit
1189        .map(|limit| start.saturating_add(limit).min(found.len()))
1190        .unwrap_or(found.len());
1191    let sessions = found[start.min(found.len())..end].to_vec();
1192    let next_cursor = (end < found.len())
1193        .then(|| sessions.last().map(encode_cursor))
1194        .flatten();
1195    Ok((sessions, next_cursor))
1196}
1197
1198fn enrich_descriptors(
1199    query: &DiscoveryQuery,
1200    sessions: &mut [SessionDescriptor],
1201    codex_history: Option<&CodexHistoryTopicIndex>,
1202) -> Result<()> {
1203    let codex_topics = if query.include_topic_candidates && codex_history.is_none() {
1204        codex_history_topics(&query.homes.codex, sessions).unwrap_or_default()
1205    } else {
1206        HashMap::new()
1207    };
1208    for descriptor in sessions {
1209        if query.include_topic_candidates {
1210            descriptor.preview_candidates =
1211                if descriptor.locator.harness.as_str() == HarnessId::CODEX {
1212                    codex_history
1213                        .and_then(|history| history.topics.get(&descriptor.locator.session_id))
1214                        .or_else(|| codex_topics.get(&descriptor.locator.session_id))
1215                        .cloned()
1216                        .unwrap_or_else(|| {
1217                            topic_message_candidates(&descriptor.locator).unwrap_or_default()
1218                        })
1219                } else {
1220                    topic_message_candidates(&descriptor.locator).unwrap_or_default()
1221                };
1222        }
1223        descriptor.latest_message_candidates =
1224            latest_message_candidates(&descriptor.locator).unwrap_or_default();
1225    }
1226    Ok(())
1227}
1228
1229fn descriptor_matches(descriptor: &SessionDescriptor, search: &str) -> bool {
1230    [
1231        Some(descriptor.locator.harness.as_str()),
1232        Some(descriptor.locator.session_id.as_str()),
1233        descriptor.title.as_deref(),
1234        descriptor.cwd.as_ref().and_then(|path| path.to_str()),
1235        descriptor.model.as_deref(),
1236    ]
1237    .into_iter()
1238    .flatten()
1239    .any(|value| value.to_lowercase().contains(search))
1240}
1241
1242fn descriptor_cursor_key(descriptor: &SessionDescriptor) -> (Option<u64>, String, String) {
1243    (
1244        descriptor.updated_at_ms,
1245        descriptor.locator.harness.as_str().to_string(),
1246        descriptor.locator.session_id.clone(),
1247    )
1248}
1249
1250fn encode_cursor(descriptor: &SessionDescriptor) -> String {
1251    let json = serde_json::to_vec(&descriptor_cursor_key(descriptor)).unwrap_or_default();
1252    let mut encoded = String::with_capacity(json.len() * 2);
1253    for byte in json {
1254        use std::fmt::Write;
1255        let _ = write!(&mut encoded, "{byte:02x}");
1256    }
1257    encoded
1258}
1259
1260fn decode_cursor(cursor: &str) -> Result<(Option<u64>, String, String)> {
1261    if cursor.len() % 2 != 0 {
1262        return Err(Error::Other("discovery cursor is invalid".into()));
1263    }
1264    let bytes = (0..cursor.len())
1265        .step_by(2)
1266        .map(|index| u8::from_str_radix(&cursor[index..index + 2], 16))
1267        .collect::<std::result::Result<Vec<_>, _>>()
1268        .map_err(|_| Error::Other("discovery cursor is invalid".into()))?;
1269    serde_json::from_slice(&bytes).map_err(|_| Error::Other("discovery cursor is invalid".into()))
1270}
1271
1272#[derive(Default)]
1273struct HeaderMeta {
1274    session_id: Option<String>,
1275    cwd: Option<PathBuf>,
1276    title: Option<String>,
1277    model: Option<String>,
1278    parent_session_id: Option<String>,
1279}
1280
1281fn discover_jsonl(
1282    root: &Path,
1283    harness: &str,
1284    workspace: Option<&Path>,
1285    include_child_sessions: bool,
1286    found: &mut Vec<SessionDescriptor>,
1287) {
1288    let mut files = Vec::new();
1289    collect_jsonl(root, harness, include_child_sessions, &mut files);
1290    for path in files {
1291        let Ok(meta) = read_header(&path, harness) else {
1292            continue;
1293        };
1294        if workspace.is_some_and(|wanted| {
1295            meta.cwd
1296                .as_deref()
1297                .is_none_or(|cwd| !recorded_cwd_matches(cwd, wanted))
1298        }) {
1299            continue;
1300        }
1301        let session_id = meta.session_id.unwrap_or_else(|| {
1302            path.file_stem()
1303                .and_then(|value| value.to_str())
1304                .unwrap_or("unknown")
1305                .to_string()
1306        });
1307        let parent_session_id = meta.parent_session_id.or_else(|| {
1308            (harness == HarnessId::CLAUDE_CODE)
1309                .then(|| claude_subagent_parent_id(&path))
1310                .flatten()
1311        });
1312        found.push(SessionDescriptor {
1313            locator: SessionLocator {
1314                harness: HarnessId::new(harness),
1315                session_id,
1316                storage: StorageLocator::File { path: path.clone() },
1317            },
1318            cwd: meta.cwd,
1319            title: meta.title,
1320            preview_candidates: Vec::new(),
1321            latest_message_candidates: Vec::new(),
1322            updated_at_ms: modified_ms(&path),
1323            message_count: None,
1324            model: meta.model,
1325            parent_session_id,
1326            child_session_count: if harness == HarnessId::CLAUDE_CODE && !include_child_sessions {
1327                count_claude_subagents(&path)
1328            } else {
1329                0
1330            },
1331            nouns: OrchestrationNouns::default(),
1332        });
1333    }
1334}
1335
1336fn collect_jsonl(root: &Path, harness: &str, include_child_sessions: bool, out: &mut Vec<PathBuf>) {
1337    let Ok(entries) = fs::read_dir(root) else {
1338        return;
1339    };
1340    for entry in entries.flatten() {
1341        let Ok(kind) = entry.file_type() else {
1342            continue;
1343        };
1344        let path = entry.path();
1345        if kind.is_dir() {
1346            if harness == HarnessId::CLAUDE_CODE
1347                && path.file_name().and_then(|v| v.to_str()) == Some("subagents")
1348                && !include_child_sessions
1349            {
1350                continue;
1351            }
1352            collect_jsonl(&path, harness, include_child_sessions, out);
1353        } else if kind.is_file() && path.extension().and_then(|v| v.to_str()) == Some("jsonl") {
1354            out.push(path);
1355        }
1356    }
1357}
1358
1359fn read_header(path: &Path, harness: &str) -> Result<HeaderMeta> {
1360    let file = File::open(path)?;
1361    let mut result = HeaderMeta::default();
1362    let mut bytes = 0usize;
1363    for line in BufReader::new(file).lines().take(32) {
1364        let line = line?;
1365        bytes += line.len();
1366        if bytes > 256 * 1024 {
1367            break;
1368        }
1369        let Ok(value) = serde_json::from_str::<Value>(&line) else {
1370            continue;
1371        };
1372        update_header_meta(&mut result, &value, harness);
1373        if result.session_id.is_some() && result.cwd.is_some() && result.model.is_some() {
1374            break;
1375        }
1376    }
1377    if result.session_id.is_none() && result.cwd.is_none() {
1378        return Err(Error::Other(format!(
1379            "{} has no recognizable {harness} session header",
1380            path.display()
1381        )));
1382    }
1383    Ok(result)
1384}
1385
1386fn update_header_meta(result: &mut HeaderMeta, value: &Value, harness: &str) {
1387    match harness {
1388        HarnessId::CLAUDE_CODE => {
1389            fill_string(&mut result.session_id, value.get("sessionId"));
1390            fill_path(&mut result.cwd, value.get("cwd"));
1391            fill_string(
1392                &mut result.model,
1393                value.get("message").and_then(|v| v.get("model")),
1394            );
1395        }
1396        HarnessId::CODEX => {
1397            let payload = value.get("payload").unwrap_or(&Value::Null);
1398            if value.get("type").and_then(Value::as_str) == Some("session_meta") {
1399                fill_string(&mut result.session_id, payload.get("id"));
1400                fill_path(&mut result.cwd, payload.get("cwd"));
1401                fill_string(&mut result.title, payload.get("thread_name"));
1402                fill_string(&mut result.title, payload.get("title"));
1403                fill_string(
1404                    &mut result.parent_session_id,
1405                    payload.get("parent_thread_id"),
1406                );
1407                if let Some(parent) = payload
1408                    .pointer("/source/subagent/thread_spawn/parent_thread_id")
1409                    .and_then(Value::as_str)
1410                {
1411                    result.parent_session_id = Some(parent.to_string());
1412                }
1413                if result.title.is_none() {
1414                    result.title = payload
1415                        .pointer("/source/subagent/thread_spawn/agent_path")
1416                        .and_then(Value::as_str)
1417                        .and_then(|path| path.rsplit('/').find(|part| !part.is_empty()))
1418                        .map(humanize_topic);
1419                }
1420            }
1421            if value.get("type").and_then(Value::as_str) == Some("turn_context") {
1422                fill_path(&mut result.cwd, payload.get("cwd"));
1423                fill_string(&mut result.model, payload.get("model"));
1424            }
1425        }
1426        HarnessId::PI => {
1427            if value.get("type").and_then(Value::as_str) == Some("session") {
1428                fill_string(&mut result.session_id, value.get("id"));
1429                fill_path(&mut result.cwd, value.get("cwd"));
1430            }
1431            fill_string(
1432                &mut result.model,
1433                value.get("message").and_then(|v| v.get("model")),
1434            );
1435        }
1436        _ => {}
1437    }
1438}
1439
1440/// Collapse native child rollouts into their root conversation before sorting
1441/// and pagination. A child's write time contributes to the root so active
1442/// delegated work keeps the conversation visible without creating extra rows.
1443fn roll_up_session_children(found: &mut Vec<SessionDescriptor>, include_children: bool) {
1444    let by_id = found
1445        .iter()
1446        .enumerate()
1447        .map(|(index, descriptor)| {
1448            (
1449                (
1450                    descriptor.locator.harness.as_str().to_string(),
1451                    descriptor.locator.session_id.clone(),
1452                ),
1453                index,
1454            )
1455        })
1456        .collect::<HashMap<_, _>>();
1457    let mut root_updates = HashMap::<usize, u64>::new();
1458    let mut root_child_counts = HashMap::<usize, usize>::new();
1459
1460    for descriptor in found.iter() {
1461        let Some(mut parent_id) = descriptor.parent_session_id.as_deref() else {
1462            continue;
1463        };
1464        let harness = descriptor.locator.harness.as_str();
1465        let mut root = None;
1466        let mut visited = HashSet::new();
1467        while visited.insert(parent_id.to_string()) {
1468            let Some(&parent_index) = by_id.get(&(harness.to_string(), parent_id.to_string()))
1469            else {
1470                break;
1471            };
1472            root = Some(parent_index);
1473            let Some(next_parent) = found[parent_index].parent_session_id.as_deref() else {
1474                break;
1475            };
1476            parent_id = next_parent;
1477        }
1478        if let (Some(root), Some(updated_at_ms)) = (root, descriptor.updated_at_ms) {
1479            root_updates
1480                .entry(root)
1481                .and_modify(|current| *current = (*current).max(updated_at_ms))
1482                .or_insert(updated_at_ms);
1483        }
1484        if let Some(root) = root {
1485            *root_child_counts.entry(root).or_default() += 1;
1486        }
1487    }
1488
1489    for (root, child_updated_at_ms) in root_updates {
1490        found[root].updated_at_ms = Some(
1491            found[root]
1492                .updated_at_ms
1493                .unwrap_or_default()
1494                .max(child_updated_at_ms),
1495        );
1496    }
1497    for (root, child_count) in root_child_counts {
1498        found[root].child_session_count = child_count;
1499    }
1500    if !include_children {
1501        found.retain(|descriptor| descriptor.parent_session_id.is_none());
1502    }
1503}
1504
1505fn retain_session_family(found: &mut Vec<SessionDescriptor>, root_session_id: &str) {
1506    let parent_by_id = found
1507        .iter()
1508        .map(|descriptor| {
1509            (
1510                descriptor.locator.session_id.clone(),
1511                descriptor.parent_session_id.clone(),
1512            )
1513        })
1514        .collect::<HashMap<_, _>>();
1515    found.retain(|descriptor| {
1516        let mut current = descriptor.locator.session_id.clone();
1517        let mut visited = HashSet::new();
1518        while visited.insert(current.clone()) {
1519            if current == root_session_id {
1520                return true;
1521            }
1522            let Some(Some(parent)) = parent_by_id.get(&current) else {
1523                return false;
1524            };
1525            current = parent.clone();
1526        }
1527        false
1528    });
1529}
1530
1531fn claude_subagent_parent_id(path: &Path) -> Option<String> {
1532    let subagents = path.parent()?;
1533    if subagents.file_name()?.to_str()? != "subagents" {
1534        return None;
1535    }
1536    subagents
1537        .parent()?
1538        .file_name()?
1539        .to_str()
1540        .map(str::to_string)
1541}
1542
1543fn count_claude_subagents(parent_path: &Path) -> usize {
1544    let Some(parent) = parent_path.parent() else {
1545        return 0;
1546    };
1547    let Some(stem) = parent_path.file_stem() else {
1548        return 0;
1549    };
1550    let root = parent.join(stem).join("subagents");
1551    let mut files = Vec::new();
1552    collect_jsonl(&root, HarnessId::CLAUDE_CODE, true, &mut files);
1553    files.len()
1554}
1555
1556fn is_zero(value: &usize) -> bool {
1557    *value == 0
1558}
1559
1560fn humanize_topic(value: &str) -> String {
1561    let text = value.replace(['_', '-'], " ");
1562    let mut characters = text.chars();
1563    match characters.next() {
1564        Some(first) => first.to_uppercase().collect::<String>() + characters.as_str(),
1565        None => text,
1566    }
1567}
1568
1569fn discover_gemini(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
1570    let slug_to_cwd = std::fs::read_to_string(root.join("projects.json"))
1571        .ok()
1572        .and_then(|text| serde_json::from_str::<Value>(&text).ok())
1573        .and_then(|value| value.get("projects").and_then(Value::as_object).cloned())
1574        .map(|projects| {
1575            projects
1576                .into_iter()
1577                .filter_map(|(cwd, slug)| Some((slug.as_str()?.to_string(), PathBuf::from(cwd))))
1578                .collect::<HashMap<_, _>>()
1579        })
1580        .unwrap_or_default();
1581    let mut files = Vec::new();
1582    collect_jsonl(&root.join("tmp"), HarnessId::GEMINI, false, &mut files);
1583    let worker_count = std::thread::available_parallelism()
1584        .map(usize::from)
1585        .unwrap_or(4)
1586        .clamp(1, 8)
1587        .min(files.len().max(1));
1588    let chunk_size = files.len().max(1).div_ceil(worker_count);
1589    let discovered = std::thread::scope(|scope| {
1590        files
1591            .chunks(chunk_size)
1592            .map(|paths| {
1593                scope.spawn(|| {
1594                    paths
1595                        .iter()
1596                        .filter_map(|path| gemini_descriptor(path, &slug_to_cwd, workspace))
1597                        .collect::<Vec<_>>()
1598                })
1599            })
1600            .collect::<Vec<_>>()
1601            .into_iter()
1602            .flat_map(|worker| {
1603                worker
1604                    .join()
1605                    .expect("Gemini discovery worker must not panic")
1606            })
1607            .collect::<Vec<_>>()
1608    });
1609    found.extend(discovered);
1610}
1611
1612fn gemini_descriptor(
1613    path: &Path,
1614    slug_to_cwd: &HashMap<String, PathBuf>,
1615    workspace: Option<&Path>,
1616) -> Option<SessionDescriptor> {
1617    if path
1618        .parent()
1619        .and_then(Path::file_name)
1620        .and_then(|name| name.to_str())
1621        != Some("chats")
1622    {
1623        return None;
1624    }
1625    let slug = path
1626        .parent()
1627        .and_then(Path::parent)
1628        .and_then(Path::file_name)
1629        .and_then(|name| name.to_str());
1630    let cwd = slug.and_then(|slug| slug_to_cwd.get(slug)).cloned();
1631    if workspace.is_some_and(|wanted| {
1632        cwd.as_deref()
1633            .is_none_or(|actual| !recorded_cwd_matches(actual, wanted))
1634    }) {
1635        return None;
1636    }
1637
1638    // The native session id lives on line one. A small decoration budget keeps
1639    // the common title/model case without turning 1,800 sessions into a
1640    // sequential 60 MiB read before the list can render.
1641    let file = File::open(path).ok()?;
1642    let mut reader = BufReader::new(file.take(64 * 1024));
1643    let mut header = String::new();
1644    reader.read_line(&mut header).ok()?;
1645    let header = serde_json::from_str::<Value>(&header).ok()?;
1646    let session_id = header.get("sessionId")?.as_str()?.to_string();
1647    let mut model = None;
1648    for line in reader
1649        .take(4 * 1024)
1650        .lines()
1651        .map_while(std::result::Result::ok)
1652    {
1653        let Ok(value) = serde_json::from_str::<Value>(&line) else {
1654            continue;
1655        };
1656        let kind = value.get("type").and_then(Value::as_str);
1657        if kind != Some("user") && kind != Some("gemini") {
1658            continue;
1659        }
1660        if model.is_none() {
1661            model = value
1662                .get("model")
1663                .and_then(Value::as_str)
1664                .map(str::to_string);
1665        }
1666        if model.is_some() {
1667            break;
1668        }
1669    }
1670    Some(SessionDescriptor {
1671        locator: SessionLocator {
1672            harness: HarnessId::from(HarnessId::GEMINI),
1673            session_id,
1674            storage: StorageLocator::File {
1675                path: path.to_path_buf(),
1676            },
1677        },
1678        cwd,
1679        title: None,
1680        preview_candidates: Vec::new(),
1681        latest_message_candidates: Vec::new(),
1682        updated_at_ms: modified_ms(path),
1683        message_count: None,
1684        model,
1685        parent_session_id: None,
1686        child_session_count: 0,
1687        nouns: OrchestrationNouns::default(),
1688    })
1689}
1690
1691fn display_text(content: Option<&Value>) -> Option<String> {
1692    match content? {
1693        Value::String(text) => Some(text.clone()),
1694        Value::Array(parts) => Some(
1695            parts
1696                .iter()
1697                .filter_map(|part| part.get("text").and_then(Value::as_str))
1698                .collect::<Vec<_>>()
1699                .join(" ")
1700                .trim()
1701                .to_string(),
1702        ),
1703        _ => None,
1704    }
1705}
1706
1707/// Hermes (UNI-15): enumerate sessions from the single `state.db` SQLite
1708/// store, strictly read-only (the store is a live, shared, WAL,
1709/// single-writer database owned by a running Hermes install). A missing or
1710/// non-Hermes file is skipped silently, like every other absent home.
1711fn discover_hermes(db_path: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
1712    if !db_path.is_file() {
1713        return;
1714    }
1715    let Ok(conn) = Connection::open_with_flags(
1716        db_path,
1717        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
1718    ) else {
1719        return;
1720    };
1721    let fingerprint_ok = ["sessions", "messages", "schema_version"].iter().all(|t| {
1722        conn.query_row(
1723            "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1",
1724            [t],
1725            |_| Ok(()),
1726        )
1727        .is_ok()
1728    });
1729    if !fingerprint_ok {
1730        return;
1731    }
1732    let Ok(mut statement) = conn.prepare(
1733        "SELECT id, cwd, title, model, message_count, started_at, ended_at, parent_session_id, \
1734         source, model_config FROM sessions ORDER BY started_at DESC",
1735    ) else {
1736        return;
1737    };
1738    let Ok(rows) = statement.query_map([], |row| {
1739        Ok((
1740            row.get::<_, String>(0)?,
1741            row.get::<_, Option<String>>(1)?,
1742            row.get::<_, Option<String>>(2)?,
1743            row.get::<_, Option<String>>(3)?,
1744            row.get::<_, Option<i64>>(4)?,
1745            row.get::<_, Option<f64>>(5)?,
1746            row.get::<_, Option<f64>>(6)?,
1747            row.get::<_, Option<String>>(7)?,
1748            row.get::<_, Option<String>>(8)?,
1749            row.get::<_, Option<String>>(9)?,
1750        ))
1751    }) else {
1752        return;
1753    };
1754    for row in rows.flatten() {
1755        let (
1756            id,
1757            cwd,
1758            title,
1759            model,
1760            message_count,
1761            started_at,
1762            ended_at,
1763            parent,
1764            source,
1765            model_config,
1766        ) = row;
1767        let cwd = cwd.map(PathBuf::from);
1768        if let Some(filter) = workspace {
1769            if cwd.as_deref() != Some(filter) {
1770                continue;
1771            }
1772        }
1773        let updated_at_ms = ended_at
1774            .or(started_at)
1775            .map(|seconds| (seconds * 1000.0) as u64);
1776        // ORCH-6: the same derivation `Session::from_hermes_sqlite` runs, over
1777        // the same row — discovery reads the gateway columns it already has
1778        // open instead of guessing a lighter-weight variant.
1779        let mut meta = SessionMeta::new(SessionSource::Hermes);
1780        meta.cwd = cwd.clone();
1781        if let Some(hermes_source) = source.filter(|value| !value.is_empty()) {
1782            meta.lineage
1783                .insert("hermes_source".to_string(), hermes_source);
1784        }
1785        if let Some(parent_id) = parent.as_deref() {
1786            meta.lineage.insert(
1787                "hermes_lineage_kind".to_string(),
1788                crate::session::hermes_lineage_kind(
1789                    &conn,
1790                    parent_id,
1791                    model_config.as_deref(),
1792                    started_at,
1793                )
1794                .to_string(),
1795            );
1796        }
1797        hermes_capture_nouns(&conn, &id, &mut meta);
1798        found.push(SessionDescriptor {
1799            locator: SessionLocator {
1800                harness: HarnessId::new(HarnessId::HERMES),
1801                session_id: id,
1802                storage: StorageLocator::File {
1803                    path: db_path.to_path_buf(),
1804                },
1805            },
1806            cwd,
1807            title: title.filter(|t| !t.is_empty()),
1808            preview_candidates: Vec::new(),
1809            latest_message_candidates: Vec::new(),
1810            updated_at_ms,
1811            message_count: message_count.map(|count| count.max(0) as usize),
1812            model,
1813            parent_session_id: parent,
1814            child_session_count: 0,
1815            nouns: OrchestrationNouns::from_meta(&meta),
1816        });
1817    }
1818}
1819
1820/// The orchestrator (ORC-7): every profile folder's `state.db` `bindings`
1821/// table is one row per conversation the orchestrator holds. A binding is not
1822/// a transcript — the transcript belongs to the WORKER harness it points at —
1823/// so the row carries the surface, trigger, profile and worker identity, and
1824/// its locator addresses the worker's own storage when the binding recorded
1825/// one. Strictly read-only, like every other store here.
1826fn discover_orchestrator(
1827    root: &Path,
1828    workspace: Option<&Path>,
1829    found: &mut Vec<SessionDescriptor>,
1830) {
1831    // A binding has no cwd of its own; a workspace filter can only exclude it.
1832    if workspace.is_some() {
1833        return;
1834    }
1835    for (profile, dir) in orchestrator_profile_dirs(root) {
1836        let db_path = dir.join("state.db");
1837        if !db_path.is_file() {
1838            continue;
1839        }
1840        let Ok(conn) = Connection::open_with_flags(
1841            &db_path,
1842            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
1843        ) else {
1844            continue;
1845        };
1846        let Ok(mut statement) = conn.prepare(
1847            "SELECT platform, chat_type, chat_id, thread_id, participant_id, worker_harness, \
1848             worker_session_id, worker_locator, started_at, last_activity_at, ended_at, \
1849             end_reason, handoff_to, handoff_state, handoff_error, recurrence_job_id, \
1850             CAST(strftime('%s', last_activity_at) AS INTEGER) \
1851             FROM bindings ORDER BY last_activity_at DESC, started_at DESC",
1852        ) else {
1853            continue;
1854        };
1855        let Ok(rows) = statement.query_map([], |row| {
1856            let text = |index: usize| -> rusqlite::Result<Option<String>> {
1857                Ok(row
1858                    .get::<_, Option<String>>(index)?
1859                    .filter(|value| !value.is_empty()))
1860            };
1861            Ok((
1862                OrchestratorBindingRow {
1863                    platform: text(0)?.unwrap_or_default(),
1864                    chat_type: text(1)?.unwrap_or_default(),
1865                    chat_id: text(2)?,
1866                    thread_id: text(3)?,
1867                    participant_id: text(4)?,
1868                    worker_harness: text(5)?.unwrap_or_default(),
1869                    worker_session_id: text(6)?.filter(|s| !s.is_empty()),
1870                    worker_locator: text(7)?,
1871                    started_at: text(8)?,
1872                    last_activity_at: text(9)?,
1873                    ended_at: text(10)?,
1874                    end_reason: text(11)?,
1875                    handoff_to: text(12)?,
1876                    handoff_state: text(13)?,
1877                    handoff_error: text(14)?,
1878                    recurrence_job_id: text(15)?,
1879                },
1880                row.get::<_, Option<i64>>(16)?,
1881            ))
1882        }) else {
1883            continue;
1884        };
1885        for (row, last_activity_epoch) in rows.flatten() {
1886            found.push(orchestrator_descriptor(
1887                &db_path,
1888                &profile,
1889                &row,
1890                last_activity_epoch,
1891            ));
1892        }
1893    }
1894}
1895
1896fn orchestrator_descriptor(
1897    db_path: &Path,
1898    profile: &str,
1899    row: &OrchestratorBindingRow,
1900    last_activity_epoch: Option<i64>,
1901) -> SessionDescriptor {
1902    let binding = Binding::from_orchestrator_row(profile, row);
1903    let nouns = binding.nouns();
1904    // The row's title names the worker the binding points at: that pair is
1905    // the only address from which the conversation itself can be read.
1906    let mut title = format!(
1907        "{} {}",
1908        row.worker_harness,
1909        row.worker_session_id
1910            .as_deref()
1911            .unwrap_or("(no worker session yet)")
1912    );
1913    if let Some(reason) = row.end_reason.as_deref().filter(|_| row.ended_at.is_some()) {
1914        title.push_str(&format!(" (ended: {reason})"));
1915    }
1916    SessionDescriptor {
1917        locator: SessionLocator {
1918            harness: HarnessId::new(HarnessId::ORCHESTRATOR),
1919            session_id: row.worker_session_id.clone().unwrap_or_default(),
1920            storage: StorageLocator::File {
1921                path: row
1922                    .worker_locator
1923                    .clone()
1924                    .map_or_else(|| db_path.to_path_buf(), PathBuf::from),
1925            },
1926        },
1927        cwd: None,
1928        title: Some(title),
1929        preview_candidates: Vec::new(),
1930        latest_message_candidates: Vec::new(),
1931        updated_at_ms: last_activity_epoch.map(|seconds| (seconds.max(0) as u64) * 1000),
1932        message_count: None,
1933        model: None,
1934        parent_session_id: None,
1935        child_session_count: 0,
1936        nouns,
1937    }
1938}
1939
1940/// OpenClaw >= 2026.7: `<home>/agents/<agentId>/sessions/<uuid>.jsonl` are
1941/// plain pi-v3 dialect session files. `.trajectory.jsonl` runtime traces and
1942/// `.trajectory-path.json` pointers live in the SAME directory and are
1943/// excluded by suffix plus a header check (their first line carries
1944/// `traceSchema`, never `type:"session"`). Read-only discovery (UNI-16).
1945fn discover_openclaw(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
1946    let agents = root.join("agents");
1947    let Ok(agent_dirs) = std::fs::read_dir(&agents) else {
1948        return;
1949    };
1950    for agent_dir in agent_dirs.flatten() {
1951        let sessions = agent_dir.path().join("sessions");
1952        let Ok(files) = std::fs::read_dir(&sessions) else {
1953            continue;
1954        };
1955        for file in files.flatten() {
1956            let path = file.path();
1957            let name = file.file_name();
1958            let name = name.to_string_lossy();
1959            if !name.ends_with(".jsonl") || name.ends_with(".trajectory.jsonl") {
1960                continue;
1961            }
1962            let Ok(text) = std::fs::read_to_string(&path) else {
1963                continue;
1964            };
1965            let Some(header_line) = text.lines().find(|line| !line.trim().is_empty()) else {
1966                continue;
1967            };
1968            let Ok(header) = serde_json::from_str::<serde_json::Value>(header_line) else {
1969                continue;
1970            };
1971            if header.get("type").and_then(serde_json::Value::as_str) != Some("session") {
1972                continue;
1973            }
1974            let session_id = header
1975                .get("id")
1976                .and_then(serde_json::Value::as_str)
1977                .unwrap_or_else(|| name.trim_end_matches(".jsonl"))
1978                .to_string();
1979            let cwd = header
1980                .get("cwd")
1981                .and_then(serde_json::Value::as_str)
1982                .map(PathBuf::from);
1983            if let Some(filter) = workspace {
1984                if cwd.as_deref() != Some(filter) {
1985                    continue;
1986                }
1987            }
1988            let updated_at_ms = file
1989                .metadata()
1990                .ok()
1991                .and_then(|metadata| metadata.modified().ok())
1992                .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
1993                .map(|elapsed| elapsed.as_millis() as u64);
1994            let message_count = text
1995                .lines()
1996                .filter(|line| line.contains("\"type\":\"message\""))
1997                .count();
1998            // ORCH-6: the same two facts `Session::load` reads for an OpenClaw
1999            // file — the gateway `sessionKey` in the header, and the agent id
2000            // in `agents/<id>/`.
2001            let mut meta = SessionMeta::new(SessionSource::OpenClaw);
2002            meta.cwd = cwd.clone();
2003            openclaw_capture_header_nouns(&header, &mut meta);
2004            if meta.profile.is_none() {
2005                meta.profile = openclaw_agent_id_from_path(&path);
2006            }
2007            found.push(SessionDescriptor {
2008                locator: SessionLocator {
2009                    harness: HarnessId::new(HarnessId::OPENCLAW),
2010                    session_id,
2011                    storage: StorageLocator::File { path },
2012                },
2013                cwd,
2014                title: None,
2015                preview_candidates: Vec::new(),
2016                latest_message_candidates: Vec::new(),
2017                updated_at_ms,
2018                message_count: Some(message_count),
2019                model: None,
2020                parent_session_id: None,
2021                child_session_count: 0,
2022                nouns: OrchestrationNouns::from_meta(&meta),
2023            });
2024        }
2025    }
2026}
2027
2028fn discover_supercode(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
2029    for info in list_native_store(root) {
2030        let path = if info.archived {
2031            root.join("archived").join(format!("{}.jsonl", info.name))
2032        } else {
2033            root.join(format!("{}.jsonl", info.name))
2034        };
2035        let header = read_native_store_header(&path);
2036        if workspace.is_some_and(|wanted| {
2037            header
2038                .as_ref()
2039                .and_then(|meta| meta.cwd.as_deref())
2040                .is_none_or(|cwd| !recorded_cwd_matches(cwd, wanted))
2041        }) {
2042            continue;
2043        }
2044        let title = (!info.title.trim().is_empty()).then_some(info.title);
2045        let updated_at_ms =
2046            modified_ms(&path).or_else(|| modified_ms(&path.with_extension("sidecar.jsonl")));
2047        found.push(SessionDescriptor {
2048            locator: SessionLocator {
2049                harness: HarnessId::from(HarnessId::SUPERCODE),
2050                session_id: info.name,
2051                storage: StorageLocator::File { path: path.clone() },
2052            },
2053            cwd: header.as_ref().and_then(|meta| meta.cwd.clone()),
2054            title,
2055            preview_candidates: Vec::new(),
2056            latest_message_candidates: Vec::new(),
2057            updated_at_ms,
2058            message_count: None,
2059            model: header.and_then(|meta| meta.model),
2060            parent_session_id: None,
2061            child_session_count: 0,
2062            nouns: OrchestrationNouns::default(),
2063        });
2064    }
2065}
2066
2067/// Read only the bounded native envelope needed by discovery. Loading a
2068/// sidecar-backed session here used to deserialize the complete byte-lossless
2069/// transcript family, making a workspace list proportional to every saved
2070/// Supercode transcript on the machine.
2071fn read_native_store_header(path: &Path) -> Option<HeaderMeta> {
2072    let name = path.file_stem()?.to_str()?;
2073    let sidecar = path.with_file_name(format!("{name}.sidecar.jsonl"));
2074    let source_path = if sidecar.is_file() {
2075        sidecar
2076    } else {
2077        path.to_path_buf()
2078    };
2079    let file = File::open(source_path).ok()?;
2080    let mut result = HeaderMeta::default();
2081    let mut source = None;
2082    let mut bytes = 0usize;
2083    for line in BufReader::new(file).lines().take(32) {
2084        let line = line.ok()?;
2085        bytes += line.len();
2086        if bytes > 256 * 1024 {
2087            break;
2088        }
2089        let Ok(value) = serde_json::from_str::<Value>(&line) else {
2090            continue;
2091        };
2092        if source.is_none() {
2093            source = value.get("source").and_then(Value::as_str).map(|source| {
2094                if source == "claude_code" {
2095                    HarnessId::CLAUDE_CODE.to_string()
2096                } else {
2097                    source.to_string()
2098                }
2099            });
2100            fill_string(&mut result.session_id, value.get("session_id"));
2101        }
2102        if let Some(harness) = source.as_deref() {
2103            update_header_meta(&mut result, &value, harness);
2104        }
2105        if result.cwd.is_some() && result.model.is_some() {
2106            break;
2107        }
2108    }
2109    Some(result)
2110}
2111
2112#[derive(Deserialize)]
2113struct NativeStoreInfo {
2114    name: String,
2115    #[serde(default)]
2116    title: String,
2117    #[serde(skip)]
2118    archived: bool,
2119}
2120
2121fn list_native_store(root: &Path) -> Vec<NativeStoreInfo> {
2122    let mut sessions = Vec::new();
2123    for archived in [false, true] {
2124        let directory = if archived {
2125            root.join("archived")
2126        } else {
2127            root.to_path_buf()
2128        };
2129        let Ok(entries) = fs::read_dir(directory) else {
2130            continue;
2131        };
2132        for entry in entries.flatten() {
2133            let path = entry.path();
2134            if !path.to_string_lossy().ends_with(".meta.json") {
2135                continue;
2136            }
2137            let Ok(text) = fs::read_to_string(path) else {
2138                continue;
2139            };
2140            let Ok(mut info) = serde_json::from_str::<NativeStoreInfo>(&text) else {
2141                continue;
2142            };
2143            info.archived = archived;
2144            sessions.push(info);
2145        }
2146    }
2147    sessions.sort_by(|left, right| left.name.cmp(&right.name));
2148    sessions
2149}
2150
2151fn discover_grok(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
2152    let Ok(workspaces) = fs::read_dir(root) else {
2153        return;
2154    };
2155    for workspace_entry in workspaces.flatten() {
2156        let encoded = workspace_entry.file_name();
2157        let Some(cwd) = encoded
2158            .to_str()
2159            .and_then(percent_decode_path)
2160            .map(PathBuf::from)
2161        else {
2162            continue;
2163        };
2164        if workspace.is_some_and(|wanted| !recorded_cwd_matches(&cwd, wanted)) {
2165            continue;
2166        }
2167        let Ok(sessions) = fs::read_dir(workspace_entry.path()) else {
2168            continue;
2169        };
2170        for session_entry in sessions.flatten() {
2171            let session_dir = session_entry.path();
2172            if !session_dir.is_dir() {
2173                continue;
2174            }
2175            let transcript = session_dir.join("chat_history.jsonl");
2176            if !transcript.is_file() {
2177                continue;
2178            }
2179            let Some(session_id) = session_dir
2180                .file_name()
2181                .and_then(|name| name.to_str())
2182                .map(str::to_string)
2183            else {
2184                continue;
2185            };
2186            let summary = fs::read_to_string(session_dir.join("summary.json"))
2187                .ok()
2188                .and_then(|text| serde_json::from_str::<Value>(&text).ok());
2189            let title = summary
2190                .as_ref()
2191                .and_then(|value| value.get("generated_title"))
2192                .and_then(Value::as_str)
2193                .filter(|title| !title.is_empty())
2194                .map(str::to_string);
2195            let model = summary
2196                .as_ref()
2197                .and_then(|value| value.get("current_model_id"))
2198                .and_then(Value::as_str)
2199                .map(str::to_string);
2200            let message_count = summary
2201                .as_ref()
2202                .and_then(|value| value.get("num_chat_messages"))
2203                .and_then(Value::as_u64)
2204                .and_then(|count| usize::try_from(count).ok());
2205            let updated_at_ms = summary
2206                .as_ref()
2207                .and_then(|value| value.get("updated_at"))
2208                .and_then(Value::as_str)
2209                .and_then(crate::sidecar::rfc3339_to_ms)
2210                .and_then(|millis| u64::try_from(millis).ok())
2211                .or_else(|| modified_ms(&transcript));
2212            found.push(SessionDescriptor {
2213                locator: SessionLocator {
2214                    harness: HarnessId::from(HarnessId::GROK),
2215                    session_id,
2216                    storage: StorageLocator::File { path: transcript },
2217                },
2218                cwd: Some(cwd.clone()),
2219                title,
2220                preview_candidates: Vec::new(),
2221                latest_message_candidates: Vec::new(),
2222                updated_at_ms,
2223                message_count,
2224                model,
2225                parent_session_id: None,
2226                child_session_count: 0,
2227                nouns: OrchestrationNouns::default(),
2228            });
2229        }
2230    }
2231}
2232
2233fn discover_opencode(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
2234    let mut dbs = Vec::new();
2235    if root.is_file() {
2236        dbs.push(root.to_path_buf());
2237    } else if let Ok(entries) = fs::read_dir(root) {
2238        dbs.extend(entries.flatten().map(|entry| entry.path()).filter(|path| {
2239            path.file_name()
2240                .and_then(|v| v.to_str())
2241                .is_some_and(|name| name.starts_with("opencode") && name.ends_with(".db"))
2242        }));
2243    }
2244    dbs.sort();
2245    for db in dbs {
2246        let Ok(conn) = Connection::open_with_flags(
2247            &db,
2248            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
2249        ) else {
2250            continue;
2251        };
2252        let has_model = conn.prepare("SELECT model FROM session LIMIT 0").is_ok();
2253        let model_column = if has_model { "s.model" } else { "NULL" };
2254        let query = format!(
2255            "SELECT s.id, s.directory, s.title, s.time_updated, {model_column}, COUNT(m.id) \
2256             FROM session s LEFT JOIN message m ON m.session_id = s.id \
2257             GROUP BY s.id ORDER BY s.time_updated DESC"
2258        );
2259        let Ok(mut stmt) = conn.prepare(&query) else {
2260            continue;
2261        };
2262        let Ok(rows) = stmt.query_map([], |row| {
2263            Ok((
2264                row.get::<_, String>(0)?,
2265                row.get::<_, String>(1)?,
2266                row.get::<_, String>(2)?,
2267                row.get::<_, i64>(3)?,
2268                row.get::<_, Option<String>>(4)?,
2269                row.get::<_, i64>(5)?,
2270            ))
2271        }) else {
2272            continue;
2273        };
2274        for row in rows.flatten() {
2275            let (id, cwd, title, updated, model, messages) = row;
2276            let cwd = PathBuf::from(cwd);
2277            if workspace.is_some_and(|wanted| !recorded_cwd_matches(&cwd, wanted)) {
2278                continue;
2279            }
2280            found.push(SessionDescriptor {
2281                locator: SessionLocator {
2282                    harness: HarnessId::from(HarnessId::OPENCODE),
2283                    session_id: id.clone(),
2284                    storage: StorageLocator::Sqlite {
2285                        path: db.clone(),
2286                        selector: id,
2287                    },
2288                },
2289                cwd: Some(cwd),
2290                title: (!title.is_empty()).then_some(title),
2291                preview_candidates: Vec::new(),
2292                latest_message_candidates: Vec::new(),
2293                updated_at_ms: u64::try_from(updated).ok(),
2294                message_count: usize::try_from(messages).ok(),
2295                model,
2296                parent_session_id: None,
2297                child_session_count: 0,
2298                nouns: OrchestrationNouns::default(),
2299            });
2300        }
2301    }
2302}
2303
2304fn discover_goose(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
2305    let db = if root.is_file() {
2306        root.to_path_buf()
2307    } else if root.join("sessions.db").is_file() {
2308        root.join("sessions.db")
2309    } else {
2310        root.join("sessions/sessions.db")
2311    };
2312    let Ok(connection) = Connection::open_with_flags(
2313        &db,
2314        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
2315    ) else {
2316        return;
2317    };
2318    let Ok(mut statement) = connection.prepare(
2319        "SELECT s.id, s.working_dir, s.name, s.updated_at, s.model_config_json, \
2320                COUNT(m.id) \
2321         FROM sessions s LEFT JOIN messages m ON m.session_id = s.id \
2322         WHERE s.archived_at IS NULL \
2323         GROUP BY s.id ORDER BY s.updated_at DESC",
2324    ) else {
2325        return;
2326    };
2327    let Ok(rows) = statement.query_map([], |row| {
2328        Ok((
2329            row.get::<_, String>(0)?,
2330            row.get::<_, String>(1)?,
2331            row.get::<_, String>(2)?,
2332            row.get::<_, String>(3)?,
2333            row.get::<_, Option<String>>(4)?,
2334            row.get::<_, i64>(5)?,
2335        ))
2336    }) else {
2337        return;
2338    };
2339    for row in rows.flatten() {
2340        let (id, cwd, title, updated_at, model_config, message_count) = row;
2341        let cwd = PathBuf::from(cwd);
2342        if workspace.is_some_and(|wanted| !recorded_cwd_matches(&cwd, wanted)) {
2343            continue;
2344        }
2345        let model = model_config
2346            .as_deref()
2347            .and_then(|value| serde_json::from_str::<Value>(value).ok())
2348            .and_then(|value| {
2349                value
2350                    .get("model_name")
2351                    .or_else(|| value.get("modelName"))
2352                    .and_then(Value::as_str)
2353                    .map(str::to_string)
2354            });
2355        let updated_at_ms = crate::sidecar::rfc3339_to_ms(&updated_at)
2356            .or_else(|| {
2357                // SQLite's CURRENT_TIMESTAMP uses `YYYY-MM-DD HH:MM:SS`.
2358                crate::sidecar::rfc3339_to_ms(&format!("{}Z", updated_at.replace(' ', "T")))
2359            })
2360            .and_then(|value| u64::try_from(value).ok());
2361        found.push(SessionDescriptor {
2362            locator: SessionLocator {
2363                harness: HarnessId::from(HarnessId::GOOSE),
2364                session_id: id.clone(),
2365                storage: StorageLocator::Sqlite {
2366                    path: db.clone(),
2367                    selector: id,
2368                },
2369            },
2370            cwd: Some(cwd),
2371            title: (!title.trim().is_empty()).then_some(title),
2372            preview_candidates: Vec::new(),
2373            latest_message_candidates: Vec::new(),
2374            updated_at_ms,
2375            message_count: usize::try_from(message_count).ok(),
2376            model,
2377            parent_session_id: None,
2378            child_session_count: 0,
2379            nouns: OrchestrationNouns::default(),
2380        });
2381    }
2382}
2383
2384const LATEST_PREVIEW_CANDIDATES: usize = 8;
2385const TOPIC_PREVIEW_HEAD_BYTES: u64 = 512 * 1024;
2386const LATEST_PREVIEW_TAIL_BYTES: u64 = 512 * 1024;
2387const LATEST_PREVIEW_MAX_BYTES: u64 = 4 * 1024 * 1024;
2388
2389fn topic_message_candidates(locator: &SessionLocator) -> Result<Vec<SessionPreviewCandidate>> {
2390    match &locator.storage {
2391        StorageLocator::File { path }
2392            if matches!(
2393                locator.harness.as_str(),
2394                HarnessId::CLAUDE_CODE | HarnessId::CODEX
2395            ) =>
2396        {
2397            topic_file_message_candidates(path, locator.harness.as_str())
2398        }
2399        _ => Ok(Vec::new()),
2400    }
2401}
2402
2403fn codex_history_topics(
2404    sessions_root: &Path,
2405    sessions: &[SessionDescriptor],
2406) -> Result<HashMap<String, Vec<SessionPreviewCandidate>>> {
2407    let wanted: HashSet<&str> = sessions
2408        .iter()
2409        .filter(|descriptor| descriptor.locator.harness.as_str() == HarnessId::CODEX)
2410        .map(|descriptor| descriptor.locator.session_id.as_str())
2411        .collect();
2412    if wanted.is_empty() {
2413        return Ok(HashMap::new());
2414    }
2415    let Some(root) = sessions_root.parent() else {
2416        return Ok(HashMap::new());
2417    };
2418    let file = match File::open(root.join("history.jsonl")) {
2419        Ok(file) => file,
2420        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(HashMap::new()),
2421        Err(error) => return Err(error.into()),
2422    };
2423    let mut topics = HashMap::new();
2424    for line in BufReader::new(file).lines() {
2425        let Ok(value) = serde_json::from_str::<Value>(&line?) else {
2426            continue;
2427        };
2428        let Some(session_id) = value.get("session_id").and_then(Value::as_str) else {
2429            continue;
2430        };
2431        if !wanted.contains(session_id) || topics.contains_key(session_id) {
2432            continue;
2433        }
2434        let mut candidates = Vec::new();
2435        push_message_candidate(&mut candidates, "user", value.get("text"), HashMap::new());
2436        if !candidates.is_empty() {
2437            topics.insert(session_id.to_string(), candidates);
2438            if topics.len() == wanted.len() {
2439                break;
2440            }
2441        }
2442    }
2443    Ok(topics)
2444}
2445
2446fn latest_message_candidates(locator: &SessionLocator) -> Result<Vec<SessionPreviewCandidate>> {
2447    match &locator.storage {
2448        // A Hermes locator addresses one session inside the whole-store `state.db`; the file's
2449        // tail is SQLite pages, not transcript lines, so the preview comes from the store by id.
2450        StorageLocator::File { path } | StorageLocator::Sqlite { path, .. }
2451            if locator.harness.as_str() == HarnessId::HERMES =>
2452        {
2453            latest_hermes_message_candidates(path, &locator.session_id)
2454        }
2455        StorageLocator::File { path } => {
2456            latest_file_message_candidates(path, locator.harness.as_str())
2457        }
2458        StorageLocator::Sqlite { path, selector }
2459            if locator.harness.as_str() == HarnessId::OPENCODE =>
2460        {
2461            latest_opencode_message_candidates(path, selector)
2462        }
2463        StorageLocator::Sqlite { path, selector }
2464            if locator.harness.as_str() == HarnessId::GOOSE =>
2465        {
2466            latest_goose_message_candidates(path, selector)
2467        }
2468        StorageLocator::Sqlite { .. } => Ok(Vec::new()),
2469    }
2470}
2471
2472fn topic_file_message_candidates(
2473    path: &Path,
2474    harness: &str,
2475) -> Result<Vec<SessionPreviewCandidate>> {
2476    let mut file = File::open(path)?;
2477    let mut bytes = Vec::with_capacity(TOPIC_PREVIEW_HEAD_BYTES as usize);
2478    file.by_ref()
2479        .take(TOPIC_PREVIEW_HEAD_BYTES)
2480        .read_to_end(&mut bytes)?;
2481    if file.metadata()?.len() > TOPIC_PREVIEW_HEAD_BYTES {
2482        if let Some(newline) = bytes.iter().rposition(|byte| *byte == b'\n') {
2483            bytes.truncate(newline);
2484        }
2485    }
2486    let text = String::from_utf8(bytes).map_err(|_| {
2487        Error::Other(format!(
2488            "{} contains non-UTF-8 data in its topic-preview window",
2489            path.display()
2490        ))
2491    })?;
2492    let mut candidates = Vec::new();
2493    for line in text.lines() {
2494        let Ok(value) = serde_json::from_str::<Value>(line) else {
2495            continue;
2496        };
2497        push_topic_message_candidate(&mut candidates, harness, &value);
2498        if candidates.len() >= LATEST_PREVIEW_CANDIDATES {
2499            break;
2500        }
2501    }
2502    Ok(candidates)
2503}
2504
2505fn latest_file_message_candidates(
2506    path: &Path,
2507    harness: &str,
2508) -> Result<Vec<SessionPreviewCandidate>> {
2509    let mut candidates =
2510        latest_file_message_candidates_with_limit(path, harness, LATEST_PREVIEW_TAIL_BYTES)?;
2511    if candidates.is_empty() {
2512        candidates =
2513            latest_file_message_candidates_with_limit(path, harness, LATEST_PREVIEW_MAX_BYTES)?;
2514    }
2515    Ok(candidates)
2516}
2517
2518fn latest_file_message_candidates_with_limit(
2519    path: &Path,
2520    harness: &str,
2521    byte_limit: u64,
2522) -> Result<Vec<SessionPreviewCandidate>> {
2523    let mut file = File::open(path)?;
2524    let file_len = file.metadata()?.len();
2525    let start = file_len.saturating_sub(byte_limit);
2526    file.seek(SeekFrom::Start(start))?;
2527    let mut bytes = Vec::with_capacity((file_len - start) as usize);
2528    file.read_to_end(&mut bytes)?;
2529    if start > 0 {
2530        if let Some(newline) = bytes.iter().position(|byte| *byte == b'\n') {
2531            bytes.drain(..=newline);
2532        } else {
2533            return Ok(Vec::new());
2534        }
2535    }
2536    let text = String::from_utf8(bytes).map_err(|_| {
2537        Error::Other(format!(
2538            "{} contains non-UTF-8 data in its list-preview window",
2539            path.display()
2540        ))
2541    })?;
2542    let mut candidates = Vec::new();
2543    for line in text.lines().rev() {
2544        let Ok(value) = serde_json::from_str::<Value>(line) else {
2545            continue;
2546        };
2547        let (role, content, metadata) = match harness {
2548            HarnessId::CLAUDE_CODE => {
2549                let role = value.get("type").and_then(Value::as_str);
2550                if !matches!(role, Some("user" | "assistant")) {
2551                    continue;
2552                }
2553                let metadata = if role == Some("user") {
2554                    crate::session::claude_user_provenance(&value)
2555                        .into_iter()
2556                        .collect()
2557                } else {
2558                    HashMap::new()
2559                };
2560                (
2561                    role.unwrap_or_default(),
2562                    value
2563                        .get("message")
2564                        .and_then(|message| message.get("content")),
2565                    metadata,
2566                )
2567            }
2568            HarnessId::CODEX => {
2569                let payload = value.get("payload").unwrap_or(&Value::Null);
2570                if value.get("type").and_then(Value::as_str) != Some("response_item")
2571                    || payload.get("type").and_then(Value::as_str) != Some("message")
2572                {
2573                    continue;
2574                }
2575                let Some(role @ ("user" | "assistant")) =
2576                    payload.get("role").and_then(Value::as_str)
2577                else {
2578                    continue;
2579                };
2580                (role, payload.get("content"), HashMap::new())
2581            }
2582            HarnessId::PI => {
2583                if value.get("type").and_then(Value::as_str) != Some("message") {
2584                    continue;
2585                }
2586                let message = value.get("message").unwrap_or(&Value::Null);
2587                let Some(role @ ("user" | "assistant")) =
2588                    message.get("role").and_then(Value::as_str)
2589                else {
2590                    continue;
2591                };
2592                (role, message.get("content"), HashMap::new())
2593            }
2594            HarnessId::GEMINI => {
2595                let Some(kind @ ("user" | "gemini")) = value.get("type").and_then(Value::as_str)
2596                else {
2597                    continue;
2598                };
2599                (
2600                    if kind == "gemini" {
2601                        "assistant"
2602                    } else {
2603                        "user"
2604                    },
2605                    value.get("content"),
2606                    HashMap::new(),
2607                )
2608            }
2609            HarnessId::GROK => {
2610                let Some(role @ ("user" | "assistant")) = value.get("type").and_then(Value::as_str)
2611                else {
2612                    continue;
2613                };
2614                (role, value.get("content"), HashMap::new())
2615            }
2616            HarnessId::SUPERCODE => {
2617                let Some(role @ ("user" | "assistant")) = value.get("role").and_then(Value::as_str)
2618                else {
2619                    continue;
2620                };
2621                (role, value.get("content"), HashMap::new())
2622            }
2623            _ => continue,
2624        };
2625        let mut metadata = metadata;
2626        if matches!(harness, HarnessId::CLAUDE_CODE | HarnessId::CODEX) {
2627            if let Some(timestamp) = value.get("timestamp").and_then(Value::as_str) {
2628                metadata.insert("timestamp".to_string(), timestamp.to_string());
2629            }
2630        }
2631        push_message_candidate_with_cursor(
2632            &mut candidates,
2633            role,
2634            content,
2635            metadata,
2636            Some(message_candidate_cursor(harness, &value)),
2637        );
2638        if candidates.len() >= LATEST_PREVIEW_CANDIDATES {
2639            break;
2640        }
2641    }
2642    Ok(candidates)
2643}
2644
2645fn push_topic_message_candidate(
2646    candidates: &mut Vec<SessionPreviewCandidate>,
2647    harness: &str,
2648    value: &Value,
2649) {
2650    let (role, content, metadata) = match harness {
2651        HarnessId::CLAUDE_CODE => {
2652            let role = value.get("type").and_then(Value::as_str);
2653            if !matches!(role, Some("user" | "assistant")) {
2654                return;
2655            }
2656            let metadata = if role == Some("user") {
2657                crate::session::claude_user_provenance(value)
2658                    .into_iter()
2659                    .collect()
2660            } else {
2661                HashMap::new()
2662            };
2663            (
2664                role.unwrap_or_default(),
2665                value
2666                    .get("message")
2667                    .and_then(|message| message.get("content")),
2668                metadata,
2669            )
2670        }
2671        HarnessId::CODEX => {
2672            let payload = value.get("payload").unwrap_or(&Value::Null);
2673            if value.get("type").and_then(Value::as_str) != Some("response_item")
2674                || payload.get("type").and_then(Value::as_str) != Some("message")
2675            {
2676                return;
2677            }
2678            let Some(role @ ("user" | "assistant")) = payload.get("role").and_then(Value::as_str)
2679            else {
2680                return;
2681            };
2682            (role, payload.get("content"), HashMap::new())
2683        }
2684        _ => return,
2685    };
2686    push_message_candidate(candidates, role, content, metadata);
2687}
2688
2689fn latest_opencode_message_candidates(
2690    path: &Path,
2691    session_id: &str,
2692) -> Result<Vec<SessionPreviewCandidate>> {
2693    let connection = Connection::open_with_flags(
2694        path,
2695        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
2696    )
2697    .map_err(|error| Error::Other(format!("failed to open list-preview store: {error}")))?;
2698    let mut statement = connection
2699        .prepare(
2700            "SELECT m.data, p.data FROM message m JOIN part p ON p.message_id = m.id \
2701         WHERE m.session_id = ?1 ORDER BY m.time_created DESC, p.time_created DESC LIMIT 32",
2702        )
2703        .map_err(|error| Error::Other(format!("failed to prepare list-preview query: {error}")))?;
2704    let rows = statement
2705        .query_map([session_id], |row| {
2706            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2707        })
2708        .map_err(|error| Error::Other(format!("failed to read list-preview rows: {error}")))?;
2709    let mut candidates = Vec::new();
2710    for row in rows.flatten() {
2711        let (Ok(message), Ok(part)) = (
2712            serde_json::from_str::<Value>(&row.0),
2713            serde_json::from_str::<Value>(&row.1),
2714        ) else {
2715            continue;
2716        };
2717        let Some(role @ ("user" | "assistant")) = message.get("role").and_then(Value::as_str)
2718        else {
2719            continue;
2720        };
2721        if part.get("type").and_then(Value::as_str) != Some("text") {
2722            continue;
2723        }
2724        push_message_candidate(&mut candidates, role, part.get("text"), HashMap::new());
2725        if candidates.len() >= LATEST_PREVIEW_CANDIDATES {
2726            break;
2727        }
2728    }
2729    Ok(candidates)
2730}
2731
2732fn latest_hermes_message_candidates(
2733    path: &Path,
2734    session_id: &str,
2735) -> Result<Vec<SessionPreviewCandidate>> {
2736    let connection = Connection::open_with_flags(
2737        path,
2738        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
2739    )
2740    .map_err(|error| Error::Other(format!("failed to open list-preview store: {error}")))?;
2741    let mut statement = connection
2742        .prepare(
2743            "SELECT role, content FROM messages WHERE session_id = ?1 AND active = 1 \
2744             AND role IN ('user', 'assistant') AND content IS NOT NULL AND content != '' \
2745             ORDER BY timestamp DESC, id DESC LIMIT 32",
2746        )
2747        .map_err(|error| Error::Other(format!("failed to prepare list-preview query: {error}")))?;
2748    let rows = statement
2749        .query_map([session_id], |row| {
2750            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2751        })
2752        .map_err(|error| Error::Other(format!("failed to read list-preview rows: {error}")))?;
2753    let mut candidates = Vec::new();
2754    for (role, content) in rows.flatten() {
2755        push_message_candidate(
2756            &mut candidates,
2757            &role,
2758            Some(&Value::String(content)),
2759            HashMap::new(),
2760        );
2761        if candidates.len() >= LATEST_PREVIEW_CANDIDATES {
2762            break;
2763        }
2764    }
2765    Ok(candidates)
2766}
2767
2768fn latest_goose_message_candidates(
2769    path: &Path,
2770    session_id: &str,
2771) -> Result<Vec<SessionPreviewCandidate>> {
2772    let connection = Connection::open_with_flags(
2773        path,
2774        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
2775    )
2776    .map_err(|error| Error::Other(format!("failed to open list-preview store: {error}")))?;
2777    let mut statement = connection
2778        .prepare(
2779            "SELECT role, content_json FROM messages WHERE session_id = ?1 \
2780         ORDER BY created_timestamp DESC, id DESC LIMIT 16",
2781        )
2782        .map_err(|error| Error::Other(format!("failed to prepare list-preview query: {error}")))?;
2783    let rows = statement
2784        .query_map([session_id], |row| {
2785            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2786        })
2787        .map_err(|error| Error::Other(format!("failed to read list-preview rows: {error}")))?;
2788    let mut candidates = Vec::new();
2789    for row in rows.flatten() {
2790        let (role, content) = row;
2791        if !matches!(role.as_str(), "user" | "assistant") {
2792            continue;
2793        }
2794        let Ok(content) = serde_json::from_str::<Value>(&content) else {
2795            continue;
2796        };
2797        push_message_candidate(&mut candidates, &role, Some(&content), HashMap::new());
2798        if candidates.len() >= LATEST_PREVIEW_CANDIDATES {
2799            break;
2800        }
2801    }
2802    Ok(candidates)
2803}
2804
2805fn push_message_candidate(
2806    candidates: &mut Vec<SessionPreviewCandidate>,
2807    role: &str,
2808    content: Option<&Value>,
2809    metadata: HashMap<String, String>,
2810) {
2811    push_message_candidate_with_cursor(candidates, role, content, metadata, None);
2812}
2813
2814fn push_message_candidate_with_cursor(
2815    candidates: &mut Vec<SessionPreviewCandidate>,
2816    role: &str,
2817    content: Option<&Value>,
2818    metadata: HashMap<String, String>,
2819    cursor: Option<String>,
2820) {
2821    if candidates.len() >= LATEST_PREVIEW_CANDIDATES {
2822        return;
2823    }
2824    let Some(text) = display_text(content).filter(|text| !text.trim().is_empty()) else {
2825        return;
2826    };
2827    const MAX_CHARS: usize = 4_096;
2828    candidates.push(SessionPreviewCandidate {
2829        cursor,
2830        role: role.to_string(),
2831        content: text.chars().take(MAX_CHARS).collect(),
2832        metadata,
2833    });
2834}
2835
2836fn message_candidate_cursor(harness: &str, value: &Value) -> String {
2837    let native_identity = value
2838        .get("uuid")
2839        .or_else(|| value.get("id"))
2840        .or_else(|| value.pointer("/message/id"))
2841        .or_else(|| value.pointer("/payload/id"))
2842        .and_then(Value::as_str)
2843        .or_else(|| value.get("timestamp").and_then(Value::as_str));
2844    let mut hasher = blake3::Hasher::new();
2845    hasher.update(b"supercode.session-preview-cursor.v1\0");
2846    hasher.update(harness.as_bytes());
2847    hasher.update(b"\0");
2848    if let Some(identity) = native_identity {
2849        hasher.update(identity.as_bytes());
2850    } else {
2851        // Some formats do not publish message ids. Hashing the complete native
2852        // record is still stable across discovery refreshes and reveals none
2853        // of the record itself to an untrusted presentation surface.
2854        hasher.update(value.to_string().as_bytes());
2855    }
2856    format!("v1:{}", &hasher.finalize().to_hex()[..24])
2857}
2858
2859fn fill_string(target: &mut Option<String>, value: Option<&Value>) {
2860    if target.is_none() {
2861        *target = value.and_then(Value::as_str).map(str::to_owned);
2862    }
2863}
2864
2865fn fill_path(target: &mut Option<PathBuf>, value: Option<&Value>) {
2866    if target.is_none() {
2867        *target = value.and_then(Value::as_str).map(PathBuf::from);
2868    }
2869}
2870
2871fn modified_ms(path: &Path) -> Option<u64> {
2872    fs::metadata(path)
2873        .ok()?
2874        .modified()
2875        .ok()?
2876        .duration_since(UNIX_EPOCH)
2877        .ok()
2878        .and_then(|duration| u64::try_from(duration.as_millis()).ok())
2879}
2880
2881/// A workspace filter is satisfiable only by a session whose RECORDED working
2882/// directory is absolute. A relative recorded cwd (OpenCode has shipped
2883/// literal `"."` session rows) carries no information about where the session
2884/// ran; resolving it against the discoverer's own current directory made such
2885/// a session match every workspace discovery happened to run from.
2886fn recorded_cwd_matches(recorded: &Path, wanted: &Path) -> bool {
2887    recorded.is_absolute() && same_path(recorded, wanted)
2888}
2889
2890fn same_path(left: &Path, right: &Path) -> bool {
2891    match (fs::canonicalize(left), fs::canonicalize(right)) {
2892        (Ok(left), Ok(right)) => left == right,
2893        _ => normalize_path(left) == normalize_path(right),
2894    }
2895}
2896
2897fn normalize_path(path: &Path) -> PathBuf {
2898    let absolute = if path.is_absolute() {
2899        path.to_path_buf()
2900    } else {
2901        std::env::current_dir()
2902            .unwrap_or_else(|_| PathBuf::from("."))
2903            .join(path)
2904    };
2905    let mut normalized = PathBuf::new();
2906    for component in absolute.components() {
2907        match component {
2908            Component::CurDir => {}
2909            Component::ParentDir => {
2910                normalized.pop();
2911            }
2912            other => normalized.push(other.as_os_str()),
2913        }
2914    }
2915    normalized
2916}
2917
2918#[cfg(test)]
2919mod tests {
2920    use super::*;
2921    use std::io::Write;
2922    use std::time::{SystemTime, UNIX_EPOCH};
2923
2924    fn temp_dir(label: &str) -> PathBuf {
2925        let nonce = SystemTime::now()
2926            .duration_since(UNIX_EPOCH)
2927            .unwrap()
2928            .as_nanos();
2929        let path = std::env::temp_dir().join(format!(
2930            "supercode-catalog-{label}-{}-{nonce}",
2931            std::process::id()
2932        ));
2933        fs::create_dir_all(&path).unwrap();
2934        path
2935    }
2936
2937    #[test]
2938    fn codex_history_index_reads_appends_and_repairs_replacements() {
2939        let root = temp_dir("codex-history-index");
2940        let sessions = root.join("sessions");
2941        fs::create_dir_all(&sessions).unwrap();
2942        let history = root.join("history.jsonl");
2943        fs::write(
2944            &history,
2945            "{\"session_id\":\"alpha\",\"text\":\"first topic\"}\n",
2946        )
2947        .unwrap();
2948
2949        let mut index = CodexHistoryTopicIndex::new(&sessions);
2950        assert_eq!(index.refresh().unwrap(), BTreeSet::from(["alpha".into()]));
2951        assert_eq!(index.topics["alpha"][0].content, "first topic");
2952        assert!(index.refresh().unwrap().is_empty());
2953
2954        let mut file = fs::OpenOptions::new().append(true).open(&history).unwrap();
2955        write!(
2956            file,
2957            "{{\"session_id\":\"alpha\",\"text\":\"later topic\"}}\n\
2958             {{\"session_id\":\"beta\",\"text\":\"second topic\"}}\n"
2959        )
2960        .unwrap();
2961        file.flush().unwrap();
2962        assert_eq!(index.refresh().unwrap(), BTreeSet::from(["beta".into()]));
2963        assert_eq!(index.topics["alpha"][0].content, "first topic");
2964        assert_eq!(index.topics["beta"][0].content, "second topic");
2965
2966        fs::write(
2967            &history,
2968            "{\"session_id\":\"gamma\",\"text\":\"replacement\"}\n",
2969        )
2970        .unwrap();
2971        assert_eq!(
2972            index.refresh().unwrap(),
2973            BTreeSet::from(["alpha".into(), "beta".into(), "gamma".into()])
2974        );
2975        assert!(!index.topics.contains_key("alpha"));
2976        assert_eq!(index.topics["gamma"][0].content, "replacement");
2977
2978        fs::remove_dir_all(root).ok();
2979    }
2980
2981    #[test]
2982    fn codex_history_index_retains_an_incomplete_appended_record() {
2983        let root = temp_dir("codex-history-partial");
2984        let sessions = root.join("sessions");
2985        fs::create_dir_all(&sessions).unwrap();
2986        let history = root.join("history.jsonl");
2987        fs::write(&history, "{\"session_id\":\"partial\",\"text\":\"hel").unwrap();
2988
2989        let mut index = CodexHistoryTopicIndex::new(&sessions);
2990        assert!(index.refresh().unwrap().is_empty());
2991        let mut file = fs::OpenOptions::new().append(true).open(&history).unwrap();
2992        writeln!(file, "lo\"}}").unwrap();
2993        file.flush().unwrap();
2994
2995        assert_eq!(index.refresh().unwrap(), BTreeSet::from(["partial".into()]));
2996        assert_eq!(index.topics["partial"][0].content, "hello");
2997        fs::remove_dir_all(root).ok();
2998    }
2999
3000    #[test]
3001    fn cached_codex_history_enrichment_matches_stateless_discovery() {
3002        let root = temp_dir("codex-history-parity");
3003        let sessions = root.join("sessions");
3004        let workspace = root.join("workspace");
3005        fs::create_dir_all(&sessions).unwrap();
3006        fs::create_dir_all(&workspace).unwrap();
3007        fs::write(
3008            sessions.join("rollout.jsonl"),
3009            format!(
3010                "{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"alpha\",\"cwd\":{}}}}}\n{{\"type\":\"turn_context\",\"payload\":{{\"cwd\":{},\"model\":\"gpt-test\"}}}}\n{{\"type\":\"response_item\",\"payload\":{{\"type\":\"message\",\"role\":\"user\",\"content\":[{{\"type\":\"input_text\",\"text\":\"transcript fallback\"}}]}}}}\n",
3011                serde_json::to_string(&workspace.to_string_lossy()).unwrap(),
3012                serde_json::to_string(&workspace.to_string_lossy()).unwrap(),
3013            ),
3014        )
3015        .unwrap();
3016        fs::write(
3017            root.join("history.jsonl"),
3018            "{\"session_id\":\"alpha\",\"text\":\"history topic\"}\n",
3019        )
3020        .unwrap();
3021        let query = DiscoveryQuery {
3022            harnesses: vec![HarnessId::from(HarnessId::CODEX)],
3023            homes: HarnessHomes {
3024                codex: sessions.clone(),
3025                ..HarnessHomes::default()
3026            },
3027            include_topic_candidates: true,
3028            ..DiscoveryQuery::default()
3029        };
3030        let catalog = HarnessCatalog::new();
3031        let projected = catalog
3032            .project_index(&query, catalog.discover_raw_index(&query))
3033            .unwrap();
3034        let expected = catalog
3035            .enrich_index_page(&query, projected.clone())
3036            .unwrap();
3037        let mut history = CodexHistoryTopicIndex::new(&sessions);
3038        history.refresh().unwrap();
3039        let actual = catalog
3040            .enrich_index_page_with_codex_history(&query, projected, &history)
3041            .unwrap();
3042
3043        assert_eq!(actual, expected);
3044        assert_eq!(actual[0].preview_candidates[0].content, "history topic");
3045        fs::remove_dir_all(root).ok();
3046    }
3047
3048    #[test]
3049    fn locator_json_round_trip_preserves_sqlite_selector() {
3050        let locator = SessionLocator {
3051            harness: HarnessId::from(HarnessId::OPENCODE),
3052            session_id: "ses_123".into(),
3053            storage: StorageLocator::Sqlite {
3054                path: PathBuf::from("/tmp/opencode-dev.db"),
3055                selector: "ses_123".into(),
3056            },
3057        };
3058        let encoded = serde_json::to_string(&locator).unwrap();
3059        assert_eq!(
3060            serde_json::from_str::<SessionLocator>(&encoded).unwrap(),
3061            locator
3062        );
3063    }
3064
3065    #[test]
3066    fn discovers_filters_loads_and_follows_three_jsonl_harnesses() {
3067        let root = temp_dir("jsonl");
3068        let workspace = root.join("workspace");
3069        let other = root.join("other");
3070        fs::create_dir_all(&workspace).unwrap();
3071        fs::create_dir_all(&other).unwrap();
3072
3073        let claude = root.join("claude");
3074        let codex = root.join("codex");
3075        let pi = root.join("pi");
3076        fs::create_dir_all(&claude).unwrap();
3077        fs::create_dir_all(&codex).unwrap();
3078        fs::create_dir_all(&pi).unwrap();
3079        fs::write(
3080            claude.join("claude.jsonl"),
3081            format!(
3082                "{{\"type\":\"user\",\"sessionId\":\"cc-1\",\"cwd\":{},\"timestamp\":\"2026-01-01T00:00:01Z\",\"message\":{{\"role\":\"user\",\"content\":\"hi\"}}}}\n",
3083                serde_json::to_string(&workspace.to_string_lossy()).unwrap()
3084            ),
3085        )
3086        .unwrap();
3087        fs::write(
3088            codex.join("rollout.jsonl"),
3089            format!(
3090                "{{\"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",
3091                serde_json::to_string(&workspace.to_string_lossy()).unwrap()
3092            ),
3093        )
3094        .unwrap();
3095        fs::write(
3096            pi.join("pi.jsonl"),
3097            format!(
3098                "{{\"type\":\"session\",\"version\":3,\"id\":\"pi-1\",\"timestamp\":\"2026-01-01T00:00:00Z\",\"cwd\":{}}}\n{{\"type\":\"message\",\"message\":{{\"role\":\"user\",\"content\":\"inspect pi\"}}}}\n",
3099                serde_json::to_string(&workspace.to_string_lossy()).unwrap()
3100            ),
3101        )
3102        .unwrap();
3103        fs::write(
3104            pi.join("unrelated.jsonl"),
3105            format!(
3106                "{{\"type\":\"session\",\"version\":3,\"id\":\"pi-2\",\"timestamp\":\"2026-01-01T00:00:00Z\",\"cwd\":{}}}\n",
3107                serde_json::to_string(&other.to_string_lossy()).unwrap()
3108            ),
3109        )
3110        .unwrap();
3111        fs::write(claude.join("partial.jsonl"), "{truncated").unwrap();
3112
3113        let query = DiscoveryQuery {
3114            workspace: Some(workspace),
3115            homes: HarnessHomes {
3116                claude_code: claude,
3117                codex,
3118                pi,
3119                opencode: root.join("missing-opencode"),
3120                grok: root.join("missing-grok"),
3121                gemini: root.join("missing-gemini"),
3122                goose: root.join("missing-goose"),
3123                supercode: root.join("missing-supercode"),
3124                openclaw: root.join("missing-openclaw"),
3125                hermes: root.join("missing-hermes"),
3126                orchestrator: root.join("missing-orchestrator"),
3127            },
3128            ..DiscoveryQuery::default()
3129        };
3130        let catalog = HarnessCatalog::new();
3131        let found = catalog.discover(&query).unwrap();
3132        assert_eq!(found.len(), 3);
3133        assert_eq!(
3134            found
3135                .iter()
3136                .map(|item| item.locator.harness.as_str())
3137                .collect::<HashSet<_>>(),
3138            HashSet::from([HarnessId::CLAUDE_CODE, HarnessId::CODEX, HarnessId::PI])
3139        );
3140        for descriptor in found {
3141            assert!(descriptor.preview_candidates.is_empty());
3142            assert_eq!(descriptor.latest_message_candidates.len(), 1);
3143            assert_eq!(descriptor.latest_message_candidates[0].role, "user");
3144            assert!(descriptor.latest_message_candidates[0].cursor.is_some());
3145            if descriptor.locator.harness.as_str() == HarnessId::CLAUDE_CODE {
3146                assert_eq!(
3147                    descriptor.latest_message_candidates[0]
3148                        .metadata
3149                        .get("timestamp")
3150                        .map(String::as_str),
3151                    Some("2026-01-01T00:00:01Z")
3152                );
3153            } else if descriptor.locator.harness.as_str() == HarnessId::CODEX {
3154                assert_eq!(
3155                    descriptor.latest_message_candidates[0]
3156                        .metadata
3157                        .get("timestamp")
3158                        .map(String::as_str),
3159                    Some("2026-01-01T00:00:02Z")
3160                );
3161            }
3162            let loaded = catalog.load(&descriptor.locator).unwrap();
3163            assert_eq!(
3164                loaded.meta.session_id.as_deref(),
3165                Some(descriptor.locator.session_id.as_str())
3166            );
3167            let mut follower = catalog.follow(&descriptor.locator).unwrap();
3168            assert!(matches!(
3169                follower.poll().unwrap(),
3170                Some(crate::SessionWatchEvent::SessionSnapshot { .. })
3171            ));
3172        }
3173        fs::remove_dir_all(root).ok();
3174    }
3175
3176    #[test]
3177    fn preview_cursor_tracks_native_boundary_not_growing_text() {
3178        let first = serde_json::json!({
3179            "timestamp": "2026-01-01T00:00:02Z",
3180            "type": "response_item",
3181            "payload": {"type": "message", "role": "assistant", "content": "partial"}
3182        });
3183        let grown = serde_json::json!({
3184            "timestamp": "2026-01-01T00:00:02Z",
3185            "type": "response_item",
3186            "payload": {"type": "message", "role": "assistant", "content": "partial and complete"}
3187        });
3188        let next = serde_json::json!({
3189            "timestamp": "2026-01-01T00:00:03Z",
3190            "type": "response_item",
3191            "payload": {"type": "message", "role": "assistant", "content": "next"}
3192        });
3193
3194        assert_eq!(
3195            message_candidate_cursor(HarnessId::CODEX, &first),
3196            message_candidate_cursor(HarnessId::CODEX, &grown)
3197        );
3198        assert_ne!(
3199            message_candidate_cursor(HarnessId::CODEX, &first),
3200            message_candidate_cursor(HarnessId::CODEX, &next)
3201        );
3202    }
3203
3204    #[test]
3205    fn codex_child_rollouts_roll_into_roots_before_pagination() {
3206        let root = temp_dir("codex-roots");
3207        let codex = root.join("codex");
3208        fs::create_dir_all(&codex).unwrap();
3209        let write_rollout =
3210            |name: &str, payload: Value, modified_seconds: u64| {
3211                let path = codex.join(format!("{name}.jsonl"));
3212                fs::write(
3213                    &path,
3214                    format!(
3215                        "{}\n",
3216                        serde_json::json!({
3217                            "timestamp": "2026-01-01T00:00:00Z",
3218                            "type": "session_meta",
3219                            "payload": payload,
3220                        })
3221                    ),
3222                )
3223                .unwrap();
3224                File::open(&path)
3225                    .unwrap()
3226                    .set_times(fs::FileTimes::new().set_modified(
3227                        UNIX_EPOCH + std::time::Duration::from_secs(modified_seconds),
3228                    ))
3229                    .unwrap();
3230            };
3231        write_rollout(
3232            "parent",
3233            serde_json::json!({"id":"parent","cwd":"/project","source":"cli"}),
3234            100,
3235        );
3236        write_rollout(
3237            "other",
3238            serde_json::json!({"id":"other","cwd":"/project","source":"cli"}),
3239            200,
3240        );
3241        write_rollout(
3242            "child",
3243            serde_json::json!({
3244                "id": "child",
3245                "cwd": "/project",
3246                "parent_thread_id": "parent",
3247                "source": {"subagent":{"thread_spawn":{
3248                    "parent_thread_id":"parent",
3249                    "depth":1,
3250                    "agent_path":"/root/reviewer"
3251                }}}
3252            }),
3253            300,
3254        );
3255
3256        let catalog = HarnessCatalog::new();
3257        let query = DiscoveryQuery {
3258            harnesses: vec![HarnessId::from(HarnessId::CODEX)],
3259            homes: HarnessHomes {
3260                codex: codex.clone(),
3261                ..HarnessHomes::default()
3262            },
3263            limit: Some(1),
3264            ..DiscoveryQuery::default()
3265        };
3266        let roots = catalog.discover(&query).unwrap();
3267        assert_eq!(roots.len(), 1);
3268        assert_eq!(roots[0].locator.session_id, "parent");
3269        assert_eq!(roots[0].updated_at_ms, Some(300_000));
3270        assert_eq!(roots[0].parent_session_id, None);
3271        assert_eq!(roots[0].child_session_count, 1);
3272
3273        let tree = catalog
3274            .discover(&DiscoveryQuery {
3275                limit: None,
3276                include_child_sessions: true,
3277                root_session_id: Some("parent".into()),
3278                ..query
3279            })
3280            .unwrap();
3281        assert_eq!(tree.len(), 2);
3282        assert!(tree
3283            .iter()
3284            .all(|descriptor| descriptor.locator.session_id != "other"));
3285        let child = tree
3286            .iter()
3287            .find(|descriptor| descriptor.locator.session_id == "child")
3288            .unwrap();
3289        assert_eq!(child.parent_session_id.as_deref(), Some("parent"));
3290        fs::remove_dir_all(root).ok();
3291    }
3292
3293    #[test]
3294    fn discovers_loads_and_follows_opencode_sqlite() {
3295        let db = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
3296            .join("../harness/tests/fixtures/opencode_fixture/opencode.db");
3297        let catalog = HarnessCatalog::new();
3298        let found = catalog
3299            .discover(&DiscoveryQuery {
3300                harnesses: vec![HarnessId::from(HarnessId::OPENCODE)],
3301                homes: HarnessHomes {
3302                    opencode: db,
3303                    ..HarnessHomes::default()
3304                },
3305                ..DiscoveryQuery::default()
3306            })
3307            .unwrap();
3308        assert!(!found.is_empty());
3309        for descriptor in found {
3310            assert_eq!(descriptor.locator.harness.as_str(), HarnessId::OPENCODE);
3311            assert_eq!(
3312                catalog.load(&descriptor.locator).unwrap().meta.session_id,
3313                Some(descriptor.locator.session_id.clone())
3314            );
3315            assert!(catalog.follow(&descriptor.locator).is_ok());
3316        }
3317    }
3318
3319    #[test]
3320    fn discovers_loads_and_follows_hermes_sqlite_by_session() {
3321        // A copy of the committed Hermes fixture store, so the test may append to it.
3322        let root = temp_dir("hermes-follow");
3323        fs::create_dir_all(&root).unwrap();
3324        let db = root.join("state.db");
3325        fs::copy(
3326            PathBuf::from(env!("CARGO_MANIFEST_DIR"))
3327                .join("../harness/tests/fixtures/hermes_home/state.db"),
3328            &db,
3329        )
3330        .unwrap();
3331        let catalog = HarnessCatalog::new();
3332        let query = DiscoveryQuery {
3333            harnesses: vec![HarnessId::from(HarnessId::HERMES)],
3334            homes: HarnessHomes {
3335                hermes: db.clone(),
3336                ..HarnessHomes::default()
3337            },
3338            ..DiscoveryQuery::default()
3339        };
3340        let found = catalog.discover(&query).unwrap();
3341        assert!(found.len() >= 2, "{found:#?}");
3342        // Every discovered locator loads AND follows as ITS OWN session (not the store's newest),
3343        // and its list preview is that session's own latest message rather than an empty read of
3344        // the store file's tail.
3345        for descriptor in &found {
3346            assert_eq!(descriptor.locator.harness.as_str(), HarnessId::HERMES);
3347            let loaded = catalog.load(&descriptor.locator).unwrap();
3348            let last_text = loaded.messages.iter().rev().find_map(|message| {
3349                (matches!(message.role, crate::Role::User | crate::Role::Assistant))
3350                    .then(|| message.content.clone())
3351                    .flatten()
3352            });
3353            // Lineage-only fixture rows have no messages and therefore no preview.
3354            assert_eq!(
3355                descriptor
3356                    .latest_message_candidates
3357                    .first()
3358                    .map(|c| c.content.as_str()),
3359                last_text.as_deref(),
3360                "{}",
3361                descriptor.locator.session_id
3362            );
3363            assert_eq!(
3364                catalog.load(&descriptor.locator).unwrap().meta.session_id,
3365                Some(descriptor.locator.session_id.clone())
3366            );
3367            let mut follower = catalog.follow(&descriptor.locator).unwrap();
3368            match follower.poll().unwrap() {
3369                Some(crate::watch::SessionWatchEvent::SessionSnapshot { session, .. }) => {
3370                    assert_eq!(
3371                        session.meta.session_id,
3372                        Some(descriptor.locator.session_id.clone())
3373                    );
3374                }
3375                other => panic!("expected an initial snapshot, got {other:?}"),
3376            }
3377        }
3378        // Append a message to ONE session: only that session's follower wakes, with exactly the new
3379        // message, while a sibling's follower stays quiet.
3380        let target = &found[0].locator;
3381        let sibling = &found[1].locator;
3382        let mut target_follower = catalog.follow(target).unwrap();
3383        let mut sibling_follower = catalog.follow(sibling).unwrap();
3384        target_follower.poll().unwrap();
3385        sibling_follower.poll().unwrap();
3386        std::thread::sleep(std::time::Duration::from_millis(20));
3387        {
3388            let conn = rusqlite::Connection::open(&db).unwrap();
3389            conn.execute(
3390                "INSERT INTO messages (session_id, role, content, timestamp, active) VALUES (?1, 'assistant', 'appended by the follow test', ?2, 1)",
3391                rusqlite::params![target.session_id, 1_800_000_000.0_f64],
3392            )
3393            .unwrap();
3394        }
3395        match target_follower.poll().unwrap() {
3396            Some(crate::watch::SessionWatchEvent::MessagesAppended {
3397                session_id,
3398                messages,
3399                ..
3400            }) => {
3401                assert_eq!(session_id, Some(target.session_id.clone()));
3402                assert_eq!(messages.len(), 1);
3403                assert_eq!(
3404                    messages[0].content.as_deref(),
3405                    Some("appended by the follow test")
3406                );
3407            }
3408            other => panic!("expected messages_appended for the target session, got {other:?}"),
3409        }
3410        assert!(
3411            sibling_follower.poll().unwrap().is_none(),
3412            "the sibling session must not wake"
3413        );
3414        fs::remove_dir_all(&root).ok();
3415    }
3416
3417    #[test]
3418    fn discovers_loads_and_follows_gemini_conversation_records() {
3419        let root = temp_dir("gemini");
3420        let workspace = root.join("workspace");
3421        let chats = root.join("gemini/tmp/demo/chats");
3422        fs::create_dir_all(&workspace).unwrap();
3423        fs::create_dir_all(&chats).unwrap();
3424        fs::write(
3425            root.join("gemini/projects.json"),
3426            serde_json::json!({
3427                "projects": {workspace.to_string_lossy(): "demo"}
3428            })
3429            .to_string(),
3430        )
3431        .unwrap();
3432        let transcript = chats.join("gemini-id.jsonl");
3433        fs::write(
3434            &transcript,
3435            include_str!("../../harness/tests/fixtures/gemini_session.jsonl"),
3436        )
3437        .unwrap();
3438
3439        let catalog = HarnessCatalog::new();
3440        let found = catalog
3441            .discover(&DiscoveryQuery {
3442                harnesses: vec![HarnessId::from(HarnessId::GEMINI)],
3443                homes: HarnessHomes {
3444                    gemini: root.join("gemini"),
3445                    ..HarnessHomes::default()
3446                },
3447                workspace: Some(workspace.clone()),
3448                ..DiscoveryQuery::default()
3449            })
3450            .unwrap();
3451
3452        assert_eq!(found.len(), 1);
3453        assert_eq!(found[0].cwd.as_deref(), Some(workspace.as_path()));
3454        assert_eq!(found[0].message_count, None);
3455        assert_eq!(found[0].model.as_deref(), Some("gemini-2.5-pro"));
3456        assert_eq!(found[0].title, None);
3457        assert!(found[0].preview_candidates.is_empty());
3458        assert_eq!(found[0].latest_message_candidates.len(), 3);
3459        assert_eq!(
3460            found[0].latest_message_candidates[0].content,
3461            "Fixture inspected."
3462        );
3463        let loaded = catalog.load(&found[0].locator).unwrap();
3464        assert_eq!(
3465            loaded.meta.session_id.as_deref(),
3466            Some("11111111-1111-4111-8111-111111111111")
3467        );
3468        assert_eq!(loaded.messages.len(), 4);
3469        assert!(matches!(
3470            catalog.follow(&found[0].locator).unwrap().poll().unwrap(),
3471            Some(crate::SessionWatchEvent::SessionSnapshot { .. })
3472        ));
3473        fs::remove_dir_all(root).ok();
3474    }
3475
3476    #[test]
3477    fn discovers_native_store_and_pages_search_results() {
3478        let root = temp_dir("supercode");
3479        let store_root = root.join("sessions");
3480        fs::create_dir_all(&store_root).unwrap();
3481        for (name, title) in [
3482            ("alpha", "Alpha planning"),
3483            ("beta", "Beta implementation"),
3484            ("gamma", "Gamma review"),
3485        ] {
3486            fs::write(
3487                store_root.join(format!("{name}.jsonl")),
3488                format!("{{\"role\":\"user\",\"content\":\"{title}\"}}\n"),
3489            )
3490            .unwrap();
3491            fs::write(
3492                store_root.join(format!("{name}.meta.json")),
3493                serde_json::json!({"name": name, "title": title}).to_string(),
3494            )
3495            .unwrap();
3496        }
3497        let catalog = HarnessCatalog::new();
3498        let base = DiscoveryQuery {
3499            harnesses: vec![HarnessId::from(HarnessId::SUPERCODE)],
3500            homes: HarnessHomes {
3501                supercode: store_root,
3502                ..HarnessHomes::default()
3503            },
3504            limit: Some(1),
3505            ..DiscoveryQuery::default()
3506        };
3507
3508        let first = catalog.discover_page(&base).unwrap();
3509        assert_eq!(first.sessions.len(), 1);
3510        assert!(first.next_cursor.is_some());
3511        let second = catalog
3512            .discover_page(&DiscoveryQuery {
3513                cursor: first.next_cursor,
3514                ..base.clone()
3515            })
3516            .unwrap();
3517        assert_eq!(second.sessions.len(), 1);
3518        assert_ne!(
3519            first.sessions[0].locator.session_id,
3520            second.sessions[0].locator.session_id
3521        );
3522        let search = catalog
3523            .discover_page(&DiscoveryQuery {
3524                limit: None,
3525                query: Some("implementation".into()),
3526                ..base
3527            })
3528            .unwrap();
3529        assert_eq!(search.sessions.len(), 1);
3530        assert_eq!(search.sessions[0].locator.session_id, "beta");
3531        assert_eq!(search.sessions[0].message_count, None);
3532        assert_eq!(
3533            catalog
3534                .load(&search.sessions[0].locator)
3535                .unwrap()
3536                .messages
3537                .len(),
3538            1
3539        );
3540        fs::remove_dir_all(root).ok();
3541    }
3542
3543    #[test]
3544    fn native_workspace_discovery_reads_bounded_sidecar_headers() {
3545        let root = temp_dir("supercode-bounded-header");
3546        let store_root = root.join("sessions");
3547        let workspace = root.join("project");
3548        fs::create_dir_all(&store_root).unwrap();
3549        fs::create_dir_all(&workspace).unwrap();
3550        let name = "bounded-native";
3551        fs::write(
3552            store_root.join(format!("{name}.meta.json")),
3553            serde_json::json!({"name": name, "title": "Bounded native"}).to_string(),
3554        )
3555        .unwrap();
3556        fs::write(
3557            store_root.join(format!("{name}.jsonl")),
3558            "{\"role\":\"user\",\"content\":\"projected view\"}\n",
3559        )
3560        .unwrap();
3561        let sidecar = [
3562            serde_json::json!({
3563                "supercode_native": 2,
3564                "source": "claude_code",
3565                "session_id": "native-session"
3566            })
3567            .to_string(),
3568            serde_json::json!({
3569                "type": "user",
3570                "sessionId": "native-session",
3571                "cwd": workspace,
3572                "message": {"role": "user", "content": "hello"}
3573            })
3574            .to_string(),
3575            serde_json::json!({
3576                "type": "assistant",
3577                "sessionId": "native-session",
3578                "cwd": workspace,
3579                "message": {"role": "assistant", "model": "claude-sonnet-5", "content": []}
3580            })
3581            .to_string(),
3582            // A full native-family parse rejects this trailing residue. Header
3583            // discovery must not touch it after it has enough metadata.
3584            "not-json".into(),
3585        ]
3586        .join("\n");
3587        fs::write(
3588            store_root.join(format!("{name}.sidecar.jsonl")),
3589            format!("{sidecar}\n"),
3590        )
3591        .unwrap();
3592
3593        let found = HarnessCatalog::new()
3594            .discover(&DiscoveryQuery {
3595                workspace: Some(workspace.clone()),
3596                harnesses: vec![HarnessId::from(HarnessId::SUPERCODE)],
3597                homes: HarnessHomes {
3598                    supercode: store_root,
3599                    ..HarnessHomes::default()
3600                },
3601                ..DiscoveryQuery::default()
3602            })
3603            .unwrap();
3604
3605        assert_eq!(found.len(), 1);
3606        assert_eq!(found[0].cwd.as_deref(), Some(workspace.as_path()));
3607        assert_eq!(found[0].model.as_deref(), Some("claude-sonnet-5"));
3608        assert_eq!(found[0].message_count, None);
3609        fs::remove_dir_all(root).ok();
3610    }
3611
3612    #[test]
3613    fn discovers_current_opencode_schema_without_a_session_model_column() {
3614        let root = temp_dir("opencode-current");
3615        let db = root.join("opencode.db");
3616        let conn = Connection::open(&db).unwrap();
3617        conn.execute_batch(
3618            "CREATE TABLE session (
3619                id TEXT PRIMARY KEY,
3620                directory TEXT NOT NULL,
3621                title TEXT NOT NULL,
3622                time_updated INTEGER NOT NULL
3623             );
3624             CREATE TABLE message (
3625                id TEXT PRIMARY KEY,
3626                session_id TEXT NOT NULL
3627             );
3628             INSERT INTO session VALUES ('ses_current', '/tmp/work', 'Current', 42);
3629             INSERT INTO message VALUES ('msg_current', 'ses_current');",
3630        )
3631        .unwrap();
3632        drop(conn);
3633
3634        let found = HarnessCatalog::new()
3635            .discover(&DiscoveryQuery {
3636                harnesses: vec![HarnessId::from(HarnessId::OPENCODE)],
3637                homes: HarnessHomes {
3638                    opencode: db,
3639                    ..HarnessHomes::default()
3640                },
3641                ..DiscoveryQuery::default()
3642            })
3643            .unwrap();
3644
3645        assert_eq!(found.len(), 1);
3646        assert_eq!(found[0].locator.session_id, "ses_current");
3647        assert_eq!(found[0].message_count, Some(1));
3648        assert_eq!(found[0].model, None);
3649        fs::remove_dir_all(root).ok();
3650    }
3651
3652    #[test]
3653    fn workspace_filter_never_matches_a_relative_recorded_cwd() {
3654        // OpenCode has shipped session rows whose `directory` is the literal
3655        // ".". Resolving that against the discoverer's own cwd made the
3656        // session match every workspace discovery ran from — the workspace
3657        // here IS the test process cwd, the exact aliasing that leaked.
3658        let root = temp_dir("opencode-relative-cwd");
3659        let db = root.join("opencode.db");
3660        let conn = Connection::open(&db).unwrap();
3661        let here = std::env::current_dir().unwrap();
3662        conn.execute_batch(&format!(
3663            "CREATE TABLE session (
3664                id TEXT PRIMARY KEY,
3665                directory TEXT NOT NULL,
3666                title TEXT NOT NULL,
3667                time_updated INTEGER NOT NULL
3668             );
3669             CREATE TABLE message (
3670                id TEXT PRIMARY KEY,
3671                session_id TEXT NOT NULL
3672             );
3673             INSERT INTO session VALUES ('ses_relative', '.', 'Ghost', 41);
3674             INSERT INTO session VALUES ('ses_here', '{}', 'Real', 42);",
3675            here.display()
3676        ))
3677        .unwrap();
3678        drop(conn);
3679
3680        let found = HarnessCatalog::new()
3681            .discover(&DiscoveryQuery {
3682                workspace: Some(here),
3683                harnesses: vec![HarnessId::from(HarnessId::OPENCODE)],
3684                homes: HarnessHomes {
3685                    opencode: db,
3686                    ..HarnessHomes::default()
3687                },
3688                ..DiscoveryQuery::default()
3689            })
3690            .unwrap();
3691
3692        assert_eq!(found.len(), 1);
3693        assert_eq!(found[0].locator.session_id, "ses_here");
3694        fs::remove_dir_all(root).ok();
3695    }
3696}