Skip to main content

supercode/
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::HashSet;
7use std::fs::{self, File};
8use std::io::{BufRead, BufReader};
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::session::percent_decode_path;
17use crate::{Error, Fidelity, Result, Session, SessionFollower};
18
19/// Extensible identifier for a coding harness.
20#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
21#[serde(transparent)]
22pub struct HarnessId(pub String);
23
24impl HarnessId {
25    /// Claude Code's stable identifier.
26    pub const CLAUDE_CODE: &'static str = "claude-code";
27    /// Codex's stable identifier.
28    pub const CODEX: &'static str = "codex";
29    /// Pi's stable identifier.
30    pub const PI: &'static str = "pi";
31    /// OpenCode's stable identifier.
32    pub const OPENCODE: &'static str = "opencode";
33    /// Grok's stable identifier.
34    pub const GROK: &'static str = "grok";
35
36    /// Construct an identifier without restricting third-party harness names.
37    pub fn new(value: impl Into<String>) -> Self {
38        Self(value.into())
39    }
40
41    /// Return the identifier as a string slice.
42    pub fn as_str(&self) -> &str {
43        &self.0
44    }
45}
46
47impl From<&str> for HarnessId {
48    fn from(value: &str) -> Self {
49        Self::new(value)
50    }
51}
52
53/// Durable storage address for a persisted session.
54#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
55#[serde(tag = "kind", rename_all = "snake_case")]
56pub enum StorageLocator {
57    /// One session stored in one file.
58    File {
59        /// Absolute or caller-resolvable path to the transcript.
60        path: PathBuf,
61    },
62    /// One logical session selected from a SQLite store.
63    Sqlite {
64        /// Path to the SQLite database.
65        path: PathBuf,
66        /// Harness-native stable selector, currently an OpenCode session id.
67        selector: String,
68    },
69}
70
71impl StorageLocator {
72    /// Return the underlying file or database path.
73    pub fn path(&self) -> &Path {
74        match self {
75            Self::File { path } | Self::Sqlite { path, .. } => path,
76        }
77    }
78}
79
80/// Stable identity for a persisted harness session.
81#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
82pub struct SessionLocator {
83    /// Harness which owns the storage format.
84    pub harness: HarnessId,
85    /// Harness-native session identity.
86    pub session_id: String,
87    /// Exact storage address needed to load the session again.
88    pub storage: StorageLocator,
89}
90
91/// Lightweight metadata returned by catalog discovery.
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93pub struct SessionDescriptor {
94    /// Durable address accepted by [`HarnessCatalog::load`] and
95    /// [`HarnessCatalog::follow`].
96    pub locator: SessionLocator,
97    /// Working directory recorded by the harness.
98    pub cwd: Option<PathBuf>,
99    /// Harness-provided title, when cheaply available.
100    pub title: Option<String>,
101    /// Last update time as Unix epoch milliseconds.
102    pub updated_at_ms: Option<u64>,
103    /// Harness message-record count, when available without loading the session.
104    pub message_count: Option<usize>,
105    /// Model recorded in lightweight session metadata.
106    pub model: Option<String>,
107}
108
109/// Configurable session roots for the built-in harnesses.
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(default)]
112pub struct HarnessHomes {
113    /// Directory containing Claude Code project session directories.
114    pub claude_code: PathBuf,
115    /// Directory containing Codex rollout sessions.
116    pub codex: PathBuf,
117    /// Directory containing Pi project session directories.
118    pub pi: PathBuf,
119    /// OpenCode data root, or an explicit `opencode*.db` path.
120    pub opencode: PathBuf,
121    /// Grok session root containing percent-encoded workspace directories.
122    pub grok: PathBuf,
123}
124
125impl Default for HarnessHomes {
126    fn default() -> Self {
127        let home = std::env::var_os("HOME")
128            .map(PathBuf::from)
129            .unwrap_or_else(|| PathBuf::from("."));
130        let claude_root = std::env::var_os("CLAUDE_CONFIG_DIR")
131            .map(PathBuf::from)
132            .unwrap_or_else(|| home.join(".claude"));
133        let codex_root = std::env::var_os("CODEX_HOME")
134            .map(PathBuf::from)
135            .unwrap_or_else(|| home.join(".codex"));
136        let pi = std::env::var_os("PI_CODING_AGENT_SESSION_DIR")
137            .map(PathBuf::from)
138            .unwrap_or_else(|| {
139                std::env::var_os("PI_CODING_AGENT_DIR")
140                    .map(PathBuf::from)
141                    .unwrap_or_else(|| home.join(".pi/agent"))
142                    .join("sessions")
143            });
144        let opencode = std::env::var_os("OPENCODE_DB")
145            .map(PathBuf::from)
146            .unwrap_or_else(|| {
147                std::env::var_os("XDG_DATA_HOME")
148                    .map(PathBuf::from)
149                    .unwrap_or_else(|| home.join(".local/share"))
150                    .join("opencode")
151            });
152        let grok = std::env::var_os("GROK_HOME")
153            .map(PathBuf::from)
154            .unwrap_or_else(|| home.join(".grok"))
155            .join("sessions");
156        Self {
157            claude_code: claude_root.join("projects"),
158            codex: codex_root.join("sessions"),
159            pi,
160            opencode,
161            grok,
162        }
163    }
164}
165
166/// Filters and roots used for one catalog scan.
167#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
168#[serde(default)]
169pub struct DiscoveryQuery {
170    /// Only return sessions whose recorded working directory is this path.
171    pub workspace: Option<PathBuf>,
172    /// Harnesses to scan. Empty means all built-ins.
173    pub harnesses: Vec<HarnessId>,
174    /// Storage roots to scan.
175    pub homes: HarnessHomes,
176    /// Maximum number of results after newest-first sorting.
177    pub limit: Option<usize>,
178}
179
180/// Read-only entry point for discovering, loading, and following persisted
181/// harness sessions.
182#[derive(Debug, Default, Clone, Copy)]
183pub struct HarnessCatalog;
184
185impl HarnessCatalog {
186    /// Construct a catalog. It holds no cache or global mutable state.
187    pub fn new() -> Self {
188        Self
189    }
190
191    /// Discover sessions using lightweight headers/indexes rather than full
192    /// transcript normalization. Malformed or concurrently-created entries
193    /// are skipped without aborting the rest of the scan.
194    pub fn discover(&self, query: &DiscoveryQuery) -> Result<Vec<SessionDescriptor>> {
195        let selected: HashSet<&str> = if query.harnesses.is_empty() {
196            [
197                HarnessId::CLAUDE_CODE,
198                HarnessId::CODEX,
199                HarnessId::PI,
200                HarnessId::OPENCODE,
201                HarnessId::GROK,
202            ]
203            .into_iter()
204            .collect()
205        } else {
206            query.harnesses.iter().map(HarnessId::as_str).collect()
207        };
208        let mut found = Vec::new();
209        if selected.contains(HarnessId::CLAUDE_CODE) {
210            discover_jsonl(
211                &query.homes.claude_code,
212                HarnessId::CLAUDE_CODE,
213                query.workspace.as_deref(),
214                &mut found,
215            );
216        }
217        if selected.contains(HarnessId::CODEX) {
218            discover_jsonl(
219                &query.homes.codex,
220                HarnessId::CODEX,
221                query.workspace.as_deref(),
222                &mut found,
223            );
224        }
225        if selected.contains(HarnessId::PI) {
226            discover_jsonl(
227                &query.homes.pi,
228                HarnessId::PI,
229                query.workspace.as_deref(),
230                &mut found,
231            );
232        }
233        if selected.contains(HarnessId::OPENCODE) {
234            discover_opencode(
235                &query.homes.opencode,
236                query.workspace.as_deref(),
237                &mut found,
238            );
239        }
240        if selected.contains(HarnessId::GROK) {
241            discover_grok(&query.homes.grok, query.workspace.as_deref(), &mut found);
242        }
243        found.sort_by(|a, b| {
244            b.updated_at_ms
245                .cmp(&a.updated_at_ms)
246                .then_with(|| a.locator.session_id.cmp(&b.locator.session_id))
247        });
248        if let Some(limit) = query.limit {
249            found.truncate(limit);
250        }
251        Ok(found)
252    }
253
254    /// Load the complete normalized session named by a durable locator.
255    pub fn load(&self, locator: &SessionLocator) -> Result<Session> {
256        self.load_with_fidelity(locator, Fidelity::ByteLossless)
257    }
258
259    /// [`Self::load`] at a declared fidelity.
260    ///
261    /// Read-only surfaces (a session mirror, `follow`) pass
262    /// [`Fidelity::Semantic`] so a compacted transcript renders instead of
263    /// erroring; every continuation/transfer/export caller keeps the strict
264    /// default. See [`Session::load_with_fidelity`].
265    pub fn load_with_fidelity(
266        &self,
267        locator: &SessionLocator,
268        fidelity: Fidelity,
269    ) -> Result<Session> {
270        match &locator.storage {
271            StorageLocator::File { path } => Session::load_with_fidelity(path, fidelity),
272            StorageLocator::Sqlite { path, selector } => {
273                Session::from_opencode_sqlite(path, Some(selector))
274            }
275        }
276    }
277
278    /// Open a passive change-triggered follower for a durable locator.
279    pub fn follow(&self, locator: &SessionLocator) -> Result<SessionFollower> {
280        self.follow_with_fidelity(locator, Fidelity::ByteLossless)
281    }
282
283    /// [`Self::follow`] at a declared fidelity — see [`Self::load_with_fidelity`].
284    pub fn follow_with_fidelity(
285        &self,
286        locator: &SessionLocator,
287        fidelity: Fidelity,
288    ) -> Result<SessionFollower> {
289        SessionFollower::open_locator_with_fidelity(locator, fidelity)
290    }
291}
292
293#[derive(Default)]
294struct HeaderMeta {
295    session_id: Option<String>,
296    cwd: Option<PathBuf>,
297    model: Option<String>,
298}
299
300fn discover_jsonl(
301    root: &Path,
302    harness: &str,
303    workspace: Option<&Path>,
304    found: &mut Vec<SessionDescriptor>,
305) {
306    let mut files = Vec::new();
307    collect_jsonl(root, harness, &mut files);
308    for path in files {
309        let Ok(meta) = read_header(&path, harness) else {
310            continue;
311        };
312        if workspace.is_some_and(|wanted| {
313            meta.cwd
314                .as_deref()
315                .is_none_or(|cwd| !recorded_cwd_matches(cwd, wanted))
316        }) {
317            continue;
318        }
319        let session_id = meta.session_id.unwrap_or_else(|| {
320            path.file_stem()
321                .and_then(|value| value.to_str())
322                .unwrap_or("unknown")
323                .to_string()
324        });
325        found.push(SessionDescriptor {
326            locator: SessionLocator {
327                harness: HarnessId::new(harness),
328                session_id,
329                storage: StorageLocator::File { path: path.clone() },
330            },
331            cwd: meta.cwd,
332            title: None,
333            updated_at_ms: modified_ms(&path),
334            message_count: None,
335            model: meta.model,
336        });
337    }
338}
339
340fn collect_jsonl(root: &Path, harness: &str, out: &mut Vec<PathBuf>) {
341    let Ok(entries) = fs::read_dir(root) else {
342        return;
343    };
344    for entry in entries.flatten() {
345        let Ok(kind) = entry.file_type() else {
346            continue;
347        };
348        let path = entry.path();
349        if kind.is_dir() {
350            if harness == HarnessId::CLAUDE_CODE
351                && path.file_name().and_then(|v| v.to_str()) == Some("subagents")
352            {
353                continue;
354            }
355            collect_jsonl(&path, harness, out);
356        } else if kind.is_file() && path.extension().and_then(|v| v.to_str()) == Some("jsonl") {
357            out.push(path);
358        }
359    }
360}
361
362fn read_header(path: &Path, harness: &str) -> Result<HeaderMeta> {
363    let file = File::open(path)?;
364    let mut result = HeaderMeta::default();
365    let mut bytes = 0usize;
366    for line in BufReader::new(file).lines().take(32) {
367        let line = line?;
368        bytes += line.len();
369        if bytes > 256 * 1024 {
370            break;
371        }
372        let Ok(value) = serde_json::from_str::<Value>(&line) else {
373            continue;
374        };
375        match harness {
376            HarnessId::CLAUDE_CODE => {
377                fill_string(&mut result.session_id, value.get("sessionId"));
378                fill_path(&mut result.cwd, value.get("cwd"));
379                fill_string(
380                    &mut result.model,
381                    value.get("message").and_then(|v| v.get("model")),
382                );
383            }
384            HarnessId::CODEX => {
385                let payload = value.get("payload").unwrap_or(&Value::Null);
386                if value.get("type").and_then(Value::as_str) == Some("session_meta") {
387                    fill_string(&mut result.session_id, payload.get("id"));
388                    fill_path(&mut result.cwd, payload.get("cwd"));
389                }
390                if value.get("type").and_then(Value::as_str) == Some("turn_context") {
391                    fill_path(&mut result.cwd, payload.get("cwd"));
392                    fill_string(&mut result.model, payload.get("model"));
393                }
394            }
395            HarnessId::PI => {
396                if value.get("type").and_then(Value::as_str) == Some("session") {
397                    fill_string(&mut result.session_id, value.get("id"));
398                    fill_path(&mut result.cwd, value.get("cwd"));
399                }
400                fill_string(
401                    &mut result.model,
402                    value.get("message").and_then(|v| v.get("model")),
403                );
404            }
405            _ => {}
406        }
407        if result.session_id.is_some() && result.cwd.is_some() && result.model.is_some() {
408            break;
409        }
410    }
411    if result.session_id.is_none() && result.cwd.is_none() {
412        return Err(Error::Other(format!(
413            "{} has no recognizable {harness} session header",
414            path.display()
415        )));
416    }
417    Ok(result)
418}
419
420fn discover_grok(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
421    let Ok(workspaces) = fs::read_dir(root) else {
422        return;
423    };
424    for workspace_entry in workspaces.flatten() {
425        let encoded = workspace_entry.file_name();
426        let Some(cwd) = encoded
427            .to_str()
428            .and_then(percent_decode_path)
429            .map(PathBuf::from)
430        else {
431            continue;
432        };
433        if workspace.is_some_and(|wanted| !recorded_cwd_matches(&cwd, wanted)) {
434            continue;
435        }
436        let Ok(sessions) = fs::read_dir(workspace_entry.path()) else {
437            continue;
438        };
439        for session_entry in sessions.flatten() {
440            let session_dir = session_entry.path();
441            if !session_dir.is_dir() {
442                continue;
443            }
444            let transcript = session_dir.join("chat_history.jsonl");
445            if !transcript.is_file() {
446                continue;
447            }
448            let Some(session_id) = session_dir
449                .file_name()
450                .and_then(|name| name.to_str())
451                .map(str::to_string)
452            else {
453                continue;
454            };
455            let summary = fs::read_to_string(session_dir.join("summary.json"))
456                .ok()
457                .and_then(|text| serde_json::from_str::<Value>(&text).ok());
458            let title = summary
459                .as_ref()
460                .and_then(|value| value.get("generated_title"))
461                .and_then(Value::as_str)
462                .filter(|title| !title.is_empty())
463                .map(str::to_string);
464            let model = summary
465                .as_ref()
466                .and_then(|value| value.get("current_model_id"))
467                .and_then(Value::as_str)
468                .map(str::to_string);
469            let message_count = summary
470                .as_ref()
471                .and_then(|value| value.get("num_chat_messages"))
472                .and_then(Value::as_u64)
473                .and_then(|count| usize::try_from(count).ok());
474            let updated_at_ms = summary
475                .as_ref()
476                .and_then(|value| value.get("updated_at"))
477                .and_then(Value::as_str)
478                .and_then(crate::sidecar::rfc3339_to_ms)
479                .and_then(|millis| u64::try_from(millis).ok())
480                .or_else(|| modified_ms(&transcript));
481            found.push(SessionDescriptor {
482                locator: SessionLocator {
483                    harness: HarnessId::from(HarnessId::GROK),
484                    session_id,
485                    storage: StorageLocator::File { path: transcript },
486                },
487                cwd: Some(cwd.clone()),
488                title,
489                updated_at_ms,
490                message_count,
491                model,
492            });
493        }
494    }
495}
496
497fn discover_opencode(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
498    let mut dbs = Vec::new();
499    if root.is_file() {
500        dbs.push(root.to_path_buf());
501    } else if let Ok(entries) = fs::read_dir(root) {
502        dbs.extend(entries.flatten().map(|entry| entry.path()).filter(|path| {
503            path.file_name()
504                .and_then(|v| v.to_str())
505                .is_some_and(|name| name.starts_with("opencode") && name.ends_with(".db"))
506        }));
507    }
508    dbs.sort();
509    for db in dbs {
510        let Ok(conn) = Connection::open_with_flags(
511            &db,
512            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
513        ) else {
514            continue;
515        };
516        let has_model = conn.prepare("SELECT model FROM session LIMIT 0").is_ok();
517        let model_column = if has_model { "s.model" } else { "NULL" };
518        let query = format!(
519            "SELECT s.id, s.directory, s.title, s.time_updated, {model_column}, COUNT(m.id) \
520             FROM session s LEFT JOIN message m ON m.session_id = s.id \
521             GROUP BY s.id ORDER BY s.time_updated DESC"
522        );
523        let Ok(mut stmt) = conn.prepare(&query) else {
524            continue;
525        };
526        let Ok(rows) = stmt.query_map([], |row| {
527            Ok((
528                row.get::<_, String>(0)?,
529                row.get::<_, String>(1)?,
530                row.get::<_, String>(2)?,
531                row.get::<_, i64>(3)?,
532                row.get::<_, Option<String>>(4)?,
533                row.get::<_, i64>(5)?,
534            ))
535        }) else {
536            continue;
537        };
538        for row in rows.flatten() {
539            let (id, cwd, title, updated, model, messages) = row;
540            let cwd = PathBuf::from(cwd);
541            if workspace.is_some_and(|wanted| !recorded_cwd_matches(&cwd, wanted)) {
542                continue;
543            }
544            found.push(SessionDescriptor {
545                locator: SessionLocator {
546                    harness: HarnessId::from(HarnessId::OPENCODE),
547                    session_id: id.clone(),
548                    storage: StorageLocator::Sqlite {
549                        path: db.clone(),
550                        selector: id,
551                    },
552                },
553                cwd: Some(cwd),
554                title: (!title.is_empty()).then_some(title),
555                updated_at_ms: u64::try_from(updated).ok(),
556                message_count: usize::try_from(messages).ok(),
557                model,
558            });
559        }
560    }
561}
562
563fn fill_string(target: &mut Option<String>, value: Option<&Value>) {
564    if target.is_none() {
565        *target = value.and_then(Value::as_str).map(str::to_owned);
566    }
567}
568
569fn fill_path(target: &mut Option<PathBuf>, value: Option<&Value>) {
570    if target.is_none() {
571        *target = value.and_then(Value::as_str).map(PathBuf::from);
572    }
573}
574
575fn modified_ms(path: &Path) -> Option<u64> {
576    fs::metadata(path)
577        .ok()?
578        .modified()
579        .ok()?
580        .duration_since(UNIX_EPOCH)
581        .ok()
582        .and_then(|duration| u64::try_from(duration.as_millis()).ok())
583}
584
585/// A workspace filter is satisfiable only by a session whose RECORDED working
586/// directory is absolute. A relative recorded cwd (OpenCode has shipped
587/// literal `"."` session rows) carries no information about where the session
588/// ran; resolving it against the discoverer's own current directory made such
589/// a session match every workspace discovery happened to run from.
590fn recorded_cwd_matches(recorded: &Path, wanted: &Path) -> bool {
591    recorded.is_absolute() && same_path(recorded, wanted)
592}
593
594fn same_path(left: &Path, right: &Path) -> bool {
595    match (fs::canonicalize(left), fs::canonicalize(right)) {
596        (Ok(left), Ok(right)) => left == right,
597        _ => normalize_path(left) == normalize_path(right),
598    }
599}
600
601fn normalize_path(path: &Path) -> PathBuf {
602    let absolute = if path.is_absolute() {
603        path.to_path_buf()
604    } else {
605        std::env::current_dir()
606            .unwrap_or_else(|_| PathBuf::from("."))
607            .join(path)
608    };
609    let mut normalized = PathBuf::new();
610    for component in absolute.components() {
611        match component {
612            Component::CurDir => {}
613            Component::ParentDir => {
614                normalized.pop();
615            }
616            other => normalized.push(other.as_os_str()),
617        }
618    }
619    normalized
620}
621
622#[cfg(test)]
623mod tests {
624    use super::*;
625    use std::time::{SystemTime, UNIX_EPOCH};
626
627    fn temp_dir(label: &str) -> PathBuf {
628        let nonce = SystemTime::now()
629            .duration_since(UNIX_EPOCH)
630            .unwrap()
631            .as_nanos();
632        let path = std::env::temp_dir().join(format!(
633            "supercode-catalog-{label}-{}-{nonce}",
634            std::process::id()
635        ));
636        fs::create_dir_all(&path).unwrap();
637        path
638    }
639
640    #[test]
641    fn locator_json_round_trip_preserves_sqlite_selector() {
642        let locator = SessionLocator {
643            harness: HarnessId::from(HarnessId::OPENCODE),
644            session_id: "ses_123".into(),
645            storage: StorageLocator::Sqlite {
646                path: PathBuf::from("/tmp/opencode-dev.db"),
647                selector: "ses_123".into(),
648            },
649        };
650        let encoded = serde_json::to_string(&locator).unwrap();
651        assert_eq!(
652            serde_json::from_str::<SessionLocator>(&encoded).unwrap(),
653            locator
654        );
655    }
656
657    #[test]
658    fn discovers_filters_loads_and_follows_three_jsonl_harnesses() {
659        let root = temp_dir("jsonl");
660        let workspace = root.join("workspace");
661        let other = root.join("other");
662        fs::create_dir_all(&workspace).unwrap();
663        fs::create_dir_all(&other).unwrap();
664
665        let claude = root.join("claude");
666        let codex = root.join("codex");
667        let pi = root.join("pi");
668        fs::create_dir_all(&claude).unwrap();
669        fs::create_dir_all(&codex).unwrap();
670        fs::create_dir_all(&pi).unwrap();
671        fs::write(
672            claude.join("claude.jsonl"),
673            format!(
674                "{{\"type\":\"user\",\"sessionId\":\"cc-1\",\"cwd\":{},\"message\":{{\"role\":\"user\",\"content\":\"hi\"}}}}\n",
675                serde_json::to_string(&workspace.to_string_lossy()).unwrap()
676            ),
677        )
678        .unwrap();
679        fs::write(
680            codex.join("rollout.jsonl"),
681            format!(
682                "{{\"timestamp\":\"2026-01-01T00:00:00Z\",\"type\":\"session_meta\",\"payload\":{{\"id\":\"cx-1\",\"cwd\":{}}}}}\n",
683                serde_json::to_string(&workspace.to_string_lossy()).unwrap()
684            ),
685        )
686        .unwrap();
687        fs::write(
688            pi.join("pi.jsonl"),
689            format!(
690                "{{\"type\":\"session\",\"version\":3,\"id\":\"pi-1\",\"timestamp\":\"2026-01-01T00:00:00Z\",\"cwd\":{}}}\n",
691                serde_json::to_string(&workspace.to_string_lossy()).unwrap()
692            ),
693        )
694        .unwrap();
695        fs::write(
696            pi.join("unrelated.jsonl"),
697            format!(
698                "{{\"type\":\"session\",\"version\":3,\"id\":\"pi-2\",\"timestamp\":\"2026-01-01T00:00:00Z\",\"cwd\":{}}}\n",
699                serde_json::to_string(&other.to_string_lossy()).unwrap()
700            ),
701        )
702        .unwrap();
703        fs::write(claude.join("partial.jsonl"), "{truncated").unwrap();
704
705        let query = DiscoveryQuery {
706            workspace: Some(workspace),
707            homes: HarnessHomes {
708                claude_code: claude,
709                codex,
710                pi,
711                opencode: root.join("missing-opencode"),
712                grok: root.join("missing-grok"),
713            },
714            ..DiscoveryQuery::default()
715        };
716        let catalog = HarnessCatalog::new();
717        let found = catalog.discover(&query).unwrap();
718        assert_eq!(found.len(), 3);
719        assert_eq!(
720            found
721                .iter()
722                .map(|item| item.locator.harness.as_str())
723                .collect::<HashSet<_>>(),
724            HashSet::from([HarnessId::CLAUDE_CODE, HarnessId::CODEX, HarnessId::PI])
725        );
726        for descriptor in found {
727            let loaded = catalog.load(&descriptor.locator).unwrap();
728            assert_eq!(
729                loaded.meta.session_id.as_deref(),
730                Some(descriptor.locator.session_id.as_str())
731            );
732            let mut follower = catalog.follow(&descriptor.locator).unwrap();
733            assert!(matches!(
734                follower.poll().unwrap(),
735                Some(crate::SessionWatchEvent::SessionSnapshot { .. })
736            ));
737        }
738        fs::remove_dir_all(root).ok();
739    }
740
741    #[test]
742    fn discovers_loads_and_follows_opencode_sqlite() {
743        let db = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
744            .join("tests/fixtures/opencode_fixture/opencode.db");
745        let catalog = HarnessCatalog::new();
746        let found = catalog
747            .discover(&DiscoveryQuery {
748                harnesses: vec![HarnessId::from(HarnessId::OPENCODE)],
749                homes: HarnessHomes {
750                    opencode: db,
751                    ..HarnessHomes::default()
752                },
753                ..DiscoveryQuery::default()
754            })
755            .unwrap();
756        assert!(!found.is_empty());
757        for descriptor in found {
758            assert_eq!(descriptor.locator.harness.as_str(), HarnessId::OPENCODE);
759            assert_eq!(
760                catalog.load(&descriptor.locator).unwrap().meta.session_id,
761                Some(descriptor.locator.session_id.clone())
762            );
763            assert!(catalog.follow(&descriptor.locator).is_ok());
764        }
765    }
766
767    #[test]
768    fn discovers_current_opencode_schema_without_a_session_model_column() {
769        let root = temp_dir("opencode-current");
770        let db = root.join("opencode.db");
771        let conn = Connection::open(&db).unwrap();
772        conn.execute_batch(
773            "CREATE TABLE session (
774                id TEXT PRIMARY KEY,
775                directory TEXT NOT NULL,
776                title TEXT NOT NULL,
777                time_updated INTEGER NOT NULL
778             );
779             CREATE TABLE message (
780                id TEXT PRIMARY KEY,
781                session_id TEXT NOT NULL
782             );
783             INSERT INTO session VALUES ('ses_current', '/tmp/work', 'Current', 42);
784             INSERT INTO message VALUES ('msg_current', 'ses_current');",
785        )
786        .unwrap();
787        drop(conn);
788
789        let found = HarnessCatalog::new()
790            .discover(&DiscoveryQuery {
791                harnesses: vec![HarnessId::from(HarnessId::OPENCODE)],
792                homes: HarnessHomes {
793                    opencode: db,
794                    ..HarnessHomes::default()
795                },
796                ..DiscoveryQuery::default()
797            })
798            .unwrap();
799
800        assert_eq!(found.len(), 1);
801        assert_eq!(found[0].locator.session_id, "ses_current");
802        assert_eq!(found[0].message_count, Some(1));
803        assert_eq!(found[0].model, None);
804        fs::remove_dir_all(root).ok();
805    }
806
807    #[test]
808    fn workspace_filter_never_matches_a_relative_recorded_cwd() {
809        // OpenCode has shipped session rows whose `directory` is the literal
810        // ".". Resolving that against the discoverer's own cwd made the
811        // session match every workspace discovery ran from — the workspace
812        // here IS the test process cwd, the exact aliasing that leaked.
813        let root = temp_dir("opencode-relative-cwd");
814        let db = root.join("opencode.db");
815        let conn = Connection::open(&db).unwrap();
816        let here = std::env::current_dir().unwrap();
817        conn.execute_batch(&format!(
818            "CREATE TABLE session (
819                id TEXT PRIMARY KEY,
820                directory TEXT NOT NULL,
821                title TEXT NOT NULL,
822                time_updated INTEGER NOT NULL
823             );
824             CREATE TABLE message (
825                id TEXT PRIMARY KEY,
826                session_id TEXT NOT NULL
827             );
828             INSERT INTO session VALUES ('ses_relative', '.', 'Ghost', 41);
829             INSERT INTO session VALUES ('ses_here', '{}', 'Real', 42);",
830            here.display()
831        ))
832        .unwrap();
833        drop(conn);
834
835        let found = HarnessCatalog::new()
836            .discover(&DiscoveryQuery {
837                workspace: Some(here),
838                harnesses: vec![HarnessId::from(HarnessId::OPENCODE)],
839                homes: HarnessHomes {
840                    opencode: db,
841                    ..HarnessHomes::default()
842                },
843                ..DiscoveryQuery::default()
844            })
845            .unwrap();
846
847        assert_eq!(found.len(), 1);
848        assert_eq!(found[0].locator.session_id, "ses_here");
849        fs::remove_dir_all(root).ok();
850    }
851}