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 if model_config
1915 .as_deref()
1916 .is_some_and(|c| c.contains("\"_supercode_mirror\""))
1917 {
1918 continue;
1919 }
1920 let cwd = cwd.map(PathBuf::from);
1921 if let Some(filter) = workspace {
1922 if cwd.as_deref() != Some(filter) {
1923 continue;
1924 }
1925 }
1926 let updated_at_ms = ended_at
1927 .or(started_at)
1928 .map(|seconds| (seconds * 1000.0) as u64);
1929 let mut meta = SessionMeta::new(SessionSource::Hermes);
1933 meta.cwd = cwd.clone();
1934 if let Some(hermes_source) = source.filter(|value| !value.is_empty()) {
1935 meta.lineage
1936 .insert("hermes_source".to_string(), hermes_source);
1937 }
1938 if let Some(parent_id) = parent.as_deref() {
1939 meta.lineage.insert(
1940 "hermes_lineage_kind".to_string(),
1941 crate::session::hermes_lineage_kind(
1942 &conn,
1943 parent_id,
1944 model_config.as_deref(),
1945 started_at,
1946 )
1947 .to_string(),
1948 );
1949 }
1950 hermes_capture_nouns(&conn, &id, &mut meta);
1951 found.push(SessionDescriptor {
1952 locator: SessionLocator {
1953 harness: HarnessId::new(HarnessId::HERMES),
1954 session_id: id,
1955 storage: StorageLocator::File {
1956 path: db_path.to_path_buf(),
1957 },
1958 },
1959 cwd,
1960 title: title.filter(|t| !t.is_empty()),
1961 preview_candidates: Vec::new(),
1962 latest_message_candidates: Vec::new(),
1963 updated_at_ms,
1964 message_count: message_count.map(|count| count.max(0) as usize),
1965 model,
1966 parent_session_id: parent,
1967 child_session_count: 0,
1968 nouns: OrchestrationNouns::from_meta(&meta),
1969 });
1970 }
1971}
1972
1973fn discover_orchestrator(
1980 root: &Path,
1981 workspace: Option<&Path>,
1982 found: &mut Vec<SessionDescriptor>,
1983) {
1984 if workspace.is_some() {
1986 return;
1987 }
1988 for (profile, dir) in orchestrator_profile_dirs(root) {
1989 let db_path = dir.join("state.db");
1990 if !db_path.is_file() {
1991 continue;
1992 }
1993 let Ok(conn) = Connection::open_with_flags(
1994 &db_path,
1995 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
1996 ) else {
1997 continue;
1998 };
1999 let sessions = dir.join("sessions");
2002 let scope = std::fs::canonicalize(&sessions)
2003 .unwrap_or(sessions)
2004 .display()
2005 .to_string();
2006 let Ok(mut statement) = conn.prepare(
2007 "SELECT json_extract(entry_json, '$.metadata.supercode.binding'), \
2008 CAST(strftime('%s', json_extract(entry_json, '$.metadata.supercode.binding.last_activity_at')) AS INTEGER) \
2009 FROM gateway_routing WHERE scope = ?1 AND json_extract(entry_json, '$.metadata.supercode') IS NOT NULL \
2010 ORDER BY json_extract(entry_json, '$.metadata.supercode.binding.last_activity_at') DESC",
2011 ) else {
2012 continue;
2013 };
2014 let Ok(rows) = statement.query_map([&scope], |row| {
2015 Ok((
2016 row.get::<_, Option<String>>(0)?,
2017 row.get::<_, Option<i64>>(1)?,
2018 ))
2019 }) else {
2020 continue;
2021 };
2022 let rows = rows.flatten().filter_map(|(json, epoch)| {
2023 let b: Binding = serde_json::from_str(&json?).ok()?;
2024 let text = |v: &Option<String>| v.clone().filter(|s| !s.is_empty());
2025 Some((
2026 OrchestratorBindingRow {
2027 platform: b.key.platform.clone().unwrap_or_default(),
2028 chat_type: b.key.kind.clone().unwrap_or_default(),
2029 chat_id: text(&b.key.chat_id),
2030 thread_id: text(&b.key.thread_id),
2031 participant_id: text(&b.key.participant_id),
2032 worker_harness: b.worker.harness.as_str().to_string(),
2033 worker_session_id: text(&b.worker.session_id),
2034 worker_locator: text(&b.worker.locator),
2035 started_at: b.started_at.clone(),
2036 last_activity_at: b.last_activity_at.clone(),
2037 ended_at: b.ended_at.clone(),
2038 end_reason: b.end_reason.map(|r| r.as_str().to_string()),
2039 handoff_to: b.handoff.as_ref().and_then(|h| h.to.clone()),
2040 handoff_state: b.handoff.as_ref().map(|h| h.state.clone()),
2041 handoff_error: b.handoff.as_ref().and_then(|h| h.error.clone()),
2042 recurrence_job_id: b.recurrence.as_ref().map(|r| r.job_id.clone()),
2043 },
2044 epoch,
2045 ))
2046 });
2047 for (row, last_activity_epoch) in rows {
2048 found.push(orchestrator_descriptor(
2049 &db_path,
2050 &profile,
2051 &row,
2052 last_activity_epoch,
2053 ));
2054 }
2055 }
2056}
2057
2058fn orchestrator_descriptor(
2059 db_path: &Path,
2060 profile: &str,
2061 row: &OrchestratorBindingRow,
2062 last_activity_epoch: Option<i64>,
2063) -> SessionDescriptor {
2064 let binding = Binding::from_orchestrator_row(profile, row);
2065 let nouns = binding.nouns();
2066 let mut title = format!(
2069 "{} {}",
2070 row.worker_harness,
2071 row.worker_session_id
2072 .as_deref()
2073 .unwrap_or("(no worker session yet)")
2074 );
2075 if let Some(reason) = row.end_reason.as_deref().filter(|_| row.ended_at.is_some()) {
2076 title.push_str(&format!(" (ended: {reason})"));
2077 }
2078 SessionDescriptor {
2079 locator: SessionLocator {
2080 harness: HarnessId::new(HarnessId::ORCHESTRATOR),
2081 session_id: row.worker_session_id.clone().unwrap_or_default(),
2082 storage: StorageLocator::File {
2083 path: row
2084 .worker_locator
2085 .clone()
2086 .map_or_else(|| db_path.to_path_buf(), PathBuf::from),
2087 },
2088 },
2089 cwd: None,
2090 title: Some(title),
2091 preview_candidates: Vec::new(),
2092 latest_message_candidates: Vec::new(),
2093 updated_at_ms: last_activity_epoch.map(|seconds| (seconds.max(0) as u64) * 1000),
2094 message_count: None,
2095 model: None,
2096 parent_session_id: None,
2097 child_session_count: 0,
2098 nouns,
2099 }
2100}
2101
2102fn discover_openclaw(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
2108 let agents = root.join("agents");
2109 let Ok(agent_dirs) = std::fs::read_dir(&agents) else {
2110 return;
2111 };
2112 for agent_dir in agent_dirs.flatten() {
2113 let sessions = agent_dir.path().join("sessions");
2114 let Ok(files) = std::fs::read_dir(&sessions) else {
2115 continue;
2116 };
2117 for file in files.flatten() {
2118 let path = file.path();
2119 let name = file.file_name();
2120 let name = name.to_string_lossy();
2121 if !name.ends_with(".jsonl") || name.ends_with(".trajectory.jsonl") {
2122 continue;
2123 }
2124 let Ok(text) = std::fs::read_to_string(&path) else {
2125 continue;
2126 };
2127 let Some(header_line) = text.lines().find(|line| !line.trim().is_empty()) else {
2128 continue;
2129 };
2130 let Ok(header) = serde_json::from_str::<serde_json::Value>(header_line) else {
2131 continue;
2132 };
2133 if header.get("type").and_then(serde_json::Value::as_str) != Some("session") {
2134 continue;
2135 }
2136 let session_id = header
2137 .get("id")
2138 .and_then(serde_json::Value::as_str)
2139 .unwrap_or_else(|| name.trim_end_matches(".jsonl"))
2140 .to_string();
2141 let cwd = header
2142 .get("cwd")
2143 .and_then(serde_json::Value::as_str)
2144 .map(PathBuf::from);
2145 if let Some(filter) = workspace {
2146 if cwd.as_deref() != Some(filter) {
2147 continue;
2148 }
2149 }
2150 let updated_at_ms = tail_facts(&path, HarnessId::OPENCLAW)
2151 .last_turn_ms
2152 .or_else(|| {
2153 file.metadata()
2154 .ok()
2155 .and_then(|metadata| metadata.modified().ok())
2156 .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
2157 .map(|elapsed| elapsed.as_millis() as u64)
2158 });
2159 let message_count = text
2160 .lines()
2161 .filter(|line| line.contains("\"type\":\"message\""))
2162 .count();
2163 let mut meta = SessionMeta::new(SessionSource::OpenClaw);
2167 meta.cwd = cwd.clone();
2168 openclaw_capture_header_nouns(&header, &mut meta);
2169 if meta.profile.is_none() {
2170 meta.profile = openclaw_agent_id_from_path(&path);
2171 }
2172 found.push(SessionDescriptor {
2173 locator: SessionLocator {
2174 harness: HarnessId::new(HarnessId::OPENCLAW),
2175 session_id,
2176 storage: StorageLocator::File { path },
2177 },
2178 cwd,
2179 title: None,
2180 preview_candidates: Vec::new(),
2181 latest_message_candidates: Vec::new(),
2182 updated_at_ms,
2183 message_count: Some(message_count),
2184 model: None,
2185 parent_session_id: None,
2186 child_session_count: 0,
2187 nouns: OrchestrationNouns::from_meta(&meta),
2188 });
2189 }
2190 }
2191}
2192
2193fn discover_supercode(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
2194 for info in list_native_store(root) {
2195 let path = if info.archived {
2196 root.join("archived").join(format!("{}.jsonl", info.name))
2197 } else {
2198 root.join(format!("{}.jsonl", info.name))
2199 };
2200 let sidecar = path.with_extension("sidecar.jsonl");
2205 let path = if path.is_file() {
2206 path
2207 } else {
2208 sidecar.clone()
2209 };
2210 let header = read_native_store_header(&path);
2211 if workspace.is_some_and(|wanted| {
2212 header
2213 .as_ref()
2214 .and_then(|meta| meta.cwd.as_deref())
2215 .is_none_or(|cwd| !recorded_cwd_matches(cwd, wanted))
2216 }) {
2217 continue;
2218 }
2219 let title = (!info.title.trim().is_empty()).then_some(info.title);
2220 let updated_at_ms = tail_facts(&sidecar, HarnessId::SUPERCODE)
2223 .last_turn_ms
2224 .or_else(|| tail_facts(&path, HarnessId::SUPERCODE).last_turn_ms)
2225 .or_else(|| modified_ms(&path))
2226 .or_else(|| modified_ms(&sidecar));
2227 found.push(SessionDescriptor {
2228 locator: SessionLocator {
2229 harness: HarnessId::from(HarnessId::SUPERCODE),
2230 session_id: info.name,
2231 storage: StorageLocator::File { path: path.clone() },
2232 },
2233 cwd: header.as_ref().and_then(|meta| meta.cwd.clone()),
2234 title,
2235 preview_candidates: Vec::new(),
2236 latest_message_candidates: Vec::new(),
2237 updated_at_ms,
2238 message_count: None,
2239 model: header.and_then(|meta| meta.model),
2240 parent_session_id: None,
2241 child_session_count: 0,
2242 nouns: OrchestrationNouns::default(),
2243 });
2244 }
2245}
2246
2247fn read_native_store_header(path: &Path) -> Option<HeaderMeta> {
2252 let name = path.file_stem()?.to_str()?;
2253 let sidecar = path.with_file_name(format!("{name}.sidecar.jsonl"));
2254 let source_path = if sidecar.is_file() {
2255 sidecar
2256 } else {
2257 path.to_path_buf()
2258 };
2259 let file = File::open(source_path).ok()?;
2260 let mut result = HeaderMeta::default();
2261 let mut source = None;
2262 let mut bytes = 0usize;
2263 for line in BufReader::new(file).lines().take(32) {
2264 let line = line.ok()?;
2265 bytes += line.len();
2266 if bytes > 256 * 1024 {
2267 break;
2268 }
2269 let Ok(value) = serde_json::from_str::<Value>(&line) else {
2270 continue;
2271 };
2272 if source.is_none() {
2273 source = value.get("source").and_then(Value::as_str).map(|source| {
2274 if source == "claude_code" {
2275 HarnessId::CLAUDE_CODE.to_string()
2276 } else {
2277 source.to_string()
2278 }
2279 });
2280 fill_string(&mut result.session_id, value.get("session_id"));
2281 }
2282 if let Some(harness) = source.as_deref() {
2283 update_header_meta(&mut result, &value, harness);
2284 }
2285 if result.cwd.is_some() && result.model.is_some() {
2286 break;
2287 }
2288 }
2289 Some(result)
2290}
2291
2292#[derive(Deserialize)]
2293struct NativeStoreInfo {
2294 name: String,
2295 #[serde(default)]
2296 title: String,
2297 #[serde(skip)]
2298 archived: bool,
2299}
2300
2301fn list_native_store(root: &Path) -> Vec<NativeStoreInfo> {
2302 let mut sessions = Vec::new();
2303 for archived in [false, true] {
2304 let directory = if archived {
2305 root.join("archived")
2306 } else {
2307 root.to_path_buf()
2308 };
2309 let Ok(entries) = fs::read_dir(directory) else {
2310 continue;
2311 };
2312 for entry in entries.flatten() {
2313 let path = entry.path();
2314 if !path.to_string_lossy().ends_with(".meta.json") {
2315 continue;
2316 }
2317 let Ok(text) = fs::read_to_string(path) else {
2318 continue;
2319 };
2320 let Ok(mut info) = serde_json::from_str::<NativeStoreInfo>(&text) else {
2321 continue;
2322 };
2323 info.archived = archived;
2324 sessions.push(info);
2325 }
2326 }
2327 sessions.sort_by(|left, right| left.name.cmp(&right.name));
2328 sessions
2329}
2330
2331fn discover_grok(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
2332 let Ok(workspaces) = fs::read_dir(root) else {
2333 return;
2334 };
2335 for workspace_entry in workspaces.flatten() {
2336 let encoded = workspace_entry.file_name();
2337 let Some(cwd) = encoded
2338 .to_str()
2339 .and_then(percent_decode_path)
2340 .map(PathBuf::from)
2341 else {
2342 continue;
2343 };
2344 if workspace.is_some_and(|wanted| !recorded_cwd_matches(&cwd, wanted)) {
2345 continue;
2346 }
2347 let Ok(sessions) = fs::read_dir(workspace_entry.path()) else {
2348 continue;
2349 };
2350 for session_entry in sessions.flatten() {
2351 let session_dir = session_entry.path();
2352 if !session_dir.is_dir() {
2353 continue;
2354 }
2355 let transcript = session_dir.join("chat_history.jsonl");
2356 if !transcript.is_file() {
2357 continue;
2358 }
2359 let Some(session_id) = session_dir
2360 .file_name()
2361 .and_then(|name| name.to_str())
2362 .map(str::to_string)
2363 else {
2364 continue;
2365 };
2366 let summary = fs::read_to_string(session_dir.join("summary.json"))
2367 .ok()
2368 .and_then(|text| serde_json::from_str::<Value>(&text).ok());
2369 let title = summary
2370 .as_ref()
2371 .and_then(|value| value.get("generated_title"))
2372 .and_then(Value::as_str)
2373 .filter(|title| !title.is_empty())
2374 .map(str::to_string);
2375 let model = summary
2376 .as_ref()
2377 .and_then(|value| value.get("current_model_id"))
2378 .and_then(Value::as_str)
2379 .map(str::to_string);
2380 let message_count = summary
2381 .as_ref()
2382 .and_then(|value| value.get("num_chat_messages"))
2383 .and_then(Value::as_u64)
2384 .and_then(|count| usize::try_from(count).ok());
2385 let updated_at_ms = summary
2386 .as_ref()
2387 .and_then(|value| value.get("updated_at"))
2388 .and_then(Value::as_str)
2389 .and_then(crate::sidecar::rfc3339_to_ms)
2390 .and_then(|millis| u64::try_from(millis).ok())
2391 .or_else(|| modified_ms(&transcript));
2392 found.push(SessionDescriptor {
2393 locator: SessionLocator {
2394 harness: HarnessId::from(HarnessId::GROK),
2395 session_id,
2396 storage: StorageLocator::File { path: transcript },
2397 },
2398 cwd: Some(cwd.clone()),
2399 title,
2400 preview_candidates: Vec::new(),
2401 latest_message_candidates: Vec::new(),
2402 updated_at_ms,
2403 message_count,
2404 model,
2405 parent_session_id: None,
2406 child_session_count: 0,
2407 nouns: OrchestrationNouns::default(),
2408 });
2409 }
2410 }
2411}
2412
2413fn discover_opencode(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
2414 let mut dbs = Vec::new();
2415 if root.is_file() {
2416 dbs.push(root.to_path_buf());
2417 } else if let Ok(entries) = fs::read_dir(root) {
2418 dbs.extend(entries.flatten().map(|entry| entry.path()).filter(|path| {
2419 path.file_name()
2420 .and_then(|v| v.to_str())
2421 .is_some_and(|name| name.starts_with("opencode") && name.ends_with(".db"))
2422 }));
2423 }
2424 dbs.sort();
2425 for db in dbs {
2426 let Ok(conn) = Connection::open_with_flags(
2427 &db,
2428 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
2429 ) else {
2430 continue;
2431 };
2432 let has_model = conn.prepare("SELECT model FROM session LIMIT 0").is_ok();
2433 let model_column = if has_model { "s.model" } else { "NULL" };
2434 let query = format!(
2435 "SELECT s.id, s.directory, s.title, s.time_updated, {model_column}, COUNT(m.id) \
2436 FROM session s LEFT JOIN message m ON m.session_id = s.id \
2437 GROUP BY s.id ORDER BY s.time_updated DESC"
2438 );
2439 let Ok(mut stmt) = conn.prepare(&query) else {
2440 continue;
2441 };
2442 let Ok(rows) = stmt.query_map([], |row| {
2443 Ok((
2444 row.get::<_, String>(0)?,
2445 row.get::<_, String>(1)?,
2446 row.get::<_, String>(2)?,
2447 row.get::<_, i64>(3)?,
2448 row.get::<_, Option<String>>(4)?,
2449 row.get::<_, i64>(5)?,
2450 ))
2451 }) else {
2452 continue;
2453 };
2454 for row in rows.flatten() {
2455 let (id, cwd, title, updated, model, messages) = row;
2456 let cwd = PathBuf::from(cwd);
2457 if workspace.is_some_and(|wanted| !recorded_cwd_matches(&cwd, wanted)) {
2458 continue;
2459 }
2460 found.push(SessionDescriptor {
2461 locator: SessionLocator {
2462 harness: HarnessId::from(HarnessId::OPENCODE),
2463 session_id: id.clone(),
2464 storage: StorageLocator::Sqlite {
2465 path: db.clone(),
2466 selector: id,
2467 },
2468 },
2469 cwd: Some(cwd),
2470 title: (!title.is_empty()).then_some(title),
2471 preview_candidates: Vec::new(),
2472 latest_message_candidates: Vec::new(),
2473 updated_at_ms: u64::try_from(updated).ok(),
2474 message_count: usize::try_from(messages).ok(),
2475 model,
2476 parent_session_id: None,
2477 child_session_count: 0,
2478 nouns: OrchestrationNouns::default(),
2479 });
2480 }
2481 }
2482}
2483
2484fn discover_goose(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
2485 let db = if root.is_file() {
2486 root.to_path_buf()
2487 } else if root.join("sessions.db").is_file() {
2488 root.join("sessions.db")
2489 } else {
2490 root.join("sessions/sessions.db")
2491 };
2492 let Ok(connection) = Connection::open_with_flags(
2493 &db,
2494 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
2495 ) else {
2496 return;
2497 };
2498 let Ok(mut statement) = connection.prepare(
2499 "SELECT s.id, s.working_dir, s.name, s.updated_at, s.model_config_json, \
2500 COUNT(m.id) \
2501 FROM sessions s LEFT JOIN messages m ON m.session_id = s.id \
2502 WHERE s.archived_at IS NULL \
2503 GROUP BY s.id ORDER BY s.updated_at DESC",
2504 ) else {
2505 return;
2506 };
2507 let Ok(rows) = statement.query_map([], |row| {
2508 Ok((
2509 row.get::<_, String>(0)?,
2510 row.get::<_, String>(1)?,
2511 row.get::<_, String>(2)?,
2512 row.get::<_, String>(3)?,
2513 row.get::<_, Option<String>>(4)?,
2514 row.get::<_, i64>(5)?,
2515 ))
2516 }) else {
2517 return;
2518 };
2519 for row in rows.flatten() {
2520 let (id, cwd, title, updated_at, model_config, message_count) = row;
2521 let cwd = PathBuf::from(cwd);
2522 if workspace.is_some_and(|wanted| !recorded_cwd_matches(&cwd, wanted)) {
2523 continue;
2524 }
2525 let model = model_config
2526 .as_deref()
2527 .and_then(|value| serde_json::from_str::<Value>(value).ok())
2528 .and_then(|value| {
2529 value
2530 .get("model_name")
2531 .or_else(|| value.get("modelName"))
2532 .and_then(Value::as_str)
2533 .map(str::to_string)
2534 });
2535 let updated_at_ms = crate::sidecar::rfc3339_to_ms(&updated_at)
2536 .or_else(|| {
2537 crate::sidecar::rfc3339_to_ms(&format!("{}Z", updated_at.replace(' ', "T")))
2539 })
2540 .and_then(|value| u64::try_from(value).ok());
2541 found.push(SessionDescriptor {
2542 locator: SessionLocator {
2543 harness: HarnessId::from(HarnessId::GOOSE),
2544 session_id: id.clone(),
2545 storage: StorageLocator::Sqlite {
2546 path: db.clone(),
2547 selector: id,
2548 },
2549 },
2550 cwd: Some(cwd),
2551 title: (!title.trim().is_empty()).then_some(title),
2552 preview_candidates: Vec::new(),
2553 latest_message_candidates: Vec::new(),
2554 updated_at_ms,
2555 message_count: usize::try_from(message_count).ok(),
2556 model,
2557 parent_session_id: None,
2558 child_session_count: 0,
2559 nouns: OrchestrationNouns::default(),
2560 });
2561 }
2562}
2563
2564const LATEST_PREVIEW_CANDIDATES: usize = 8;
2565const TOPIC_PREVIEW_HEAD_BYTES: u64 = 512 * 1024;
2566const LATEST_PREVIEW_TAIL_BYTES: u64 = 512 * 1024;
2567const LATEST_PREVIEW_MAX_BYTES: u64 = 4 * 1024 * 1024;
2568
2569fn topic_message_candidates(locator: &SessionLocator) -> Result<Vec<SessionPreviewCandidate>> {
2570 match &locator.storage {
2571 StorageLocator::File { path }
2572 if matches!(
2573 locator.harness.as_str(),
2574 HarnessId::CLAUDE_CODE | HarnessId::CODEX
2575 ) =>
2576 {
2577 topic_file_message_candidates(path, locator.harness.as_str())
2578 }
2579 _ => Ok(Vec::new()),
2580 }
2581}
2582
2583fn codex_history_topics(
2584 sessions_root: &Path,
2585 sessions: &[SessionDescriptor],
2586) -> Result<HashMap<String, Vec<SessionPreviewCandidate>>> {
2587 let wanted: HashSet<&str> = sessions
2588 .iter()
2589 .filter(|descriptor| descriptor.locator.harness.as_str() == HarnessId::CODEX)
2590 .map(|descriptor| descriptor.locator.session_id.as_str())
2591 .collect();
2592 if wanted.is_empty() {
2593 return Ok(HashMap::new());
2594 }
2595 let Some(root) = sessions_root.parent() else {
2596 return Ok(HashMap::new());
2597 };
2598 let file = match File::open(root.join("history.jsonl")) {
2599 Ok(file) => file,
2600 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(HashMap::new()),
2601 Err(error) => return Err(error.into()),
2602 };
2603 let mut topics = HashMap::new();
2604 for line in BufReader::new(file).lines() {
2605 let Ok(value) = serde_json::from_str::<Value>(&line?) else {
2606 continue;
2607 };
2608 let Some(session_id) = value.get("session_id").and_then(Value::as_str) else {
2609 continue;
2610 };
2611 if !wanted.contains(session_id) || topics.contains_key(session_id) {
2612 continue;
2613 }
2614 let mut candidates = Vec::new();
2615 push_message_candidate(&mut candidates, "user", value.get("text"), HashMap::new());
2616 if !candidates.is_empty() {
2617 topics.insert(session_id.to_string(), candidates);
2618 if topics.len() == wanted.len() {
2619 break;
2620 }
2621 }
2622 }
2623 Ok(topics)
2624}
2625
2626fn latest_message_candidates(locator: &SessionLocator) -> Result<Vec<SessionPreviewCandidate>> {
2627 match &locator.storage {
2628 StorageLocator::File { path } | StorageLocator::Sqlite { path, .. }
2631 if locator.harness.as_str() == HarnessId::HERMES =>
2632 {
2633 latest_hermes_message_candidates(path, &locator.session_id)
2634 }
2635 StorageLocator::File { path } => {
2636 latest_file_message_candidates(path, locator.harness.as_str())
2637 }
2638 StorageLocator::Sqlite { path, selector }
2639 if locator.harness.as_str() == HarnessId::OPENCODE =>
2640 {
2641 latest_opencode_message_candidates(path, selector)
2642 }
2643 StorageLocator::Sqlite { path, selector }
2644 if locator.harness.as_str() == HarnessId::GOOSE =>
2645 {
2646 latest_goose_message_candidates(path, selector)
2647 }
2648 StorageLocator::Sqlite { .. } => Ok(Vec::new()),
2649 }
2650}
2651
2652fn topic_file_message_candidates(
2653 path: &Path,
2654 harness: &str,
2655) -> Result<Vec<SessionPreviewCandidate>> {
2656 let mut file = File::open(path)?;
2657 let mut bytes = Vec::with_capacity(TOPIC_PREVIEW_HEAD_BYTES as usize);
2658 file.by_ref()
2659 .take(TOPIC_PREVIEW_HEAD_BYTES)
2660 .read_to_end(&mut bytes)?;
2661 if file.metadata()?.len() > TOPIC_PREVIEW_HEAD_BYTES {
2662 if let Some(newline) = bytes.iter().rposition(|byte| *byte == b'\n') {
2663 bytes.truncate(newline);
2664 }
2665 }
2666 let text = String::from_utf8(bytes).map_err(|_| {
2667 Error::Other(format!(
2668 "{} contains non-UTF-8 data in its topic-preview window",
2669 path.display()
2670 ))
2671 })?;
2672 if harness == HarnessId::CODEX {
2673 return Ok(codex_preview_candidates(text.lines(), false));
2674 }
2675 let mut candidates = Vec::new();
2676 for line in text.lines() {
2677 let Ok(value) = serde_json::from_str::<Value>(line) else {
2678 continue;
2679 };
2680 push_topic_message_candidate(&mut candidates, harness, &value);
2681 if candidates.len() >= LATEST_PREVIEW_CANDIDATES {
2682 break;
2683 }
2684 }
2685 Ok(candidates)
2686}
2687
2688fn latest_file_message_candidates(
2689 path: &Path,
2690 harness: &str,
2691) -> Result<Vec<SessionPreviewCandidate>> {
2692 let mut candidates =
2693 latest_file_message_candidates_with_limit(path, harness, LATEST_PREVIEW_TAIL_BYTES)?;
2694 if candidates.is_empty() {
2695 candidates =
2696 latest_file_message_candidates_with_limit(path, harness, LATEST_PREVIEW_MAX_BYTES)?;
2697 }
2698 Ok(candidates)
2699}
2700
2701fn latest_file_message_candidates_with_limit(
2702 path: &Path,
2703 harness: &str,
2704 byte_limit: u64,
2705) -> Result<Vec<SessionPreviewCandidate>> {
2706 let mut file = File::open(path)?;
2707 let file_len = file.metadata()?.len();
2708 let start = file_len.saturating_sub(byte_limit);
2709 file.seek(SeekFrom::Start(start))?;
2710 let mut bytes = Vec::with_capacity((file_len - start) as usize);
2711 file.read_to_end(&mut bytes)?;
2712 if start > 0 {
2713 if let Some(newline) = bytes.iter().position(|byte| *byte == b'\n') {
2714 bytes.drain(..=newline);
2715 } else {
2716 return Ok(Vec::new());
2717 }
2718 }
2719 let text = String::from_utf8(bytes).map_err(|_| {
2720 Error::Other(format!(
2721 "{} contains non-UTF-8 data in its list-preview window",
2722 path.display()
2723 ))
2724 })?;
2725 if harness == HarnessId::CODEX {
2726 return Ok(codex_preview_candidates(text.lines().rev(), true));
2727 }
2728 let mut candidates = Vec::new();
2729 for line in text.lines().rev() {
2730 let Ok(value) = serde_json::from_str::<Value>(line) else {
2731 continue;
2732 };
2733 let (role, content, metadata) = match harness {
2734 HarnessId::CLAUDE_CODE => {
2735 let role = value.get("type").and_then(Value::as_str);
2736 if !matches!(role, Some("user" | "assistant")) {
2737 continue;
2738 }
2739 let metadata = if role == Some("user") {
2740 crate::session::claude_user_provenance(&value)
2741 .into_iter()
2742 .collect()
2743 } else {
2744 HashMap::new()
2745 };
2746 (
2747 role.unwrap_or_default(),
2748 value
2749 .get("message")
2750 .and_then(|message| message.get("content")),
2751 metadata,
2752 )
2753 }
2754 HarnessId::PI => {
2755 if value.get("type").and_then(Value::as_str) != Some("message") {
2756 continue;
2757 }
2758 let message = value.get("message").unwrap_or(&Value::Null);
2759 let Some(role @ ("user" | "assistant")) =
2760 message.get("role").and_then(Value::as_str)
2761 else {
2762 continue;
2763 };
2764 (role, message.get("content"), HashMap::new())
2765 }
2766 HarnessId::GEMINI => {
2767 let Some(kind @ ("user" | "gemini")) = value.get("type").and_then(Value::as_str)
2768 else {
2769 continue;
2770 };
2771 (
2772 if kind == "gemini" {
2773 "assistant"
2774 } else {
2775 "user"
2776 },
2777 value.get("content"),
2778 HashMap::new(),
2779 )
2780 }
2781 HarnessId::GROK => {
2782 let Some(role @ ("user" | "assistant")) = value.get("type").and_then(Value::as_str)
2783 else {
2784 continue;
2785 };
2786 (role, value.get("content"), HashMap::new())
2787 }
2788 HarnessId::SUPERCODE => {
2789 let Some(role @ ("user" | "assistant")) = value.get("role").and_then(Value::as_str)
2790 else {
2791 continue;
2792 };
2793 (role, value.get("content"), HashMap::new())
2794 }
2795 _ => continue,
2796 };
2797 let mut metadata = metadata;
2798 if matches!(harness, HarnessId::CLAUDE_CODE | HarnessId::CODEX) {
2799 if let Some(timestamp) = value.get("timestamp").and_then(Value::as_str) {
2800 metadata.insert("timestamp".to_string(), timestamp.to_string());
2801 }
2802 }
2803 push_message_candidate_with_cursor(
2804 &mut candidates,
2805 role,
2806 content,
2807 metadata,
2808 Some(message_candidate_cursor(harness, &value)),
2809 );
2810 if candidates.len() >= LATEST_PREVIEW_CANDIDATES {
2811 break;
2812 }
2813 }
2814 Ok(candidates)
2815}
2816
2817struct CodexPreviewRecord {
2818 native: Value,
2819 role: String,
2820 text: String,
2821}
2822
2823fn codex_preview_candidates<'a>(
2829 lines: impl Iterator<Item = &'a str>,
2830 latest: bool,
2831) -> Vec<SessionPreviewCandidate> {
2832 let mut candidates = Vec::new();
2833 let mut pending: Option<CodexPreviewRecord> = None;
2834 for line in lines {
2835 let Ok(native) = serde_json::from_str::<Value>(line) else {
2836 continue;
2837 };
2838 let Some((role, content)) = codex_preview_message(&native) else {
2839 continue;
2840 };
2841 let Some(text) = display_text(content).filter(|text| !text.trim().is_empty()) else {
2842 continue;
2843 };
2844 let current = CodexPreviewRecord {
2845 role: role.to_string(),
2846 text,
2847 native,
2848 };
2849 if let Some(previous) = pending.take() {
2850 if previous.role == current.role
2851 && previous.text == current.text
2852 && previous.native.get("type") != current.native.get("type")
2853 {
2854 let canonical = if previous.native.get("type").and_then(Value::as_str)
2855 == Some("response_item")
2856 {
2857 previous
2858 } else {
2859 current
2860 };
2861 push_codex_preview_candidate(&mut candidates, canonical, latest);
2862 } else {
2863 push_codex_preview_candidate(&mut candidates, previous, latest);
2864 pending = Some(current);
2865 }
2866 } else {
2867 pending = Some(current);
2868 }
2869 if candidates.len() >= LATEST_PREVIEW_CANDIDATES {
2870 break;
2871 }
2872 }
2873 if let Some(last) = pending {
2874 push_codex_preview_candidate(&mut candidates, last, latest);
2875 }
2876 candidates
2877}
2878
2879fn push_codex_preview_candidate(
2880 candidates: &mut Vec<SessionPreviewCandidate>,
2881 record: CodexPreviewRecord,
2882 latest: bool,
2883) {
2884 let mut metadata = HashMap::new();
2885 if latest {
2886 if let Some(timestamp) = record.native.get("timestamp").and_then(Value::as_str) {
2887 metadata.insert("timestamp".to_string(), timestamp.to_string());
2888 }
2889 }
2890 let cursor = latest.then(|| message_candidate_cursor(HarnessId::CODEX, &record.native));
2891 push_message_candidate_with_cursor(
2892 candidates,
2893 &record.role,
2894 Some(&Value::String(record.text)),
2895 metadata,
2896 cursor,
2897 );
2898}
2899
2900fn codex_preview_message(value: &Value) -> Option<(&str, Option<&Value>)> {
2902 let payload = value.get("payload")?;
2903 match (
2904 value.get("type").and_then(Value::as_str)?,
2905 payload.get("type").and_then(Value::as_str)?,
2906 ) {
2907 ("response_item", "message") => {
2908 let role @ ("user" | "assistant") = payload.get("role").and_then(Value::as_str)? else {
2909 return None;
2910 };
2911 Some((role, payload.get("content")))
2912 }
2913 ("event_msg", "user_message") => Some(("user", payload.get("message"))),
2914 ("event_msg", "agent_message") => Some(("assistant", payload.get("message"))),
2915 _ => None,
2916 }
2917}
2918
2919fn push_topic_message_candidate(
2920 candidates: &mut Vec<SessionPreviewCandidate>,
2921 harness: &str,
2922 value: &Value,
2923) {
2924 let (role, content, metadata) = match harness {
2925 HarnessId::CLAUDE_CODE => {
2926 let role = value.get("type").and_then(Value::as_str);
2927 if !matches!(role, Some("user" | "assistant")) {
2928 return;
2929 }
2930 let metadata = if role == Some("user") {
2931 crate::session::claude_user_provenance(value)
2932 .into_iter()
2933 .collect()
2934 } else {
2935 HashMap::new()
2936 };
2937 (
2938 role.unwrap_or_default(),
2939 value
2940 .get("message")
2941 .and_then(|message| message.get("content")),
2942 metadata,
2943 )
2944 }
2945 _ => return,
2946 };
2947 push_message_candidate(candidates, role, content, metadata);
2948}
2949
2950fn latest_opencode_message_candidates(
2951 path: &Path,
2952 session_id: &str,
2953) -> Result<Vec<SessionPreviewCandidate>> {
2954 let connection = Connection::open_with_flags(
2955 path,
2956 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
2957 )
2958 .map_err(|error| Error::Other(format!("failed to open list-preview store: {error}")))?;
2959 let mut statement = connection
2960 .prepare(
2961 "SELECT m.data, p.data FROM message m JOIN part p ON p.message_id = m.id \
2962 WHERE m.session_id = ?1 ORDER BY m.time_created DESC, p.time_created DESC LIMIT 32",
2963 )
2964 .map_err(|error| Error::Other(format!("failed to prepare list-preview query: {error}")))?;
2965 let rows = statement
2966 .query_map([session_id], |row| {
2967 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2968 })
2969 .map_err(|error| Error::Other(format!("failed to read list-preview rows: {error}")))?;
2970 let mut candidates = Vec::new();
2971 for row in rows.flatten() {
2972 let (Ok(message), Ok(part)) = (
2973 serde_json::from_str::<Value>(&row.0),
2974 serde_json::from_str::<Value>(&row.1),
2975 ) else {
2976 continue;
2977 };
2978 let Some(role @ ("user" | "assistant")) = message.get("role").and_then(Value::as_str)
2979 else {
2980 continue;
2981 };
2982 if part.get("type").and_then(Value::as_str) != Some("text") {
2983 continue;
2984 }
2985 push_message_candidate(&mut candidates, role, part.get("text"), HashMap::new());
2986 if candidates.len() >= LATEST_PREVIEW_CANDIDATES {
2987 break;
2988 }
2989 }
2990 Ok(candidates)
2991}
2992
2993fn latest_hermes_message_candidates(
2994 path: &Path,
2995 session_id: &str,
2996) -> Result<Vec<SessionPreviewCandidate>> {
2997 let connection = Connection::open_with_flags(
2998 path,
2999 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
3000 )
3001 .map_err(|error| Error::Other(format!("failed to open list-preview store: {error}")))?;
3002 let mut statement = connection
3003 .prepare(
3004 "SELECT role, content FROM messages WHERE session_id = ?1 AND active = 1 \
3005 AND role IN ('user', 'assistant') AND content IS NOT NULL AND content != '' \
3006 ORDER BY timestamp DESC, id DESC LIMIT 32",
3007 )
3008 .map_err(|error| Error::Other(format!("failed to prepare list-preview query: {error}")))?;
3009 let rows = statement
3010 .query_map([session_id], |row| {
3011 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3012 })
3013 .map_err(|error| Error::Other(format!("failed to read list-preview rows: {error}")))?;
3014 let mut candidates = Vec::new();
3015 for (role, content) in rows.flatten() {
3016 push_message_candidate(
3017 &mut candidates,
3018 &role,
3019 Some(&Value::String(content)),
3020 HashMap::new(),
3021 );
3022 if candidates.len() >= LATEST_PREVIEW_CANDIDATES {
3023 break;
3024 }
3025 }
3026 Ok(candidates)
3027}
3028
3029fn latest_goose_message_candidates(
3030 path: &Path,
3031 session_id: &str,
3032) -> Result<Vec<SessionPreviewCandidate>> {
3033 let connection = Connection::open_with_flags(
3034 path,
3035 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
3036 )
3037 .map_err(|error| Error::Other(format!("failed to open list-preview store: {error}")))?;
3038 let mut statement = connection
3039 .prepare(
3040 "SELECT role, content_json FROM messages WHERE session_id = ?1 \
3041 ORDER BY created_timestamp DESC, id DESC LIMIT 16",
3042 )
3043 .map_err(|error| Error::Other(format!("failed to prepare list-preview query: {error}")))?;
3044 let rows = statement
3045 .query_map([session_id], |row| {
3046 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3047 })
3048 .map_err(|error| Error::Other(format!("failed to read list-preview rows: {error}")))?;
3049 let mut candidates = Vec::new();
3050 for row in rows.flatten() {
3051 let (role, content) = row;
3052 if !matches!(role.as_str(), "user" | "assistant") {
3053 continue;
3054 }
3055 let Ok(content) = serde_json::from_str::<Value>(&content) else {
3056 continue;
3057 };
3058 push_message_candidate(&mut candidates, &role, Some(&content), HashMap::new());
3059 if candidates.len() >= LATEST_PREVIEW_CANDIDATES {
3060 break;
3061 }
3062 }
3063 Ok(candidates)
3064}
3065
3066fn push_message_candidate(
3067 candidates: &mut Vec<SessionPreviewCandidate>,
3068 role: &str,
3069 content: Option<&Value>,
3070 metadata: HashMap<String, String>,
3071) {
3072 push_message_candidate_with_cursor(candidates, role, content, metadata, None);
3073}
3074
3075fn push_message_candidate_with_cursor(
3076 candidates: &mut Vec<SessionPreviewCandidate>,
3077 role: &str,
3078 content: Option<&Value>,
3079 metadata: HashMap<String, String>,
3080 cursor: Option<String>,
3081) {
3082 if candidates.len() >= LATEST_PREVIEW_CANDIDATES {
3083 return;
3084 }
3085 let Some(text) = display_text(content).filter(|text| !text.trim().is_empty()) else {
3086 return;
3087 };
3088 const MAX_CHARS: usize = 4_096;
3089 candidates.push(SessionPreviewCandidate {
3090 cursor,
3091 role: role.to_string(),
3092 content: text.chars().take(MAX_CHARS).collect(),
3093 metadata,
3094 });
3095}
3096
3097fn message_candidate_cursor(harness: &str, value: &Value) -> String {
3098 let native_identity = value
3099 .get("uuid")
3100 .or_else(|| value.get("id"))
3101 .or_else(|| value.pointer("/message/id"))
3102 .or_else(|| value.pointer("/payload/id"))
3103 .and_then(Value::as_str)
3104 .or_else(|| value.get("timestamp").and_then(Value::as_str));
3105 let mut hasher = blake3::Hasher::new();
3106 hasher.update(b"supercode.session-preview-cursor.v1\0");
3107 hasher.update(harness.as_bytes());
3108 hasher.update(b"\0");
3109 if let Some(identity) = native_identity {
3110 hasher.update(identity.as_bytes());
3111 } else {
3112 hasher.update(value.to_string().as_bytes());
3116 }
3117 format!("v1:{}", &hasher.finalize().to_hex()[..24])
3118}
3119
3120fn fill_string(target: &mut Option<String>, value: Option<&Value>) {
3121 if target.is_none() {
3122 *target = value.and_then(Value::as_str).map(str::to_owned);
3123 }
3124}
3125
3126fn fill_path(target: &mut Option<PathBuf>, value: Option<&Value>) {
3127 if target.is_none() {
3128 *target = value.and_then(Value::as_str).map(PathBuf::from);
3129 }
3130}
3131
3132#[derive(Default)]
3135struct TailFacts {
3136 last_turn_ms: Option<u64>,
3138 model: Option<String>,
3140}
3141
3142fn tail_facts(path: &Path, harness: &str) -> TailFacts {
3158 let mut facts = TailFacts::default();
3159 let Ok(mut file) = File::open(path) else {
3160 return facts;
3161 };
3162 let Ok(len) = file.metadata().map(|meta| meta.len()) else {
3163 return facts;
3164 };
3165 let mut window = TAIL_SCAN_START.min(len);
3166 loop {
3167 if file.seek(SeekFrom::Start(len - window)).is_err() {
3168 return facts;
3169 }
3170 let Ok(size) = usize::try_from(window) else {
3171 return facts;
3172 };
3173 let mut buf = vec![0u8; size];
3174 if file.read_exact(&mut buf).is_err() {
3175 return facts;
3176 }
3177 let floor = if window < len {
3188 buf.iter().position(|byte| *byte == b'\n').map(|at| at + 1)
3189 } else {
3190 Some(0)
3191 };
3192 if let Some(floor) = floor {
3193 let mut end = buf.len();
3194 while end > floor && !(facts.last_turn_ms.is_some() && facts.model.is_some()) {
3195 let start = buf[floor..end]
3196 .iter()
3197 .rposition(|byte| *byte == b'\n')
3198 .map_or(floor, |at| floor + at + 1);
3199 if let Ok(record) = std::str::from_utf8(&buf[start..end])
3200 .map_err(|_| ())
3201 .and_then(|line| serde_json::from_str::<Value>(line).map_err(|_| ()))
3202 {
3203 if facts.last_turn_ms.is_none() {
3204 facts.last_turn_ms = record_timestamp(&record, harness)
3205 .and_then(crate::sidecar::rfc3339_to_ms)
3206 .and_then(|millis| u64::try_from(millis).ok());
3207 }
3208 if facts.model.is_none() {
3209 facts.model = record_model(&record, harness).map(str::to_owned);
3210 }
3211 }
3212 end = start.saturating_sub(1);
3213 }
3214 }
3215 if facts.last_turn_ms.is_some() || window >= len || window >= TAIL_SCAN_LIMIT {
3216 return facts;
3217 }
3218 window = (window * 2).min(len).min(TAIL_SCAN_LIMIT);
3219 }
3220}
3221
3222fn record_timestamp<'a>(record: &'a Value, harness: &str) -> Option<&'a str> {
3226 let key = match harness {
3227 HarnessId::SUPERCODE => "ts",
3228 _ => "timestamp",
3229 };
3230 record.get(key)?.as_str()
3231}
3232
3233fn record_model<'a>(record: &'a Value, harness: &str) -> Option<&'a str> {
3236 match harness {
3237 HarnessId::CLAUDE_CODE | HarnessId::PI => record.get("message")?.get("model")?.as_str(),
3238 HarnessId::CODEX => {
3239 if record.get("type")?.as_str()? != "turn_context" {
3240 return None;
3241 }
3242 record.get("payload")?.get("model")?.as_str()
3243 }
3244 _ => None,
3245 }
3246}
3247
3248const TAIL_SCAN_START: u64 = 16 * 1024;
3251
3252const TAIL_SCAN_LIMIT: u64 = 1024 * 1024;
3255
3256fn modified_ms(path: &Path) -> Option<u64> {
3257 fs::metadata(path)
3258 .ok()?
3259 .modified()
3260 .ok()?
3261 .duration_since(UNIX_EPOCH)
3262 .ok()
3263 .and_then(|duration| u64::try_from(duration.as_millis()).ok())
3264}
3265
3266fn recorded_cwd_matches(recorded: &Path, wanted: &Path) -> bool {
3272 recorded.is_absolute() && same_path(recorded, wanted)
3273}
3274
3275fn same_path(left: &Path, right: &Path) -> bool {
3276 match (fs::canonicalize(left), fs::canonicalize(right)) {
3277 (Ok(left), Ok(right)) => left == right,
3278 _ => normalize_path(left) == normalize_path(right),
3279 }
3280}
3281
3282fn normalize_path(path: &Path) -> PathBuf {
3283 let absolute = if path.is_absolute() {
3284 path.to_path_buf()
3285 } else {
3286 std::env::current_dir()
3287 .unwrap_or_else(|_| PathBuf::from("."))
3288 .join(path)
3289 };
3290 let mut normalized = PathBuf::new();
3291 for component in absolute.components() {
3292 match component {
3293 Component::CurDir => {}
3294 Component::ParentDir => {
3295 normalized.pop();
3296 }
3297 other => normalized.push(other.as_os_str()),
3298 }
3299 }
3300 normalized
3301}
3302
3303#[cfg(test)]
3304mod tests {
3305 use super::*;
3306 use std::io::Write;
3307 use std::time::{SystemTime, UNIX_EPOCH};
3308
3309 fn temp_dir(label: &str) -> PathBuf {
3310 let nonce = SystemTime::now()
3311 .duration_since(UNIX_EPOCH)
3312 .unwrap()
3313 .as_nanos();
3314 let path = std::env::temp_dir().join(format!(
3315 "supercode-catalog-{label}-{}-{nonce}",
3316 std::process::id()
3317 ));
3318 fs::create_dir_all(&path).unwrap();
3319 path
3320 }
3321
3322 #[test]
3323 fn codex_history_index_reads_appends_and_repairs_replacements() {
3324 let root = temp_dir("codex-history-index");
3325 let sessions = root.join("sessions");
3326 fs::create_dir_all(&sessions).unwrap();
3327 let history = root.join("history.jsonl");
3328 fs::write(
3329 &history,
3330 "{\"session_id\":\"alpha\",\"text\":\"first topic\"}\n",
3331 )
3332 .unwrap();
3333
3334 let mut index = CodexHistoryTopicIndex::new(&sessions);
3335 assert_eq!(index.refresh().unwrap(), BTreeSet::from(["alpha".into()]));
3336 assert_eq!(index.topics["alpha"][0].content, "first topic");
3337 assert!(index.refresh().unwrap().is_empty());
3338
3339 let mut file = fs::OpenOptions::new().append(true).open(&history).unwrap();
3340 write!(
3341 file,
3342 "{{\"session_id\":\"alpha\",\"text\":\"later topic\"}}\n\
3343 {{\"session_id\":\"beta\",\"text\":\"second topic\"}}\n"
3344 )
3345 .unwrap();
3346 file.flush().unwrap();
3347 assert_eq!(index.refresh().unwrap(), BTreeSet::from(["beta".into()]));
3348 assert_eq!(index.topics["alpha"][0].content, "first topic");
3349 assert_eq!(index.topics["beta"][0].content, "second topic");
3350
3351 fs::write(
3352 &history,
3353 "{\"session_id\":\"gamma\",\"text\":\"replacement\"}\n",
3354 )
3355 .unwrap();
3356 assert_eq!(
3357 index.refresh().unwrap(),
3358 BTreeSet::from(["alpha".into(), "beta".into(), "gamma".into()])
3359 );
3360 assert!(!index.topics.contains_key("alpha"));
3361 assert_eq!(index.topics["gamma"][0].content, "replacement");
3362
3363 fs::remove_dir_all(root).ok();
3364 }
3365
3366 #[test]
3367 fn codex_history_index_retains_an_incomplete_appended_record() {
3368 let root = temp_dir("codex-history-partial");
3369 let sessions = root.join("sessions");
3370 fs::create_dir_all(&sessions).unwrap();
3371 let history = root.join("history.jsonl");
3372 fs::write(&history, "{\"session_id\":\"partial\",\"text\":\"hel").unwrap();
3373
3374 let mut index = CodexHistoryTopicIndex::new(&sessions);
3375 assert!(index.refresh().unwrap().is_empty());
3376 let mut file = fs::OpenOptions::new().append(true).open(&history).unwrap();
3377 writeln!(file, "lo\"}}").unwrap();
3378 file.flush().unwrap();
3379
3380 assert_eq!(index.refresh().unwrap(), BTreeSet::from(["partial".into()]));
3381 assert_eq!(index.topics["partial"][0].content, "hello");
3382 fs::remove_dir_all(root).ok();
3383 }
3384
3385 #[test]
3386 fn cached_codex_history_enrichment_matches_stateless_discovery() {
3387 let root = temp_dir("codex-history-parity");
3388 let sessions = root.join("sessions");
3389 let workspace = root.join("workspace");
3390 fs::create_dir_all(&sessions).unwrap();
3391 fs::create_dir_all(&workspace).unwrap();
3392 fs::write(
3393 sessions.join("rollout.jsonl"),
3394 format!(
3395 "{{\"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",
3396 serde_json::to_string(&workspace.to_string_lossy()).unwrap(),
3397 serde_json::to_string(&workspace.to_string_lossy()).unwrap(),
3398 ),
3399 )
3400 .unwrap();
3401 fs::write(
3402 root.join("history.jsonl"),
3403 "{\"session_id\":\"alpha\",\"text\":\"history topic\"}\n",
3404 )
3405 .unwrap();
3406 let query = DiscoveryQuery {
3407 harnesses: vec![HarnessId::from(HarnessId::CODEX)],
3408 homes: HarnessHomes {
3409 codex: sessions.clone(),
3410 ..HarnessHomes::default()
3411 },
3412 include_topic_candidates: true,
3413 ..DiscoveryQuery::default()
3414 };
3415 let catalog = HarnessCatalog::new();
3416 let projected = catalog
3417 .project_index(&query, catalog.discover_raw_index(&query))
3418 .unwrap();
3419 let expected = catalog
3420 .enrich_index_page(&query, projected.clone())
3421 .unwrap();
3422 let mut history = CodexHistoryTopicIndex::new(&sessions);
3423 history.refresh().unwrap();
3424 let actual = catalog
3425 .enrich_index_page_with_codex_history(&query, projected, &history)
3426 .unwrap();
3427
3428 assert_eq!(actual, expected);
3429 assert_eq!(actual[0].preview_candidates[0].content, "history topic");
3430 fs::remove_dir_all(root).ok();
3431 }
3432
3433 #[test]
3434 fn locator_json_round_trip_preserves_sqlite_selector() {
3435 let locator = SessionLocator {
3436 harness: HarnessId::from(HarnessId::OPENCODE),
3437 session_id: "ses_123".into(),
3438 storage: StorageLocator::Sqlite {
3439 path: PathBuf::from("/tmp/opencode-dev.db"),
3440 selector: "ses_123".into(),
3441 },
3442 };
3443 let encoded = serde_json::to_string(&locator).unwrap();
3444 assert_eq!(
3445 serde_json::from_str::<SessionLocator>(&encoded).unwrap(),
3446 locator
3447 );
3448 }
3449
3450 #[test]
3451 fn discovers_filters_loads_and_follows_three_jsonl_harnesses() {
3452 let root = temp_dir("jsonl");
3453 let workspace = root.join("workspace");
3454 let other = root.join("other");
3455 fs::create_dir_all(&workspace).unwrap();
3456 fs::create_dir_all(&other).unwrap();
3457
3458 let claude = root.join("claude");
3459 let codex = root.join("codex");
3460 let pi = root.join("pi");
3461 fs::create_dir_all(&claude).unwrap();
3462 fs::create_dir_all(&codex).unwrap();
3463 fs::create_dir_all(&pi).unwrap();
3464 fs::write(
3465 claude.join("claude.jsonl"),
3466 format!(
3467 "{{\"type\":\"user\",\"sessionId\":\"cc-1\",\"cwd\":{},\"timestamp\":\"2026-01-01T00:00:01Z\",\"message\":{{\"role\":\"user\",\"content\":\"hi\"}}}}\n",
3468 serde_json::to_string(&workspace.to_string_lossy()).unwrap()
3469 ),
3470 )
3471 .unwrap();
3472 fs::write(
3473 codex.join("rollout.jsonl"),
3474 format!(
3475 "{{\"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",
3476 serde_json::to_string(&workspace.to_string_lossy()).unwrap()
3477 ),
3478 )
3479 .unwrap();
3480 fs::write(
3481 pi.join("pi.jsonl"),
3482 format!(
3483 "{{\"type\":\"session\",\"version\":3,\"id\":\"pi-1\",\"timestamp\":\"2026-01-01T00:00:00Z\",\"cwd\":{}}}\n{{\"type\":\"message\",\"message\":{{\"role\":\"user\",\"content\":\"inspect pi\"}}}}\n",
3484 serde_json::to_string(&workspace.to_string_lossy()).unwrap()
3485 ),
3486 )
3487 .unwrap();
3488 fs::write(
3489 pi.join("unrelated.jsonl"),
3490 format!(
3491 "{{\"type\":\"session\",\"version\":3,\"id\":\"pi-2\",\"timestamp\":\"2026-01-01T00:00:00Z\",\"cwd\":{}}}\n",
3492 serde_json::to_string(&other.to_string_lossy()).unwrap()
3493 ),
3494 )
3495 .unwrap();
3496 fs::write(claude.join("partial.jsonl"), "{truncated").unwrap();
3497
3498 let query = DiscoveryQuery {
3499 workspace: Some(workspace),
3500 homes: HarnessHomes {
3501 claude_code: claude,
3502 codex,
3503 pi,
3504 opencode: root.join("missing-opencode"),
3505 grok: root.join("missing-grok"),
3506 gemini: root.join("missing-gemini"),
3507 goose: root.join("missing-goose"),
3508 supercode: root.join("missing-supercode"),
3509 openclaw: root.join("missing-openclaw"),
3510 hermes: root.join("missing-hermes"),
3511 orchestrator: root.join("missing-orchestrator"),
3512 },
3513 ..DiscoveryQuery::default()
3514 };
3515 let catalog = HarnessCatalog::new();
3516 let found = catalog.discover(&query).unwrap();
3517 assert_eq!(found.len(), 3);
3518 assert_eq!(
3519 found
3520 .iter()
3521 .map(|item| item.locator.harness.as_str())
3522 .collect::<HashSet<_>>(),
3523 HashSet::from([HarnessId::CLAUDE_CODE, HarnessId::CODEX, HarnessId::PI])
3524 );
3525 for descriptor in found {
3526 assert!(descriptor.preview_candidates.is_empty());
3527 assert_eq!(descriptor.latest_message_candidates.len(), 1);
3528 assert_eq!(descriptor.latest_message_candidates[0].role, "user");
3529 assert!(descriptor.latest_message_candidates[0].cursor.is_some());
3530 if descriptor.locator.harness.as_str() == HarnessId::CLAUDE_CODE {
3531 assert_eq!(
3532 descriptor.latest_message_candidates[0]
3533 .metadata
3534 .get("timestamp")
3535 .map(String::as_str),
3536 Some("2026-01-01T00:00:01Z")
3537 );
3538 } else if descriptor.locator.harness.as_str() == HarnessId::CODEX {
3539 assert_eq!(
3540 descriptor.latest_message_candidates[0]
3541 .metadata
3542 .get("timestamp")
3543 .map(String::as_str),
3544 Some("2026-01-01T00:00:02Z")
3545 );
3546 }
3547 let loaded = catalog.load(&descriptor.locator).unwrap();
3548 assert_eq!(
3549 loaded.meta.session_id.as_deref(),
3550 Some(descriptor.locator.session_id.as_str())
3551 );
3552 let mut follower = catalog.follow(&descriptor.locator).unwrap();
3553 assert!(matches!(
3554 follower.poll().unwrap(),
3555 Some(crate::SessionWatchEvent::SessionSnapshot { .. })
3556 ));
3557 }
3558 fs::remove_dir_all(root).ok();
3559 }
3560
3561 #[test]
3562 fn codex_event_messages_supply_bounded_native_order_previews() {
3563 let root = temp_dir("codex-event-previews");
3566 let path = root.join("rollout.jsonl");
3567 let user = serde_json::json!({
3568 "timestamp": "2026-01-01T00:00:01Z", "type": "event_msg",
3569 "payload": {"type": "user_message", "message": "Investigate the worker"}
3570 });
3571 let answer = serde_json::json!({
3572 "timestamp": "2026-01-01T00:00:02Z", "type": "event_msg",
3573 "payload": {"type": "agent_message", "message": "Worker findings"}
3574 });
3575 let noise = serde_json::json!({
3576 "type": "event_msg", "payload": {"type": "token_count", "message": "not a message"}
3577 });
3578 fs::write(&path, format!("{user}\n{answer}\n{noise}\n{{partial")).unwrap();
3579 let latest = latest_file_message_candidates(&path, HarnessId::CODEX).unwrap();
3580 assert_eq!(latest.len(), 2);
3581 assert_eq!(latest[0].role, "assistant");
3582 assert_eq!(latest[0].content, "Worker findings");
3583 assert_eq!(latest[1].role, "user");
3584 assert_eq!(latest[1].content, "Investigate the worker");
3585 assert_eq!(
3586 latest[0].metadata.get("timestamp").map(String::as_str),
3587 Some("2026-01-01T00:00:02Z")
3588 );
3589 assert_eq!(
3590 latest[0].cursor.as_deref(),
3591 Some(message_candidate_cursor(HarnessId::CODEX, &answer).as_str())
3592 );
3593 let topics = topic_file_message_candidates(&path, HarnessId::CODEX).unwrap();
3594 assert_eq!(topics.len(), 2);
3595 assert_eq!(topics[0].content, "Investigate the worker");
3596
3597 let mut context_pairs = String::new();
3598 for index in 0..5 {
3599 let content = if index < 4 {
3600 format!("# AGENTS.md instructions for /work/{index}\n\n<INSTRUCTIONS>Context</INSTRUCTIONS>")
3601 } else {
3602 "The actual user request".to_string()
3603 };
3604 let event = serde_json::json!({
3605 "type": "event_msg", "payload": {"type": "user_message", "message": content}
3606 });
3607 let response = serde_json::json!({
3608 "type": "response_item", "payload": {"type": "message", "role": "user", "content": content}
3609 });
3610 context_pairs.push_str(&format!("{event}\n{response}\n"));
3611 }
3612 fs::write(&path, context_pairs).unwrap();
3613 let topics = topic_file_message_candidates(&path, HarnessId::CODEX).unwrap();
3614 assert!(topics
3615 .iter()
3616 .any(|candidate| candidate.content == "The actual user request"));
3617
3618 let mut paired = String::new();
3621 for index in 0..12 {
3622 let content = format!("answer {index}");
3623 let event = serde_json::json!({
3624 "type": "event_msg", "payload": {"type": "agent_message", "message": content}
3625 });
3626 let response = serde_json::json!({
3627 "id": format!("response-{index}"), "type": "response_item",
3628 "payload": {"type": "message", "role": "assistant", "content": content}
3629 });
3630 if index % 2 == 0 {
3631 paired.push_str(&format!("{event}\n{response}\n"));
3632 } else {
3633 paired.push_str(&format!("{response}\n{event}\n"));
3634 }
3635 }
3636 fs::write(&path, paired).unwrap();
3637 let latest = latest_file_message_candidates(&path, HarnessId::CODEX).unwrap();
3638 assert_eq!(latest.len(), LATEST_PREVIEW_CANDIDATES);
3639 assert_eq!(latest[0].content, "answer 11");
3640 assert_eq!(latest[1].content, "answer 10");
3641 assert_eq!(latest[7].content, "answer 4");
3642 for (offset, candidate) in latest.iter().enumerate() {
3643 let response = serde_json::json!({"id": format!("response-{}", 11 - offset)});
3644 assert_eq!(
3645 candidate.cursor.as_deref(),
3646 Some(message_candidate_cursor(HarnessId::CODEX, &response).as_str())
3647 );
3648 }
3649 let topics = topic_file_message_candidates(&path, HarnessId::CODEX).unwrap();
3650 assert_eq!(topics.len(), LATEST_PREVIEW_CANDIDATES);
3651 assert_eq!(topics[7].content, "answer 7");
3652
3653 let event = serde_json::json!({
3654 "type": "event_msg", "payload": {"type": "agent_message", "message": "again"}
3655 });
3656 let response = serde_json::json!({
3657 "type": "response_item", "payload": {"type": "message", "role": "assistant", "content": "again"}
3658 });
3659 for records in [
3660 format!("{event}\n{event}\n"),
3661 format!("{response}\n{response}\n"),
3662 format!("{event}\n{response}\n{event}\n{response}\n"),
3663 format!("{response}\n{event}\n{response}\n{event}\n"),
3664 ] {
3665 fs::write(&path, records).unwrap();
3666 assert_eq!(
3667 latest_file_message_candidates(&path, HarnessId::CODEX)
3668 .unwrap()
3669 .len(),
3670 2
3671 );
3672 assert_eq!(
3673 topic_file_message_candidates(&path, HarnessId::CODEX)
3674 .unwrap()
3675 .len(),
3676 2
3677 );
3678 }
3679
3680 let prefix = "x".repeat(4096);
3682 let distinct_event = serde_json::json!({
3683 "type": "event_msg", "payload": {"type": "agent_message", "message": format!("{prefix}A")}
3684 });
3685 let distinct_response = serde_json::json!({
3686 "type": "response_item", "payload": {"type": "message", "role": "assistant", "content": format!("{prefix}B")}
3687 });
3688 fs::write(&path, format!("{distinct_event}\n{distinct_response}\n")).unwrap();
3689 assert_eq!(
3690 latest_file_message_candidates(&path, HarnessId::CODEX)
3691 .unwrap()
3692 .len(),
3693 2
3694 );
3695 assert_eq!(
3696 topic_file_message_candidates(&path, HarnessId::CODEX)
3697 .unwrap()
3698 .len(),
3699 2
3700 );
3701 let long = serde_json::json!({
3702 "type": "event_msg", "payload": {"type": "agent_message", "message": "x".repeat(5000)}
3703 });
3704 fs::write(&path, format!("{long}\n")).unwrap();
3705 let latest = latest_file_message_candidates(&path, HarnessId::CODEX).unwrap();
3706 assert_eq!(latest[0].content.len(), 4096);
3707 fs::remove_dir_all(root).ok();
3708 }
3709
3710 #[test]
3711 fn preview_cursor_tracks_native_boundary_not_growing_text() {
3712 let first = serde_json::json!({
3713 "timestamp": "2026-01-01T00:00:02Z",
3714 "type": "response_item",
3715 "payload": {"type": "message", "role": "assistant", "content": "partial"}
3716 });
3717 let grown = serde_json::json!({
3718 "timestamp": "2026-01-01T00:00:02Z",
3719 "type": "response_item",
3720 "payload": {"type": "message", "role": "assistant", "content": "partial and complete"}
3721 });
3722 let next = serde_json::json!({
3723 "timestamp": "2026-01-01T00:00:03Z",
3724 "type": "response_item",
3725 "payload": {"type": "message", "role": "assistant", "content": "next"}
3726 });
3727
3728 assert_eq!(
3729 message_candidate_cursor(HarnessId::CODEX, &first),
3730 message_candidate_cursor(HarnessId::CODEX, &grown)
3731 );
3732 assert_ne!(
3733 message_candidate_cursor(HarnessId::CODEX, &first),
3734 message_candidate_cursor(HarnessId::CODEX, &next)
3735 );
3736 }
3737
3738 #[test]
3739 fn codex_child_rollouts_roll_into_roots_before_pagination() {
3740 let root = temp_dir("codex-roots");
3741 let codex = root.join("codex");
3742 fs::create_dir_all(&codex).unwrap();
3743 let write_rollout =
3744 |name: &str, payload: Value, modified_seconds: u64| {
3745 let path = codex.join(format!("{name}.jsonl"));
3746 fs::write(
3747 &path,
3748 format!(
3749 "{}\n",
3750 serde_json::json!({
3751 "timestamp": "2026-01-01T00:00:00Z",
3752 "type": "session_meta",
3753 "payload": payload,
3754 })
3755 ),
3756 )
3757 .unwrap();
3758 File::open(&path)
3759 .unwrap()
3760 .set_times(fs::FileTimes::new().set_modified(
3761 UNIX_EPOCH + std::time::Duration::from_secs(modified_seconds),
3762 ))
3763 .unwrap();
3764 };
3765 write_rollout(
3766 "parent",
3767 serde_json::json!({"id":"parent","cwd":"/project","source":"cli"}),
3768 100,
3769 );
3770 write_rollout(
3771 "other",
3772 serde_json::json!({"id":"other","cwd":"/project","source":"cli"}),
3773 200,
3774 );
3775 write_rollout(
3776 "child",
3777 serde_json::json!({
3778 "id": "child",
3779 "cwd": "/project",
3780 "parent_thread_id": "parent",
3781 "source": {"subagent":{"thread_spawn":{
3782 "parent_thread_id":"parent",
3783 "depth":1,
3784 "agent_path":"/root/reviewer"
3785 }}}
3786 }),
3787 300,
3788 );
3789
3790 let catalog = HarnessCatalog::new();
3791 let query = DiscoveryQuery {
3792 harnesses: vec![HarnessId::from(HarnessId::CODEX)],
3793 homes: HarnessHomes {
3794 codex: codex.clone(),
3795 ..HarnessHomes::default()
3796 },
3797 limit: Some(1),
3798 ..DiscoveryQuery::default()
3799 };
3800 let roots = catalog.discover(&query).unwrap();
3801 assert_eq!(roots.len(), 1);
3802 assert_eq!(roots[0].locator.session_id, "parent");
3803 assert_eq!(roots[0].updated_at_ms, Some(300_000));
3804 assert_eq!(roots[0].parent_session_id, None);
3805 assert_eq!(roots[0].child_session_count, 1);
3806
3807 let tree = catalog
3808 .discover(&DiscoveryQuery {
3809 limit: None,
3810 include_child_sessions: true,
3811 root_session_id: Some("parent".into()),
3812 ..query
3813 })
3814 .unwrap();
3815 assert_eq!(tree.len(), 2);
3816 assert!(tree
3817 .iter()
3818 .all(|descriptor| descriptor.locator.session_id != "other"));
3819 let child = tree
3820 .iter()
3821 .find(|descriptor| descriptor.locator.session_id == "child")
3822 .unwrap();
3823 assert_eq!(child.parent_session_id.as_deref(), Some("parent"));
3824 fs::remove_dir_all(root).ok();
3825 }
3826
3827 #[test]
3828 fn discovers_loads_and_follows_opencode_sqlite() {
3829 let db = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
3830 .join("../harness/tests/fixtures/opencode_fixture/opencode.db");
3831 let catalog = HarnessCatalog::new();
3832 let found = catalog
3833 .discover(&DiscoveryQuery {
3834 harnesses: vec![HarnessId::from(HarnessId::OPENCODE)],
3835 homes: HarnessHomes {
3836 opencode: db,
3837 ..HarnessHomes::default()
3838 },
3839 ..DiscoveryQuery::default()
3840 })
3841 .unwrap();
3842 assert!(!found.is_empty());
3843 for descriptor in found {
3844 assert_eq!(descriptor.locator.harness.as_str(), HarnessId::OPENCODE);
3845 assert_eq!(
3846 catalog.load(&descriptor.locator).unwrap().meta.session_id,
3847 Some(descriptor.locator.session_id.clone())
3848 );
3849 assert!(catalog.follow(&descriptor.locator).is_ok());
3850 }
3851 }
3852
3853 #[test]
3854 fn discovers_loads_and_follows_hermes_sqlite_by_session() {
3855 let root = temp_dir("hermes-follow");
3857 fs::create_dir_all(&root).unwrap();
3858 let db = root.join("state.db");
3859 fs::copy(
3860 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
3861 .join("../harness/tests/fixtures/hermes_home/state.db"),
3862 &db,
3863 )
3864 .unwrap();
3865 let catalog = HarnessCatalog::new();
3866 let query = DiscoveryQuery {
3867 harnesses: vec![HarnessId::from(HarnessId::HERMES)],
3868 homes: HarnessHomes {
3869 hermes: db.clone(),
3870 ..HarnessHomes::default()
3871 },
3872 ..DiscoveryQuery::default()
3873 };
3874 let found = catalog.discover(&query).unwrap();
3875 assert!(found.len() >= 2, "{found:#?}");
3876 for descriptor in &found {
3880 assert_eq!(descriptor.locator.harness.as_str(), HarnessId::HERMES);
3881 let loaded = catalog.load(&descriptor.locator).unwrap();
3882 let last_text = loaded.messages.iter().rev().find_map(|message| {
3883 (matches!(message.role, crate::Role::User | crate::Role::Assistant))
3884 .then(|| message.content.clone())
3885 .flatten()
3886 });
3887 assert_eq!(
3889 descriptor
3890 .latest_message_candidates
3891 .first()
3892 .map(|c| c.content.as_str()),
3893 last_text.as_deref(),
3894 "{}",
3895 descriptor.locator.session_id
3896 );
3897 assert_eq!(
3898 catalog.load(&descriptor.locator).unwrap().meta.session_id,
3899 Some(descriptor.locator.session_id.clone())
3900 );
3901 let mut follower = catalog.follow(&descriptor.locator).unwrap();
3902 match follower.poll().unwrap() {
3903 Some(crate::watch::SessionWatchEvent::SessionSnapshot { session, .. }) => {
3904 assert_eq!(
3905 session.meta.session_id,
3906 Some(descriptor.locator.session_id.clone())
3907 );
3908 }
3909 other => panic!("expected an initial snapshot, got {other:?}"),
3910 }
3911 }
3912 let target = &found[0].locator;
3915 let sibling = &found[1].locator;
3916 let mut target_follower = catalog.follow(target).unwrap();
3917 let mut sibling_follower = catalog.follow(sibling).unwrap();
3918 target_follower.poll().unwrap();
3919 sibling_follower.poll().unwrap();
3920 std::thread::sleep(std::time::Duration::from_millis(20));
3921 {
3922 let conn = rusqlite::Connection::open(&db).unwrap();
3923 conn.execute(
3924 "INSERT INTO messages (session_id, role, content, timestamp, active) VALUES (?1, 'assistant', 'appended by the follow test', ?2, 1)",
3925 rusqlite::params![target.session_id, 1_800_000_000.0_f64],
3926 )
3927 .unwrap();
3928 }
3929 match target_follower.poll().unwrap() {
3930 Some(crate::watch::SessionWatchEvent::MessagesAppended {
3931 session_id,
3932 messages,
3933 ..
3934 }) => {
3935 assert_eq!(session_id, Some(target.session_id.clone()));
3936 assert_eq!(messages.len(), 1);
3937 assert_eq!(
3938 messages[0].content.as_deref(),
3939 Some("appended by the follow test")
3940 );
3941 }
3942 other => panic!("expected messages_appended for the target session, got {other:?}"),
3943 }
3944 assert!(
3945 sibling_follower.poll().unwrap().is_none(),
3946 "the sibling session must not wake"
3947 );
3948 fs::remove_dir_all(&root).ok();
3949 }
3950
3951 #[test]
3952 fn discovers_loads_and_follows_gemini_conversation_records() {
3953 let root = temp_dir("gemini");
3954 let workspace = root.join("workspace");
3955 let chats = root.join("gemini/tmp/demo/chats");
3956 fs::create_dir_all(&workspace).unwrap();
3957 fs::create_dir_all(&chats).unwrap();
3958 fs::write(
3959 root.join("gemini/projects.json"),
3960 serde_json::json!({
3961 "projects": {workspace.to_string_lossy(): "demo"}
3962 })
3963 .to_string(),
3964 )
3965 .unwrap();
3966 let transcript = chats.join("gemini-id.jsonl");
3967 fs::write(
3968 &transcript,
3969 include_str!("../../harness/tests/fixtures/gemini_session.jsonl"),
3970 )
3971 .unwrap();
3972
3973 let catalog = HarnessCatalog::new();
3974 let found = catalog
3975 .discover(&DiscoveryQuery {
3976 harnesses: vec![HarnessId::from(HarnessId::GEMINI)],
3977 homes: HarnessHomes {
3978 gemini: root.join("gemini"),
3979 ..HarnessHomes::default()
3980 },
3981 workspace: Some(workspace.clone()),
3982 ..DiscoveryQuery::default()
3983 })
3984 .unwrap();
3985
3986 assert_eq!(found.len(), 1);
3987 assert_eq!(found[0].cwd.as_deref(), Some(workspace.as_path()));
3988 assert_eq!(found[0].message_count, None);
3989 assert_eq!(found[0].model.as_deref(), Some("gemini-2.5-pro"));
3990 assert_eq!(found[0].title, None);
3991 assert!(found[0].preview_candidates.is_empty());
3992 assert_eq!(found[0].latest_message_candidates.len(), 3);
3993 assert_eq!(
3994 found[0].latest_message_candidates[0].content,
3995 "Fixture inspected."
3996 );
3997 let loaded = catalog.load(&found[0].locator).unwrap();
3998 assert_eq!(
3999 loaded.meta.session_id.as_deref(),
4000 Some("11111111-1111-4111-8111-111111111111")
4001 );
4002 assert_eq!(loaded.messages.len(), 4);
4003 assert!(matches!(
4004 catalog.follow(&found[0].locator).unwrap().poll().unwrap(),
4005 Some(crate::SessionWatchEvent::SessionSnapshot { .. })
4006 ));
4007 fs::remove_dir_all(root).ok();
4008 }
4009
4010 #[test]
4011 fn preview_search_filters_before_pagination_without_changing_metadata_search() {
4012 let root = temp_dir("preview-search");
4015 for (id, first, last) in [
4016 ("topic-hit", "NEBULA opening", "Finished"),
4017 ("latest-hit", "Ordinary opening", "Found the nebula"),
4018 ("no-hit", "Unrelated opening", "Finished"),
4019 ] {
4020 fs::write(root.join(format!("{id}.jsonl")), format!("{}\n{}\n",
4021 serde_json::json!({"sessionId": id, "cwd": "/work", "type": "user", "message": {"role": "user", "content": first}}),
4022 serde_json::json!({"sessionId": id, "type": "assistant", "message": {"role": "assistant", "content": last}}),
4023 )).unwrap();
4024 }
4025 let query: DiscoveryQuery = serde_json::from_value(serde_json::json!({
4026 "harnesses": ["claude-code"], "homes": {"claude_code": root},
4027 "query": " nebula ", "search_previews": true, "limit": 1
4028 }))
4029 .unwrap();
4030 let catalog = HarnessCatalog::new();
4031 let first = catalog.discover_page(&query).unwrap();
4032 assert!(first.receipt.searched_previews);
4033 assert_eq!(first.receipt.total_matched, 2);
4034 assert_eq!(first.sessions.len(), 1);
4035 assert!(first.receipt.truncated);
4036 let second = catalog
4037 .discover_page(&DiscoveryQuery {
4038 cursor: first.next_cursor.clone(),
4039 ..query.clone()
4040 })
4041 .unwrap();
4042 assert_eq!(second.receipt.total_matched, 2);
4043 assert_eq!(second.sessions.len(), 1);
4044 assert_ne!(first.sessions[0].locator, second.sessions[0].locator);
4045 assert!(!second.receipt.truncated);
4046 let mut metadata = serde_json::to_value(&query).unwrap();
4047 metadata["search_previews"] = false.into();
4048 let metadata_page = catalog
4049 .discover_page(&serde_json::from_value(metadata).unwrap())
4050 .unwrap();
4051 assert!(metadata_page.sessions.is_empty());
4052 assert!(serde_json::to_value(&metadata_page.receipt)
4053 .unwrap()
4054 .get("searched_previews")
4055 .is_none());
4056
4057 let all = catalog
4060 .discover_page(&DiscoveryQuery {
4061 query: Some("hit".into()),
4062 limit: None,
4063 ..query.clone()
4064 })
4065 .unwrap();
4066 assert_eq!(all.sessions.len(), 3);
4067 assert_eq!(all.receipt.total_matched, 3);
4068 let elsewhere = catalog
4069 .discover_page(&DiscoveryQuery {
4070 workspace: Some("/elsewhere".into()),
4071 ..query.clone()
4072 })
4073 .unwrap();
4074 assert_eq!(elsewhere.receipt.total_matched, 0);
4075 let excluded_by_time = catalog
4076 .discover_page(&DiscoveryQuery {
4077 updated_after_ms: Some(u64::MAX),
4078 ..query.clone()
4079 })
4080 .unwrap();
4081 assert_eq!(excluded_by_time.receipt.total_matched, 0);
4082 for invalid in [
4083 DiscoveryQuery {
4084 query: None,
4085 ..query.clone()
4086 },
4087 DiscoveryQuery {
4088 query: Some(" ".into()),
4089 ..query.clone()
4090 },
4091 DiscoveryQuery {
4092 limit: Some(0),
4093 ..query.clone()
4094 },
4095 DiscoveryQuery {
4096 cursor: Some("bad-cursor".into()),
4097 ..query.clone()
4098 },
4099 DiscoveryQuery {
4100 cursor: first.next_cursor,
4101 query: Some("absent".into()),
4102 ..query.clone()
4103 },
4104 ] {
4105 assert!(catalog.discover_page(&invalid).is_err());
4106 }
4107 assert!(catalog.project_index_page(&query, Vec::new()).is_err());
4108 fs::remove_dir_all(root).ok();
4109 }
4110
4111 #[test]
4112 fn preview_search_uses_codex_first_history_topic_and_bounded_candidates() {
4113 let root = temp_dir("preview-search-codex");
4114 let sessions = root.join("sessions");
4115 fs::create_dir_all(&sessions).unwrap();
4116 fs::write(
4117 root.join("history.jsonl"),
4118 format!(
4119 "{}\n{}\n",
4120 serde_json::json!({"session_id": "history-hit", "text": "Original nebula topic"}),
4121 serde_json::json!({"session_id": "history-hit", "text": "laterhistoryonly"}),
4122 ),
4123 )
4124 .unwrap();
4125 for id in ["history-hit", "latest-hit", "bounded"] {
4126 let mut content = format!(
4127 "{}\n",
4128 serde_json::json!({
4129 "type": "session_meta", "payload": {"id": id, "cwd": "/work"}
4130 })
4131 );
4132 for index in 0..20 {
4133 let message = if id == "latest-hit" && index == 19 {
4134 "Found NEBULA".to_string()
4135 } else if index == 10 {
4136 "middlehistoryonly".to_string()
4137 } else {
4138 format!("{}beyondtextcap", "x".repeat(4096))
4139 };
4140 content.push_str(&format!("{}\n", serde_json::json!({
4141 "type": "event_msg", "payload": {"type": "agent_message", "message": message}
4142 })));
4143 }
4144 fs::write(sessions.join(format!("{id}.jsonl")), content).unwrap();
4145 }
4146 let catalog = HarnessCatalog::new();
4147 let query: DiscoveryQuery = serde_json::from_value(serde_json::json!({
4148 "harnesses": ["codex"], "homes": {"codex": sessions},
4149 "query": "nebula", "search_previews": true
4150 }))
4151 .unwrap();
4152 let page = catalog.discover_page(&query).unwrap();
4153 assert_eq!(page.receipt.total_matched, 2);
4154 for row in &page.sessions {
4155 assert!(row.preview_candidates.len() <= 8);
4156 assert!(row.latest_message_candidates.len() <= 8);
4157 assert!(row
4158 .preview_candidates
4159 .iter()
4160 .chain(&row.latest_message_candidates)
4161 .all(|candidate| candidate.content.chars().count() <= 4096));
4162 }
4163 for text in ["middlehistoryonly", "laterhistoryonly", "beyondtextcap"] {
4164 assert!(
4165 catalog
4166 .discover_page(&DiscoveryQuery {
4167 query: Some(text.into()),
4168 ..query.clone()
4169 })
4170 .unwrap()
4171 .sessions
4172 .is_empty(),
4173 "not a full-history search: {text}"
4174 );
4175 }
4176 fs::remove_dir_all(root).unwrap();
4177 }
4178
4179 #[test]
4180 fn discovers_native_store_and_pages_search_results() {
4181 let root = temp_dir("supercode");
4182 let store_root = root.join("sessions");
4183 fs::create_dir_all(&store_root).unwrap();
4184 for (name, title) in [
4185 ("alpha", "Alpha planning"),
4186 ("beta", "Beta implementation"),
4187 ("gamma", "Gamma review"),
4188 ] {
4189 fs::write(
4190 store_root.join(format!("{name}.jsonl")),
4191 format!("{{\"role\":\"user\",\"content\":\"{title}\"}}\n"),
4192 )
4193 .unwrap();
4194 fs::write(
4195 store_root.join(format!("{name}.meta.json")),
4196 serde_json::json!({"name": name, "title": title}).to_string(),
4197 )
4198 .unwrap();
4199 }
4200 let catalog = HarnessCatalog::new();
4201 let base = DiscoveryQuery {
4202 harnesses: vec![HarnessId::from(HarnessId::SUPERCODE)],
4203 homes: HarnessHomes {
4204 supercode: store_root,
4205 ..HarnessHomes::default()
4206 },
4207 limit: Some(1),
4208 ..DiscoveryQuery::default()
4209 };
4210
4211 let first = catalog.discover_page(&base).unwrap();
4212 assert_eq!(first.sessions.len(), 1);
4213 assert!(first.next_cursor.is_some());
4214 let second = catalog
4215 .discover_page(&DiscoveryQuery {
4216 cursor: first.next_cursor,
4217 ..base.clone()
4218 })
4219 .unwrap();
4220 assert_eq!(second.sessions.len(), 1);
4221 assert_ne!(
4222 first.sessions[0].locator.session_id,
4223 second.sessions[0].locator.session_id
4224 );
4225 let search = catalog
4226 .discover_page(&DiscoveryQuery {
4227 limit: None,
4228 query: Some("implementation".into()),
4229 ..base
4230 })
4231 .unwrap();
4232 assert_eq!(search.sessions.len(), 1);
4233 assert_eq!(search.sessions[0].locator.session_id, "beta");
4234 assert_eq!(search.sessions[0].message_count, None);
4235 assert_eq!(
4236 catalog
4237 .load(&search.sessions[0].locator)
4238 .unwrap()
4239 .messages
4240 .len(),
4241 1
4242 );
4243 fs::remove_dir_all(root).ok();
4244 }
4245
4246 #[test]
4247 fn native_workspace_discovery_reads_bounded_sidecar_headers() {
4248 let root = temp_dir("supercode-bounded-header");
4249 let store_root = root.join("sessions");
4250 let workspace = root.join("project");
4251 fs::create_dir_all(&store_root).unwrap();
4252 fs::create_dir_all(&workspace).unwrap();
4253 let name = "bounded-native";
4254 fs::write(
4255 store_root.join(format!("{name}.meta.json")),
4256 serde_json::json!({"name": name, "title": "Bounded native"}).to_string(),
4257 )
4258 .unwrap();
4259 fs::write(
4260 store_root.join(format!("{name}.jsonl")),
4261 "{\"role\":\"user\",\"content\":\"projected view\"}\n",
4262 )
4263 .unwrap();
4264 let sidecar = [
4265 serde_json::json!({
4266 "supercode_native": 2,
4267 "source": "claude_code",
4268 "session_id": "native-session"
4269 })
4270 .to_string(),
4271 serde_json::json!({
4272 "type": "user",
4273 "sessionId": "native-session",
4274 "cwd": workspace,
4275 "message": {"role": "user", "content": "hello"}
4276 })
4277 .to_string(),
4278 serde_json::json!({
4279 "type": "assistant",
4280 "sessionId": "native-session",
4281 "cwd": workspace,
4282 "message": {"role": "assistant", "model": "claude-sonnet-5", "content": []}
4283 })
4284 .to_string(),
4285 "not-json".into(),
4288 ]
4289 .join("\n");
4290 fs::write(
4291 store_root.join(format!("{name}.sidecar.jsonl")),
4292 format!("{sidecar}\n"),
4293 )
4294 .unwrap();
4295
4296 let found = HarnessCatalog::new()
4297 .discover(&DiscoveryQuery {
4298 workspace: Some(workspace.clone()),
4299 harnesses: vec![HarnessId::from(HarnessId::SUPERCODE)],
4300 homes: HarnessHomes {
4301 supercode: store_root,
4302 ..HarnessHomes::default()
4303 },
4304 ..DiscoveryQuery::default()
4305 })
4306 .unwrap();
4307
4308 assert_eq!(found.len(), 1);
4309 assert_eq!(found[0].cwd.as_deref(), Some(workspace.as_path()));
4310 assert_eq!(found[0].model.as_deref(), Some("claude-sonnet-5"));
4311 assert_eq!(found[0].message_count, None);
4312 fs::remove_dir_all(root).ok();
4313 }
4314
4315 #[test]
4316 fn discovers_current_opencode_schema_without_a_session_model_column() {
4317 let root = temp_dir("opencode-current");
4318 let db = root.join("opencode.db");
4319 let conn = Connection::open(&db).unwrap();
4320 conn.execute_batch(
4321 "CREATE TABLE session (
4322 id TEXT PRIMARY KEY,
4323 directory TEXT NOT NULL,
4324 title TEXT NOT NULL,
4325 time_updated INTEGER NOT NULL
4326 );
4327 CREATE TABLE message (
4328 id TEXT PRIMARY KEY,
4329 session_id TEXT NOT NULL
4330 );
4331 INSERT INTO session VALUES ('ses_current', '/tmp/work', 'Current', 42);
4332 INSERT INTO message VALUES ('msg_current', 'ses_current');",
4333 )
4334 .unwrap();
4335 drop(conn);
4336
4337 let found = HarnessCatalog::new()
4338 .discover(&DiscoveryQuery {
4339 harnesses: vec![HarnessId::from(HarnessId::OPENCODE)],
4340 homes: HarnessHomes {
4341 opencode: db,
4342 ..HarnessHomes::default()
4343 },
4344 ..DiscoveryQuery::default()
4345 })
4346 .unwrap();
4347
4348 assert_eq!(found.len(), 1);
4349 assert_eq!(found[0].locator.session_id, "ses_current");
4350 assert_eq!(found[0].message_count, Some(1));
4351 assert_eq!(found[0].model, None);
4352 fs::remove_dir_all(root).ok();
4353 }
4354
4355 #[test]
4356 fn workspace_filter_never_matches_a_relative_recorded_cwd() {
4357 let root = temp_dir("opencode-relative-cwd");
4362 let db = root.join("opencode.db");
4363 let conn = Connection::open(&db).unwrap();
4364 let here = std::env::current_dir().unwrap();
4365 conn.execute_batch(&format!(
4366 "CREATE TABLE session (
4367 id TEXT PRIMARY KEY,
4368 directory TEXT NOT NULL,
4369 title TEXT NOT NULL,
4370 time_updated INTEGER NOT NULL
4371 );
4372 CREATE TABLE message (
4373 id TEXT PRIMARY KEY,
4374 session_id TEXT NOT NULL
4375 );
4376 INSERT INTO session VALUES ('ses_relative', '.', 'Ghost', 41);
4377 INSERT INTO session VALUES ('ses_here', '{}', 'Real', 42);",
4378 here.display()
4379 ))
4380 .unwrap();
4381 drop(conn);
4382
4383 let found = HarnessCatalog::new()
4384 .discover(&DiscoveryQuery {
4385 workspace: Some(here),
4386 harnesses: vec![HarnessId::from(HarnessId::OPENCODE)],
4387 homes: HarnessHomes {
4388 opencode: db,
4389 ..HarnessHomes::default()
4390 },
4391 ..DiscoveryQuery::default()
4392 })
4393 .unwrap();
4394
4395 assert_eq!(found.len(), 1);
4396 assert_eq!(found[0].locator.session_id, "ses_here");
4397 fs::remove_dir_all(root).ok();
4398 }
4399}