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