1use std::collections::{BTreeSet, HashMap, HashSet};
7use std::fs::{self, File};
8use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
9use std::path::{Component, Path, PathBuf};
10use std::time::UNIX_EPOCH;
11
12use rusqlite::Connection;
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15
16use crate::native_store::load_native_store_family;
17use crate::ontology::{Binding, OrchestratorBindingRow};
18use crate::session::{
19 hermes_capture_nouns, openclaw_agent_id_from_path, openclaw_capture_header_nouns,
20 percent_decode_path, OrchestrationNouns, SessionMeta, SessionSource,
21};
22use crate::{Error, Fidelity, Result, Session, SessionFollower};
23
24pub use crate::ontology::HarnessId;
25
26#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
28#[serde(tag = "kind", rename_all = "snake_case")]
29pub enum StorageLocator {
30 File {
32 path: PathBuf,
34 },
35 Sqlite {
37 path: PathBuf,
39 selector: String,
41 },
42}
43
44impl StorageLocator {
45 pub fn path(&self) -> &Path {
47 match self {
48 Self::File { path } | Self::Sqlite { path, .. } => path,
49 }
50 }
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
55pub struct SessionLocator {
56 pub harness: HarnessId,
58 pub session_id: String,
60 pub storage: StorageLocator,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct SessionDescriptor {
67 pub locator: SessionLocator,
70 pub cwd: Option<PathBuf>,
72 pub title: Option<String>,
74 #[serde(default, skip_serializing_if = "Vec::is_empty")]
79 pub preview_candidates: Vec<SessionPreviewCandidate>,
80 #[serde(default, skip_serializing_if = "Vec::is_empty")]
85 pub latest_message_candidates: Vec<SessionPreviewCandidate>,
86 pub updated_at_ms: Option<u64>,
95 pub message_count: Option<usize>,
100 pub model: Option<String>,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub parent_session_id: Option<String>,
107 #[serde(default, skip_serializing_if = "is_zero")]
111 pub child_session_count: usize,
112 #[serde(flatten)]
118 pub nouns: OrchestrationNouns,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123pub struct SessionPreviewCandidate {
124 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub cursor: Option<String>,
129 pub role: String,
132 pub content: String,
134 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
136 pub metadata: HashMap<String, String>,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141pub struct DiscoveryPage {
142 pub sessions: Vec<SessionDescriptor>,
144 pub next_cursor: Option<String>,
146 #[serde(default)]
149 pub receipt: DiscoveryReceipt,
150}
151
152#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
154#[serde(default)]
155pub struct DiscoveryReceipt {
156 #[serde(skip_serializing_if = "is_false")]
159 pub searched_previews: bool,
160 #[serde(skip_serializing_if = "Option::is_none")]
162 pub requested_after_ms: Option<u64>,
163 #[serde(skip_serializing_if = "Option::is_none")]
165 pub requested_before_ms: Option<u64>,
166 #[serde(skip_serializing_if = "Option::is_none")]
168 pub requested_limit: Option<usize>,
169 #[serde(skip_serializing_if = "Option::is_none")]
171 pub oldest_returned_ms: Option<u64>,
172 #[serde(skip_serializing_if = "Option::is_none")]
174 pub newest_returned_ms: Option<u64>,
175 pub returned: usize,
177 pub total_matched: usize,
179 pub truncated: bool,
183}
184
185#[derive(Debug)]
192pub struct CodexHistoryTopicIndex {
193 path: PathBuf,
194 fingerprint: Option<CodexHistoryFingerprint>,
195 offset: u64,
196 trailing: Vec<u8>,
197 topics: HashMap<String, Vec<SessionPreviewCandidate>>,
198}
199
200#[derive(Debug, Clone, Copy, PartialEq, Eq)]
201struct CodexHistoryFingerprint {
202 len: u64,
203 modified_ns: u128,
204 identity: u128,
205}
206
207impl CodexHistoryTopicIndex {
208 pub fn new(sessions_root: &Path) -> Self {
211 let root = sessions_root.parent().unwrap_or(sessions_root);
212 Self {
213 path: root.join("history.jsonl"),
214 fingerprint: None,
215 offset: 0,
216 trailing: Vec::new(),
217 topics: HashMap::new(),
218 }
219 }
220
221 pub fn path(&self) -> &Path {
223 &self.path
224 }
225
226 pub fn refresh(&mut self) -> Result<BTreeSet<String>> {
232 let metadata = match fs::metadata(&self.path) {
233 Ok(metadata) => metadata,
234 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
235 let changed = self.topics.keys().cloned().collect();
236 self.fingerprint = None;
237 self.offset = 0;
238 self.trailing.clear();
239 self.topics.clear();
240 return Ok(changed);
241 }
242 Err(error) => return Err(error.into()),
243 };
244 let fingerprint = codex_history_fingerprint(&metadata)?;
245 if self.fingerprint == Some(fingerprint) {
246 return Ok(BTreeSet::new());
247 }
248
249 let is_append = self.fingerprint.is_some_and(|previous| {
250 previous.identity == fingerprint.identity
251 && previous.len < fingerprint.len
252 && self.offset <= previous.len
253 });
254 if is_append {
255 let mut file = File::open(&self.path)?;
256 file.seek(SeekFrom::Start(self.offset))?;
257 let mut bytes = Vec::with_capacity(
258 usize::try_from(fingerprint.len.saturating_sub(self.offset)).unwrap_or(0),
259 );
260 file.read_to_end(&mut bytes)?;
261 self.offset = file.stream_position()?;
262 let changed = self.ingest(bytes);
263 self.fingerprint = Some(CodexHistoryFingerprint {
264 len: self.offset,
265 ..fingerprint
266 });
267 return Ok(changed);
268 }
269
270 let previous = std::mem::take(&mut self.topics);
271 let mut file = File::open(&self.path)?;
272 let mut bytes = Vec::with_capacity(usize::try_from(fingerprint.len).unwrap_or(0));
273 file.read_to_end(&mut bytes)?;
274 self.offset = file.stream_position()?;
275 self.trailing.clear();
276 self.ingest(bytes);
277 self.fingerprint = Some(CodexHistoryFingerprint {
278 len: self.offset,
279 ..fingerprint
280 });
281 Ok(changed_topic_ids(&previous, &self.topics))
282 }
283
284 fn ingest(&mut self, bytes: Vec<u8>) -> BTreeSet<String> {
285 let mut input = std::mem::take(&mut self.trailing);
286 input.extend(bytes);
287 let complete_len = input
288 .iter()
289 .rposition(|byte| *byte == b'\n')
290 .map_or(0, |index| index + 1);
291 let mut changed = BTreeSet::new();
292 for line in input[..complete_len].split(|byte| *byte == b'\n') {
293 self.ingest_line(line, &mut changed);
294 }
295 self.trailing.extend_from_slice(&input[complete_len..]);
296 if !self.trailing.is_empty() {
297 let trailing = self.trailing.clone();
298 if self.ingest_line(&trailing, &mut changed) {
299 self.trailing.clear();
300 }
301 }
302 changed
303 }
304
305 fn ingest_line(&mut self, line: &[u8], changed: &mut BTreeSet<String>) -> bool {
306 let Ok(value) = serde_json::from_slice::<Value>(line) else {
307 return false;
308 };
309 let Some(session_id) = value.get("session_id").and_then(Value::as_str) else {
310 return true;
311 };
312 if self.topics.contains_key(session_id) {
313 return true;
314 }
315 let mut candidates = Vec::new();
316 push_message_candidate(&mut candidates, "user", value.get("text"), HashMap::new());
317 if !candidates.is_empty() {
318 self.topics.insert(session_id.to_string(), candidates);
319 changed.insert(session_id.to_string());
320 }
321 true
322 }
323}
324
325#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
327#[serde(default)]
328pub struct HarnessHomes {
329 pub claude_code: PathBuf,
331 pub codex: PathBuf,
333 pub pi: PathBuf,
335 pub opencode: PathBuf,
337 pub grok: PathBuf,
339 pub gemini: PathBuf,
341 pub goose: PathBuf,
343 pub supercode: PathBuf,
345 pub openclaw: PathBuf,
349 pub hermes: PathBuf,
352 pub orchestrator: PathBuf,
358}
359
360pub fn orchestrator_profile_dirs(root: &Path) -> Vec<(String, PathBuf)> {
366 if !root.is_dir() {
367 return Vec::new();
368 }
369 let mut dirs = vec![("default".to_string(), root.to_path_buf())];
370 if let Ok(entries) = fs::read_dir(root.join("profiles")) {
371 let mut named: Vec<(String, PathBuf)> = entries
372 .flatten()
373 .filter(|entry| entry.path().is_dir())
374 .filter_map(|entry| {
375 entry
376 .file_name()
377 .into_string()
378 .ok()
379 .map(|name| (name, entry.path()))
380 })
381 .filter(|(name, _)| !name.starts_with('.'))
385 .collect();
386 named.sort();
387 dirs.extend(named);
388 }
389 dirs
390}
391
392pub fn hermes_session_stores(db_path: &Path) -> Vec<PathBuf> {
396 let mut stores = vec![db_path.to_path_buf()];
397 let (Some(root), Some(name)) = (db_path.parent(), db_path.file_name()) else {
398 return stores;
399 };
400 for (_, dir) in orchestrator_profile_dirs(root).into_iter().skip(1) {
401 stores.push(dir.join(name));
402 }
403 stores
404}
405
406impl Default for HarnessHomes {
407 fn default() -> Self {
408 let home = std::env::var_os("HOME")
409 .map(PathBuf::from)
410 .unwrap_or_else(|| PathBuf::from("."));
411 let claude_root = std::env::var_os("CLAUDE_CONFIG_DIR")
412 .map(PathBuf::from)
413 .unwrap_or_else(|| home.join(".claude"));
414 let codex_root = std::env::var_os("CODEX_HOME")
415 .map(PathBuf::from)
416 .unwrap_or_else(|| home.join(".codex"));
417 let pi = std::env::var_os("PI_CODING_AGENT_SESSION_DIR")
418 .map(PathBuf::from)
419 .unwrap_or_else(|| {
420 std::env::var_os("PI_CODING_AGENT_DIR")
421 .map(PathBuf::from)
422 .unwrap_or_else(|| home.join(".pi/agent"))
423 .join("sessions")
424 });
425 let opencode = std::env::var_os("OPENCODE_DB")
426 .map(PathBuf::from)
427 .unwrap_or_else(|| {
428 std::env::var_os("XDG_DATA_HOME")
429 .map(PathBuf::from)
430 .unwrap_or_else(|| home.join(".local/share"))
431 .join("opencode")
432 });
433 let grok = std::env::var_os("GROK_HOME")
434 .map(PathBuf::from)
435 .unwrap_or_else(|| home.join(".grok"))
436 .join("sessions");
437 let gemini = std::env::var_os("GEMINI_CLI_HOME")
438 .map(PathBuf::from)
439 .unwrap_or_else(|| home.join(".gemini"));
440 let openclaw = std::env::var_os("OPENCLAW_STATE_DIR")
447 .map(PathBuf::from)
448 .or_else(|| {
449 std::env::var_os("OPENCLAW_HOME").map(|root| PathBuf::from(root).join(".openclaw"))
450 })
451 .unwrap_or_else(|| home.join(".openclaw"));
452 let orchestrator = std::env::var_os("SUPERCODE_ORCHESTRATOR_HOME")
453 .map(PathBuf::from)
454 .unwrap_or_else(|| home.join(".supercode/orchestrator"));
455 let hermes = std::env::var_os("HERMES_HOME")
456 .map(PathBuf::from)
457 .unwrap_or_else(|| home.join(".hermes"))
458 .join("state.db");
459 let goose = std::env::var_os("GOOSE_PATH_ROOT")
460 .map(PathBuf::from)
461 .map(|root| root.join("data/sessions/sessions.db"))
462 .unwrap_or_else(|| {
463 #[cfg(target_os = "macos")]
464 {
465 home.join("Library/Application Support/Block/goose/sessions/sessions.db")
466 }
467 #[cfg(target_os = "windows")]
468 {
469 std::env::var_os("APPDATA")
470 .map(PathBuf::from)
471 .unwrap_or_else(|| home.join("AppData/Roaming"))
472 .join("Block/goose/sessions/sessions.db")
473 }
474 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
475 {
476 std::env::var_os("XDG_DATA_HOME")
477 .map(PathBuf::from)
478 .unwrap_or_else(|| home.join(".local/share"))
479 .join("goose/sessions/sessions.db")
480 }
481 });
482 let supercode = std::env::var_os("SUPERCODE_HOME")
483 .map(PathBuf::from)
484 .unwrap_or_else(|| {
485 std::env::var_os("XDG_CONFIG_HOME")
486 .map(PathBuf::from)
487 .unwrap_or_else(|| home.join(".config"))
488 .join("supercode")
489 })
490 .join("sessions");
491 Self {
492 claude_code: claude_root.join("projects"),
493 codex: codex_root.join("sessions"),
494 gemini,
495 goose,
496 supercode,
497 openclaw,
498 hermes,
499 orchestrator,
500 pi,
501 opencode,
502 grok,
503 }
504 }
505}
506
507fn codex_history_fingerprint(metadata: &fs::Metadata) -> Result<CodexHistoryFingerprint> {
508 let modified_ns = metadata
509 .modified()?
510 .duration_since(UNIX_EPOCH)
511 .map_err(|error| Error::Other(format!("history timestamp predates Unix epoch: {error}")))?
512 .as_nanos();
513 #[cfg(unix)]
514 let identity = {
515 use std::os::unix::fs::MetadataExt;
516 (u128::from(metadata.dev()) << 64) | u128::from(metadata.ino())
517 };
518 #[cfg(not(unix))]
519 let identity = 0;
520 Ok(CodexHistoryFingerprint {
521 len: metadata.len(),
522 modified_ns,
523 identity,
524 })
525}
526
527fn changed_topic_ids(
528 before: &HashMap<String, Vec<SessionPreviewCandidate>>,
529 after: &HashMap<String, Vec<SessionPreviewCandidate>>,
530) -> BTreeSet<String> {
531 before
532 .keys()
533 .chain(after.keys())
534 .filter(|session_id| before.get(*session_id) != after.get(*session_id))
535 .cloned()
536 .collect()
537}
538
539#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
541#[serde(default)]
542pub struct DiscoveryQuery {
543 pub workspace: Option<PathBuf>,
545 pub workspace_family: Option<PathBuf>,
553 pub updated_after_ms: Option<u64>,
555 pub updated_before_ms: Option<u64>,
557 pub harnesses: Vec<HarnessId>,
559 pub homes: HarnessHomes,
561 pub query: Option<String>,
563 pub search_previews: bool,
567 pub cursor: Option<String>,
569 pub limit: Option<usize>,
571 pub include_topic_candidates: bool,
575 pub include_child_sessions: bool,
579 pub root_session_id: Option<String>,
582 pub profile: Option<String>,
587}
588
589#[derive(Debug, Clone, PartialEq, Eq)]
595struct RepoFamily {
596 identity: PathBuf,
599 is_repository: bool,
602 origin_url: Option<String>,
604}
605
606impl RepoFamily {
607 fn of(path: &Path) -> Self {
608 let start = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
609 let mut current = Some(start.as_path());
610 while let Some(dir) = current {
611 let dot_git = dir.join(".git");
612 if dot_git.is_dir() {
613 let identity = std::fs::canonicalize(&dot_git).unwrap_or_else(|_| dot_git.clone());
614 let origin_url = read_origin_url(&identity);
615 return Self {
616 identity,
617 is_repository: true,
618 origin_url,
619 };
620 }
621 if dot_git.is_file() {
622 if let Ok(text) = std::fs::read_to_string(&dot_git) {
626 if let Some(gitdir) = text
627 .lines()
628 .find_map(|line| line.trim().strip_prefix("gitdir:"))
629 {
630 let gitdir = PathBuf::from(gitdir.trim());
631 let gitdir = if gitdir.is_absolute() {
632 gitdir
633 } else {
634 dir.join(gitdir)
635 };
636 let common = gitdir
637 .parent()
638 .filter(|parent| parent.ends_with("worktrees"))
639 .and_then(Path::parent)
640 .map(Path::to_path_buf)
641 .unwrap_or(gitdir);
642 let identity = std::fs::canonicalize(&common).unwrap_or(common);
643 let origin_url = read_origin_url(&identity);
644 return Self {
645 identity,
646 is_repository: true,
647 origin_url,
648 };
649 }
650 }
651 }
652 current = dir.parent();
653 }
654 Self {
655 identity: start,
656 is_repository: false,
657 origin_url: None,
658 }
659 }
660
661 fn joins(&self, other: &Self) -> bool {
665 if self.identity == other.identity {
666 return true;
667 }
668 self.is_repository
669 && other.is_repository
670 && matches!((&self.origin_url, &other.origin_url), (Some(a), Some(b)) if a == b)
671 }
672}
673
674fn read_origin_url(common_dir: &Path) -> Option<String> {
676 let text = std::fs::read_to_string(common_dir.join("config")).ok()?;
677 let mut in_origin = false;
678 for line in text.lines() {
679 let line = line.trim();
680 if line.starts_with('[') {
681 in_origin = line.starts_with("[remote \"origin\"]");
682 continue;
683 }
684 if in_origin {
685 if let Some(value) = line.strip_prefix("url") {
686 let value = value.trim_start();
687 if let Some(url) = value.strip_prefix('=') {
688 let url = url.trim();
689 if !url.is_empty() {
690 return Some(url.to_string());
691 }
692 }
693 }
694 }
695 }
696 None
697}
698
699#[derive(Debug, Default, Clone, Copy)]
702pub struct HarnessCatalog;
703
704impl HarnessCatalog {
705 pub fn new() -> Self {
707 Self
708 }
709
710 pub fn discover(&self, query: &DiscoveryQuery) -> Result<Vec<SessionDescriptor>> {
714 Ok(self.discover_page(query)?.sessions)
715 }
716
717 pub fn discover_raw_index(&self, query: &DiscoveryQuery) -> Vec<SessionDescriptor> {
722 self.scan_descriptors(query, true)
723 }
724
725 pub fn project_index(
728 &self,
729 query: &DiscoveryQuery,
730 descriptors: impl IntoIterator<Item = SessionDescriptor>,
731 ) -> Result<Vec<SessionDescriptor>> {
732 Ok(self.project_index_page(query, descriptors)?.sessions)
733 }
734
735 pub fn project_index_page(
738 &self,
739 query: &DiscoveryQuery,
740 descriptors: impl IntoIterator<Item = SessionDescriptor>,
741 ) -> Result<DiscoveryPage> {
742 if query.search_previews {
743 return Err(Error::Other(
744 "preview search requires discover_page, not a metadata-only index projection"
745 .into(),
746 ));
747 }
748 let mut found = descriptors.into_iter().collect::<Vec<_>>();
749 project_descriptors(query, &mut found);
750 let total_matched = found.len();
751 let (sessions, next_cursor) = paginate_descriptors(query, found)?;
752 let receipt = DiscoveryReceipt {
753 searched_previews: false,
754 requested_after_ms: query.updated_after_ms,
755 requested_before_ms: query.updated_before_ms,
756 requested_limit: query.limit,
757 oldest_returned_ms: sessions.iter().filter_map(|s| s.updated_at_ms).min(),
758 newest_returned_ms: sessions.iter().filter_map(|s| s.updated_at_ms).max(),
759 returned: sessions.len(),
760 total_matched,
761 truncated: next_cursor.is_some(),
762 };
763 Ok(DiscoveryPage {
764 sessions,
765 next_cursor,
766 receipt,
767 })
768 }
769
770 pub fn enrich_index_page(
773 &self,
774 query: &DiscoveryQuery,
775 mut sessions: Vec<SessionDescriptor>,
776 ) -> Result<Vec<SessionDescriptor>> {
777 enrich_descriptors(query, &mut sessions, None)?;
778 Ok(sessions)
779 }
780
781 pub fn enrich_index_page_with_codex_history(
787 &self,
788 query: &DiscoveryQuery,
789 mut sessions: Vec<SessionDescriptor>,
790 codex_history: &CodexHistoryTopicIndex,
791 ) -> Result<Vec<SessionDescriptor>> {
792 enrich_descriptors(query, &mut sessions, Some(codex_history))?;
793 Ok(sessions)
794 }
795
796 pub fn discover_page(&self, query: &DiscoveryQuery) -> Result<DiscoveryPage> {
798 if query.search_previews {
799 return self.discover_preview_page(query);
800 }
801 let mut page = self.project_index_page(
802 query,
803 self.scan_descriptors(query, query.include_child_sessions),
804 )?;
805 enrich_descriptors(query, &mut page.sessions, None)?;
806 Ok(page)
807 }
808
809 fn discover_preview_page(&self, query: &DiscoveryQuery) -> Result<DiscoveryPage> {
810 let search = query
811 .query
812 .as_deref()
813 .map(str::trim)
814 .filter(|text| !text.is_empty())
815 .ok_or_else(|| Error::Other("search_previews requires a nonempty query".into()))?
816 .to_lowercase();
817 if query.limit == Some(0) {
818 return Err(Error::Other("preview search limit must be positive".into()));
819 }
820 let cursor = query.cursor.as_deref().map(decode_cursor).transpose()?;
821 let mut eligible_query = query.clone();
822 eligible_query.query = None;
823 eligible_query.search_previews = false;
824 eligible_query.include_topic_candidates = true;
825 let mut eligible = self.scan_descriptors(query, query.include_child_sessions);
826 project_descriptors(&eligible_query, &mut eligible);
827 let topics = codex_history_topics(&query.homes.codex, &eligible).unwrap_or_default();
829 let mut sessions = Vec::new();
830 let mut total_matched = 0;
831 let mut cursor_seen = cursor.is_none();
832 let mut more = false;
833 for mut descriptor in eligible {
834 let metadata_match = descriptor_matches(&descriptor, &search);
835 if !metadata_match {
836 let topic = topics
837 .get(&descriptor.locator.session_id)
838 .filter(|_| descriptor.locator.harness.as_str() == HarnessId::CODEX);
839 enrich_descriptor(&eligible_query, &mut descriptor, topic);
840 if !descriptor
841 .preview_candidates
842 .iter()
843 .chain(&descriptor.latest_message_candidates)
844 .any(|candidate| candidate.content.to_lowercase().contains(&search))
845 {
846 continue;
847 }
848 }
849 total_matched += 1;
850 if !cursor_seen {
851 cursor_seen = cursor.as_ref() == Some(&descriptor_cursor_key(&descriptor));
852 continue;
853 }
854 if sessions.len() < query.limit.unwrap_or(usize::MAX) {
855 if metadata_match {
856 let topic = topics
857 .get(&descriptor.locator.session_id)
858 .filter(|_| descriptor.locator.harness.as_str() == HarnessId::CODEX);
859 enrich_descriptor(&eligible_query, &mut descriptor, topic);
860 }
861 sessions.push(descriptor);
862 } else {
863 more = true;
864 }
865 }
866 if !cursor_seen {
867 return Err(Error::Other("discovery cursor is stale or invalid".into()));
868 }
869 let next_cursor = more.then(|| sessions.last().map(encode_cursor)).flatten();
870 let receipt = DiscoveryReceipt {
871 searched_previews: true,
872 requested_after_ms: query.updated_after_ms,
873 requested_before_ms: query.updated_before_ms,
874 requested_limit: query.limit,
875 oldest_returned_ms: sessions.iter().filter_map(|s| s.updated_at_ms).min(),
876 newest_returned_ms: sessions.iter().filter_map(|s| s.updated_at_ms).max(),
877 returned: sessions.len(),
878 total_matched,
879 truncated: more,
880 };
881 Ok(DiscoveryPage {
882 sessions,
883 next_cursor,
884 receipt,
885 })
886 }
887
888 fn scan_descriptors(
889 &self,
890 query: &DiscoveryQuery,
891 include_child_sessions: bool,
892 ) -> Vec<SessionDescriptor> {
893 let selected: HashSet<&str> = if query.harnesses.is_empty() {
894 [
895 HarnessId::CLAUDE_CODE,
896 HarnessId::CODEX,
897 HarnessId::PI,
898 HarnessId::OPENCODE,
899 HarnessId::GROK,
900 HarnessId::GEMINI,
901 HarnessId::GOOSE,
902 HarnessId::SUPERCODE,
903 HarnessId::OPENCLAW,
904 HarnessId::HERMES,
905 HarnessId::ORCHESTRATOR,
906 ]
907 .into_iter()
908 .collect()
909 } else {
910 query.harnesses.iter().map(HarnessId::as_str).collect()
911 };
912 let mut found = Vec::new();
913 if selected.contains(HarnessId::CLAUDE_CODE) {
914 discover_jsonl(
915 &query.homes.claude_code,
916 HarnessId::CLAUDE_CODE,
917 query.workspace.as_deref(),
918 include_child_sessions,
919 &mut found,
920 );
921 }
922 if selected.contains(HarnessId::CODEX) {
923 discover_jsonl(
924 &query.homes.codex,
925 HarnessId::CODEX,
926 query.workspace.as_deref(),
927 include_child_sessions,
928 &mut found,
929 );
930 }
931 if selected.contains(HarnessId::PI) {
932 discover_jsonl(
933 &query.homes.pi,
934 HarnessId::PI,
935 query.workspace.as_deref(),
936 include_child_sessions,
937 &mut found,
938 );
939 }
940 if selected.contains(HarnessId::OPENCODE) {
941 discover_opencode(
942 &query.homes.opencode,
943 query.workspace.as_deref(),
944 &mut found,
945 );
946 }
947 if selected.contains(HarnessId::GROK) {
948 discover_grok(&query.homes.grok, query.workspace.as_deref(), &mut found);
949 }
950 if selected.contains(HarnessId::GEMINI) {
951 discover_gemini(&query.homes.gemini, query.workspace.as_deref(), &mut found);
952 }
953 if selected.contains(HarnessId::GOOSE) {
954 discover_goose(&query.homes.goose, query.workspace.as_deref(), &mut found);
955 }
956 if selected.contains(HarnessId::OPENCLAW) {
957 discover_openclaw(
958 &query.homes.openclaw,
959 query.workspace.as_deref(),
960 &mut found,
961 );
962 }
963 if selected.contains(HarnessId::HERMES) {
964 for store in hermes_session_stores(&query.homes.hermes) {
965 discover_hermes(&store, query.workspace.as_deref(), &mut found);
966 }
967 }
968 if selected.contains(HarnessId::ORCHESTRATOR) {
969 discover_orchestrator(
970 &query.homes.orchestrator,
971 query.workspace.as_deref(),
972 &mut found,
973 );
974 }
975 if selected.contains(HarnessId::SUPERCODE) {
976 discover_supercode(
977 &query.homes.supercode,
978 query.workspace.as_deref(),
979 &mut found,
980 );
981 }
982 for descriptor in &mut found {
983 finalize_nouns(descriptor);
984 }
985 found
986 }
987
988 pub fn refresh_file_descriptor(
996 &self,
997 locator: &SessionLocator,
998 workspace: Option<&Path>,
999 include_topic_candidates: bool,
1000 ) -> Result<Option<SessionDescriptor>> {
1001 let Some(mut descriptor) = self.refresh_file_index_descriptor(locator, workspace)? else {
1002 return Ok(None);
1003 };
1004 if include_topic_candidates {
1005 descriptor.preview_candidates =
1006 topic_message_candidates(&descriptor.locator).unwrap_or_default();
1007 }
1008 descriptor.latest_message_candidates =
1009 latest_message_candidates(&descriptor.locator).unwrap_or_default();
1010 Ok(Some(descriptor))
1011 }
1012
1013 pub fn refresh_file_index_descriptor(
1017 &self,
1018 locator: &SessionLocator,
1019 workspace: Option<&Path>,
1020 ) -> Result<Option<SessionDescriptor>> {
1021 let StorageLocator::File { path } = &locator.storage else {
1022 return Ok(None);
1023 };
1024 if !matches!(
1025 locator.harness.as_str(),
1026 HarnessId::CLAUDE_CODE | HarnessId::CODEX
1027 ) {
1028 return Ok(None);
1029 }
1030 if !path.is_file() {
1031 return Ok(None);
1032 }
1033 let Ok(meta) = read_header(path, locator.harness.as_str()) else {
1034 return Ok(None);
1038 };
1039 if workspace.is_some_and(|wanted| {
1040 meta.cwd
1041 .as_deref()
1042 .is_none_or(|cwd| !recorded_cwd_matches(cwd, wanted))
1043 }) {
1044 return Ok(None);
1045 }
1046 let parent_session_id = meta.parent_session_id.or_else(|| {
1047 (locator.harness.as_str() == HarnessId::CLAUDE_CODE)
1048 .then(|| claude_subagent_parent_id(path))
1049 .flatten()
1050 });
1051 let tail = tail_facts(path, locator.harness.as_str());
1052 let descriptor = SessionDescriptor {
1053 locator: SessionLocator {
1054 harness: locator.harness.clone(),
1055 session_id: meta
1056 .session_id
1057 .unwrap_or_else(|| locator.session_id.clone()),
1058 storage: StorageLocator::File { path: path.clone() },
1059 },
1060 cwd: meta.cwd,
1061 title: meta.title,
1062 preview_candidates: Vec::new(),
1063 latest_message_candidates: Vec::new(),
1064 updated_at_ms: tail.last_turn_ms.or_else(|| modified_ms(path)),
1065 message_count: None,
1066 model: tail.model.or(meta.model),
1067 parent_session_id,
1068 child_session_count: 0,
1069 nouns: OrchestrationNouns::default(),
1070 };
1071 Ok(Some(descriptor))
1072 }
1073
1074 pub fn load(&self, locator: &SessionLocator) -> Result<Session> {
1076 self.load_with_fidelity(locator, Fidelity::ByteLossless)
1077 }
1078
1079 pub fn load_with_fidelity(
1086 &self,
1087 locator: &SessionLocator,
1088 fidelity: Fidelity,
1089 ) -> Result<Session> {
1090 if let Some(session) = load_hermes_locator(locator) {
1091 return session;
1092 }
1093 match &locator.storage {
1094 StorageLocator::File { path } => {
1095 if let Some(session) = load_native_store_family(path)? {
1096 Ok(session)
1097 } else {
1098 Ok(Session::load_with_fidelity(path, fidelity)?)
1099 }
1100 }
1101 StorageLocator::Sqlite { path, selector } => {
1102 if locator.harness.as_str() == HarnessId::GOOSE {
1103 Ok(Session::from_goose_sqlite(path, selector)?)
1104 } else {
1105 Ok(Session::from_opencode_sqlite(path, Some(selector))?)
1106 }
1107 }
1108 }
1109 }
1110
1111 #[doc(hidden)]
1115 pub fn load_parent_with_fidelity(
1116 &self,
1117 locator: &SessionLocator,
1118 fidelity: Fidelity,
1119 ) -> Result<Session> {
1120 if let Some(session) = load_hermes_locator(locator) {
1121 return session;
1122 }
1123 match &locator.storage {
1124 StorageLocator::File { path } => {
1125 if let Some(session) = load_native_store_family(path)? {
1126 Ok(session)
1127 } else {
1128 Ok(Session::load_parent_with_fidelity(path, fidelity)?)
1129 }
1130 }
1131 StorageLocator::Sqlite { path, selector } => {
1132 if locator.harness.as_str() == HarnessId::GOOSE {
1133 Ok(Session::from_goose_sqlite(path, selector)?)
1134 } else {
1135 Ok(Session::from_opencode_sqlite(path, Some(selector))?)
1136 }
1137 }
1138 }
1139 }
1140
1141 #[doc(hidden)]
1144 pub fn load_display_view(
1145 &self,
1146 locator: &SessionLocator,
1147 fidelity: Fidelity,
1148 message_limit: usize,
1149 ) -> Result<Session> {
1150 if let Some(session) = load_hermes_locator(locator) {
1151 let mut session = session?;
1152 if session.messages.len() > message_limit.max(1) {
1153 session
1154 .messages
1155 .drain(..session.messages.len() - message_limit.max(1));
1156 }
1157 return Ok(session);
1158 }
1159 match &locator.storage {
1160 StorageLocator::File { path } => {
1161 if let Some(mut session) = load_native_store_family(path)? {
1162 if session.messages.len() > message_limit.max(1) {
1163 session
1164 .messages
1165 .drain(..session.messages.len() - message_limit.max(1));
1166 }
1167 Ok(session)
1168 } else {
1169 Ok(Session::load_display_view(path, fidelity, message_limit)?)
1170 }
1171 }
1172 StorageLocator::Sqlite { path, selector } => {
1173 let mut session = if locator.harness.as_str() == HarnessId::GOOSE {
1174 Session::from_goose_sqlite_display(path, selector, message_limit)?
1175 } else {
1176 Session::from_opencode_sqlite(path, Some(selector))?
1177 };
1178 if session.messages.len() > message_limit.max(1) {
1179 session
1180 .messages
1181 .drain(..session.messages.len() - message_limit.max(1));
1182 }
1183 Ok(session)
1184 }
1185 }
1186 }
1187
1188 pub fn follow(&self, locator: &SessionLocator) -> Result<SessionFollower> {
1190 self.follow_with_fidelity(locator, Fidelity::ByteLossless)
1191 }
1192
1193 pub fn follow_with_fidelity(
1195 &self,
1196 locator: &SessionLocator,
1197 fidelity: Fidelity,
1198 ) -> Result<SessionFollower> {
1199 SessionFollower::open_locator_with_fidelity(locator, fidelity)
1200 }
1201
1202 #[doc(hidden)]
1204 pub fn follow_read_view(
1205 &self,
1206 locator: &SessionLocator,
1207 fidelity: Fidelity,
1208 include_subagents: bool,
1209 message_limit: Option<usize>,
1210 max_message_chars: Option<usize>,
1211 display_history: bool,
1212 ) -> Result<SessionFollower> {
1213 SessionFollower::open_locator_with_view(
1214 locator,
1215 fidelity,
1216 include_subagents,
1217 message_limit,
1218 max_message_chars,
1219 display_history,
1220 )
1221 }
1222}
1223
1224fn load_hermes_locator(locator: &SessionLocator) -> Option<Result<Session>> {
1230 if locator.harness.as_str() != HarnessId::HERMES {
1231 return None;
1232 }
1233 let StorageLocator::File { path } = &locator.storage else {
1234 return None;
1235 };
1236 Some(Session::from_hermes_sqlite(path, Some(&locator.session_id)))
1237}
1238
1239fn finalize_nouns(descriptor: &mut SessionDescriptor) {
1246 let mut meta = SessionMeta::new(SessionSource::Native);
1247 meta.cwd = descriptor.cwd.clone();
1248 meta.trigger = descriptor.nouns.trigger;
1249 meta.surface = descriptor.nouns.surface.clone();
1250 meta.profile = descriptor.nouns.profile.clone();
1251 meta.recurrence = descriptor.nouns.recurrence.clone();
1252 meta.cross_surface = descriptor.nouns.cross_surface.clone();
1253 descriptor.nouns = OrchestrationNouns::from_meta(&meta);
1254}
1255
1256fn project_descriptors(query: &DiscoveryQuery, found: &mut Vec<SessionDescriptor>) {
1257 roll_up_session_children(found, query.include_child_sessions);
1258 if let Some(root_session_id) = query.root_session_id.as_deref() {
1259 retain_session_family(found, root_session_id);
1260 }
1261 if let Some(family_path) = query.workspace_family.as_deref() {
1262 let family = RepoFamily::of(family_path);
1263 let mut cache: HashMap<PathBuf, bool> = HashMap::new();
1264 found.retain(|descriptor| {
1265 let Some(cwd) = descriptor.cwd.as_deref() else {
1266 return false;
1267 };
1268 *cache
1269 .entry(cwd.to_path_buf())
1270 .or_insert_with(|| RepoFamily::of(cwd).joins(&family))
1271 });
1272 }
1273 if let Some(profile) = query
1274 .profile
1275 .as_deref()
1276 .map(str::trim)
1277 .filter(|p| !p.is_empty())
1278 {
1279 found.retain(|descriptor| descriptor.nouns.profile.as_deref() == Some(profile));
1280 }
1281 if let Some(after) = query.updated_after_ms {
1282 found.retain(|descriptor| descriptor.updated_at_ms.is_some_and(|at| at >= after));
1283 }
1284 if let Some(before) = query.updated_before_ms {
1285 found.retain(|descriptor| descriptor.updated_at_ms.is_some_and(|at| at <= before));
1286 }
1287 found.sort_by(|a, b| {
1288 b.updated_at_ms
1289 .cmp(&a.updated_at_ms)
1290 .then_with(|| a.locator.harness.cmp(&b.locator.harness))
1291 .then_with(|| a.locator.session_id.cmp(&b.locator.session_id))
1292 });
1293 if let Some(search) = query
1294 .query
1295 .as_deref()
1296 .map(str::trim)
1297 .filter(|query| !query.is_empty())
1298 {
1299 let search = search.to_lowercase();
1300 found.retain(|descriptor| descriptor_matches(descriptor, &search));
1301 }
1302}
1303
1304fn paginate_descriptors(
1305 query: &DiscoveryQuery,
1306 found: Vec<SessionDescriptor>,
1307) -> Result<(Vec<SessionDescriptor>, Option<String>)> {
1308 let start = match query.cursor.as_deref() {
1309 Some(cursor) => {
1310 let key = decode_cursor(cursor)?;
1311 found
1312 .iter()
1313 .position(|descriptor| descriptor_cursor_key(descriptor) == key)
1314 .map(|index| index + 1)
1315 .ok_or_else(|| Error::Other("discovery cursor is stale or invalid".into()))?
1316 }
1317 None => 0,
1318 };
1319 let end = query
1320 .limit
1321 .map(|limit| start.saturating_add(limit).min(found.len()))
1322 .unwrap_or(found.len());
1323 let sessions = found[start.min(found.len())..end].to_vec();
1324 let next_cursor = (end < found.len())
1325 .then(|| sessions.last().map(encode_cursor))
1326 .flatten();
1327 Ok((sessions, next_cursor))
1328}
1329
1330fn enrich_descriptors(
1331 query: &DiscoveryQuery,
1332 sessions: &mut [SessionDescriptor],
1333 codex_history: Option<&CodexHistoryTopicIndex>,
1334) -> Result<()> {
1335 let codex_topics = if query.include_topic_candidates && codex_history.is_none() {
1336 codex_history_topics(&query.homes.codex, sessions).unwrap_or_default()
1337 } else {
1338 HashMap::new()
1339 };
1340 for descriptor in sessions {
1341 let topic = (descriptor.locator.harness.as_str() == HarnessId::CODEX)
1342 .then(|| {
1343 codex_history
1344 .and_then(|history| history.topics.get(&descriptor.locator.session_id))
1345 .or_else(|| codex_topics.get(&descriptor.locator.session_id))
1346 })
1347 .flatten();
1348 enrich_descriptor(query, descriptor, topic);
1349 }
1350 Ok(())
1351}
1352
1353fn enrich_descriptor(
1354 query: &DiscoveryQuery,
1355 descriptor: &mut SessionDescriptor,
1356 codex_topic: Option<&Vec<SessionPreviewCandidate>>,
1357) {
1358 if query.include_topic_candidates {
1359 descriptor.preview_candidates = codex_topic
1360 .cloned()
1361 .unwrap_or_else(|| topic_message_candidates(&descriptor.locator).unwrap_or_default());
1362 }
1363 descriptor.latest_message_candidates =
1364 latest_message_candidates(&descriptor.locator).unwrap_or_default();
1365}
1366
1367fn is_false(value: &bool) -> bool {
1368 !value
1369}
1370
1371fn descriptor_matches(descriptor: &SessionDescriptor, search: &str) -> bool {
1372 [
1373 Some(descriptor.locator.harness.as_str()),
1374 Some(descriptor.locator.session_id.as_str()),
1375 descriptor.title.as_deref(),
1376 descriptor.cwd.as_ref().and_then(|path| path.to_str()),
1377 descriptor.model.as_deref(),
1378 ]
1379 .into_iter()
1380 .flatten()
1381 .any(|value| value.to_lowercase().contains(search))
1382}
1383
1384fn descriptor_cursor_key(descriptor: &SessionDescriptor) -> (Option<u64>, String, String) {
1385 (
1386 descriptor.updated_at_ms,
1387 descriptor.locator.harness.as_str().to_string(),
1388 descriptor.locator.session_id.clone(),
1389 )
1390}
1391
1392fn encode_cursor(descriptor: &SessionDescriptor) -> String {
1393 let json = serde_json::to_vec(&descriptor_cursor_key(descriptor)).unwrap_or_default();
1394 let mut encoded = String::with_capacity(json.len() * 2);
1395 for byte in json {
1396 use std::fmt::Write;
1397 let _ = write!(&mut encoded, "{byte:02x}");
1398 }
1399 encoded
1400}
1401
1402fn decode_cursor(cursor: &str) -> Result<(Option<u64>, String, String)> {
1403 if cursor.len() % 2 != 0 {
1404 return Err(Error::Other("discovery cursor is invalid".into()));
1405 }
1406 let bytes = (0..cursor.len())
1407 .step_by(2)
1408 .map(|index| u8::from_str_radix(&cursor[index..index + 2], 16))
1409 .collect::<std::result::Result<Vec<_>, _>>()
1410 .map_err(|_| Error::Other("discovery cursor is invalid".into()))?;
1411 serde_json::from_slice(&bytes).map_err(|_| Error::Other("discovery cursor is invalid".into()))
1412}
1413
1414#[derive(Default)]
1415struct HeaderMeta {
1416 session_id: Option<String>,
1417 cwd: Option<PathBuf>,
1418 title: Option<String>,
1419 model: Option<String>,
1420 parent_session_id: Option<String>,
1421}
1422
1423fn discover_jsonl(
1424 root: &Path,
1425 harness: &str,
1426 workspace: Option<&Path>,
1427 include_child_sessions: bool,
1428 found: &mut Vec<SessionDescriptor>,
1429) {
1430 let mut files = Vec::new();
1431 collect_jsonl(root, harness, include_child_sessions, &mut files);
1432 for path in files {
1433 let Ok(meta) = read_header(&path, harness) else {
1434 continue;
1435 };
1436 if workspace.is_some_and(|wanted| {
1437 meta.cwd
1438 .as_deref()
1439 .is_none_or(|cwd| !recorded_cwd_matches(cwd, wanted))
1440 }) {
1441 continue;
1442 }
1443 let session_id = meta.session_id.unwrap_or_else(|| {
1444 path.file_stem()
1445 .and_then(|value| value.to_str())
1446 .unwrap_or("unknown")
1447 .to_string()
1448 });
1449 let parent_session_id = meta.parent_session_id.or_else(|| {
1450 (harness == HarnessId::CLAUDE_CODE)
1451 .then(|| claude_subagent_parent_id(&path))
1452 .flatten()
1453 });
1454 let tail = tail_facts(&path, harness);
1455 found.push(SessionDescriptor {
1456 locator: SessionLocator {
1457 harness: HarnessId::new(harness),
1458 session_id,
1459 storage: StorageLocator::File { path: path.clone() },
1460 },
1461 cwd: meta.cwd,
1462 title: meta.title,
1463 preview_candidates: Vec::new(),
1464 latest_message_candidates: Vec::new(),
1465 updated_at_ms: tail.last_turn_ms.or_else(|| modified_ms(&path)),
1466 message_count: None,
1467 model: tail.model.or(meta.model),
1468 parent_session_id,
1469 child_session_count: if harness == HarnessId::CLAUDE_CODE && !include_child_sessions {
1470 count_claude_subagents(&path)
1471 } else {
1472 0
1473 },
1474 nouns: OrchestrationNouns::default(),
1475 });
1476 }
1477}
1478
1479fn collect_jsonl(root: &Path, harness: &str, include_child_sessions: bool, out: &mut Vec<PathBuf>) {
1480 let Ok(entries) = fs::read_dir(root) else {
1481 return;
1482 };
1483 for entry in entries.flatten() {
1484 let Ok(kind) = entry.file_type() else {
1485 continue;
1486 };
1487 let path = entry.path();
1488 if kind.is_dir() {
1489 if harness == HarnessId::CLAUDE_CODE
1490 && path.file_name().and_then(|v| v.to_str()) == Some("subagents")
1491 && !include_child_sessions
1492 {
1493 continue;
1494 }
1495 collect_jsonl(&path, harness, include_child_sessions, out);
1496 } else if kind.is_file() && path.extension().and_then(|v| v.to_str()) == Some("jsonl") {
1497 out.push(path);
1498 }
1499 }
1500}
1501
1502fn read_header(path: &Path, harness: &str) -> Result<HeaderMeta> {
1503 let file = File::open(path)?;
1504 let mut result = HeaderMeta::default();
1505 let mut bytes = 0usize;
1506 for line in BufReader::new(file).lines().take(32) {
1507 let line = line?;
1508 bytes += line.len();
1509 if bytes > 256 * 1024 {
1510 break;
1511 }
1512 let Ok(value) = serde_json::from_str::<Value>(&line) else {
1513 continue;
1514 };
1515 update_header_meta(&mut result, &value, harness);
1516 if result.session_id.is_some() && result.cwd.is_some() && result.model.is_some() {
1517 break;
1518 }
1519 }
1520 if result.session_id.is_none() && result.cwd.is_none() {
1521 return Err(Error::Other(format!(
1522 "{} has no recognizable {harness} session header",
1523 path.display()
1524 )));
1525 }
1526 Ok(result)
1527}
1528
1529fn update_header_meta(result: &mut HeaderMeta, value: &Value, harness: &str) {
1530 match harness {
1531 HarnessId::CLAUDE_CODE => {
1532 fill_string(&mut result.session_id, value.get("sessionId"));
1533 fill_path(&mut result.cwd, value.get("cwd"));
1534 fill_string(
1535 &mut result.model,
1536 value.get("message").and_then(|v| v.get("model")),
1537 );
1538 }
1539 HarnessId::CODEX => {
1540 let payload = value.get("payload").unwrap_or(&Value::Null);
1541 if value.get("type").and_then(Value::as_str) == Some("session_meta") {
1542 fill_string(&mut result.session_id, payload.get("id"));
1543 fill_path(&mut result.cwd, payload.get("cwd"));
1544 fill_string(&mut result.title, payload.get("thread_name"));
1545 fill_string(&mut result.title, payload.get("title"));
1546 fill_string(
1547 &mut result.parent_session_id,
1548 payload.get("parent_thread_id"),
1549 );
1550 if let Some(parent) = payload
1551 .pointer("/source/subagent/thread_spawn/parent_thread_id")
1552 .and_then(Value::as_str)
1553 {
1554 result.parent_session_id = Some(parent.to_string());
1555 }
1556 if result.title.is_none() {
1557 result.title = payload
1558 .pointer("/source/subagent/thread_spawn/agent_path")
1559 .and_then(Value::as_str)
1560 .and_then(|path| path.rsplit('/').find(|part| !part.is_empty()))
1561 .map(humanize_topic);
1562 }
1563 }
1564 if value.get("type").and_then(Value::as_str) == Some("turn_context") {
1565 fill_path(&mut result.cwd, payload.get("cwd"));
1566 fill_string(&mut result.model, payload.get("model"));
1567 }
1568 }
1569 HarnessId::PI => {
1570 if value.get("type").and_then(Value::as_str) == Some("session") {
1571 fill_string(&mut result.session_id, value.get("id"));
1572 fill_path(&mut result.cwd, value.get("cwd"));
1573 }
1574 fill_string(
1575 &mut result.model,
1576 value.get("message").and_then(|v| v.get("model")),
1577 );
1578 }
1579 _ => {}
1580 }
1581}
1582
1583fn roll_up_session_children(found: &mut Vec<SessionDescriptor>, include_children: bool) {
1587 let by_id = found
1588 .iter()
1589 .enumerate()
1590 .map(|(index, descriptor)| {
1591 (
1592 (
1593 descriptor.locator.harness.as_str().to_string(),
1594 descriptor.locator.session_id.clone(),
1595 ),
1596 index,
1597 )
1598 })
1599 .collect::<HashMap<_, _>>();
1600 let mut root_updates = HashMap::<usize, u64>::new();
1601 let mut root_child_counts = HashMap::<usize, usize>::new();
1602
1603 for descriptor in found.iter() {
1604 let Some(mut parent_id) = descriptor.parent_session_id.as_deref() else {
1605 continue;
1606 };
1607 let harness = descriptor.locator.harness.as_str();
1608 let mut root = None;
1609 let mut visited = HashSet::new();
1610 while visited.insert(parent_id.to_string()) {
1611 let Some(&parent_index) = by_id.get(&(harness.to_string(), parent_id.to_string()))
1612 else {
1613 break;
1614 };
1615 root = Some(parent_index);
1616 let Some(next_parent) = found[parent_index].parent_session_id.as_deref() else {
1617 break;
1618 };
1619 parent_id = next_parent;
1620 }
1621 if let (Some(root), Some(updated_at_ms)) = (root, descriptor.updated_at_ms) {
1622 root_updates
1623 .entry(root)
1624 .and_modify(|current| *current = (*current).max(updated_at_ms))
1625 .or_insert(updated_at_ms);
1626 }
1627 if let Some(root) = root {
1628 *root_child_counts.entry(root).or_default() += 1;
1629 }
1630 }
1631
1632 for (root, child_updated_at_ms) in root_updates {
1633 found[root].updated_at_ms = Some(
1634 found[root]
1635 .updated_at_ms
1636 .unwrap_or_default()
1637 .max(child_updated_at_ms),
1638 );
1639 }
1640 for (root, child_count) in root_child_counts {
1641 found[root].child_session_count = child_count;
1642 }
1643 if !include_children {
1644 found.retain(|descriptor| descriptor.parent_session_id.is_none());
1645 }
1646}
1647
1648fn retain_session_family(found: &mut Vec<SessionDescriptor>, root_session_id: &str) {
1649 let parent_by_id = found
1650 .iter()
1651 .map(|descriptor| {
1652 (
1653 descriptor.locator.session_id.clone(),
1654 descriptor.parent_session_id.clone(),
1655 )
1656 })
1657 .collect::<HashMap<_, _>>();
1658 found.retain(|descriptor| {
1659 let mut current = descriptor.locator.session_id.clone();
1660 let mut visited = HashSet::new();
1661 while visited.insert(current.clone()) {
1662 if current == root_session_id {
1663 return true;
1664 }
1665 let Some(Some(parent)) = parent_by_id.get(¤t) else {
1666 return false;
1667 };
1668 current = parent.clone();
1669 }
1670 false
1671 });
1672}
1673
1674fn claude_subagent_parent_id(path: &Path) -> Option<String> {
1675 let subagents = path.parent()?;
1676 if subagents.file_name()?.to_str()? != "subagents" {
1677 return None;
1678 }
1679 subagents
1680 .parent()?
1681 .file_name()?
1682 .to_str()
1683 .map(str::to_string)
1684}
1685
1686fn count_claude_subagents(parent_path: &Path) -> usize {
1687 let Some(parent) = parent_path.parent() else {
1688 return 0;
1689 };
1690 let Some(stem) = parent_path.file_stem() else {
1691 return 0;
1692 };
1693 let root = parent.join(stem).join("subagents");
1694 let mut files = Vec::new();
1695 collect_jsonl(&root, HarnessId::CLAUDE_CODE, true, &mut files);
1696 files.len()
1697}
1698
1699fn is_zero(value: &usize) -> bool {
1700 *value == 0
1701}
1702
1703fn humanize_topic(value: &str) -> String {
1704 let text = value.replace(['_', '-'], " ");
1705 let mut characters = text.chars();
1706 match characters.next() {
1707 Some(first) => first.to_uppercase().collect::<String>() + characters.as_str(),
1708 None => text,
1709 }
1710}
1711
1712fn discover_gemini(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
1713 let slug_to_cwd = std::fs::read_to_string(root.join("projects.json"))
1714 .ok()
1715 .and_then(|text| serde_json::from_str::<Value>(&text).ok())
1716 .and_then(|value| value.get("projects").and_then(Value::as_object).cloned())
1717 .map(|projects| {
1718 projects
1719 .into_iter()
1720 .filter_map(|(cwd, slug)| Some((slug.as_str()?.to_string(), PathBuf::from(cwd))))
1721 .collect::<HashMap<_, _>>()
1722 })
1723 .unwrap_or_default();
1724 let mut files = Vec::new();
1725 collect_jsonl(&root.join("tmp"), HarnessId::GEMINI, false, &mut files);
1726 let worker_count = std::thread::available_parallelism()
1727 .map(usize::from)
1728 .unwrap_or(4)
1729 .clamp(1, 8)
1730 .min(files.len().max(1));
1731 let chunk_size = files.len().max(1).div_ceil(worker_count);
1732 let discovered = std::thread::scope(|scope| {
1733 files
1734 .chunks(chunk_size)
1735 .map(|paths| {
1736 scope.spawn(|| {
1737 paths
1738 .iter()
1739 .filter_map(|path| gemini_descriptor(path, &slug_to_cwd, workspace))
1740 .collect::<Vec<_>>()
1741 })
1742 })
1743 .collect::<Vec<_>>()
1744 .into_iter()
1745 .flat_map(|worker| {
1746 worker
1747 .join()
1748 .expect("Gemini discovery worker must not panic")
1749 })
1750 .collect::<Vec<_>>()
1751 });
1752 found.extend(discovered);
1753}
1754
1755fn gemini_descriptor(
1756 path: &Path,
1757 slug_to_cwd: &HashMap<String, PathBuf>,
1758 workspace: Option<&Path>,
1759) -> Option<SessionDescriptor> {
1760 if path
1761 .parent()
1762 .and_then(Path::file_name)
1763 .and_then(|name| name.to_str())
1764 != Some("chats")
1765 {
1766 return None;
1767 }
1768 let slug = path
1769 .parent()
1770 .and_then(Path::parent)
1771 .and_then(Path::file_name)
1772 .and_then(|name| name.to_str());
1773 let cwd = slug.and_then(|slug| slug_to_cwd.get(slug)).cloned();
1774 if workspace.is_some_and(|wanted| {
1775 cwd.as_deref()
1776 .is_none_or(|actual| !recorded_cwd_matches(actual, wanted))
1777 }) {
1778 return None;
1779 }
1780
1781 let file = File::open(path).ok()?;
1785 let mut reader = BufReader::new(file.take(64 * 1024));
1786 let mut header = String::new();
1787 reader.read_line(&mut header).ok()?;
1788 let header = serde_json::from_str::<Value>(&header).ok()?;
1789 let session_id = header.get("sessionId")?.as_str()?.to_string();
1790 let mut model = None;
1791 for line in reader
1792 .take(4 * 1024)
1793 .lines()
1794 .map_while(std::result::Result::ok)
1795 {
1796 let Ok(value) = serde_json::from_str::<Value>(&line) else {
1797 continue;
1798 };
1799 let kind = value.get("type").and_then(Value::as_str);
1800 if kind != Some("user") && kind != Some("gemini") {
1801 continue;
1802 }
1803 if model.is_none() {
1804 model = value
1805 .get("model")
1806 .and_then(Value::as_str)
1807 .map(str::to_string);
1808 }
1809 if model.is_some() {
1810 break;
1811 }
1812 }
1813 Some(SessionDescriptor {
1814 locator: SessionLocator {
1815 harness: HarnessId::from(HarnessId::GEMINI),
1816 session_id,
1817 storage: StorageLocator::File {
1818 path: path.to_path_buf(),
1819 },
1820 },
1821 cwd,
1822 title: None,
1823 preview_candidates: Vec::new(),
1824 latest_message_candidates: Vec::new(),
1825 updated_at_ms: tail_facts(path, HarnessId::GEMINI)
1826 .last_turn_ms
1827 .or_else(|| modified_ms(path)),
1828 message_count: None,
1829 model,
1830 parent_session_id: None,
1831 child_session_count: 0,
1832 nouns: OrchestrationNouns::default(),
1833 })
1834}
1835
1836fn display_text(content: Option<&Value>) -> Option<String> {
1837 match content? {
1838 Value::String(text) => Some(text.clone()),
1839 Value::Array(parts) => Some(
1840 parts
1841 .iter()
1842 .filter_map(|part| part.get("text").and_then(Value::as_str))
1843 .collect::<Vec<_>>()
1844 .join(" ")
1845 .trim()
1846 .to_string(),
1847 ),
1848 _ => None,
1849 }
1850}
1851
1852fn discover_hermes(db_path: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
1857 if !db_path.is_file() {
1858 return;
1859 }
1860 let Ok(conn) = Connection::open_with_flags(
1861 db_path,
1862 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
1863 ) else {
1864 return;
1865 };
1866 let fingerprint_ok = ["sessions", "messages", "schema_version"].iter().all(|t| {
1867 conn.query_row(
1868 "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1",
1869 [t],
1870 |_| Ok(()),
1871 )
1872 .is_ok()
1873 });
1874 if !fingerprint_ok {
1875 return;
1876 }
1877 let Ok(mut statement) = conn.prepare(
1878 "SELECT id, cwd, title, model, message_count, started_at, ended_at, parent_session_id, \
1879 source, model_config FROM sessions ORDER BY started_at DESC",
1880 ) else {
1881 return;
1882 };
1883 let Ok(rows) = statement.query_map([], |row| {
1884 Ok((
1885 row.get::<_, String>(0)?,
1886 row.get::<_, Option<String>>(1)?,
1887 row.get::<_, Option<String>>(2)?,
1888 row.get::<_, Option<String>>(3)?,
1889 row.get::<_, Option<i64>>(4)?,
1890 row.get::<_, Option<f64>>(5)?,
1891 row.get::<_, Option<f64>>(6)?,
1892 row.get::<_, Option<String>>(7)?,
1893 row.get::<_, Option<String>>(8)?,
1894 row.get::<_, Option<String>>(9)?,
1895 ))
1896 }) else {
1897 return;
1898 };
1899 for row in rows.flatten() {
1900 let (
1901 id,
1902 cwd,
1903 title,
1904 model,
1905 message_count,
1906 started_at,
1907 ended_at,
1908 parent,
1909 source,
1910 model_config,
1911 ) = row;
1912 let cwd = cwd.map(PathBuf::from);
1913 if let Some(filter) = workspace {
1914 if cwd.as_deref() != Some(filter) {
1915 continue;
1916 }
1917 }
1918 let updated_at_ms = ended_at
1919 .or(started_at)
1920 .map(|seconds| (seconds * 1000.0) as u64);
1921 let mut meta = SessionMeta::new(SessionSource::Hermes);
1925 meta.cwd = cwd.clone();
1926 if let Some(hermes_source) = source.filter(|value| !value.is_empty()) {
1927 meta.lineage
1928 .insert("hermes_source".to_string(), hermes_source);
1929 }
1930 if let Some(parent_id) = parent.as_deref() {
1931 meta.lineage.insert(
1932 "hermes_lineage_kind".to_string(),
1933 crate::session::hermes_lineage_kind(
1934 &conn,
1935 parent_id,
1936 model_config.as_deref(),
1937 started_at,
1938 )
1939 .to_string(),
1940 );
1941 }
1942 hermes_capture_nouns(&conn, &id, &mut meta);
1943 found.push(SessionDescriptor {
1944 locator: SessionLocator {
1945 harness: HarnessId::new(HarnessId::HERMES),
1946 session_id: id,
1947 storage: StorageLocator::File {
1948 path: db_path.to_path_buf(),
1949 },
1950 },
1951 cwd,
1952 title: title.filter(|t| !t.is_empty()),
1953 preview_candidates: Vec::new(),
1954 latest_message_candidates: Vec::new(),
1955 updated_at_ms,
1956 message_count: message_count.map(|count| count.max(0) as usize),
1957 model,
1958 parent_session_id: parent,
1959 child_session_count: 0,
1960 nouns: OrchestrationNouns::from_meta(&meta),
1961 });
1962 }
1963}
1964
1965fn discover_orchestrator(
1972 root: &Path,
1973 workspace: Option<&Path>,
1974 found: &mut Vec<SessionDescriptor>,
1975) {
1976 if workspace.is_some() {
1978 return;
1979 }
1980 for (profile, dir) in orchestrator_profile_dirs(root) {
1981 let db_path = dir.join("state.db");
1982 if !db_path.is_file() {
1983 continue;
1984 }
1985 let Ok(conn) = Connection::open_with_flags(
1986 &db_path,
1987 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
1988 ) else {
1989 continue;
1990 };
1991 let Ok(mut statement) = conn.prepare(
1992 "SELECT platform, chat_type, chat_id, thread_id, participant_id, worker_harness, \
1993 worker_session_id, worker_locator, started_at, last_activity_at, ended_at, \
1994 end_reason, handoff_to, handoff_state, handoff_error, recurrence_job_id, \
1995 CAST(strftime('%s', last_activity_at) AS INTEGER) \
1996 FROM bindings ORDER BY last_activity_at DESC, started_at DESC",
1997 ) else {
1998 continue;
1999 };
2000 let Ok(rows) = statement.query_map([], |row| {
2001 let text = |index: usize| -> rusqlite::Result<Option<String>> {
2002 Ok(row
2003 .get::<_, Option<String>>(index)?
2004 .filter(|value| !value.is_empty()))
2005 };
2006 Ok((
2007 OrchestratorBindingRow {
2008 platform: text(0)?.unwrap_or_default(),
2009 chat_type: text(1)?.unwrap_or_default(),
2010 chat_id: text(2)?,
2011 thread_id: text(3)?,
2012 participant_id: text(4)?,
2013 worker_harness: text(5)?.unwrap_or_default(),
2014 worker_session_id: text(6)?.filter(|s| !s.is_empty()),
2015 worker_locator: text(7)?,
2016 started_at: text(8)?,
2017 last_activity_at: text(9)?,
2018 ended_at: text(10)?,
2019 end_reason: text(11)?,
2020 handoff_to: text(12)?,
2021 handoff_state: text(13)?,
2022 handoff_error: text(14)?,
2023 recurrence_job_id: text(15)?,
2024 },
2025 row.get::<_, Option<i64>>(16)?,
2026 ))
2027 }) else {
2028 continue;
2029 };
2030 for (row, last_activity_epoch) in rows.flatten() {
2031 found.push(orchestrator_descriptor(
2032 &db_path,
2033 &profile,
2034 &row,
2035 last_activity_epoch,
2036 ));
2037 }
2038 }
2039}
2040
2041fn orchestrator_descriptor(
2042 db_path: &Path,
2043 profile: &str,
2044 row: &OrchestratorBindingRow,
2045 last_activity_epoch: Option<i64>,
2046) -> SessionDescriptor {
2047 let binding = Binding::from_orchestrator_row(profile, row);
2048 let nouns = binding.nouns();
2049 let mut title = format!(
2052 "{} {}",
2053 row.worker_harness,
2054 row.worker_session_id
2055 .as_deref()
2056 .unwrap_or("(no worker session yet)")
2057 );
2058 if let Some(reason) = row.end_reason.as_deref().filter(|_| row.ended_at.is_some()) {
2059 title.push_str(&format!(" (ended: {reason})"));
2060 }
2061 SessionDescriptor {
2062 locator: SessionLocator {
2063 harness: HarnessId::new(HarnessId::ORCHESTRATOR),
2064 session_id: row.worker_session_id.clone().unwrap_or_default(),
2065 storage: StorageLocator::File {
2066 path: row
2067 .worker_locator
2068 .clone()
2069 .map_or_else(|| db_path.to_path_buf(), PathBuf::from),
2070 },
2071 },
2072 cwd: None,
2073 title: Some(title),
2074 preview_candidates: Vec::new(),
2075 latest_message_candidates: Vec::new(),
2076 updated_at_ms: last_activity_epoch.map(|seconds| (seconds.max(0) as u64) * 1000),
2077 message_count: None,
2078 model: None,
2079 parent_session_id: None,
2080 child_session_count: 0,
2081 nouns,
2082 }
2083}
2084
2085fn discover_openclaw(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
2091 let agents = root.join("agents");
2092 let Ok(agent_dirs) = std::fs::read_dir(&agents) else {
2093 return;
2094 };
2095 for agent_dir in agent_dirs.flatten() {
2096 let sessions = agent_dir.path().join("sessions");
2097 let Ok(files) = std::fs::read_dir(&sessions) else {
2098 continue;
2099 };
2100 for file in files.flatten() {
2101 let path = file.path();
2102 let name = file.file_name();
2103 let name = name.to_string_lossy();
2104 if !name.ends_with(".jsonl") || name.ends_with(".trajectory.jsonl") {
2105 continue;
2106 }
2107 let Ok(text) = std::fs::read_to_string(&path) else {
2108 continue;
2109 };
2110 let Some(header_line) = text.lines().find(|line| !line.trim().is_empty()) else {
2111 continue;
2112 };
2113 let Ok(header) = serde_json::from_str::<serde_json::Value>(header_line) else {
2114 continue;
2115 };
2116 if header.get("type").and_then(serde_json::Value::as_str) != Some("session") {
2117 continue;
2118 }
2119 let session_id = header
2120 .get("id")
2121 .and_then(serde_json::Value::as_str)
2122 .unwrap_or_else(|| name.trim_end_matches(".jsonl"))
2123 .to_string();
2124 let cwd = header
2125 .get("cwd")
2126 .and_then(serde_json::Value::as_str)
2127 .map(PathBuf::from);
2128 if let Some(filter) = workspace {
2129 if cwd.as_deref() != Some(filter) {
2130 continue;
2131 }
2132 }
2133 let updated_at_ms = tail_facts(&path, HarnessId::OPENCLAW)
2134 .last_turn_ms
2135 .or_else(|| {
2136 file.metadata()
2137 .ok()
2138 .and_then(|metadata| metadata.modified().ok())
2139 .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
2140 .map(|elapsed| elapsed.as_millis() as u64)
2141 });
2142 let message_count = text
2143 .lines()
2144 .filter(|line| line.contains("\"type\":\"message\""))
2145 .count();
2146 let mut meta = SessionMeta::new(SessionSource::OpenClaw);
2150 meta.cwd = cwd.clone();
2151 openclaw_capture_header_nouns(&header, &mut meta);
2152 if meta.profile.is_none() {
2153 meta.profile = openclaw_agent_id_from_path(&path);
2154 }
2155 found.push(SessionDescriptor {
2156 locator: SessionLocator {
2157 harness: HarnessId::new(HarnessId::OPENCLAW),
2158 session_id,
2159 storage: StorageLocator::File { path },
2160 },
2161 cwd,
2162 title: None,
2163 preview_candidates: Vec::new(),
2164 latest_message_candidates: Vec::new(),
2165 updated_at_ms,
2166 message_count: Some(message_count),
2167 model: None,
2168 parent_session_id: None,
2169 child_session_count: 0,
2170 nouns: OrchestrationNouns::from_meta(&meta),
2171 });
2172 }
2173 }
2174}
2175
2176fn discover_supercode(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
2177 for info in list_native_store(root) {
2178 let path = if info.archived {
2179 root.join("archived").join(format!("{}.jsonl", info.name))
2180 } else {
2181 root.join(format!("{}.jsonl", info.name))
2182 };
2183 let sidecar = path.with_extension("sidecar.jsonl");
2188 let path = if path.is_file() {
2189 path
2190 } else {
2191 sidecar.clone()
2192 };
2193 let header = read_native_store_header(&path);
2194 if workspace.is_some_and(|wanted| {
2195 header
2196 .as_ref()
2197 .and_then(|meta| meta.cwd.as_deref())
2198 .is_none_or(|cwd| !recorded_cwd_matches(cwd, wanted))
2199 }) {
2200 continue;
2201 }
2202 let title = (!info.title.trim().is_empty()).then_some(info.title);
2203 let updated_at_ms = tail_facts(&sidecar, HarnessId::SUPERCODE)
2206 .last_turn_ms
2207 .or_else(|| tail_facts(&path, HarnessId::SUPERCODE).last_turn_ms)
2208 .or_else(|| modified_ms(&path))
2209 .or_else(|| modified_ms(&sidecar));
2210 found.push(SessionDescriptor {
2211 locator: SessionLocator {
2212 harness: HarnessId::from(HarnessId::SUPERCODE),
2213 session_id: info.name,
2214 storage: StorageLocator::File { path: path.clone() },
2215 },
2216 cwd: header.as_ref().and_then(|meta| meta.cwd.clone()),
2217 title,
2218 preview_candidates: Vec::new(),
2219 latest_message_candidates: Vec::new(),
2220 updated_at_ms,
2221 message_count: None,
2222 model: header.and_then(|meta| meta.model),
2223 parent_session_id: None,
2224 child_session_count: 0,
2225 nouns: OrchestrationNouns::default(),
2226 });
2227 }
2228}
2229
2230fn read_native_store_header(path: &Path) -> Option<HeaderMeta> {
2235 let name = path.file_stem()?.to_str()?;
2236 let sidecar = path.with_file_name(format!("{name}.sidecar.jsonl"));
2237 let source_path = if sidecar.is_file() {
2238 sidecar
2239 } else {
2240 path.to_path_buf()
2241 };
2242 let file = File::open(source_path).ok()?;
2243 let mut result = HeaderMeta::default();
2244 let mut source = None;
2245 let mut bytes = 0usize;
2246 for line in BufReader::new(file).lines().take(32) {
2247 let line = line.ok()?;
2248 bytes += line.len();
2249 if bytes > 256 * 1024 {
2250 break;
2251 }
2252 let Ok(value) = serde_json::from_str::<Value>(&line) else {
2253 continue;
2254 };
2255 if source.is_none() {
2256 source = value.get("source").and_then(Value::as_str).map(|source| {
2257 if source == "claude_code" {
2258 HarnessId::CLAUDE_CODE.to_string()
2259 } else {
2260 source.to_string()
2261 }
2262 });
2263 fill_string(&mut result.session_id, value.get("session_id"));
2264 }
2265 if let Some(harness) = source.as_deref() {
2266 update_header_meta(&mut result, &value, harness);
2267 }
2268 if result.cwd.is_some() && result.model.is_some() {
2269 break;
2270 }
2271 }
2272 Some(result)
2273}
2274
2275#[derive(Deserialize)]
2276struct NativeStoreInfo {
2277 name: String,
2278 #[serde(default)]
2279 title: String,
2280 #[serde(skip)]
2281 archived: bool,
2282}
2283
2284fn list_native_store(root: &Path) -> Vec<NativeStoreInfo> {
2285 let mut sessions = Vec::new();
2286 for archived in [false, true] {
2287 let directory = if archived {
2288 root.join("archived")
2289 } else {
2290 root.to_path_buf()
2291 };
2292 let Ok(entries) = fs::read_dir(directory) else {
2293 continue;
2294 };
2295 for entry in entries.flatten() {
2296 let path = entry.path();
2297 if !path.to_string_lossy().ends_with(".meta.json") {
2298 continue;
2299 }
2300 let Ok(text) = fs::read_to_string(path) else {
2301 continue;
2302 };
2303 let Ok(mut info) = serde_json::from_str::<NativeStoreInfo>(&text) else {
2304 continue;
2305 };
2306 info.archived = archived;
2307 sessions.push(info);
2308 }
2309 }
2310 sessions.sort_by(|left, right| left.name.cmp(&right.name));
2311 sessions
2312}
2313
2314fn discover_grok(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
2315 let Ok(workspaces) = fs::read_dir(root) else {
2316 return;
2317 };
2318 for workspace_entry in workspaces.flatten() {
2319 let encoded = workspace_entry.file_name();
2320 let Some(cwd) = encoded
2321 .to_str()
2322 .and_then(percent_decode_path)
2323 .map(PathBuf::from)
2324 else {
2325 continue;
2326 };
2327 if workspace.is_some_and(|wanted| !recorded_cwd_matches(&cwd, wanted)) {
2328 continue;
2329 }
2330 let Ok(sessions) = fs::read_dir(workspace_entry.path()) else {
2331 continue;
2332 };
2333 for session_entry in sessions.flatten() {
2334 let session_dir = session_entry.path();
2335 if !session_dir.is_dir() {
2336 continue;
2337 }
2338 let transcript = session_dir.join("chat_history.jsonl");
2339 if !transcript.is_file() {
2340 continue;
2341 }
2342 let Some(session_id) = session_dir
2343 .file_name()
2344 .and_then(|name| name.to_str())
2345 .map(str::to_string)
2346 else {
2347 continue;
2348 };
2349 let summary = fs::read_to_string(session_dir.join("summary.json"))
2350 .ok()
2351 .and_then(|text| serde_json::from_str::<Value>(&text).ok());
2352 let title = summary
2353 .as_ref()
2354 .and_then(|value| value.get("generated_title"))
2355 .and_then(Value::as_str)
2356 .filter(|title| !title.is_empty())
2357 .map(str::to_string);
2358 let model = summary
2359 .as_ref()
2360 .and_then(|value| value.get("current_model_id"))
2361 .and_then(Value::as_str)
2362 .map(str::to_string);
2363 let message_count = summary
2364 .as_ref()
2365 .and_then(|value| value.get("num_chat_messages"))
2366 .and_then(Value::as_u64)
2367 .and_then(|count| usize::try_from(count).ok());
2368 let updated_at_ms = summary
2369 .as_ref()
2370 .and_then(|value| value.get("updated_at"))
2371 .and_then(Value::as_str)
2372 .and_then(crate::sidecar::rfc3339_to_ms)
2373 .and_then(|millis| u64::try_from(millis).ok())
2374 .or_else(|| modified_ms(&transcript));
2375 found.push(SessionDescriptor {
2376 locator: SessionLocator {
2377 harness: HarnessId::from(HarnessId::GROK),
2378 session_id,
2379 storage: StorageLocator::File { path: transcript },
2380 },
2381 cwd: Some(cwd.clone()),
2382 title,
2383 preview_candidates: Vec::new(),
2384 latest_message_candidates: Vec::new(),
2385 updated_at_ms,
2386 message_count,
2387 model,
2388 parent_session_id: None,
2389 child_session_count: 0,
2390 nouns: OrchestrationNouns::default(),
2391 });
2392 }
2393 }
2394}
2395
2396fn discover_opencode(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
2397 let mut dbs = Vec::new();
2398 if root.is_file() {
2399 dbs.push(root.to_path_buf());
2400 } else if let Ok(entries) = fs::read_dir(root) {
2401 dbs.extend(entries.flatten().map(|entry| entry.path()).filter(|path| {
2402 path.file_name()
2403 .and_then(|v| v.to_str())
2404 .is_some_and(|name| name.starts_with("opencode") && name.ends_with(".db"))
2405 }));
2406 }
2407 dbs.sort();
2408 for db in dbs {
2409 let Ok(conn) = Connection::open_with_flags(
2410 &db,
2411 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
2412 ) else {
2413 continue;
2414 };
2415 let has_model = conn.prepare("SELECT model FROM session LIMIT 0").is_ok();
2416 let model_column = if has_model { "s.model" } else { "NULL" };
2417 let query = format!(
2418 "SELECT s.id, s.directory, s.title, s.time_updated, {model_column}, COUNT(m.id) \
2419 FROM session s LEFT JOIN message m ON m.session_id = s.id \
2420 GROUP BY s.id ORDER BY s.time_updated DESC"
2421 );
2422 let Ok(mut stmt) = conn.prepare(&query) else {
2423 continue;
2424 };
2425 let Ok(rows) = stmt.query_map([], |row| {
2426 Ok((
2427 row.get::<_, String>(0)?,
2428 row.get::<_, String>(1)?,
2429 row.get::<_, String>(2)?,
2430 row.get::<_, i64>(3)?,
2431 row.get::<_, Option<String>>(4)?,
2432 row.get::<_, i64>(5)?,
2433 ))
2434 }) else {
2435 continue;
2436 };
2437 for row in rows.flatten() {
2438 let (id, cwd, title, updated, model, messages) = row;
2439 let cwd = PathBuf::from(cwd);
2440 if workspace.is_some_and(|wanted| !recorded_cwd_matches(&cwd, wanted)) {
2441 continue;
2442 }
2443 found.push(SessionDescriptor {
2444 locator: SessionLocator {
2445 harness: HarnessId::from(HarnessId::OPENCODE),
2446 session_id: id.clone(),
2447 storage: StorageLocator::Sqlite {
2448 path: db.clone(),
2449 selector: id,
2450 },
2451 },
2452 cwd: Some(cwd),
2453 title: (!title.is_empty()).then_some(title),
2454 preview_candidates: Vec::new(),
2455 latest_message_candidates: Vec::new(),
2456 updated_at_ms: u64::try_from(updated).ok(),
2457 message_count: usize::try_from(messages).ok(),
2458 model,
2459 parent_session_id: None,
2460 child_session_count: 0,
2461 nouns: OrchestrationNouns::default(),
2462 });
2463 }
2464 }
2465}
2466
2467fn discover_goose(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
2468 let db = if root.is_file() {
2469 root.to_path_buf()
2470 } else if root.join("sessions.db").is_file() {
2471 root.join("sessions.db")
2472 } else {
2473 root.join("sessions/sessions.db")
2474 };
2475 let Ok(connection) = Connection::open_with_flags(
2476 &db,
2477 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
2478 ) else {
2479 return;
2480 };
2481 let Ok(mut statement) = connection.prepare(
2482 "SELECT s.id, s.working_dir, s.name, s.updated_at, s.model_config_json, \
2483 COUNT(m.id) \
2484 FROM sessions s LEFT JOIN messages m ON m.session_id = s.id \
2485 WHERE s.archived_at IS NULL \
2486 GROUP BY s.id ORDER BY s.updated_at DESC",
2487 ) else {
2488 return;
2489 };
2490 let Ok(rows) = statement.query_map([], |row| {
2491 Ok((
2492 row.get::<_, String>(0)?,
2493 row.get::<_, String>(1)?,
2494 row.get::<_, String>(2)?,
2495 row.get::<_, String>(3)?,
2496 row.get::<_, Option<String>>(4)?,
2497 row.get::<_, i64>(5)?,
2498 ))
2499 }) else {
2500 return;
2501 };
2502 for row in rows.flatten() {
2503 let (id, cwd, title, updated_at, model_config, message_count) = row;
2504 let cwd = PathBuf::from(cwd);
2505 if workspace.is_some_and(|wanted| !recorded_cwd_matches(&cwd, wanted)) {
2506 continue;
2507 }
2508 let model = model_config
2509 .as_deref()
2510 .and_then(|value| serde_json::from_str::<Value>(value).ok())
2511 .and_then(|value| {
2512 value
2513 .get("model_name")
2514 .or_else(|| value.get("modelName"))
2515 .and_then(Value::as_str)
2516 .map(str::to_string)
2517 });
2518 let updated_at_ms = crate::sidecar::rfc3339_to_ms(&updated_at)
2519 .or_else(|| {
2520 crate::sidecar::rfc3339_to_ms(&format!("{}Z", updated_at.replace(' ', "T")))
2522 })
2523 .and_then(|value| u64::try_from(value).ok());
2524 found.push(SessionDescriptor {
2525 locator: SessionLocator {
2526 harness: HarnessId::from(HarnessId::GOOSE),
2527 session_id: id.clone(),
2528 storage: StorageLocator::Sqlite {
2529 path: db.clone(),
2530 selector: id,
2531 },
2532 },
2533 cwd: Some(cwd),
2534 title: (!title.trim().is_empty()).then_some(title),
2535 preview_candidates: Vec::new(),
2536 latest_message_candidates: Vec::new(),
2537 updated_at_ms,
2538 message_count: usize::try_from(message_count).ok(),
2539 model,
2540 parent_session_id: None,
2541 child_session_count: 0,
2542 nouns: OrchestrationNouns::default(),
2543 });
2544 }
2545}
2546
2547const LATEST_PREVIEW_CANDIDATES: usize = 8;
2548const TOPIC_PREVIEW_HEAD_BYTES: u64 = 512 * 1024;
2549const LATEST_PREVIEW_TAIL_BYTES: u64 = 512 * 1024;
2550const LATEST_PREVIEW_MAX_BYTES: u64 = 4 * 1024 * 1024;
2551
2552fn topic_message_candidates(locator: &SessionLocator) -> Result<Vec<SessionPreviewCandidate>> {
2553 match &locator.storage {
2554 StorageLocator::File { path }
2555 if matches!(
2556 locator.harness.as_str(),
2557 HarnessId::CLAUDE_CODE | HarnessId::CODEX
2558 ) =>
2559 {
2560 topic_file_message_candidates(path, locator.harness.as_str())
2561 }
2562 _ => Ok(Vec::new()),
2563 }
2564}
2565
2566fn codex_history_topics(
2567 sessions_root: &Path,
2568 sessions: &[SessionDescriptor],
2569) -> Result<HashMap<String, Vec<SessionPreviewCandidate>>> {
2570 let wanted: HashSet<&str> = sessions
2571 .iter()
2572 .filter(|descriptor| descriptor.locator.harness.as_str() == HarnessId::CODEX)
2573 .map(|descriptor| descriptor.locator.session_id.as_str())
2574 .collect();
2575 if wanted.is_empty() {
2576 return Ok(HashMap::new());
2577 }
2578 let Some(root) = sessions_root.parent() else {
2579 return Ok(HashMap::new());
2580 };
2581 let file = match File::open(root.join("history.jsonl")) {
2582 Ok(file) => file,
2583 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(HashMap::new()),
2584 Err(error) => return Err(error.into()),
2585 };
2586 let mut topics = HashMap::new();
2587 for line in BufReader::new(file).lines() {
2588 let Ok(value) = serde_json::from_str::<Value>(&line?) else {
2589 continue;
2590 };
2591 let Some(session_id) = value.get("session_id").and_then(Value::as_str) else {
2592 continue;
2593 };
2594 if !wanted.contains(session_id) || topics.contains_key(session_id) {
2595 continue;
2596 }
2597 let mut candidates = Vec::new();
2598 push_message_candidate(&mut candidates, "user", value.get("text"), HashMap::new());
2599 if !candidates.is_empty() {
2600 topics.insert(session_id.to_string(), candidates);
2601 if topics.len() == wanted.len() {
2602 break;
2603 }
2604 }
2605 }
2606 Ok(topics)
2607}
2608
2609fn latest_message_candidates(locator: &SessionLocator) -> Result<Vec<SessionPreviewCandidate>> {
2610 match &locator.storage {
2611 StorageLocator::File { path } | StorageLocator::Sqlite { path, .. }
2614 if locator.harness.as_str() == HarnessId::HERMES =>
2615 {
2616 latest_hermes_message_candidates(path, &locator.session_id)
2617 }
2618 StorageLocator::File { path } => {
2619 latest_file_message_candidates(path, locator.harness.as_str())
2620 }
2621 StorageLocator::Sqlite { path, selector }
2622 if locator.harness.as_str() == HarnessId::OPENCODE =>
2623 {
2624 latest_opencode_message_candidates(path, selector)
2625 }
2626 StorageLocator::Sqlite { path, selector }
2627 if locator.harness.as_str() == HarnessId::GOOSE =>
2628 {
2629 latest_goose_message_candidates(path, selector)
2630 }
2631 StorageLocator::Sqlite { .. } => Ok(Vec::new()),
2632 }
2633}
2634
2635fn topic_file_message_candidates(
2636 path: &Path,
2637 harness: &str,
2638) -> Result<Vec<SessionPreviewCandidate>> {
2639 let mut file = File::open(path)?;
2640 let mut bytes = Vec::with_capacity(TOPIC_PREVIEW_HEAD_BYTES as usize);
2641 file.by_ref()
2642 .take(TOPIC_PREVIEW_HEAD_BYTES)
2643 .read_to_end(&mut bytes)?;
2644 if file.metadata()?.len() > TOPIC_PREVIEW_HEAD_BYTES {
2645 if let Some(newline) = bytes.iter().rposition(|byte| *byte == b'\n') {
2646 bytes.truncate(newline);
2647 }
2648 }
2649 let text = String::from_utf8(bytes).map_err(|_| {
2650 Error::Other(format!(
2651 "{} contains non-UTF-8 data in its topic-preview window",
2652 path.display()
2653 ))
2654 })?;
2655 if harness == HarnessId::CODEX {
2656 return Ok(codex_preview_candidates(text.lines(), false));
2657 }
2658 let mut candidates = Vec::new();
2659 for line in text.lines() {
2660 let Ok(value) = serde_json::from_str::<Value>(line) else {
2661 continue;
2662 };
2663 push_topic_message_candidate(&mut candidates, harness, &value);
2664 if candidates.len() >= LATEST_PREVIEW_CANDIDATES {
2665 break;
2666 }
2667 }
2668 Ok(candidates)
2669}
2670
2671fn latest_file_message_candidates(
2672 path: &Path,
2673 harness: &str,
2674) -> Result<Vec<SessionPreviewCandidate>> {
2675 let mut candidates =
2676 latest_file_message_candidates_with_limit(path, harness, LATEST_PREVIEW_TAIL_BYTES)?;
2677 if candidates.is_empty() {
2678 candidates =
2679 latest_file_message_candidates_with_limit(path, harness, LATEST_PREVIEW_MAX_BYTES)?;
2680 }
2681 Ok(candidates)
2682}
2683
2684fn latest_file_message_candidates_with_limit(
2685 path: &Path,
2686 harness: &str,
2687 byte_limit: u64,
2688) -> Result<Vec<SessionPreviewCandidate>> {
2689 let mut file = File::open(path)?;
2690 let file_len = file.metadata()?.len();
2691 let start = file_len.saturating_sub(byte_limit);
2692 file.seek(SeekFrom::Start(start))?;
2693 let mut bytes = Vec::with_capacity((file_len - start) as usize);
2694 file.read_to_end(&mut bytes)?;
2695 if start > 0 {
2696 if let Some(newline) = bytes.iter().position(|byte| *byte == b'\n') {
2697 bytes.drain(..=newline);
2698 } else {
2699 return Ok(Vec::new());
2700 }
2701 }
2702 let text = String::from_utf8(bytes).map_err(|_| {
2703 Error::Other(format!(
2704 "{} contains non-UTF-8 data in its list-preview window",
2705 path.display()
2706 ))
2707 })?;
2708 if harness == HarnessId::CODEX {
2709 return Ok(codex_preview_candidates(text.lines().rev(), true));
2710 }
2711 let mut candidates = Vec::new();
2712 for line in text.lines().rev() {
2713 let Ok(value) = serde_json::from_str::<Value>(line) else {
2714 continue;
2715 };
2716 let (role, content, metadata) = match harness {
2717 HarnessId::CLAUDE_CODE => {
2718 let role = value.get("type").and_then(Value::as_str);
2719 if !matches!(role, Some("user" | "assistant")) {
2720 continue;
2721 }
2722 let metadata = if role == Some("user") {
2723 crate::session::claude_user_provenance(&value)
2724 .into_iter()
2725 .collect()
2726 } else {
2727 HashMap::new()
2728 };
2729 (
2730 role.unwrap_or_default(),
2731 value
2732 .get("message")
2733 .and_then(|message| message.get("content")),
2734 metadata,
2735 )
2736 }
2737 HarnessId::PI => {
2738 if value.get("type").and_then(Value::as_str) != Some("message") {
2739 continue;
2740 }
2741 let message = value.get("message").unwrap_or(&Value::Null);
2742 let Some(role @ ("user" | "assistant")) =
2743 message.get("role").and_then(Value::as_str)
2744 else {
2745 continue;
2746 };
2747 (role, message.get("content"), HashMap::new())
2748 }
2749 HarnessId::GEMINI => {
2750 let Some(kind @ ("user" | "gemini")) = value.get("type").and_then(Value::as_str)
2751 else {
2752 continue;
2753 };
2754 (
2755 if kind == "gemini" {
2756 "assistant"
2757 } else {
2758 "user"
2759 },
2760 value.get("content"),
2761 HashMap::new(),
2762 )
2763 }
2764 HarnessId::GROK => {
2765 let Some(role @ ("user" | "assistant")) = value.get("type").and_then(Value::as_str)
2766 else {
2767 continue;
2768 };
2769 (role, value.get("content"), HashMap::new())
2770 }
2771 HarnessId::SUPERCODE => {
2772 let Some(role @ ("user" | "assistant")) = value.get("role").and_then(Value::as_str)
2773 else {
2774 continue;
2775 };
2776 (role, value.get("content"), HashMap::new())
2777 }
2778 _ => continue,
2779 };
2780 let mut metadata = metadata;
2781 if matches!(harness, HarnessId::CLAUDE_CODE | HarnessId::CODEX) {
2782 if let Some(timestamp) = value.get("timestamp").and_then(Value::as_str) {
2783 metadata.insert("timestamp".to_string(), timestamp.to_string());
2784 }
2785 }
2786 push_message_candidate_with_cursor(
2787 &mut candidates,
2788 role,
2789 content,
2790 metadata,
2791 Some(message_candidate_cursor(harness, &value)),
2792 );
2793 if candidates.len() >= LATEST_PREVIEW_CANDIDATES {
2794 break;
2795 }
2796 }
2797 Ok(candidates)
2798}
2799
2800struct CodexPreviewRecord {
2801 native: Value,
2802 role: String,
2803 text: String,
2804}
2805
2806fn codex_preview_candidates<'a>(
2812 lines: impl Iterator<Item = &'a str>,
2813 latest: bool,
2814) -> Vec<SessionPreviewCandidate> {
2815 let mut candidates = Vec::new();
2816 let mut pending: Option<CodexPreviewRecord> = None;
2817 for line in lines {
2818 let Ok(native) = serde_json::from_str::<Value>(line) else {
2819 continue;
2820 };
2821 let Some((role, content)) = codex_preview_message(&native) else {
2822 continue;
2823 };
2824 let Some(text) = display_text(content).filter(|text| !text.trim().is_empty()) else {
2825 continue;
2826 };
2827 let current = CodexPreviewRecord {
2828 role: role.to_string(),
2829 text,
2830 native,
2831 };
2832 if let Some(previous) = pending.take() {
2833 if previous.role == current.role
2834 && previous.text == current.text
2835 && previous.native.get("type") != current.native.get("type")
2836 {
2837 let canonical = if previous.native.get("type").and_then(Value::as_str)
2838 == Some("response_item")
2839 {
2840 previous
2841 } else {
2842 current
2843 };
2844 push_codex_preview_candidate(&mut candidates, canonical, latest);
2845 } else {
2846 push_codex_preview_candidate(&mut candidates, previous, latest);
2847 pending = Some(current);
2848 }
2849 } else {
2850 pending = Some(current);
2851 }
2852 if candidates.len() >= LATEST_PREVIEW_CANDIDATES {
2853 break;
2854 }
2855 }
2856 if let Some(last) = pending {
2857 push_codex_preview_candidate(&mut candidates, last, latest);
2858 }
2859 candidates
2860}
2861
2862fn push_codex_preview_candidate(
2863 candidates: &mut Vec<SessionPreviewCandidate>,
2864 record: CodexPreviewRecord,
2865 latest: bool,
2866) {
2867 let mut metadata = HashMap::new();
2868 if latest {
2869 if let Some(timestamp) = record.native.get("timestamp").and_then(Value::as_str) {
2870 metadata.insert("timestamp".to_string(), timestamp.to_string());
2871 }
2872 }
2873 let cursor = latest.then(|| message_candidate_cursor(HarnessId::CODEX, &record.native));
2874 push_message_candidate_with_cursor(
2875 candidates,
2876 &record.role,
2877 Some(&Value::String(record.text)),
2878 metadata,
2879 cursor,
2880 );
2881}
2882
2883fn codex_preview_message(value: &Value) -> Option<(&str, Option<&Value>)> {
2885 let payload = value.get("payload")?;
2886 match (
2887 value.get("type").and_then(Value::as_str)?,
2888 payload.get("type").and_then(Value::as_str)?,
2889 ) {
2890 ("response_item", "message") => {
2891 let role @ ("user" | "assistant") = payload.get("role").and_then(Value::as_str)? else {
2892 return None;
2893 };
2894 Some((role, payload.get("content")))
2895 }
2896 ("event_msg", "user_message") => Some(("user", payload.get("message"))),
2897 ("event_msg", "agent_message") => Some(("assistant", payload.get("message"))),
2898 _ => None,
2899 }
2900}
2901
2902fn push_topic_message_candidate(
2903 candidates: &mut Vec<SessionPreviewCandidate>,
2904 harness: &str,
2905 value: &Value,
2906) {
2907 let (role, content, metadata) = match harness {
2908 HarnessId::CLAUDE_CODE => {
2909 let role = value.get("type").and_then(Value::as_str);
2910 if !matches!(role, Some("user" | "assistant")) {
2911 return;
2912 }
2913 let metadata = if role == Some("user") {
2914 crate::session::claude_user_provenance(value)
2915 .into_iter()
2916 .collect()
2917 } else {
2918 HashMap::new()
2919 };
2920 (
2921 role.unwrap_or_default(),
2922 value
2923 .get("message")
2924 .and_then(|message| message.get("content")),
2925 metadata,
2926 )
2927 }
2928 _ => return,
2929 };
2930 push_message_candidate(candidates, role, content, metadata);
2931}
2932
2933fn latest_opencode_message_candidates(
2934 path: &Path,
2935 session_id: &str,
2936) -> Result<Vec<SessionPreviewCandidate>> {
2937 let connection = Connection::open_with_flags(
2938 path,
2939 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
2940 )
2941 .map_err(|error| Error::Other(format!("failed to open list-preview store: {error}")))?;
2942 let mut statement = connection
2943 .prepare(
2944 "SELECT m.data, p.data FROM message m JOIN part p ON p.message_id = m.id \
2945 WHERE m.session_id = ?1 ORDER BY m.time_created DESC, p.time_created DESC LIMIT 32",
2946 )
2947 .map_err(|error| Error::Other(format!("failed to prepare list-preview query: {error}")))?;
2948 let rows = statement
2949 .query_map([session_id], |row| {
2950 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2951 })
2952 .map_err(|error| Error::Other(format!("failed to read list-preview rows: {error}")))?;
2953 let mut candidates = Vec::new();
2954 for row in rows.flatten() {
2955 let (Ok(message), Ok(part)) = (
2956 serde_json::from_str::<Value>(&row.0),
2957 serde_json::from_str::<Value>(&row.1),
2958 ) else {
2959 continue;
2960 };
2961 let Some(role @ ("user" | "assistant")) = message.get("role").and_then(Value::as_str)
2962 else {
2963 continue;
2964 };
2965 if part.get("type").and_then(Value::as_str) != Some("text") {
2966 continue;
2967 }
2968 push_message_candidate(&mut candidates, role, part.get("text"), HashMap::new());
2969 if candidates.len() >= LATEST_PREVIEW_CANDIDATES {
2970 break;
2971 }
2972 }
2973 Ok(candidates)
2974}
2975
2976fn latest_hermes_message_candidates(
2977 path: &Path,
2978 session_id: &str,
2979) -> Result<Vec<SessionPreviewCandidate>> {
2980 let connection = Connection::open_with_flags(
2981 path,
2982 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
2983 )
2984 .map_err(|error| Error::Other(format!("failed to open list-preview store: {error}")))?;
2985 let mut statement = connection
2986 .prepare(
2987 "SELECT role, content FROM messages WHERE session_id = ?1 AND active = 1 \
2988 AND role IN ('user', 'assistant') AND content IS NOT NULL AND content != '' \
2989 ORDER BY timestamp DESC, id DESC LIMIT 32",
2990 )
2991 .map_err(|error| Error::Other(format!("failed to prepare list-preview query: {error}")))?;
2992 let rows = statement
2993 .query_map([session_id], |row| {
2994 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2995 })
2996 .map_err(|error| Error::Other(format!("failed to read list-preview rows: {error}")))?;
2997 let mut candidates = Vec::new();
2998 for (role, content) in rows.flatten() {
2999 push_message_candidate(
3000 &mut candidates,
3001 &role,
3002 Some(&Value::String(content)),
3003 HashMap::new(),
3004 );
3005 if candidates.len() >= LATEST_PREVIEW_CANDIDATES {
3006 break;
3007 }
3008 }
3009 Ok(candidates)
3010}
3011
3012fn latest_goose_message_candidates(
3013 path: &Path,
3014 session_id: &str,
3015) -> Result<Vec<SessionPreviewCandidate>> {
3016 let connection = Connection::open_with_flags(
3017 path,
3018 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
3019 )
3020 .map_err(|error| Error::Other(format!("failed to open list-preview store: {error}")))?;
3021 let mut statement = connection
3022 .prepare(
3023 "SELECT role, content_json FROM messages WHERE session_id = ?1 \
3024 ORDER BY created_timestamp DESC, id DESC LIMIT 16",
3025 )
3026 .map_err(|error| Error::Other(format!("failed to prepare list-preview query: {error}")))?;
3027 let rows = statement
3028 .query_map([session_id], |row| {
3029 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3030 })
3031 .map_err(|error| Error::Other(format!("failed to read list-preview rows: {error}")))?;
3032 let mut candidates = Vec::new();
3033 for row in rows.flatten() {
3034 let (role, content) = row;
3035 if !matches!(role.as_str(), "user" | "assistant") {
3036 continue;
3037 }
3038 let Ok(content) = serde_json::from_str::<Value>(&content) else {
3039 continue;
3040 };
3041 push_message_candidate(&mut candidates, &role, Some(&content), HashMap::new());
3042 if candidates.len() >= LATEST_PREVIEW_CANDIDATES {
3043 break;
3044 }
3045 }
3046 Ok(candidates)
3047}
3048
3049fn push_message_candidate(
3050 candidates: &mut Vec<SessionPreviewCandidate>,
3051 role: &str,
3052 content: Option<&Value>,
3053 metadata: HashMap<String, String>,
3054) {
3055 push_message_candidate_with_cursor(candidates, role, content, metadata, None);
3056}
3057
3058fn push_message_candidate_with_cursor(
3059 candidates: &mut Vec<SessionPreviewCandidate>,
3060 role: &str,
3061 content: Option<&Value>,
3062 metadata: HashMap<String, String>,
3063 cursor: Option<String>,
3064) {
3065 if candidates.len() >= LATEST_PREVIEW_CANDIDATES {
3066 return;
3067 }
3068 let Some(text) = display_text(content).filter(|text| !text.trim().is_empty()) else {
3069 return;
3070 };
3071 const MAX_CHARS: usize = 4_096;
3072 candidates.push(SessionPreviewCandidate {
3073 cursor,
3074 role: role.to_string(),
3075 content: text.chars().take(MAX_CHARS).collect(),
3076 metadata,
3077 });
3078}
3079
3080fn message_candidate_cursor(harness: &str, value: &Value) -> String {
3081 let native_identity = value
3082 .get("uuid")
3083 .or_else(|| value.get("id"))
3084 .or_else(|| value.pointer("/message/id"))
3085 .or_else(|| value.pointer("/payload/id"))
3086 .and_then(Value::as_str)
3087 .or_else(|| value.get("timestamp").and_then(Value::as_str));
3088 let mut hasher = blake3::Hasher::new();
3089 hasher.update(b"supercode.session-preview-cursor.v1\0");
3090 hasher.update(harness.as_bytes());
3091 hasher.update(b"\0");
3092 if let Some(identity) = native_identity {
3093 hasher.update(identity.as_bytes());
3094 } else {
3095 hasher.update(value.to_string().as_bytes());
3099 }
3100 format!("v1:{}", &hasher.finalize().to_hex()[..24])
3101}
3102
3103fn fill_string(target: &mut Option<String>, value: Option<&Value>) {
3104 if target.is_none() {
3105 *target = value.and_then(Value::as_str).map(str::to_owned);
3106 }
3107}
3108
3109fn fill_path(target: &mut Option<PathBuf>, value: Option<&Value>) {
3110 if target.is_none() {
3111 *target = value.and_then(Value::as_str).map(PathBuf::from);
3112 }
3113}
3114
3115#[derive(Default)]
3118struct TailFacts {
3119 last_turn_ms: Option<u64>,
3121 model: Option<String>,
3123}
3124
3125fn tail_facts(path: &Path, harness: &str) -> TailFacts {
3141 let mut facts = TailFacts::default();
3142 let Ok(mut file) = File::open(path) else {
3143 return facts;
3144 };
3145 let Ok(len) = file.metadata().map(|meta| meta.len()) else {
3146 return facts;
3147 };
3148 let mut window = TAIL_SCAN_START.min(len);
3149 loop {
3150 if file.seek(SeekFrom::Start(len - window)).is_err() {
3151 return facts;
3152 }
3153 let Ok(size) = usize::try_from(window) else {
3154 return facts;
3155 };
3156 let mut buf = vec![0u8; size];
3157 if file.read_exact(&mut buf).is_err() {
3158 return facts;
3159 }
3160 let floor = if window < len {
3171 buf.iter().position(|byte| *byte == b'\n').map(|at| at + 1)
3172 } else {
3173 Some(0)
3174 };
3175 if let Some(floor) = floor {
3176 let mut end = buf.len();
3177 while end > floor && !(facts.last_turn_ms.is_some() && facts.model.is_some()) {
3178 let start = buf[floor..end]
3179 .iter()
3180 .rposition(|byte| *byte == b'\n')
3181 .map_or(floor, |at| floor + at + 1);
3182 if let Ok(record) = std::str::from_utf8(&buf[start..end])
3183 .map_err(|_| ())
3184 .and_then(|line| serde_json::from_str::<Value>(line).map_err(|_| ()))
3185 {
3186 if facts.last_turn_ms.is_none() {
3187 facts.last_turn_ms = record_timestamp(&record, harness)
3188 .and_then(crate::sidecar::rfc3339_to_ms)
3189 .and_then(|millis| u64::try_from(millis).ok());
3190 }
3191 if facts.model.is_none() {
3192 facts.model = record_model(&record, harness).map(str::to_owned);
3193 }
3194 }
3195 end = start.saturating_sub(1);
3196 }
3197 }
3198 if facts.last_turn_ms.is_some() || window >= len || window >= TAIL_SCAN_LIMIT {
3199 return facts;
3200 }
3201 window = (window * 2).min(len).min(TAIL_SCAN_LIMIT);
3202 }
3203}
3204
3205fn record_timestamp<'a>(record: &'a Value, harness: &str) -> Option<&'a str> {
3209 let key = match harness {
3210 HarnessId::SUPERCODE => "ts",
3211 _ => "timestamp",
3212 };
3213 record.get(key)?.as_str()
3214}
3215
3216fn record_model<'a>(record: &'a Value, harness: &str) -> Option<&'a str> {
3219 match harness {
3220 HarnessId::CLAUDE_CODE | HarnessId::PI => record.get("message")?.get("model")?.as_str(),
3221 HarnessId::CODEX => {
3222 if record.get("type")?.as_str()? != "turn_context" {
3223 return None;
3224 }
3225 record.get("payload")?.get("model")?.as_str()
3226 }
3227 _ => None,
3228 }
3229}
3230
3231const TAIL_SCAN_START: u64 = 16 * 1024;
3234
3235const TAIL_SCAN_LIMIT: u64 = 1024 * 1024;
3238
3239fn modified_ms(path: &Path) -> Option<u64> {
3240 fs::metadata(path)
3241 .ok()?
3242 .modified()
3243 .ok()?
3244 .duration_since(UNIX_EPOCH)
3245 .ok()
3246 .and_then(|duration| u64::try_from(duration.as_millis()).ok())
3247}
3248
3249fn recorded_cwd_matches(recorded: &Path, wanted: &Path) -> bool {
3255 recorded.is_absolute() && same_path(recorded, wanted)
3256}
3257
3258fn same_path(left: &Path, right: &Path) -> bool {
3259 match (fs::canonicalize(left), fs::canonicalize(right)) {
3260 (Ok(left), Ok(right)) => left == right,
3261 _ => normalize_path(left) == normalize_path(right),
3262 }
3263}
3264
3265fn normalize_path(path: &Path) -> PathBuf {
3266 let absolute = if path.is_absolute() {
3267 path.to_path_buf()
3268 } else {
3269 std::env::current_dir()
3270 .unwrap_or_else(|_| PathBuf::from("."))
3271 .join(path)
3272 };
3273 let mut normalized = PathBuf::new();
3274 for component in absolute.components() {
3275 match component {
3276 Component::CurDir => {}
3277 Component::ParentDir => {
3278 normalized.pop();
3279 }
3280 other => normalized.push(other.as_os_str()),
3281 }
3282 }
3283 normalized
3284}
3285
3286#[cfg(test)]
3287mod tests {
3288 use super::*;
3289 use std::io::Write;
3290 use std::time::{SystemTime, UNIX_EPOCH};
3291
3292 fn temp_dir(label: &str) -> PathBuf {
3293 let nonce = SystemTime::now()
3294 .duration_since(UNIX_EPOCH)
3295 .unwrap()
3296 .as_nanos();
3297 let path = std::env::temp_dir().join(format!(
3298 "supercode-catalog-{label}-{}-{nonce}",
3299 std::process::id()
3300 ));
3301 fs::create_dir_all(&path).unwrap();
3302 path
3303 }
3304
3305 #[test]
3306 fn codex_history_index_reads_appends_and_repairs_replacements() {
3307 let root = temp_dir("codex-history-index");
3308 let sessions = root.join("sessions");
3309 fs::create_dir_all(&sessions).unwrap();
3310 let history = root.join("history.jsonl");
3311 fs::write(
3312 &history,
3313 "{\"session_id\":\"alpha\",\"text\":\"first topic\"}\n",
3314 )
3315 .unwrap();
3316
3317 let mut index = CodexHistoryTopicIndex::new(&sessions);
3318 assert_eq!(index.refresh().unwrap(), BTreeSet::from(["alpha".into()]));
3319 assert_eq!(index.topics["alpha"][0].content, "first topic");
3320 assert!(index.refresh().unwrap().is_empty());
3321
3322 let mut file = fs::OpenOptions::new().append(true).open(&history).unwrap();
3323 write!(
3324 file,
3325 "{{\"session_id\":\"alpha\",\"text\":\"later topic\"}}\n\
3326 {{\"session_id\":\"beta\",\"text\":\"second topic\"}}\n"
3327 )
3328 .unwrap();
3329 file.flush().unwrap();
3330 assert_eq!(index.refresh().unwrap(), BTreeSet::from(["beta".into()]));
3331 assert_eq!(index.topics["alpha"][0].content, "first topic");
3332 assert_eq!(index.topics["beta"][0].content, "second topic");
3333
3334 fs::write(
3335 &history,
3336 "{\"session_id\":\"gamma\",\"text\":\"replacement\"}\n",
3337 )
3338 .unwrap();
3339 assert_eq!(
3340 index.refresh().unwrap(),
3341 BTreeSet::from(["alpha".into(), "beta".into(), "gamma".into()])
3342 );
3343 assert!(!index.topics.contains_key("alpha"));
3344 assert_eq!(index.topics["gamma"][0].content, "replacement");
3345
3346 fs::remove_dir_all(root).ok();
3347 }
3348
3349 #[test]
3350 fn codex_history_index_retains_an_incomplete_appended_record() {
3351 let root = temp_dir("codex-history-partial");
3352 let sessions = root.join("sessions");
3353 fs::create_dir_all(&sessions).unwrap();
3354 let history = root.join("history.jsonl");
3355 fs::write(&history, "{\"session_id\":\"partial\",\"text\":\"hel").unwrap();
3356
3357 let mut index = CodexHistoryTopicIndex::new(&sessions);
3358 assert!(index.refresh().unwrap().is_empty());
3359 let mut file = fs::OpenOptions::new().append(true).open(&history).unwrap();
3360 writeln!(file, "lo\"}}").unwrap();
3361 file.flush().unwrap();
3362
3363 assert_eq!(index.refresh().unwrap(), BTreeSet::from(["partial".into()]));
3364 assert_eq!(index.topics["partial"][0].content, "hello");
3365 fs::remove_dir_all(root).ok();
3366 }
3367
3368 #[test]
3369 fn cached_codex_history_enrichment_matches_stateless_discovery() {
3370 let root = temp_dir("codex-history-parity");
3371 let sessions = root.join("sessions");
3372 let workspace = root.join("workspace");
3373 fs::create_dir_all(&sessions).unwrap();
3374 fs::create_dir_all(&workspace).unwrap();
3375 fs::write(
3376 sessions.join("rollout.jsonl"),
3377 format!(
3378 "{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"alpha\",\"cwd\":{}}}}}\n{{\"type\":\"turn_context\",\"payload\":{{\"cwd\":{},\"model\":\"gpt-test\"}}}}\n{{\"type\":\"response_item\",\"payload\":{{\"type\":\"message\",\"role\":\"user\",\"content\":[{{\"type\":\"input_text\",\"text\":\"transcript fallback\"}}]}}}}\n",
3379 serde_json::to_string(&workspace.to_string_lossy()).unwrap(),
3380 serde_json::to_string(&workspace.to_string_lossy()).unwrap(),
3381 ),
3382 )
3383 .unwrap();
3384 fs::write(
3385 root.join("history.jsonl"),
3386 "{\"session_id\":\"alpha\",\"text\":\"history topic\"}\n",
3387 )
3388 .unwrap();
3389 let query = DiscoveryQuery {
3390 harnesses: vec![HarnessId::from(HarnessId::CODEX)],
3391 homes: HarnessHomes {
3392 codex: sessions.clone(),
3393 ..HarnessHomes::default()
3394 },
3395 include_topic_candidates: true,
3396 ..DiscoveryQuery::default()
3397 };
3398 let catalog = HarnessCatalog::new();
3399 let projected = catalog
3400 .project_index(&query, catalog.discover_raw_index(&query))
3401 .unwrap();
3402 let expected = catalog
3403 .enrich_index_page(&query, projected.clone())
3404 .unwrap();
3405 let mut history = CodexHistoryTopicIndex::new(&sessions);
3406 history.refresh().unwrap();
3407 let actual = catalog
3408 .enrich_index_page_with_codex_history(&query, projected, &history)
3409 .unwrap();
3410
3411 assert_eq!(actual, expected);
3412 assert_eq!(actual[0].preview_candidates[0].content, "history topic");
3413 fs::remove_dir_all(root).ok();
3414 }
3415
3416 #[test]
3417 fn locator_json_round_trip_preserves_sqlite_selector() {
3418 let locator = SessionLocator {
3419 harness: HarnessId::from(HarnessId::OPENCODE),
3420 session_id: "ses_123".into(),
3421 storage: StorageLocator::Sqlite {
3422 path: PathBuf::from("/tmp/opencode-dev.db"),
3423 selector: "ses_123".into(),
3424 },
3425 };
3426 let encoded = serde_json::to_string(&locator).unwrap();
3427 assert_eq!(
3428 serde_json::from_str::<SessionLocator>(&encoded).unwrap(),
3429 locator
3430 );
3431 }
3432
3433 #[test]
3434 fn discovers_filters_loads_and_follows_three_jsonl_harnesses() {
3435 let root = temp_dir("jsonl");
3436 let workspace = root.join("workspace");
3437 let other = root.join("other");
3438 fs::create_dir_all(&workspace).unwrap();
3439 fs::create_dir_all(&other).unwrap();
3440
3441 let claude = root.join("claude");
3442 let codex = root.join("codex");
3443 let pi = root.join("pi");
3444 fs::create_dir_all(&claude).unwrap();
3445 fs::create_dir_all(&codex).unwrap();
3446 fs::create_dir_all(&pi).unwrap();
3447 fs::write(
3448 claude.join("claude.jsonl"),
3449 format!(
3450 "{{\"type\":\"user\",\"sessionId\":\"cc-1\",\"cwd\":{},\"timestamp\":\"2026-01-01T00:00:01Z\",\"message\":{{\"role\":\"user\",\"content\":\"hi\"}}}}\n",
3451 serde_json::to_string(&workspace.to_string_lossy()).unwrap()
3452 ),
3453 )
3454 .unwrap();
3455 fs::write(
3456 codex.join("rollout.jsonl"),
3457 format!(
3458 "{{\"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",
3459 serde_json::to_string(&workspace.to_string_lossy()).unwrap()
3460 ),
3461 )
3462 .unwrap();
3463 fs::write(
3464 pi.join("pi.jsonl"),
3465 format!(
3466 "{{\"type\":\"session\",\"version\":3,\"id\":\"pi-1\",\"timestamp\":\"2026-01-01T00:00:00Z\",\"cwd\":{}}}\n{{\"type\":\"message\",\"message\":{{\"role\":\"user\",\"content\":\"inspect pi\"}}}}\n",
3467 serde_json::to_string(&workspace.to_string_lossy()).unwrap()
3468 ),
3469 )
3470 .unwrap();
3471 fs::write(
3472 pi.join("unrelated.jsonl"),
3473 format!(
3474 "{{\"type\":\"session\",\"version\":3,\"id\":\"pi-2\",\"timestamp\":\"2026-01-01T00:00:00Z\",\"cwd\":{}}}\n",
3475 serde_json::to_string(&other.to_string_lossy()).unwrap()
3476 ),
3477 )
3478 .unwrap();
3479 fs::write(claude.join("partial.jsonl"), "{truncated").unwrap();
3480
3481 let query = DiscoveryQuery {
3482 workspace: Some(workspace),
3483 homes: HarnessHomes {
3484 claude_code: claude,
3485 codex,
3486 pi,
3487 opencode: root.join("missing-opencode"),
3488 grok: root.join("missing-grok"),
3489 gemini: root.join("missing-gemini"),
3490 goose: root.join("missing-goose"),
3491 supercode: root.join("missing-supercode"),
3492 openclaw: root.join("missing-openclaw"),
3493 hermes: root.join("missing-hermes"),
3494 orchestrator: root.join("missing-orchestrator"),
3495 },
3496 ..DiscoveryQuery::default()
3497 };
3498 let catalog = HarnessCatalog::new();
3499 let found = catalog.discover(&query).unwrap();
3500 assert_eq!(found.len(), 3);
3501 assert_eq!(
3502 found
3503 .iter()
3504 .map(|item| item.locator.harness.as_str())
3505 .collect::<HashSet<_>>(),
3506 HashSet::from([HarnessId::CLAUDE_CODE, HarnessId::CODEX, HarnessId::PI])
3507 );
3508 for descriptor in found {
3509 assert!(descriptor.preview_candidates.is_empty());
3510 assert_eq!(descriptor.latest_message_candidates.len(), 1);
3511 assert_eq!(descriptor.latest_message_candidates[0].role, "user");
3512 assert!(descriptor.latest_message_candidates[0].cursor.is_some());
3513 if descriptor.locator.harness.as_str() == HarnessId::CLAUDE_CODE {
3514 assert_eq!(
3515 descriptor.latest_message_candidates[0]
3516 .metadata
3517 .get("timestamp")
3518 .map(String::as_str),
3519 Some("2026-01-01T00:00:01Z")
3520 );
3521 } else if descriptor.locator.harness.as_str() == HarnessId::CODEX {
3522 assert_eq!(
3523 descriptor.latest_message_candidates[0]
3524 .metadata
3525 .get("timestamp")
3526 .map(String::as_str),
3527 Some("2026-01-01T00:00:02Z")
3528 );
3529 }
3530 let loaded = catalog.load(&descriptor.locator).unwrap();
3531 assert_eq!(
3532 loaded.meta.session_id.as_deref(),
3533 Some(descriptor.locator.session_id.as_str())
3534 );
3535 let mut follower = catalog.follow(&descriptor.locator).unwrap();
3536 assert!(matches!(
3537 follower.poll().unwrap(),
3538 Some(crate::SessionWatchEvent::SessionSnapshot { .. })
3539 ));
3540 }
3541 fs::remove_dir_all(root).ok();
3542 }
3543
3544 #[test]
3545 fn codex_event_messages_supply_bounded_native_order_previews() {
3546 let root = temp_dir("codex-event-previews");
3549 let path = root.join("rollout.jsonl");
3550 let user = serde_json::json!({
3551 "timestamp": "2026-01-01T00:00:01Z", "type": "event_msg",
3552 "payload": {"type": "user_message", "message": "Investigate the worker"}
3553 });
3554 let answer = serde_json::json!({
3555 "timestamp": "2026-01-01T00:00:02Z", "type": "event_msg",
3556 "payload": {"type": "agent_message", "message": "Worker findings"}
3557 });
3558 let noise = serde_json::json!({
3559 "type": "event_msg", "payload": {"type": "token_count", "message": "not a message"}
3560 });
3561 fs::write(&path, format!("{user}\n{answer}\n{noise}\n{{partial")).unwrap();
3562 let latest = latest_file_message_candidates(&path, HarnessId::CODEX).unwrap();
3563 assert_eq!(latest.len(), 2);
3564 assert_eq!(latest[0].role, "assistant");
3565 assert_eq!(latest[0].content, "Worker findings");
3566 assert_eq!(latest[1].role, "user");
3567 assert_eq!(latest[1].content, "Investigate the worker");
3568 assert_eq!(
3569 latest[0].metadata.get("timestamp").map(String::as_str),
3570 Some("2026-01-01T00:00:02Z")
3571 );
3572 assert_eq!(
3573 latest[0].cursor.as_deref(),
3574 Some(message_candidate_cursor(HarnessId::CODEX, &answer).as_str())
3575 );
3576 let topics = topic_file_message_candidates(&path, HarnessId::CODEX).unwrap();
3577 assert_eq!(topics.len(), 2);
3578 assert_eq!(topics[0].content, "Investigate the worker");
3579
3580 let mut context_pairs = String::new();
3581 for index in 0..5 {
3582 let content = if index < 4 {
3583 format!("# AGENTS.md instructions for /work/{index}\n\n<INSTRUCTIONS>Context</INSTRUCTIONS>")
3584 } else {
3585 "The actual user request".to_string()
3586 };
3587 let event = serde_json::json!({
3588 "type": "event_msg", "payload": {"type": "user_message", "message": content}
3589 });
3590 let response = serde_json::json!({
3591 "type": "response_item", "payload": {"type": "message", "role": "user", "content": content}
3592 });
3593 context_pairs.push_str(&format!("{event}\n{response}\n"));
3594 }
3595 fs::write(&path, context_pairs).unwrap();
3596 let topics = topic_file_message_candidates(&path, HarnessId::CODEX).unwrap();
3597 assert!(topics
3598 .iter()
3599 .any(|candidate| candidate.content == "The actual user request"));
3600
3601 let mut paired = String::new();
3604 for index in 0..12 {
3605 let content = format!("answer {index}");
3606 let event = serde_json::json!({
3607 "type": "event_msg", "payload": {"type": "agent_message", "message": content}
3608 });
3609 let response = serde_json::json!({
3610 "id": format!("response-{index}"), "type": "response_item",
3611 "payload": {"type": "message", "role": "assistant", "content": content}
3612 });
3613 if index % 2 == 0 {
3614 paired.push_str(&format!("{event}\n{response}\n"));
3615 } else {
3616 paired.push_str(&format!("{response}\n{event}\n"));
3617 }
3618 }
3619 fs::write(&path, paired).unwrap();
3620 let latest = latest_file_message_candidates(&path, HarnessId::CODEX).unwrap();
3621 assert_eq!(latest.len(), LATEST_PREVIEW_CANDIDATES);
3622 assert_eq!(latest[0].content, "answer 11");
3623 assert_eq!(latest[1].content, "answer 10");
3624 assert_eq!(latest[7].content, "answer 4");
3625 for (offset, candidate) in latest.iter().enumerate() {
3626 let response = serde_json::json!({"id": format!("response-{}", 11 - offset)});
3627 assert_eq!(
3628 candidate.cursor.as_deref(),
3629 Some(message_candidate_cursor(HarnessId::CODEX, &response).as_str())
3630 );
3631 }
3632 let topics = topic_file_message_candidates(&path, HarnessId::CODEX).unwrap();
3633 assert_eq!(topics.len(), LATEST_PREVIEW_CANDIDATES);
3634 assert_eq!(topics[7].content, "answer 7");
3635
3636 let event = serde_json::json!({
3637 "type": "event_msg", "payload": {"type": "agent_message", "message": "again"}
3638 });
3639 let response = serde_json::json!({
3640 "type": "response_item", "payload": {"type": "message", "role": "assistant", "content": "again"}
3641 });
3642 for records in [
3643 format!("{event}\n{event}\n"),
3644 format!("{response}\n{response}\n"),
3645 format!("{event}\n{response}\n{event}\n{response}\n"),
3646 format!("{response}\n{event}\n{response}\n{event}\n"),
3647 ] {
3648 fs::write(&path, records).unwrap();
3649 assert_eq!(
3650 latest_file_message_candidates(&path, HarnessId::CODEX)
3651 .unwrap()
3652 .len(),
3653 2
3654 );
3655 assert_eq!(
3656 topic_file_message_candidates(&path, HarnessId::CODEX)
3657 .unwrap()
3658 .len(),
3659 2
3660 );
3661 }
3662
3663 let prefix = "x".repeat(4096);
3665 let distinct_event = serde_json::json!({
3666 "type": "event_msg", "payload": {"type": "agent_message", "message": format!("{prefix}A")}
3667 });
3668 let distinct_response = serde_json::json!({
3669 "type": "response_item", "payload": {"type": "message", "role": "assistant", "content": format!("{prefix}B")}
3670 });
3671 fs::write(&path, format!("{distinct_event}\n{distinct_response}\n")).unwrap();
3672 assert_eq!(
3673 latest_file_message_candidates(&path, HarnessId::CODEX)
3674 .unwrap()
3675 .len(),
3676 2
3677 );
3678 assert_eq!(
3679 topic_file_message_candidates(&path, HarnessId::CODEX)
3680 .unwrap()
3681 .len(),
3682 2
3683 );
3684 let long = serde_json::json!({
3685 "type": "event_msg", "payload": {"type": "agent_message", "message": "x".repeat(5000)}
3686 });
3687 fs::write(&path, format!("{long}\n")).unwrap();
3688 let latest = latest_file_message_candidates(&path, HarnessId::CODEX).unwrap();
3689 assert_eq!(latest[0].content.len(), 4096);
3690 fs::remove_dir_all(root).ok();
3691 }
3692
3693 #[test]
3694 fn preview_cursor_tracks_native_boundary_not_growing_text() {
3695 let first = serde_json::json!({
3696 "timestamp": "2026-01-01T00:00:02Z",
3697 "type": "response_item",
3698 "payload": {"type": "message", "role": "assistant", "content": "partial"}
3699 });
3700 let grown = serde_json::json!({
3701 "timestamp": "2026-01-01T00:00:02Z",
3702 "type": "response_item",
3703 "payload": {"type": "message", "role": "assistant", "content": "partial and complete"}
3704 });
3705 let next = serde_json::json!({
3706 "timestamp": "2026-01-01T00:00:03Z",
3707 "type": "response_item",
3708 "payload": {"type": "message", "role": "assistant", "content": "next"}
3709 });
3710
3711 assert_eq!(
3712 message_candidate_cursor(HarnessId::CODEX, &first),
3713 message_candidate_cursor(HarnessId::CODEX, &grown)
3714 );
3715 assert_ne!(
3716 message_candidate_cursor(HarnessId::CODEX, &first),
3717 message_candidate_cursor(HarnessId::CODEX, &next)
3718 );
3719 }
3720
3721 #[test]
3722 fn codex_child_rollouts_roll_into_roots_before_pagination() {
3723 let root = temp_dir("codex-roots");
3724 let codex = root.join("codex");
3725 fs::create_dir_all(&codex).unwrap();
3726 let write_rollout =
3727 |name: &str, payload: Value, modified_seconds: u64| {
3728 let path = codex.join(format!("{name}.jsonl"));
3729 fs::write(
3730 &path,
3731 format!(
3732 "{}\n",
3733 serde_json::json!({
3734 "timestamp": "2026-01-01T00:00:00Z",
3735 "type": "session_meta",
3736 "payload": payload,
3737 })
3738 ),
3739 )
3740 .unwrap();
3741 File::open(&path)
3742 .unwrap()
3743 .set_times(fs::FileTimes::new().set_modified(
3744 UNIX_EPOCH + std::time::Duration::from_secs(modified_seconds),
3745 ))
3746 .unwrap();
3747 };
3748 write_rollout(
3749 "parent",
3750 serde_json::json!({"id":"parent","cwd":"/project","source":"cli"}),
3751 100,
3752 );
3753 write_rollout(
3754 "other",
3755 serde_json::json!({"id":"other","cwd":"/project","source":"cli"}),
3756 200,
3757 );
3758 write_rollout(
3759 "child",
3760 serde_json::json!({
3761 "id": "child",
3762 "cwd": "/project",
3763 "parent_thread_id": "parent",
3764 "source": {"subagent":{"thread_spawn":{
3765 "parent_thread_id":"parent",
3766 "depth":1,
3767 "agent_path":"/root/reviewer"
3768 }}}
3769 }),
3770 300,
3771 );
3772
3773 let catalog = HarnessCatalog::new();
3774 let query = DiscoveryQuery {
3775 harnesses: vec![HarnessId::from(HarnessId::CODEX)],
3776 homes: HarnessHomes {
3777 codex: codex.clone(),
3778 ..HarnessHomes::default()
3779 },
3780 limit: Some(1),
3781 ..DiscoveryQuery::default()
3782 };
3783 let roots = catalog.discover(&query).unwrap();
3784 assert_eq!(roots.len(), 1);
3785 assert_eq!(roots[0].locator.session_id, "parent");
3786 assert_eq!(roots[0].updated_at_ms, Some(300_000));
3787 assert_eq!(roots[0].parent_session_id, None);
3788 assert_eq!(roots[0].child_session_count, 1);
3789
3790 let tree = catalog
3791 .discover(&DiscoveryQuery {
3792 limit: None,
3793 include_child_sessions: true,
3794 root_session_id: Some("parent".into()),
3795 ..query
3796 })
3797 .unwrap();
3798 assert_eq!(tree.len(), 2);
3799 assert!(tree
3800 .iter()
3801 .all(|descriptor| descriptor.locator.session_id != "other"));
3802 let child = tree
3803 .iter()
3804 .find(|descriptor| descriptor.locator.session_id == "child")
3805 .unwrap();
3806 assert_eq!(child.parent_session_id.as_deref(), Some("parent"));
3807 fs::remove_dir_all(root).ok();
3808 }
3809
3810 #[test]
3811 fn discovers_loads_and_follows_opencode_sqlite() {
3812 let db = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
3813 .join("../harness/tests/fixtures/opencode_fixture/opencode.db");
3814 let catalog = HarnessCatalog::new();
3815 let found = catalog
3816 .discover(&DiscoveryQuery {
3817 harnesses: vec![HarnessId::from(HarnessId::OPENCODE)],
3818 homes: HarnessHomes {
3819 opencode: db,
3820 ..HarnessHomes::default()
3821 },
3822 ..DiscoveryQuery::default()
3823 })
3824 .unwrap();
3825 assert!(!found.is_empty());
3826 for descriptor in found {
3827 assert_eq!(descriptor.locator.harness.as_str(), HarnessId::OPENCODE);
3828 assert_eq!(
3829 catalog.load(&descriptor.locator).unwrap().meta.session_id,
3830 Some(descriptor.locator.session_id.clone())
3831 );
3832 assert!(catalog.follow(&descriptor.locator).is_ok());
3833 }
3834 }
3835
3836 #[test]
3837 fn discovers_loads_and_follows_hermes_sqlite_by_session() {
3838 let root = temp_dir("hermes-follow");
3840 fs::create_dir_all(&root).unwrap();
3841 let db = root.join("state.db");
3842 fs::copy(
3843 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
3844 .join("../harness/tests/fixtures/hermes_home/state.db"),
3845 &db,
3846 )
3847 .unwrap();
3848 let catalog = HarnessCatalog::new();
3849 let query = DiscoveryQuery {
3850 harnesses: vec![HarnessId::from(HarnessId::HERMES)],
3851 homes: HarnessHomes {
3852 hermes: db.clone(),
3853 ..HarnessHomes::default()
3854 },
3855 ..DiscoveryQuery::default()
3856 };
3857 let found = catalog.discover(&query).unwrap();
3858 assert!(found.len() >= 2, "{found:#?}");
3859 for descriptor in &found {
3863 assert_eq!(descriptor.locator.harness.as_str(), HarnessId::HERMES);
3864 let loaded = catalog.load(&descriptor.locator).unwrap();
3865 let last_text = loaded.messages.iter().rev().find_map(|message| {
3866 (matches!(message.role, crate::Role::User | crate::Role::Assistant))
3867 .then(|| message.content.clone())
3868 .flatten()
3869 });
3870 assert_eq!(
3872 descriptor
3873 .latest_message_candidates
3874 .first()
3875 .map(|c| c.content.as_str()),
3876 last_text.as_deref(),
3877 "{}",
3878 descriptor.locator.session_id
3879 );
3880 assert_eq!(
3881 catalog.load(&descriptor.locator).unwrap().meta.session_id,
3882 Some(descriptor.locator.session_id.clone())
3883 );
3884 let mut follower = catalog.follow(&descriptor.locator).unwrap();
3885 match follower.poll().unwrap() {
3886 Some(crate::watch::SessionWatchEvent::SessionSnapshot { session, .. }) => {
3887 assert_eq!(
3888 session.meta.session_id,
3889 Some(descriptor.locator.session_id.clone())
3890 );
3891 }
3892 other => panic!("expected an initial snapshot, got {other:?}"),
3893 }
3894 }
3895 let target = &found[0].locator;
3898 let sibling = &found[1].locator;
3899 let mut target_follower = catalog.follow(target).unwrap();
3900 let mut sibling_follower = catalog.follow(sibling).unwrap();
3901 target_follower.poll().unwrap();
3902 sibling_follower.poll().unwrap();
3903 std::thread::sleep(std::time::Duration::from_millis(20));
3904 {
3905 let conn = rusqlite::Connection::open(&db).unwrap();
3906 conn.execute(
3907 "INSERT INTO messages (session_id, role, content, timestamp, active) VALUES (?1, 'assistant', 'appended by the follow test', ?2, 1)",
3908 rusqlite::params![target.session_id, 1_800_000_000.0_f64],
3909 )
3910 .unwrap();
3911 }
3912 match target_follower.poll().unwrap() {
3913 Some(crate::watch::SessionWatchEvent::MessagesAppended {
3914 session_id,
3915 messages,
3916 ..
3917 }) => {
3918 assert_eq!(session_id, Some(target.session_id.clone()));
3919 assert_eq!(messages.len(), 1);
3920 assert_eq!(
3921 messages[0].content.as_deref(),
3922 Some("appended by the follow test")
3923 );
3924 }
3925 other => panic!("expected messages_appended for the target session, got {other:?}"),
3926 }
3927 assert!(
3928 sibling_follower.poll().unwrap().is_none(),
3929 "the sibling session must not wake"
3930 );
3931 fs::remove_dir_all(&root).ok();
3932 }
3933
3934 #[test]
3935 fn discovers_loads_and_follows_gemini_conversation_records() {
3936 let root = temp_dir("gemini");
3937 let workspace = root.join("workspace");
3938 let chats = root.join("gemini/tmp/demo/chats");
3939 fs::create_dir_all(&workspace).unwrap();
3940 fs::create_dir_all(&chats).unwrap();
3941 fs::write(
3942 root.join("gemini/projects.json"),
3943 serde_json::json!({
3944 "projects": {workspace.to_string_lossy(): "demo"}
3945 })
3946 .to_string(),
3947 )
3948 .unwrap();
3949 let transcript = chats.join("gemini-id.jsonl");
3950 fs::write(
3951 &transcript,
3952 include_str!("../../harness/tests/fixtures/gemini_session.jsonl"),
3953 )
3954 .unwrap();
3955
3956 let catalog = HarnessCatalog::new();
3957 let found = catalog
3958 .discover(&DiscoveryQuery {
3959 harnesses: vec![HarnessId::from(HarnessId::GEMINI)],
3960 homes: HarnessHomes {
3961 gemini: root.join("gemini"),
3962 ..HarnessHomes::default()
3963 },
3964 workspace: Some(workspace.clone()),
3965 ..DiscoveryQuery::default()
3966 })
3967 .unwrap();
3968
3969 assert_eq!(found.len(), 1);
3970 assert_eq!(found[0].cwd.as_deref(), Some(workspace.as_path()));
3971 assert_eq!(found[0].message_count, None);
3972 assert_eq!(found[0].model.as_deref(), Some("gemini-2.5-pro"));
3973 assert_eq!(found[0].title, None);
3974 assert!(found[0].preview_candidates.is_empty());
3975 assert_eq!(found[0].latest_message_candidates.len(), 3);
3976 assert_eq!(
3977 found[0].latest_message_candidates[0].content,
3978 "Fixture inspected."
3979 );
3980 let loaded = catalog.load(&found[0].locator).unwrap();
3981 assert_eq!(
3982 loaded.meta.session_id.as_deref(),
3983 Some("11111111-1111-4111-8111-111111111111")
3984 );
3985 assert_eq!(loaded.messages.len(), 4);
3986 assert!(matches!(
3987 catalog.follow(&found[0].locator).unwrap().poll().unwrap(),
3988 Some(crate::SessionWatchEvent::SessionSnapshot { .. })
3989 ));
3990 fs::remove_dir_all(root).ok();
3991 }
3992
3993 #[test]
3994 fn preview_search_filters_before_pagination_without_changing_metadata_search() {
3995 let root = temp_dir("preview-search");
3998 for (id, first, last) in [
3999 ("topic-hit", "NEBULA opening", "Finished"),
4000 ("latest-hit", "Ordinary opening", "Found the nebula"),
4001 ("no-hit", "Unrelated opening", "Finished"),
4002 ] {
4003 fs::write(root.join(format!("{id}.jsonl")), format!("{}\n{}\n",
4004 serde_json::json!({"sessionId": id, "cwd": "/work", "type": "user", "message": {"role": "user", "content": first}}),
4005 serde_json::json!({"sessionId": id, "type": "assistant", "message": {"role": "assistant", "content": last}}),
4006 )).unwrap();
4007 }
4008 let query: DiscoveryQuery = serde_json::from_value(serde_json::json!({
4009 "harnesses": ["claude-code"], "homes": {"claude_code": root},
4010 "query": " nebula ", "search_previews": true, "limit": 1
4011 }))
4012 .unwrap();
4013 let catalog = HarnessCatalog::new();
4014 let first = catalog.discover_page(&query).unwrap();
4015 assert!(first.receipt.searched_previews);
4016 assert_eq!(first.receipt.total_matched, 2);
4017 assert_eq!(first.sessions.len(), 1);
4018 assert!(first.receipt.truncated);
4019 let second = catalog
4020 .discover_page(&DiscoveryQuery {
4021 cursor: first.next_cursor.clone(),
4022 ..query.clone()
4023 })
4024 .unwrap();
4025 assert_eq!(second.receipt.total_matched, 2);
4026 assert_eq!(second.sessions.len(), 1);
4027 assert_ne!(first.sessions[0].locator, second.sessions[0].locator);
4028 assert!(!second.receipt.truncated);
4029 let mut metadata = serde_json::to_value(&query).unwrap();
4030 metadata["search_previews"] = false.into();
4031 let metadata_page = catalog
4032 .discover_page(&serde_json::from_value(metadata).unwrap())
4033 .unwrap();
4034 assert!(metadata_page.sessions.is_empty());
4035 assert!(serde_json::to_value(&metadata_page.receipt)
4036 .unwrap()
4037 .get("searched_previews")
4038 .is_none());
4039
4040 let all = catalog
4043 .discover_page(&DiscoveryQuery {
4044 query: Some("hit".into()),
4045 limit: None,
4046 ..query.clone()
4047 })
4048 .unwrap();
4049 assert_eq!(all.sessions.len(), 3);
4050 assert_eq!(all.receipt.total_matched, 3);
4051 let elsewhere = catalog
4052 .discover_page(&DiscoveryQuery {
4053 workspace: Some("/elsewhere".into()),
4054 ..query.clone()
4055 })
4056 .unwrap();
4057 assert_eq!(elsewhere.receipt.total_matched, 0);
4058 let excluded_by_time = catalog
4059 .discover_page(&DiscoveryQuery {
4060 updated_after_ms: Some(u64::MAX),
4061 ..query.clone()
4062 })
4063 .unwrap();
4064 assert_eq!(excluded_by_time.receipt.total_matched, 0);
4065 for invalid in [
4066 DiscoveryQuery {
4067 query: None,
4068 ..query.clone()
4069 },
4070 DiscoveryQuery {
4071 query: Some(" ".into()),
4072 ..query.clone()
4073 },
4074 DiscoveryQuery {
4075 limit: Some(0),
4076 ..query.clone()
4077 },
4078 DiscoveryQuery {
4079 cursor: Some("bad-cursor".into()),
4080 ..query.clone()
4081 },
4082 DiscoveryQuery {
4083 cursor: first.next_cursor,
4084 query: Some("absent".into()),
4085 ..query.clone()
4086 },
4087 ] {
4088 assert!(catalog.discover_page(&invalid).is_err());
4089 }
4090 assert!(catalog.project_index_page(&query, Vec::new()).is_err());
4091 fs::remove_dir_all(root).ok();
4092 }
4093
4094 #[test]
4095 fn preview_search_uses_codex_first_history_topic_and_bounded_candidates() {
4096 let root = temp_dir("preview-search-codex");
4097 let sessions = root.join("sessions");
4098 fs::create_dir_all(&sessions).unwrap();
4099 fs::write(
4100 root.join("history.jsonl"),
4101 format!(
4102 "{}\n{}\n",
4103 serde_json::json!({"session_id": "history-hit", "text": "Original nebula topic"}),
4104 serde_json::json!({"session_id": "history-hit", "text": "laterhistoryonly"}),
4105 ),
4106 )
4107 .unwrap();
4108 for id in ["history-hit", "latest-hit", "bounded"] {
4109 let mut content = format!(
4110 "{}\n",
4111 serde_json::json!({
4112 "type": "session_meta", "payload": {"id": id, "cwd": "/work"}
4113 })
4114 );
4115 for index in 0..20 {
4116 let message = if id == "latest-hit" && index == 19 {
4117 "Found NEBULA".to_string()
4118 } else if index == 10 {
4119 "middlehistoryonly".to_string()
4120 } else {
4121 format!("{}beyondtextcap", "x".repeat(4096))
4122 };
4123 content.push_str(&format!("{}\n", serde_json::json!({
4124 "type": "event_msg", "payload": {"type": "agent_message", "message": message}
4125 })));
4126 }
4127 fs::write(sessions.join(format!("{id}.jsonl")), content).unwrap();
4128 }
4129 let catalog = HarnessCatalog::new();
4130 let query: DiscoveryQuery = serde_json::from_value(serde_json::json!({
4131 "harnesses": ["codex"], "homes": {"codex": sessions},
4132 "query": "nebula", "search_previews": true
4133 }))
4134 .unwrap();
4135 let page = catalog.discover_page(&query).unwrap();
4136 assert_eq!(page.receipt.total_matched, 2);
4137 for row in &page.sessions {
4138 assert!(row.preview_candidates.len() <= 8);
4139 assert!(row.latest_message_candidates.len() <= 8);
4140 assert!(row
4141 .preview_candidates
4142 .iter()
4143 .chain(&row.latest_message_candidates)
4144 .all(|candidate| candidate.content.chars().count() <= 4096));
4145 }
4146 for text in ["middlehistoryonly", "laterhistoryonly", "beyondtextcap"] {
4147 assert!(
4148 catalog
4149 .discover_page(&DiscoveryQuery {
4150 query: Some(text.into()),
4151 ..query.clone()
4152 })
4153 .unwrap()
4154 .sessions
4155 .is_empty(),
4156 "not a full-history search: {text}"
4157 );
4158 }
4159 fs::remove_dir_all(root).unwrap();
4160 }
4161
4162 #[test]
4163 fn discovers_native_store_and_pages_search_results() {
4164 let root = temp_dir("supercode");
4165 let store_root = root.join("sessions");
4166 fs::create_dir_all(&store_root).unwrap();
4167 for (name, title) in [
4168 ("alpha", "Alpha planning"),
4169 ("beta", "Beta implementation"),
4170 ("gamma", "Gamma review"),
4171 ] {
4172 fs::write(
4173 store_root.join(format!("{name}.jsonl")),
4174 format!("{{\"role\":\"user\",\"content\":\"{title}\"}}\n"),
4175 )
4176 .unwrap();
4177 fs::write(
4178 store_root.join(format!("{name}.meta.json")),
4179 serde_json::json!({"name": name, "title": title}).to_string(),
4180 )
4181 .unwrap();
4182 }
4183 let catalog = HarnessCatalog::new();
4184 let base = DiscoveryQuery {
4185 harnesses: vec![HarnessId::from(HarnessId::SUPERCODE)],
4186 homes: HarnessHomes {
4187 supercode: store_root,
4188 ..HarnessHomes::default()
4189 },
4190 limit: Some(1),
4191 ..DiscoveryQuery::default()
4192 };
4193
4194 let first = catalog.discover_page(&base).unwrap();
4195 assert_eq!(first.sessions.len(), 1);
4196 assert!(first.next_cursor.is_some());
4197 let second = catalog
4198 .discover_page(&DiscoveryQuery {
4199 cursor: first.next_cursor,
4200 ..base.clone()
4201 })
4202 .unwrap();
4203 assert_eq!(second.sessions.len(), 1);
4204 assert_ne!(
4205 first.sessions[0].locator.session_id,
4206 second.sessions[0].locator.session_id
4207 );
4208 let search = catalog
4209 .discover_page(&DiscoveryQuery {
4210 limit: None,
4211 query: Some("implementation".into()),
4212 ..base
4213 })
4214 .unwrap();
4215 assert_eq!(search.sessions.len(), 1);
4216 assert_eq!(search.sessions[0].locator.session_id, "beta");
4217 assert_eq!(search.sessions[0].message_count, None);
4218 assert_eq!(
4219 catalog
4220 .load(&search.sessions[0].locator)
4221 .unwrap()
4222 .messages
4223 .len(),
4224 1
4225 );
4226 fs::remove_dir_all(root).ok();
4227 }
4228
4229 #[test]
4230 fn native_workspace_discovery_reads_bounded_sidecar_headers() {
4231 let root = temp_dir("supercode-bounded-header");
4232 let store_root = root.join("sessions");
4233 let workspace = root.join("project");
4234 fs::create_dir_all(&store_root).unwrap();
4235 fs::create_dir_all(&workspace).unwrap();
4236 let name = "bounded-native";
4237 fs::write(
4238 store_root.join(format!("{name}.meta.json")),
4239 serde_json::json!({"name": name, "title": "Bounded native"}).to_string(),
4240 )
4241 .unwrap();
4242 fs::write(
4243 store_root.join(format!("{name}.jsonl")),
4244 "{\"role\":\"user\",\"content\":\"projected view\"}\n",
4245 )
4246 .unwrap();
4247 let sidecar = [
4248 serde_json::json!({
4249 "supercode_native": 2,
4250 "source": "claude_code",
4251 "session_id": "native-session"
4252 })
4253 .to_string(),
4254 serde_json::json!({
4255 "type": "user",
4256 "sessionId": "native-session",
4257 "cwd": workspace,
4258 "message": {"role": "user", "content": "hello"}
4259 })
4260 .to_string(),
4261 serde_json::json!({
4262 "type": "assistant",
4263 "sessionId": "native-session",
4264 "cwd": workspace,
4265 "message": {"role": "assistant", "model": "claude-sonnet-5", "content": []}
4266 })
4267 .to_string(),
4268 "not-json".into(),
4271 ]
4272 .join("\n");
4273 fs::write(
4274 store_root.join(format!("{name}.sidecar.jsonl")),
4275 format!("{sidecar}\n"),
4276 )
4277 .unwrap();
4278
4279 let found = HarnessCatalog::new()
4280 .discover(&DiscoveryQuery {
4281 workspace: Some(workspace.clone()),
4282 harnesses: vec![HarnessId::from(HarnessId::SUPERCODE)],
4283 homes: HarnessHomes {
4284 supercode: store_root,
4285 ..HarnessHomes::default()
4286 },
4287 ..DiscoveryQuery::default()
4288 })
4289 .unwrap();
4290
4291 assert_eq!(found.len(), 1);
4292 assert_eq!(found[0].cwd.as_deref(), Some(workspace.as_path()));
4293 assert_eq!(found[0].model.as_deref(), Some("claude-sonnet-5"));
4294 assert_eq!(found[0].message_count, None);
4295 fs::remove_dir_all(root).ok();
4296 }
4297
4298 #[test]
4299 fn discovers_current_opencode_schema_without_a_session_model_column() {
4300 let root = temp_dir("opencode-current");
4301 let db = root.join("opencode.db");
4302 let conn = Connection::open(&db).unwrap();
4303 conn.execute_batch(
4304 "CREATE TABLE session (
4305 id TEXT PRIMARY KEY,
4306 directory TEXT NOT NULL,
4307 title TEXT NOT NULL,
4308 time_updated INTEGER NOT NULL
4309 );
4310 CREATE TABLE message (
4311 id TEXT PRIMARY KEY,
4312 session_id TEXT NOT NULL
4313 );
4314 INSERT INTO session VALUES ('ses_current', '/tmp/work', 'Current', 42);
4315 INSERT INTO message VALUES ('msg_current', 'ses_current');",
4316 )
4317 .unwrap();
4318 drop(conn);
4319
4320 let found = HarnessCatalog::new()
4321 .discover(&DiscoveryQuery {
4322 harnesses: vec![HarnessId::from(HarnessId::OPENCODE)],
4323 homes: HarnessHomes {
4324 opencode: db,
4325 ..HarnessHomes::default()
4326 },
4327 ..DiscoveryQuery::default()
4328 })
4329 .unwrap();
4330
4331 assert_eq!(found.len(), 1);
4332 assert_eq!(found[0].locator.session_id, "ses_current");
4333 assert_eq!(found[0].message_count, Some(1));
4334 assert_eq!(found[0].model, None);
4335 fs::remove_dir_all(root).ok();
4336 }
4337
4338 #[test]
4339 fn workspace_filter_never_matches_a_relative_recorded_cwd() {
4340 let root = temp_dir("opencode-relative-cwd");
4345 let db = root.join("opencode.db");
4346 let conn = Connection::open(&db).unwrap();
4347 let here = std::env::current_dir().unwrap();
4348 conn.execute_batch(&format!(
4349 "CREATE TABLE session (
4350 id TEXT PRIMARY KEY,
4351 directory TEXT NOT NULL,
4352 title TEXT NOT NULL,
4353 time_updated INTEGER NOT NULL
4354 );
4355 CREATE TABLE message (
4356 id TEXT PRIMARY KEY,
4357 session_id TEXT NOT NULL
4358 );
4359 INSERT INTO session VALUES ('ses_relative', '.', 'Ghost', 41);
4360 INSERT INTO session VALUES ('ses_here', '{}', 'Real', 42);",
4361 here.display()
4362 ))
4363 .unwrap();
4364 drop(conn);
4365
4366 let found = HarnessCatalog::new()
4367 .discover(&DiscoveryQuery {
4368 workspace: Some(here),
4369 harnesses: vec![HarnessId::from(HarnessId::OPENCODE)],
4370 homes: HarnessHomes {
4371 opencode: db,
4372 ..HarnessHomes::default()
4373 },
4374 ..DiscoveryQuery::default()
4375 })
4376 .unwrap();
4377
4378 assert_eq!(found.len(), 1);
4379 assert_eq!(found[0].locator.session_id, "ses_here");
4380 fs::remove_dir_all(root).ok();
4381 }
4382}