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