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>,
88 pub message_count: Option<usize>,
93 pub model: Option<String>,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub parent_session_id: Option<String>,
100 #[serde(default, skip_serializing_if = "is_zero")]
104 pub child_session_count: usize,
105 #[serde(flatten)]
111 pub nouns: OrchestrationNouns,
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
116pub struct SessionPreviewCandidate {
117 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub cursor: Option<String>,
122 pub role: String,
125 pub content: String,
127 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
129 pub metadata: HashMap<String, String>,
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134pub struct DiscoveryPage {
135 pub sessions: Vec<SessionDescriptor>,
137 pub next_cursor: Option<String>,
139 #[serde(default)]
142 pub receipt: DiscoveryReceipt,
143}
144
145#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
147#[serde(default)]
148pub struct DiscoveryReceipt {
149 #[serde(skip_serializing_if = "Option::is_none")]
151 pub requested_after_ms: Option<u64>,
152 #[serde(skip_serializing_if = "Option::is_none")]
154 pub requested_before_ms: Option<u64>,
155 #[serde(skip_serializing_if = "Option::is_none")]
157 pub requested_limit: Option<usize>,
158 #[serde(skip_serializing_if = "Option::is_none")]
160 pub oldest_returned_ms: Option<u64>,
161 #[serde(skip_serializing_if = "Option::is_none")]
163 pub newest_returned_ms: Option<u64>,
164 pub returned: usize,
166 pub total_matched: usize,
168 pub truncated: bool,
172}
173
174#[derive(Debug)]
181pub struct CodexHistoryTopicIndex {
182 path: PathBuf,
183 fingerprint: Option<CodexHistoryFingerprint>,
184 offset: u64,
185 trailing: Vec<u8>,
186 topics: HashMap<String, Vec<SessionPreviewCandidate>>,
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190struct CodexHistoryFingerprint {
191 len: u64,
192 modified_ns: u128,
193 identity: u128,
194}
195
196impl CodexHistoryTopicIndex {
197 pub fn new(sessions_root: &Path) -> Self {
200 let root = sessions_root.parent().unwrap_or(sessions_root);
201 Self {
202 path: root.join("history.jsonl"),
203 fingerprint: None,
204 offset: 0,
205 trailing: Vec::new(),
206 topics: HashMap::new(),
207 }
208 }
209
210 pub fn path(&self) -> &Path {
212 &self.path
213 }
214
215 pub fn refresh(&mut self) -> Result<BTreeSet<String>> {
221 let metadata = match fs::metadata(&self.path) {
222 Ok(metadata) => metadata,
223 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
224 let changed = self.topics.keys().cloned().collect();
225 self.fingerprint = None;
226 self.offset = 0;
227 self.trailing.clear();
228 self.topics.clear();
229 return Ok(changed);
230 }
231 Err(error) => return Err(error.into()),
232 };
233 let fingerprint = codex_history_fingerprint(&metadata)?;
234 if self.fingerprint == Some(fingerprint) {
235 return Ok(BTreeSet::new());
236 }
237
238 let is_append = self.fingerprint.is_some_and(|previous| {
239 previous.identity == fingerprint.identity
240 && previous.len < fingerprint.len
241 && self.offset <= previous.len
242 });
243 if is_append {
244 let mut file = File::open(&self.path)?;
245 file.seek(SeekFrom::Start(self.offset))?;
246 let mut bytes = Vec::with_capacity(
247 usize::try_from(fingerprint.len.saturating_sub(self.offset)).unwrap_or(0),
248 );
249 file.read_to_end(&mut bytes)?;
250 self.offset = file.stream_position()?;
251 let changed = self.ingest(bytes);
252 self.fingerprint = Some(CodexHistoryFingerprint {
253 len: self.offset,
254 ..fingerprint
255 });
256 return Ok(changed);
257 }
258
259 let previous = std::mem::take(&mut self.topics);
260 let mut file = File::open(&self.path)?;
261 let mut bytes = Vec::with_capacity(usize::try_from(fingerprint.len).unwrap_or(0));
262 file.read_to_end(&mut bytes)?;
263 self.offset = file.stream_position()?;
264 self.trailing.clear();
265 self.ingest(bytes);
266 self.fingerprint = Some(CodexHistoryFingerprint {
267 len: self.offset,
268 ..fingerprint
269 });
270 Ok(changed_topic_ids(&previous, &self.topics))
271 }
272
273 fn ingest(&mut self, bytes: Vec<u8>) -> BTreeSet<String> {
274 let mut input = std::mem::take(&mut self.trailing);
275 input.extend(bytes);
276 let complete_len = input
277 .iter()
278 .rposition(|byte| *byte == b'\n')
279 .map_or(0, |index| index + 1);
280 let mut changed = BTreeSet::new();
281 for line in input[..complete_len].split(|byte| *byte == b'\n') {
282 self.ingest_line(line, &mut changed);
283 }
284 self.trailing.extend_from_slice(&input[complete_len..]);
285 if !self.trailing.is_empty() {
286 let trailing = self.trailing.clone();
287 if self.ingest_line(&trailing, &mut changed) {
288 self.trailing.clear();
289 }
290 }
291 changed
292 }
293
294 fn ingest_line(&mut self, line: &[u8], changed: &mut BTreeSet<String>) -> bool {
295 let Ok(value) = serde_json::from_slice::<Value>(line) else {
296 return false;
297 };
298 let Some(session_id) = value.get("session_id").and_then(Value::as_str) else {
299 return true;
300 };
301 if self.topics.contains_key(session_id) {
302 return true;
303 }
304 let mut candidates = Vec::new();
305 push_message_candidate(&mut candidates, "user", value.get("text"), HashMap::new());
306 if !candidates.is_empty() {
307 self.topics.insert(session_id.to_string(), candidates);
308 changed.insert(session_id.to_string());
309 }
310 true
311 }
312}
313
314#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
316#[serde(default)]
317pub struct HarnessHomes {
318 pub claude_code: PathBuf,
320 pub codex: PathBuf,
322 pub pi: PathBuf,
324 pub opencode: PathBuf,
326 pub grok: PathBuf,
328 pub gemini: PathBuf,
330 pub goose: PathBuf,
332 pub supercode: PathBuf,
334 pub openclaw: PathBuf,
338 pub hermes: PathBuf,
341 pub orchestrator: PathBuf,
347}
348
349pub fn orchestrator_profile_dirs(root: &Path) -> Vec<(String, PathBuf)> {
355 if !root.is_dir() {
356 return Vec::new();
357 }
358 let mut dirs = vec![("default".to_string(), root.to_path_buf())];
359 if let Ok(entries) = fs::read_dir(root.join("profiles")) {
360 let mut named: Vec<(String, PathBuf)> = entries
361 .flatten()
362 .filter(|entry| entry.path().is_dir())
363 .filter_map(|entry| {
364 entry
365 .file_name()
366 .into_string()
367 .ok()
368 .map(|name| (name, entry.path()))
369 })
370 .filter(|(name, _)| !name.starts_with('.'))
374 .collect();
375 named.sort();
376 dirs.extend(named);
377 }
378 dirs
379}
380
381impl Default for HarnessHomes {
382 fn default() -> Self {
383 let home = std::env::var_os("HOME")
384 .map(PathBuf::from)
385 .unwrap_or_else(|| PathBuf::from("."));
386 let claude_root = std::env::var_os("CLAUDE_CONFIG_DIR")
387 .map(PathBuf::from)
388 .unwrap_or_else(|| home.join(".claude"));
389 let codex_root = std::env::var_os("CODEX_HOME")
390 .map(PathBuf::from)
391 .unwrap_or_else(|| home.join(".codex"));
392 let pi = std::env::var_os("PI_CODING_AGENT_SESSION_DIR")
393 .map(PathBuf::from)
394 .unwrap_or_else(|| {
395 std::env::var_os("PI_CODING_AGENT_DIR")
396 .map(PathBuf::from)
397 .unwrap_or_else(|| home.join(".pi/agent"))
398 .join("sessions")
399 });
400 let opencode = std::env::var_os("OPENCODE_DB")
401 .map(PathBuf::from)
402 .unwrap_or_else(|| {
403 std::env::var_os("XDG_DATA_HOME")
404 .map(PathBuf::from)
405 .unwrap_or_else(|| home.join(".local/share"))
406 .join("opencode")
407 });
408 let grok = std::env::var_os("GROK_HOME")
409 .map(PathBuf::from)
410 .unwrap_or_else(|| home.join(".grok"))
411 .join("sessions");
412 let gemini = std::env::var_os("GEMINI_CLI_HOME")
413 .map(PathBuf::from)
414 .unwrap_or_else(|| home.join(".gemini"));
415 let openclaw = std::env::var_os("OPENCLAW_STATE_DIR")
422 .map(PathBuf::from)
423 .or_else(|| {
424 std::env::var_os("OPENCLAW_HOME").map(|root| PathBuf::from(root).join(".openclaw"))
425 })
426 .unwrap_or_else(|| home.join(".openclaw"));
427 let orchestrator = std::env::var_os("SUPERCODE_ORCHESTRATOR_HOME")
428 .map(PathBuf::from)
429 .unwrap_or_else(|| home.join(".supercode/orchestrator"));
430 let hermes = std::env::var_os("HERMES_HOME")
431 .map(PathBuf::from)
432 .unwrap_or_else(|| home.join(".hermes"))
433 .join("state.db");
434 let goose = std::env::var_os("GOOSE_PATH_ROOT")
435 .map(PathBuf::from)
436 .map(|root| root.join("data/sessions/sessions.db"))
437 .unwrap_or_else(|| {
438 #[cfg(target_os = "macos")]
439 {
440 home.join("Library/Application Support/Block/goose/sessions/sessions.db")
441 }
442 #[cfg(target_os = "windows")]
443 {
444 std::env::var_os("APPDATA")
445 .map(PathBuf::from)
446 .unwrap_or_else(|| home.join("AppData/Roaming"))
447 .join("Block/goose/sessions/sessions.db")
448 }
449 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
450 {
451 std::env::var_os("XDG_DATA_HOME")
452 .map(PathBuf::from)
453 .unwrap_or_else(|| home.join(".local/share"))
454 .join("goose/sessions/sessions.db")
455 }
456 });
457 let supercode = std::env::var_os("SUPERCODE_HOME")
458 .map(PathBuf::from)
459 .unwrap_or_else(|| {
460 std::env::var_os("XDG_CONFIG_HOME")
461 .map(PathBuf::from)
462 .unwrap_or_else(|| home.join(".config"))
463 .join("supercode")
464 })
465 .join("sessions");
466 Self {
467 claude_code: claude_root.join("projects"),
468 codex: codex_root.join("sessions"),
469 gemini,
470 goose,
471 supercode,
472 openclaw,
473 hermes,
474 orchestrator,
475 pi,
476 opencode,
477 grok,
478 }
479 }
480}
481
482fn codex_history_fingerprint(metadata: &fs::Metadata) -> Result<CodexHistoryFingerprint> {
483 let modified_ns = metadata
484 .modified()?
485 .duration_since(UNIX_EPOCH)
486 .map_err(|error| Error::Other(format!("history timestamp predates Unix epoch: {error}")))?
487 .as_nanos();
488 #[cfg(unix)]
489 let identity = {
490 use std::os::unix::fs::MetadataExt;
491 (u128::from(metadata.dev()) << 64) | u128::from(metadata.ino())
492 };
493 #[cfg(not(unix))]
494 let identity = 0;
495 Ok(CodexHistoryFingerprint {
496 len: metadata.len(),
497 modified_ns,
498 identity,
499 })
500}
501
502fn changed_topic_ids(
503 before: &HashMap<String, Vec<SessionPreviewCandidate>>,
504 after: &HashMap<String, Vec<SessionPreviewCandidate>>,
505) -> BTreeSet<String> {
506 before
507 .keys()
508 .chain(after.keys())
509 .filter(|session_id| before.get(*session_id) != after.get(*session_id))
510 .cloned()
511 .collect()
512}
513
514#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
516#[serde(default)]
517pub struct DiscoveryQuery {
518 pub workspace: Option<PathBuf>,
520 pub workspace_family: Option<PathBuf>,
528 pub updated_after_ms: Option<u64>,
530 pub updated_before_ms: Option<u64>,
532 pub harnesses: Vec<HarnessId>,
534 pub homes: HarnessHomes,
536 pub query: Option<String>,
538 pub cursor: Option<String>,
540 pub limit: Option<usize>,
542 pub include_topic_candidates: bool,
546 pub include_child_sessions: bool,
550 pub root_session_id: Option<String>,
553 pub profile: Option<String>,
558}
559
560#[derive(Debug, Clone, PartialEq, Eq)]
566struct RepoFamily {
567 identity: PathBuf,
570 is_repository: bool,
573 origin_url: Option<String>,
575}
576
577impl RepoFamily {
578 fn of(path: &Path) -> Self {
579 let start = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
580 let mut current = Some(start.as_path());
581 while let Some(dir) = current {
582 let dot_git = dir.join(".git");
583 if dot_git.is_dir() {
584 let identity = std::fs::canonicalize(&dot_git).unwrap_or_else(|_| dot_git.clone());
585 let origin_url = read_origin_url(&identity);
586 return Self {
587 identity,
588 is_repository: true,
589 origin_url,
590 };
591 }
592 if dot_git.is_file() {
593 if let Ok(text) = std::fs::read_to_string(&dot_git) {
597 if let Some(gitdir) = text
598 .lines()
599 .find_map(|line| line.trim().strip_prefix("gitdir:"))
600 {
601 let gitdir = PathBuf::from(gitdir.trim());
602 let gitdir = if gitdir.is_absolute() {
603 gitdir
604 } else {
605 dir.join(gitdir)
606 };
607 let common = gitdir
608 .parent()
609 .filter(|parent| parent.ends_with("worktrees"))
610 .and_then(Path::parent)
611 .map(Path::to_path_buf)
612 .unwrap_or(gitdir);
613 let identity = std::fs::canonicalize(&common).unwrap_or(common);
614 let origin_url = read_origin_url(&identity);
615 return Self {
616 identity,
617 is_repository: true,
618 origin_url,
619 };
620 }
621 }
622 }
623 current = dir.parent();
624 }
625 Self {
626 identity: start,
627 is_repository: false,
628 origin_url: None,
629 }
630 }
631
632 fn joins(&self, other: &Self) -> bool {
636 if self.identity == other.identity {
637 return true;
638 }
639 self.is_repository
640 && other.is_repository
641 && matches!((&self.origin_url, &other.origin_url), (Some(a), Some(b)) if a == b)
642 }
643}
644
645fn read_origin_url(common_dir: &Path) -> Option<String> {
647 let text = std::fs::read_to_string(common_dir.join("config")).ok()?;
648 let mut in_origin = false;
649 for line in text.lines() {
650 let line = line.trim();
651 if line.starts_with('[') {
652 in_origin = line.starts_with("[remote \"origin\"]");
653 continue;
654 }
655 if in_origin {
656 if let Some(value) = line.strip_prefix("url") {
657 let value = value.trim_start();
658 if let Some(url) = value.strip_prefix('=') {
659 let url = url.trim();
660 if !url.is_empty() {
661 return Some(url.to_string());
662 }
663 }
664 }
665 }
666 }
667 None
668}
669
670#[derive(Debug, Default, Clone, Copy)]
673pub struct HarnessCatalog;
674
675impl HarnessCatalog {
676 pub fn new() -> Self {
678 Self
679 }
680
681 pub fn discover(&self, query: &DiscoveryQuery) -> Result<Vec<SessionDescriptor>> {
685 Ok(self.discover_page(query)?.sessions)
686 }
687
688 pub fn discover_raw_index(&self, query: &DiscoveryQuery) -> Vec<SessionDescriptor> {
693 self.scan_descriptors(query, true)
694 }
695
696 pub fn project_index(
699 &self,
700 query: &DiscoveryQuery,
701 descriptors: impl IntoIterator<Item = SessionDescriptor>,
702 ) -> Result<Vec<SessionDescriptor>> {
703 let mut found = descriptors.into_iter().collect::<Vec<_>>();
704 project_descriptors(query, &mut found);
705 let (sessions, _) = paginate_descriptors(query, found)?;
706 Ok(sessions)
707 }
708
709 pub fn enrich_index_page(
712 &self,
713 query: &DiscoveryQuery,
714 mut sessions: Vec<SessionDescriptor>,
715 ) -> Result<Vec<SessionDescriptor>> {
716 enrich_descriptors(query, &mut sessions, None)?;
717 Ok(sessions)
718 }
719
720 pub fn enrich_index_page_with_codex_history(
726 &self,
727 query: &DiscoveryQuery,
728 mut sessions: Vec<SessionDescriptor>,
729 codex_history: &CodexHistoryTopicIndex,
730 ) -> Result<Vec<SessionDescriptor>> {
731 enrich_descriptors(query, &mut sessions, Some(codex_history))?;
732 Ok(sessions)
733 }
734
735 pub fn discover_page(&self, query: &DiscoveryQuery) -> Result<DiscoveryPage> {
737 let mut found = self.scan_descriptors(query, query.include_child_sessions);
738 project_descriptors(query, &mut found);
739 let total_matched = found.len();
740 let (mut sessions, next_cursor) = paginate_descriptors(query, found)?;
741 enrich_descriptors(query, &mut sessions, None)?;
742 let receipt = DiscoveryReceipt {
743 requested_after_ms: query.updated_after_ms,
744 requested_before_ms: query.updated_before_ms,
745 requested_limit: query.limit,
746 oldest_returned_ms: sessions.iter().filter_map(|s| s.updated_at_ms).min(),
747 newest_returned_ms: sessions.iter().filter_map(|s| s.updated_at_ms).max(),
748 returned: sessions.len(),
749 total_matched,
750 truncated: next_cursor.is_some(),
751 };
752 Ok(DiscoveryPage {
753 sessions,
754 next_cursor,
755 receipt,
756 })
757 }
758
759 fn scan_descriptors(
760 &self,
761 query: &DiscoveryQuery,
762 include_child_sessions: bool,
763 ) -> Vec<SessionDescriptor> {
764 let selected: HashSet<&str> = if query.harnesses.is_empty() {
765 [
766 HarnessId::CLAUDE_CODE,
767 HarnessId::CODEX,
768 HarnessId::PI,
769 HarnessId::OPENCODE,
770 HarnessId::GROK,
771 HarnessId::GEMINI,
772 HarnessId::GOOSE,
773 HarnessId::SUPERCODE,
774 HarnessId::OPENCLAW,
775 HarnessId::HERMES,
776 HarnessId::ORCHESTRATOR,
777 ]
778 .into_iter()
779 .collect()
780 } else {
781 query.harnesses.iter().map(HarnessId::as_str).collect()
782 };
783 let mut found = Vec::new();
784 if selected.contains(HarnessId::CLAUDE_CODE) {
785 discover_jsonl(
786 &query.homes.claude_code,
787 HarnessId::CLAUDE_CODE,
788 query.workspace.as_deref(),
789 include_child_sessions,
790 &mut found,
791 );
792 }
793 if selected.contains(HarnessId::CODEX) {
794 discover_jsonl(
795 &query.homes.codex,
796 HarnessId::CODEX,
797 query.workspace.as_deref(),
798 include_child_sessions,
799 &mut found,
800 );
801 }
802 if selected.contains(HarnessId::PI) {
803 discover_jsonl(
804 &query.homes.pi,
805 HarnessId::PI,
806 query.workspace.as_deref(),
807 include_child_sessions,
808 &mut found,
809 );
810 }
811 if selected.contains(HarnessId::OPENCODE) {
812 discover_opencode(
813 &query.homes.opencode,
814 query.workspace.as_deref(),
815 &mut found,
816 );
817 }
818 if selected.contains(HarnessId::GROK) {
819 discover_grok(&query.homes.grok, query.workspace.as_deref(), &mut found);
820 }
821 if selected.contains(HarnessId::GEMINI) {
822 discover_gemini(&query.homes.gemini, query.workspace.as_deref(), &mut found);
823 }
824 if selected.contains(HarnessId::GOOSE) {
825 discover_goose(&query.homes.goose, query.workspace.as_deref(), &mut found);
826 }
827 if selected.contains(HarnessId::OPENCLAW) {
828 discover_openclaw(
829 &query.homes.openclaw,
830 query.workspace.as_deref(),
831 &mut found,
832 );
833 }
834 if selected.contains(HarnessId::HERMES) {
835 discover_hermes(&query.homes.hermes, query.workspace.as_deref(), &mut found);
836 }
837 if selected.contains(HarnessId::ORCHESTRATOR) {
838 discover_orchestrator(
839 &query.homes.orchestrator,
840 query.workspace.as_deref(),
841 &mut found,
842 );
843 }
844 if selected.contains(HarnessId::SUPERCODE) {
845 discover_supercode(
846 &query.homes.supercode,
847 query.workspace.as_deref(),
848 &mut found,
849 );
850 }
851 for descriptor in &mut found {
852 finalize_nouns(descriptor);
853 }
854 found
855 }
856
857 pub fn refresh_file_descriptor(
865 &self,
866 locator: &SessionLocator,
867 workspace: Option<&Path>,
868 include_topic_candidates: bool,
869 ) -> Result<Option<SessionDescriptor>> {
870 let Some(mut descriptor) = self.refresh_file_index_descriptor(locator, workspace)? else {
871 return Ok(None);
872 };
873 if include_topic_candidates {
874 descriptor.preview_candidates =
875 topic_message_candidates(&descriptor.locator).unwrap_or_default();
876 }
877 descriptor.latest_message_candidates =
878 latest_message_candidates(&descriptor.locator).unwrap_or_default();
879 Ok(Some(descriptor))
880 }
881
882 pub fn refresh_file_index_descriptor(
886 &self,
887 locator: &SessionLocator,
888 workspace: Option<&Path>,
889 ) -> Result<Option<SessionDescriptor>> {
890 let StorageLocator::File { path } = &locator.storage else {
891 return Ok(None);
892 };
893 if !matches!(
894 locator.harness.as_str(),
895 HarnessId::CLAUDE_CODE | HarnessId::CODEX
896 ) {
897 return Ok(None);
898 }
899 if !path.is_file() {
900 return Ok(None);
901 }
902 let Ok(meta) = read_header(path, locator.harness.as_str()) else {
903 return Ok(None);
907 };
908 if workspace.is_some_and(|wanted| {
909 meta.cwd
910 .as_deref()
911 .is_none_or(|cwd| !recorded_cwd_matches(cwd, wanted))
912 }) {
913 return Ok(None);
914 }
915 let parent_session_id = meta.parent_session_id.or_else(|| {
916 (locator.harness.as_str() == HarnessId::CLAUDE_CODE)
917 .then(|| claude_subagent_parent_id(path))
918 .flatten()
919 });
920 let descriptor = SessionDescriptor {
921 locator: SessionLocator {
922 harness: locator.harness.clone(),
923 session_id: meta
924 .session_id
925 .unwrap_or_else(|| locator.session_id.clone()),
926 storage: StorageLocator::File { path: path.clone() },
927 },
928 cwd: meta.cwd,
929 title: meta.title,
930 preview_candidates: Vec::new(),
931 latest_message_candidates: Vec::new(),
932 updated_at_ms: modified_ms(path),
933 message_count: None,
934 model: meta.model,
935 parent_session_id,
936 child_session_count: 0,
937 nouns: OrchestrationNouns::default(),
938 };
939 Ok(Some(descriptor))
940 }
941
942 pub fn load(&self, locator: &SessionLocator) -> Result<Session> {
944 self.load_with_fidelity(locator, Fidelity::ByteLossless)
945 }
946
947 pub fn load_with_fidelity(
954 &self,
955 locator: &SessionLocator,
956 fidelity: Fidelity,
957 ) -> Result<Session> {
958 if let Some(session) = load_hermes_locator(locator) {
959 return session;
960 }
961 match &locator.storage {
962 StorageLocator::File { path } => {
963 if let Some(session) = load_native_store_family(path)? {
964 Ok(session)
965 } else {
966 Ok(Session::load_with_fidelity(path, fidelity)?)
967 }
968 }
969 StorageLocator::Sqlite { path, selector } => {
970 if locator.harness.as_str() == HarnessId::GOOSE {
971 Ok(Session::from_goose_sqlite(path, selector)?)
972 } else {
973 Ok(Session::from_opencode_sqlite(path, Some(selector))?)
974 }
975 }
976 }
977 }
978
979 #[doc(hidden)]
983 pub fn load_parent_with_fidelity(
984 &self,
985 locator: &SessionLocator,
986 fidelity: Fidelity,
987 ) -> Result<Session> {
988 if let Some(session) = load_hermes_locator(locator) {
989 return session;
990 }
991 match &locator.storage {
992 StorageLocator::File { path } => {
993 if let Some(session) = load_native_store_family(path)? {
994 Ok(session)
995 } else {
996 Ok(Session::load_parent_with_fidelity(path, fidelity)?)
997 }
998 }
999 StorageLocator::Sqlite { path, selector } => {
1000 if locator.harness.as_str() == HarnessId::GOOSE {
1001 Ok(Session::from_goose_sqlite(path, selector)?)
1002 } else {
1003 Ok(Session::from_opencode_sqlite(path, Some(selector))?)
1004 }
1005 }
1006 }
1007 }
1008
1009 #[doc(hidden)]
1012 pub fn load_display_view(
1013 &self,
1014 locator: &SessionLocator,
1015 fidelity: Fidelity,
1016 message_limit: usize,
1017 ) -> Result<Session> {
1018 if let Some(session) = load_hermes_locator(locator) {
1019 let mut session = session?;
1020 if session.messages.len() > message_limit.max(1) {
1021 session
1022 .messages
1023 .drain(..session.messages.len() - message_limit.max(1));
1024 }
1025 return Ok(session);
1026 }
1027 match &locator.storage {
1028 StorageLocator::File { path } => {
1029 if let Some(mut session) = load_native_store_family(path)? {
1030 if session.messages.len() > message_limit.max(1) {
1031 session
1032 .messages
1033 .drain(..session.messages.len() - message_limit.max(1));
1034 }
1035 Ok(session)
1036 } else {
1037 Ok(Session::load_display_view(path, fidelity, message_limit)?)
1038 }
1039 }
1040 StorageLocator::Sqlite { path, selector } => {
1041 let mut session = if locator.harness.as_str() == HarnessId::GOOSE {
1042 Session::from_goose_sqlite_display(path, selector, message_limit)?
1043 } else {
1044 Session::from_opencode_sqlite(path, Some(selector))?
1045 };
1046 if session.messages.len() > message_limit.max(1) {
1047 session
1048 .messages
1049 .drain(..session.messages.len() - message_limit.max(1));
1050 }
1051 Ok(session)
1052 }
1053 }
1054 }
1055
1056 pub fn follow(&self, locator: &SessionLocator) -> Result<SessionFollower> {
1058 self.follow_with_fidelity(locator, Fidelity::ByteLossless)
1059 }
1060
1061 pub fn follow_with_fidelity(
1063 &self,
1064 locator: &SessionLocator,
1065 fidelity: Fidelity,
1066 ) -> Result<SessionFollower> {
1067 SessionFollower::open_locator_with_fidelity(locator, fidelity)
1068 }
1069
1070 #[doc(hidden)]
1072 pub fn follow_read_view(
1073 &self,
1074 locator: &SessionLocator,
1075 fidelity: Fidelity,
1076 include_subagents: bool,
1077 message_limit: Option<usize>,
1078 max_message_chars: Option<usize>,
1079 display_history: bool,
1080 ) -> Result<SessionFollower> {
1081 SessionFollower::open_locator_with_view(
1082 locator,
1083 fidelity,
1084 include_subagents,
1085 message_limit,
1086 max_message_chars,
1087 display_history,
1088 )
1089 }
1090}
1091
1092fn load_hermes_locator(locator: &SessionLocator) -> Option<Result<Session>> {
1098 if locator.harness.as_str() != HarnessId::HERMES {
1099 return None;
1100 }
1101 let StorageLocator::File { path } = &locator.storage else {
1102 return None;
1103 };
1104 Some(Session::from_hermes_sqlite(path, Some(&locator.session_id)))
1105}
1106
1107fn finalize_nouns(descriptor: &mut SessionDescriptor) {
1114 let mut meta = SessionMeta::new(SessionSource::Native);
1115 meta.cwd = descriptor.cwd.clone();
1116 meta.trigger = descriptor.nouns.trigger;
1117 meta.surface = descriptor.nouns.surface.clone();
1118 meta.profile = descriptor.nouns.profile.clone();
1119 meta.recurrence = descriptor.nouns.recurrence.clone();
1120 meta.cross_surface = descriptor.nouns.cross_surface.clone();
1121 descriptor.nouns = OrchestrationNouns::from_meta(&meta);
1122}
1123
1124fn project_descriptors(query: &DiscoveryQuery, found: &mut Vec<SessionDescriptor>) {
1125 roll_up_session_children(found, query.include_child_sessions);
1126 if let Some(root_session_id) = query.root_session_id.as_deref() {
1127 retain_session_family(found, root_session_id);
1128 }
1129 if let Some(family_path) = query.workspace_family.as_deref() {
1130 let family = RepoFamily::of(family_path);
1131 let mut cache: HashMap<PathBuf, bool> = HashMap::new();
1132 found.retain(|descriptor| {
1133 let Some(cwd) = descriptor.cwd.as_deref() else {
1134 return false;
1135 };
1136 *cache
1137 .entry(cwd.to_path_buf())
1138 .or_insert_with(|| RepoFamily::of(cwd).joins(&family))
1139 });
1140 }
1141 if let Some(profile) = query
1142 .profile
1143 .as_deref()
1144 .map(str::trim)
1145 .filter(|p| !p.is_empty())
1146 {
1147 found.retain(|descriptor| descriptor.nouns.profile.as_deref() == Some(profile));
1148 }
1149 if let Some(after) = query.updated_after_ms {
1150 found.retain(|descriptor| descriptor.updated_at_ms.is_some_and(|at| at >= after));
1151 }
1152 if let Some(before) = query.updated_before_ms {
1153 found.retain(|descriptor| descriptor.updated_at_ms.is_some_and(|at| at <= before));
1154 }
1155 found.sort_by(|a, b| {
1156 b.updated_at_ms
1157 .cmp(&a.updated_at_ms)
1158 .then_with(|| a.locator.harness.cmp(&b.locator.harness))
1159 .then_with(|| a.locator.session_id.cmp(&b.locator.session_id))
1160 });
1161 if let Some(search) = query
1162 .query
1163 .as_deref()
1164 .map(str::trim)
1165 .filter(|query| !query.is_empty())
1166 {
1167 let search = search.to_lowercase();
1168 found.retain(|descriptor| descriptor_matches(descriptor, &search));
1169 }
1170}
1171
1172fn paginate_descriptors(
1173 query: &DiscoveryQuery,
1174 found: Vec<SessionDescriptor>,
1175) -> Result<(Vec<SessionDescriptor>, Option<String>)> {
1176 let start = match query.cursor.as_deref() {
1177 Some(cursor) => {
1178 let key = decode_cursor(cursor)?;
1179 found
1180 .iter()
1181 .position(|descriptor| descriptor_cursor_key(descriptor) == key)
1182 .map(|index| index + 1)
1183 .ok_or_else(|| Error::Other("discovery cursor is stale or invalid".into()))?
1184 }
1185 None => 0,
1186 };
1187 let end = query
1188 .limit
1189 .map(|limit| start.saturating_add(limit).min(found.len()))
1190 .unwrap_or(found.len());
1191 let sessions = found[start.min(found.len())..end].to_vec();
1192 let next_cursor = (end < found.len())
1193 .then(|| sessions.last().map(encode_cursor))
1194 .flatten();
1195 Ok((sessions, next_cursor))
1196}
1197
1198fn enrich_descriptors(
1199 query: &DiscoveryQuery,
1200 sessions: &mut [SessionDescriptor],
1201 codex_history: Option<&CodexHistoryTopicIndex>,
1202) -> Result<()> {
1203 let codex_topics = if query.include_topic_candidates && codex_history.is_none() {
1204 codex_history_topics(&query.homes.codex, sessions).unwrap_or_default()
1205 } else {
1206 HashMap::new()
1207 };
1208 for descriptor in sessions {
1209 if query.include_topic_candidates {
1210 descriptor.preview_candidates =
1211 if descriptor.locator.harness.as_str() == HarnessId::CODEX {
1212 codex_history
1213 .and_then(|history| history.topics.get(&descriptor.locator.session_id))
1214 .or_else(|| codex_topics.get(&descriptor.locator.session_id))
1215 .cloned()
1216 .unwrap_or_else(|| {
1217 topic_message_candidates(&descriptor.locator).unwrap_or_default()
1218 })
1219 } else {
1220 topic_message_candidates(&descriptor.locator).unwrap_or_default()
1221 };
1222 }
1223 descriptor.latest_message_candidates =
1224 latest_message_candidates(&descriptor.locator).unwrap_or_default();
1225 }
1226 Ok(())
1227}
1228
1229fn descriptor_matches(descriptor: &SessionDescriptor, search: &str) -> bool {
1230 [
1231 Some(descriptor.locator.harness.as_str()),
1232 Some(descriptor.locator.session_id.as_str()),
1233 descriptor.title.as_deref(),
1234 descriptor.cwd.as_ref().and_then(|path| path.to_str()),
1235 descriptor.model.as_deref(),
1236 ]
1237 .into_iter()
1238 .flatten()
1239 .any(|value| value.to_lowercase().contains(search))
1240}
1241
1242fn descriptor_cursor_key(descriptor: &SessionDescriptor) -> (Option<u64>, String, String) {
1243 (
1244 descriptor.updated_at_ms,
1245 descriptor.locator.harness.as_str().to_string(),
1246 descriptor.locator.session_id.clone(),
1247 )
1248}
1249
1250fn encode_cursor(descriptor: &SessionDescriptor) -> String {
1251 let json = serde_json::to_vec(&descriptor_cursor_key(descriptor)).unwrap_or_default();
1252 let mut encoded = String::with_capacity(json.len() * 2);
1253 for byte in json {
1254 use std::fmt::Write;
1255 let _ = write!(&mut encoded, "{byte:02x}");
1256 }
1257 encoded
1258}
1259
1260fn decode_cursor(cursor: &str) -> Result<(Option<u64>, String, String)> {
1261 if cursor.len() % 2 != 0 {
1262 return Err(Error::Other("discovery cursor is invalid".into()));
1263 }
1264 let bytes = (0..cursor.len())
1265 .step_by(2)
1266 .map(|index| u8::from_str_radix(&cursor[index..index + 2], 16))
1267 .collect::<std::result::Result<Vec<_>, _>>()
1268 .map_err(|_| Error::Other("discovery cursor is invalid".into()))?;
1269 serde_json::from_slice(&bytes).map_err(|_| Error::Other("discovery cursor is invalid".into()))
1270}
1271
1272#[derive(Default)]
1273struct HeaderMeta {
1274 session_id: Option<String>,
1275 cwd: Option<PathBuf>,
1276 title: Option<String>,
1277 model: Option<String>,
1278 parent_session_id: Option<String>,
1279}
1280
1281fn discover_jsonl(
1282 root: &Path,
1283 harness: &str,
1284 workspace: Option<&Path>,
1285 include_child_sessions: bool,
1286 found: &mut Vec<SessionDescriptor>,
1287) {
1288 let mut files = Vec::new();
1289 collect_jsonl(root, harness, include_child_sessions, &mut files);
1290 for path in files {
1291 let Ok(meta) = read_header(&path, harness) else {
1292 continue;
1293 };
1294 if workspace.is_some_and(|wanted| {
1295 meta.cwd
1296 .as_deref()
1297 .is_none_or(|cwd| !recorded_cwd_matches(cwd, wanted))
1298 }) {
1299 continue;
1300 }
1301 let session_id = meta.session_id.unwrap_or_else(|| {
1302 path.file_stem()
1303 .and_then(|value| value.to_str())
1304 .unwrap_or("unknown")
1305 .to_string()
1306 });
1307 let parent_session_id = meta.parent_session_id.or_else(|| {
1308 (harness == HarnessId::CLAUDE_CODE)
1309 .then(|| claude_subagent_parent_id(&path))
1310 .flatten()
1311 });
1312 found.push(SessionDescriptor {
1313 locator: SessionLocator {
1314 harness: HarnessId::new(harness),
1315 session_id,
1316 storage: StorageLocator::File { path: path.clone() },
1317 },
1318 cwd: meta.cwd,
1319 title: meta.title,
1320 preview_candidates: Vec::new(),
1321 latest_message_candidates: Vec::new(),
1322 updated_at_ms: modified_ms(&path),
1323 message_count: None,
1324 model: meta.model,
1325 parent_session_id,
1326 child_session_count: if harness == HarnessId::CLAUDE_CODE && !include_child_sessions {
1327 count_claude_subagents(&path)
1328 } else {
1329 0
1330 },
1331 nouns: OrchestrationNouns::default(),
1332 });
1333 }
1334}
1335
1336fn collect_jsonl(root: &Path, harness: &str, include_child_sessions: bool, out: &mut Vec<PathBuf>) {
1337 let Ok(entries) = fs::read_dir(root) else {
1338 return;
1339 };
1340 for entry in entries.flatten() {
1341 let Ok(kind) = entry.file_type() else {
1342 continue;
1343 };
1344 let path = entry.path();
1345 if kind.is_dir() {
1346 if harness == HarnessId::CLAUDE_CODE
1347 && path.file_name().and_then(|v| v.to_str()) == Some("subagents")
1348 && !include_child_sessions
1349 {
1350 continue;
1351 }
1352 collect_jsonl(&path, harness, include_child_sessions, out);
1353 } else if kind.is_file() && path.extension().and_then(|v| v.to_str()) == Some("jsonl") {
1354 out.push(path);
1355 }
1356 }
1357}
1358
1359fn read_header(path: &Path, harness: &str) -> Result<HeaderMeta> {
1360 let file = File::open(path)?;
1361 let mut result = HeaderMeta::default();
1362 let mut bytes = 0usize;
1363 for line in BufReader::new(file).lines().take(32) {
1364 let line = line?;
1365 bytes += line.len();
1366 if bytes > 256 * 1024 {
1367 break;
1368 }
1369 let Ok(value) = serde_json::from_str::<Value>(&line) else {
1370 continue;
1371 };
1372 update_header_meta(&mut result, &value, harness);
1373 if result.session_id.is_some() && result.cwd.is_some() && result.model.is_some() {
1374 break;
1375 }
1376 }
1377 if result.session_id.is_none() && result.cwd.is_none() {
1378 return Err(Error::Other(format!(
1379 "{} has no recognizable {harness} session header",
1380 path.display()
1381 )));
1382 }
1383 Ok(result)
1384}
1385
1386fn update_header_meta(result: &mut HeaderMeta, value: &Value, harness: &str) {
1387 match harness {
1388 HarnessId::CLAUDE_CODE => {
1389 fill_string(&mut result.session_id, value.get("sessionId"));
1390 fill_path(&mut result.cwd, value.get("cwd"));
1391 fill_string(
1392 &mut result.model,
1393 value.get("message").and_then(|v| v.get("model")),
1394 );
1395 }
1396 HarnessId::CODEX => {
1397 let payload = value.get("payload").unwrap_or(&Value::Null);
1398 if value.get("type").and_then(Value::as_str) == Some("session_meta") {
1399 fill_string(&mut result.session_id, payload.get("id"));
1400 fill_path(&mut result.cwd, payload.get("cwd"));
1401 fill_string(&mut result.title, payload.get("thread_name"));
1402 fill_string(&mut result.title, payload.get("title"));
1403 fill_string(
1404 &mut result.parent_session_id,
1405 payload.get("parent_thread_id"),
1406 );
1407 if let Some(parent) = payload
1408 .pointer("/source/subagent/thread_spawn/parent_thread_id")
1409 .and_then(Value::as_str)
1410 {
1411 result.parent_session_id = Some(parent.to_string());
1412 }
1413 if result.title.is_none() {
1414 result.title = payload
1415 .pointer("/source/subagent/thread_spawn/agent_path")
1416 .and_then(Value::as_str)
1417 .and_then(|path| path.rsplit('/').find(|part| !part.is_empty()))
1418 .map(humanize_topic);
1419 }
1420 }
1421 if value.get("type").and_then(Value::as_str) == Some("turn_context") {
1422 fill_path(&mut result.cwd, payload.get("cwd"));
1423 fill_string(&mut result.model, payload.get("model"));
1424 }
1425 }
1426 HarnessId::PI => {
1427 if value.get("type").and_then(Value::as_str) == Some("session") {
1428 fill_string(&mut result.session_id, value.get("id"));
1429 fill_path(&mut result.cwd, value.get("cwd"));
1430 }
1431 fill_string(
1432 &mut result.model,
1433 value.get("message").and_then(|v| v.get("model")),
1434 );
1435 }
1436 _ => {}
1437 }
1438}
1439
1440fn roll_up_session_children(found: &mut Vec<SessionDescriptor>, include_children: bool) {
1444 let by_id = found
1445 .iter()
1446 .enumerate()
1447 .map(|(index, descriptor)| {
1448 (
1449 (
1450 descriptor.locator.harness.as_str().to_string(),
1451 descriptor.locator.session_id.clone(),
1452 ),
1453 index,
1454 )
1455 })
1456 .collect::<HashMap<_, _>>();
1457 let mut root_updates = HashMap::<usize, u64>::new();
1458 let mut root_child_counts = HashMap::<usize, usize>::new();
1459
1460 for descriptor in found.iter() {
1461 let Some(mut parent_id) = descriptor.parent_session_id.as_deref() else {
1462 continue;
1463 };
1464 let harness = descriptor.locator.harness.as_str();
1465 let mut root = None;
1466 let mut visited = HashSet::new();
1467 while visited.insert(parent_id.to_string()) {
1468 let Some(&parent_index) = by_id.get(&(harness.to_string(), parent_id.to_string()))
1469 else {
1470 break;
1471 };
1472 root = Some(parent_index);
1473 let Some(next_parent) = found[parent_index].parent_session_id.as_deref() else {
1474 break;
1475 };
1476 parent_id = next_parent;
1477 }
1478 if let (Some(root), Some(updated_at_ms)) = (root, descriptor.updated_at_ms) {
1479 root_updates
1480 .entry(root)
1481 .and_modify(|current| *current = (*current).max(updated_at_ms))
1482 .or_insert(updated_at_ms);
1483 }
1484 if let Some(root) = root {
1485 *root_child_counts.entry(root).or_default() += 1;
1486 }
1487 }
1488
1489 for (root, child_updated_at_ms) in root_updates {
1490 found[root].updated_at_ms = Some(
1491 found[root]
1492 .updated_at_ms
1493 .unwrap_or_default()
1494 .max(child_updated_at_ms),
1495 );
1496 }
1497 for (root, child_count) in root_child_counts {
1498 found[root].child_session_count = child_count;
1499 }
1500 if !include_children {
1501 found.retain(|descriptor| descriptor.parent_session_id.is_none());
1502 }
1503}
1504
1505fn retain_session_family(found: &mut Vec<SessionDescriptor>, root_session_id: &str) {
1506 let parent_by_id = found
1507 .iter()
1508 .map(|descriptor| {
1509 (
1510 descriptor.locator.session_id.clone(),
1511 descriptor.parent_session_id.clone(),
1512 )
1513 })
1514 .collect::<HashMap<_, _>>();
1515 found.retain(|descriptor| {
1516 let mut current = descriptor.locator.session_id.clone();
1517 let mut visited = HashSet::new();
1518 while visited.insert(current.clone()) {
1519 if current == root_session_id {
1520 return true;
1521 }
1522 let Some(Some(parent)) = parent_by_id.get(¤t) else {
1523 return false;
1524 };
1525 current = parent.clone();
1526 }
1527 false
1528 });
1529}
1530
1531fn claude_subagent_parent_id(path: &Path) -> Option<String> {
1532 let subagents = path.parent()?;
1533 if subagents.file_name()?.to_str()? != "subagents" {
1534 return None;
1535 }
1536 subagents
1537 .parent()?
1538 .file_name()?
1539 .to_str()
1540 .map(str::to_string)
1541}
1542
1543fn count_claude_subagents(parent_path: &Path) -> usize {
1544 let Some(parent) = parent_path.parent() else {
1545 return 0;
1546 };
1547 let Some(stem) = parent_path.file_stem() else {
1548 return 0;
1549 };
1550 let root = parent.join(stem).join("subagents");
1551 let mut files = Vec::new();
1552 collect_jsonl(&root, HarnessId::CLAUDE_CODE, true, &mut files);
1553 files.len()
1554}
1555
1556fn is_zero(value: &usize) -> bool {
1557 *value == 0
1558}
1559
1560fn humanize_topic(value: &str) -> String {
1561 let text = value.replace(['_', '-'], " ");
1562 let mut characters = text.chars();
1563 match characters.next() {
1564 Some(first) => first.to_uppercase().collect::<String>() + characters.as_str(),
1565 None => text,
1566 }
1567}
1568
1569fn discover_gemini(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
1570 let slug_to_cwd = std::fs::read_to_string(root.join("projects.json"))
1571 .ok()
1572 .and_then(|text| serde_json::from_str::<Value>(&text).ok())
1573 .and_then(|value| value.get("projects").and_then(Value::as_object).cloned())
1574 .map(|projects| {
1575 projects
1576 .into_iter()
1577 .filter_map(|(cwd, slug)| Some((slug.as_str()?.to_string(), PathBuf::from(cwd))))
1578 .collect::<HashMap<_, _>>()
1579 })
1580 .unwrap_or_default();
1581 let mut files = Vec::new();
1582 collect_jsonl(&root.join("tmp"), HarnessId::GEMINI, false, &mut files);
1583 let worker_count = std::thread::available_parallelism()
1584 .map(usize::from)
1585 .unwrap_or(4)
1586 .clamp(1, 8)
1587 .min(files.len().max(1));
1588 let chunk_size = files.len().max(1).div_ceil(worker_count);
1589 let discovered = std::thread::scope(|scope| {
1590 files
1591 .chunks(chunk_size)
1592 .map(|paths| {
1593 scope.spawn(|| {
1594 paths
1595 .iter()
1596 .filter_map(|path| gemini_descriptor(path, &slug_to_cwd, workspace))
1597 .collect::<Vec<_>>()
1598 })
1599 })
1600 .collect::<Vec<_>>()
1601 .into_iter()
1602 .flat_map(|worker| {
1603 worker
1604 .join()
1605 .expect("Gemini discovery worker must not panic")
1606 })
1607 .collect::<Vec<_>>()
1608 });
1609 found.extend(discovered);
1610}
1611
1612fn gemini_descriptor(
1613 path: &Path,
1614 slug_to_cwd: &HashMap<String, PathBuf>,
1615 workspace: Option<&Path>,
1616) -> Option<SessionDescriptor> {
1617 if path
1618 .parent()
1619 .and_then(Path::file_name)
1620 .and_then(|name| name.to_str())
1621 != Some("chats")
1622 {
1623 return None;
1624 }
1625 let slug = path
1626 .parent()
1627 .and_then(Path::parent)
1628 .and_then(Path::file_name)
1629 .and_then(|name| name.to_str());
1630 let cwd = slug.and_then(|slug| slug_to_cwd.get(slug)).cloned();
1631 if workspace.is_some_and(|wanted| {
1632 cwd.as_deref()
1633 .is_none_or(|actual| !recorded_cwd_matches(actual, wanted))
1634 }) {
1635 return None;
1636 }
1637
1638 let file = File::open(path).ok()?;
1642 let mut reader = BufReader::new(file.take(64 * 1024));
1643 let mut header = String::new();
1644 reader.read_line(&mut header).ok()?;
1645 let header = serde_json::from_str::<Value>(&header).ok()?;
1646 let session_id = header.get("sessionId")?.as_str()?.to_string();
1647 let mut model = None;
1648 for line in reader
1649 .take(4 * 1024)
1650 .lines()
1651 .map_while(std::result::Result::ok)
1652 {
1653 let Ok(value) = serde_json::from_str::<Value>(&line) else {
1654 continue;
1655 };
1656 let kind = value.get("type").and_then(Value::as_str);
1657 if kind != Some("user") && kind != Some("gemini") {
1658 continue;
1659 }
1660 if model.is_none() {
1661 model = value
1662 .get("model")
1663 .and_then(Value::as_str)
1664 .map(str::to_string);
1665 }
1666 if model.is_some() {
1667 break;
1668 }
1669 }
1670 Some(SessionDescriptor {
1671 locator: SessionLocator {
1672 harness: HarnessId::from(HarnessId::GEMINI),
1673 session_id,
1674 storage: StorageLocator::File {
1675 path: path.to_path_buf(),
1676 },
1677 },
1678 cwd,
1679 title: None,
1680 preview_candidates: Vec::new(),
1681 latest_message_candidates: Vec::new(),
1682 updated_at_ms: modified_ms(path),
1683 message_count: None,
1684 model,
1685 parent_session_id: None,
1686 child_session_count: 0,
1687 nouns: OrchestrationNouns::default(),
1688 })
1689}
1690
1691fn display_text(content: Option<&Value>) -> Option<String> {
1692 match content? {
1693 Value::String(text) => Some(text.clone()),
1694 Value::Array(parts) => Some(
1695 parts
1696 .iter()
1697 .filter_map(|part| part.get("text").and_then(Value::as_str))
1698 .collect::<Vec<_>>()
1699 .join(" ")
1700 .trim()
1701 .to_string(),
1702 ),
1703 _ => None,
1704 }
1705}
1706
1707fn discover_hermes(db_path: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
1712 if !db_path.is_file() {
1713 return;
1714 }
1715 let Ok(conn) = Connection::open_with_flags(
1716 db_path,
1717 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
1718 ) else {
1719 return;
1720 };
1721 let fingerprint_ok = ["sessions", "messages", "schema_version"].iter().all(|t| {
1722 conn.query_row(
1723 "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1",
1724 [t],
1725 |_| Ok(()),
1726 )
1727 .is_ok()
1728 });
1729 if !fingerprint_ok {
1730 return;
1731 }
1732 let Ok(mut statement) = conn.prepare(
1733 "SELECT id, cwd, title, model, message_count, started_at, ended_at, parent_session_id, \
1734 source, model_config FROM sessions ORDER BY started_at DESC",
1735 ) else {
1736 return;
1737 };
1738 let Ok(rows) = statement.query_map([], |row| {
1739 Ok((
1740 row.get::<_, String>(0)?,
1741 row.get::<_, Option<String>>(1)?,
1742 row.get::<_, Option<String>>(2)?,
1743 row.get::<_, Option<String>>(3)?,
1744 row.get::<_, Option<i64>>(4)?,
1745 row.get::<_, Option<f64>>(5)?,
1746 row.get::<_, Option<f64>>(6)?,
1747 row.get::<_, Option<String>>(7)?,
1748 row.get::<_, Option<String>>(8)?,
1749 row.get::<_, Option<String>>(9)?,
1750 ))
1751 }) else {
1752 return;
1753 };
1754 for row in rows.flatten() {
1755 let (
1756 id,
1757 cwd,
1758 title,
1759 model,
1760 message_count,
1761 started_at,
1762 ended_at,
1763 parent,
1764 source,
1765 model_config,
1766 ) = row;
1767 let cwd = cwd.map(PathBuf::from);
1768 if let Some(filter) = workspace {
1769 if cwd.as_deref() != Some(filter) {
1770 continue;
1771 }
1772 }
1773 let updated_at_ms = ended_at
1774 .or(started_at)
1775 .map(|seconds| (seconds * 1000.0) as u64);
1776 let mut meta = SessionMeta::new(SessionSource::Hermes);
1780 meta.cwd = cwd.clone();
1781 if let Some(hermes_source) = source.filter(|value| !value.is_empty()) {
1782 meta.lineage
1783 .insert("hermes_source".to_string(), hermes_source);
1784 }
1785 if let Some(parent_id) = parent.as_deref() {
1786 meta.lineage.insert(
1787 "hermes_lineage_kind".to_string(),
1788 crate::session::hermes_lineage_kind(
1789 &conn,
1790 parent_id,
1791 model_config.as_deref(),
1792 started_at,
1793 )
1794 .to_string(),
1795 );
1796 }
1797 hermes_capture_nouns(&conn, &id, &mut meta);
1798 found.push(SessionDescriptor {
1799 locator: SessionLocator {
1800 harness: HarnessId::new(HarnessId::HERMES),
1801 session_id: id,
1802 storage: StorageLocator::File {
1803 path: db_path.to_path_buf(),
1804 },
1805 },
1806 cwd,
1807 title: title.filter(|t| !t.is_empty()),
1808 preview_candidates: Vec::new(),
1809 latest_message_candidates: Vec::new(),
1810 updated_at_ms,
1811 message_count: message_count.map(|count| count.max(0) as usize),
1812 model,
1813 parent_session_id: parent,
1814 child_session_count: 0,
1815 nouns: OrchestrationNouns::from_meta(&meta),
1816 });
1817 }
1818}
1819
1820fn discover_orchestrator(
1827 root: &Path,
1828 workspace: Option<&Path>,
1829 found: &mut Vec<SessionDescriptor>,
1830) {
1831 if workspace.is_some() {
1833 return;
1834 }
1835 for (profile, dir) in orchestrator_profile_dirs(root) {
1836 let db_path = dir.join("state.db");
1837 if !db_path.is_file() {
1838 continue;
1839 }
1840 let Ok(conn) = Connection::open_with_flags(
1841 &db_path,
1842 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
1843 ) else {
1844 continue;
1845 };
1846 let Ok(mut statement) = conn.prepare(
1847 "SELECT platform, chat_type, chat_id, thread_id, participant_id, worker_harness, \
1848 worker_session_id, worker_locator, started_at, last_activity_at, ended_at, \
1849 end_reason, handoff_to, handoff_state, handoff_error, recurrence_job_id, \
1850 CAST(strftime('%s', last_activity_at) AS INTEGER) \
1851 FROM bindings ORDER BY last_activity_at DESC, started_at DESC",
1852 ) else {
1853 continue;
1854 };
1855 let Ok(rows) = statement.query_map([], |row| {
1856 let text = |index: usize| -> rusqlite::Result<Option<String>> {
1857 Ok(row
1858 .get::<_, Option<String>>(index)?
1859 .filter(|value| !value.is_empty()))
1860 };
1861 Ok((
1862 OrchestratorBindingRow {
1863 platform: text(0)?.unwrap_or_default(),
1864 chat_type: text(1)?.unwrap_or_default(),
1865 chat_id: text(2)?,
1866 thread_id: text(3)?,
1867 participant_id: text(4)?,
1868 worker_harness: text(5)?.unwrap_or_default(),
1869 worker_session_id: text(6)?.filter(|s| !s.is_empty()),
1870 worker_locator: text(7)?,
1871 started_at: text(8)?,
1872 last_activity_at: text(9)?,
1873 ended_at: text(10)?,
1874 end_reason: text(11)?,
1875 handoff_to: text(12)?,
1876 handoff_state: text(13)?,
1877 handoff_error: text(14)?,
1878 recurrence_job_id: text(15)?,
1879 },
1880 row.get::<_, Option<i64>>(16)?,
1881 ))
1882 }) else {
1883 continue;
1884 };
1885 for (row, last_activity_epoch) in rows.flatten() {
1886 found.push(orchestrator_descriptor(
1887 &db_path,
1888 &profile,
1889 &row,
1890 last_activity_epoch,
1891 ));
1892 }
1893 }
1894}
1895
1896fn orchestrator_descriptor(
1897 db_path: &Path,
1898 profile: &str,
1899 row: &OrchestratorBindingRow,
1900 last_activity_epoch: Option<i64>,
1901) -> SessionDescriptor {
1902 let binding = Binding::from_orchestrator_row(profile, row);
1903 let nouns = binding.nouns();
1904 let mut title = format!(
1907 "{} {}",
1908 row.worker_harness,
1909 row.worker_session_id
1910 .as_deref()
1911 .unwrap_or("(no worker session yet)")
1912 );
1913 if let Some(reason) = row.end_reason.as_deref().filter(|_| row.ended_at.is_some()) {
1914 title.push_str(&format!(" (ended: {reason})"));
1915 }
1916 SessionDescriptor {
1917 locator: SessionLocator {
1918 harness: HarnessId::new(HarnessId::ORCHESTRATOR),
1919 session_id: row.worker_session_id.clone().unwrap_or_default(),
1920 storage: StorageLocator::File {
1921 path: row
1922 .worker_locator
1923 .clone()
1924 .map_or_else(|| db_path.to_path_buf(), PathBuf::from),
1925 },
1926 },
1927 cwd: None,
1928 title: Some(title),
1929 preview_candidates: Vec::new(),
1930 latest_message_candidates: Vec::new(),
1931 updated_at_ms: last_activity_epoch.map(|seconds| (seconds.max(0) as u64) * 1000),
1932 message_count: None,
1933 model: None,
1934 parent_session_id: None,
1935 child_session_count: 0,
1936 nouns,
1937 }
1938}
1939
1940fn discover_openclaw(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
1946 let agents = root.join("agents");
1947 let Ok(agent_dirs) = std::fs::read_dir(&agents) else {
1948 return;
1949 };
1950 for agent_dir in agent_dirs.flatten() {
1951 let sessions = agent_dir.path().join("sessions");
1952 let Ok(files) = std::fs::read_dir(&sessions) else {
1953 continue;
1954 };
1955 for file in files.flatten() {
1956 let path = file.path();
1957 let name = file.file_name();
1958 let name = name.to_string_lossy();
1959 if !name.ends_with(".jsonl") || name.ends_with(".trajectory.jsonl") {
1960 continue;
1961 }
1962 let Ok(text) = std::fs::read_to_string(&path) else {
1963 continue;
1964 };
1965 let Some(header_line) = text.lines().find(|line| !line.trim().is_empty()) else {
1966 continue;
1967 };
1968 let Ok(header) = serde_json::from_str::<serde_json::Value>(header_line) else {
1969 continue;
1970 };
1971 if header.get("type").and_then(serde_json::Value::as_str) != Some("session") {
1972 continue;
1973 }
1974 let session_id = header
1975 .get("id")
1976 .and_then(serde_json::Value::as_str)
1977 .unwrap_or_else(|| name.trim_end_matches(".jsonl"))
1978 .to_string();
1979 let cwd = header
1980 .get("cwd")
1981 .and_then(serde_json::Value::as_str)
1982 .map(PathBuf::from);
1983 if let Some(filter) = workspace {
1984 if cwd.as_deref() != Some(filter) {
1985 continue;
1986 }
1987 }
1988 let updated_at_ms = file
1989 .metadata()
1990 .ok()
1991 .and_then(|metadata| metadata.modified().ok())
1992 .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
1993 .map(|elapsed| elapsed.as_millis() as u64);
1994 let message_count = text
1995 .lines()
1996 .filter(|line| line.contains("\"type\":\"message\""))
1997 .count();
1998 let mut meta = SessionMeta::new(SessionSource::OpenClaw);
2002 meta.cwd = cwd.clone();
2003 openclaw_capture_header_nouns(&header, &mut meta);
2004 if meta.profile.is_none() {
2005 meta.profile = openclaw_agent_id_from_path(&path);
2006 }
2007 found.push(SessionDescriptor {
2008 locator: SessionLocator {
2009 harness: HarnessId::new(HarnessId::OPENCLAW),
2010 session_id,
2011 storage: StorageLocator::File { path },
2012 },
2013 cwd,
2014 title: None,
2015 preview_candidates: Vec::new(),
2016 latest_message_candidates: Vec::new(),
2017 updated_at_ms,
2018 message_count: Some(message_count),
2019 model: None,
2020 parent_session_id: None,
2021 child_session_count: 0,
2022 nouns: OrchestrationNouns::from_meta(&meta),
2023 });
2024 }
2025 }
2026}
2027
2028fn discover_supercode(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
2029 for info in list_native_store(root) {
2030 let path = if info.archived {
2031 root.join("archived").join(format!("{}.jsonl", info.name))
2032 } else {
2033 root.join(format!("{}.jsonl", info.name))
2034 };
2035 let header = read_native_store_header(&path);
2036 if workspace.is_some_and(|wanted| {
2037 header
2038 .as_ref()
2039 .and_then(|meta| meta.cwd.as_deref())
2040 .is_none_or(|cwd| !recorded_cwd_matches(cwd, wanted))
2041 }) {
2042 continue;
2043 }
2044 let title = (!info.title.trim().is_empty()).then_some(info.title);
2045 let updated_at_ms =
2046 modified_ms(&path).or_else(|| modified_ms(&path.with_extension("sidecar.jsonl")));
2047 found.push(SessionDescriptor {
2048 locator: SessionLocator {
2049 harness: HarnessId::from(HarnessId::SUPERCODE),
2050 session_id: info.name,
2051 storage: StorageLocator::File { path: path.clone() },
2052 },
2053 cwd: header.as_ref().and_then(|meta| meta.cwd.clone()),
2054 title,
2055 preview_candidates: Vec::new(),
2056 latest_message_candidates: Vec::new(),
2057 updated_at_ms,
2058 message_count: None,
2059 model: header.and_then(|meta| meta.model),
2060 parent_session_id: None,
2061 child_session_count: 0,
2062 nouns: OrchestrationNouns::default(),
2063 });
2064 }
2065}
2066
2067fn read_native_store_header(path: &Path) -> Option<HeaderMeta> {
2072 let name = path.file_stem()?.to_str()?;
2073 let sidecar = path.with_file_name(format!("{name}.sidecar.jsonl"));
2074 let source_path = if sidecar.is_file() {
2075 sidecar
2076 } else {
2077 path.to_path_buf()
2078 };
2079 let file = File::open(source_path).ok()?;
2080 let mut result = HeaderMeta::default();
2081 let mut source = None;
2082 let mut bytes = 0usize;
2083 for line in BufReader::new(file).lines().take(32) {
2084 let line = line.ok()?;
2085 bytes += line.len();
2086 if bytes > 256 * 1024 {
2087 break;
2088 }
2089 let Ok(value) = serde_json::from_str::<Value>(&line) else {
2090 continue;
2091 };
2092 if source.is_none() {
2093 source = value.get("source").and_then(Value::as_str).map(|source| {
2094 if source == "claude_code" {
2095 HarnessId::CLAUDE_CODE.to_string()
2096 } else {
2097 source.to_string()
2098 }
2099 });
2100 fill_string(&mut result.session_id, value.get("session_id"));
2101 }
2102 if let Some(harness) = source.as_deref() {
2103 update_header_meta(&mut result, &value, harness);
2104 }
2105 if result.cwd.is_some() && result.model.is_some() {
2106 break;
2107 }
2108 }
2109 Some(result)
2110}
2111
2112#[derive(Deserialize)]
2113struct NativeStoreInfo {
2114 name: String,
2115 #[serde(default)]
2116 title: String,
2117 #[serde(skip)]
2118 archived: bool,
2119}
2120
2121fn list_native_store(root: &Path) -> Vec<NativeStoreInfo> {
2122 let mut sessions = Vec::new();
2123 for archived in [false, true] {
2124 let directory = if archived {
2125 root.join("archived")
2126 } else {
2127 root.to_path_buf()
2128 };
2129 let Ok(entries) = fs::read_dir(directory) else {
2130 continue;
2131 };
2132 for entry in entries.flatten() {
2133 let path = entry.path();
2134 if !path.to_string_lossy().ends_with(".meta.json") {
2135 continue;
2136 }
2137 let Ok(text) = fs::read_to_string(path) else {
2138 continue;
2139 };
2140 let Ok(mut info) = serde_json::from_str::<NativeStoreInfo>(&text) else {
2141 continue;
2142 };
2143 info.archived = archived;
2144 sessions.push(info);
2145 }
2146 }
2147 sessions.sort_by(|left, right| left.name.cmp(&right.name));
2148 sessions
2149}
2150
2151fn discover_grok(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
2152 let Ok(workspaces) = fs::read_dir(root) else {
2153 return;
2154 };
2155 for workspace_entry in workspaces.flatten() {
2156 let encoded = workspace_entry.file_name();
2157 let Some(cwd) = encoded
2158 .to_str()
2159 .and_then(percent_decode_path)
2160 .map(PathBuf::from)
2161 else {
2162 continue;
2163 };
2164 if workspace.is_some_and(|wanted| !recorded_cwd_matches(&cwd, wanted)) {
2165 continue;
2166 }
2167 let Ok(sessions) = fs::read_dir(workspace_entry.path()) else {
2168 continue;
2169 };
2170 for session_entry in sessions.flatten() {
2171 let session_dir = session_entry.path();
2172 if !session_dir.is_dir() {
2173 continue;
2174 }
2175 let transcript = session_dir.join("chat_history.jsonl");
2176 if !transcript.is_file() {
2177 continue;
2178 }
2179 let Some(session_id) = session_dir
2180 .file_name()
2181 .and_then(|name| name.to_str())
2182 .map(str::to_string)
2183 else {
2184 continue;
2185 };
2186 let summary = fs::read_to_string(session_dir.join("summary.json"))
2187 .ok()
2188 .and_then(|text| serde_json::from_str::<Value>(&text).ok());
2189 let title = summary
2190 .as_ref()
2191 .and_then(|value| value.get("generated_title"))
2192 .and_then(Value::as_str)
2193 .filter(|title| !title.is_empty())
2194 .map(str::to_string);
2195 let model = summary
2196 .as_ref()
2197 .and_then(|value| value.get("current_model_id"))
2198 .and_then(Value::as_str)
2199 .map(str::to_string);
2200 let message_count = summary
2201 .as_ref()
2202 .and_then(|value| value.get("num_chat_messages"))
2203 .and_then(Value::as_u64)
2204 .and_then(|count| usize::try_from(count).ok());
2205 let updated_at_ms = summary
2206 .as_ref()
2207 .and_then(|value| value.get("updated_at"))
2208 .and_then(Value::as_str)
2209 .and_then(crate::sidecar::rfc3339_to_ms)
2210 .and_then(|millis| u64::try_from(millis).ok())
2211 .or_else(|| modified_ms(&transcript));
2212 found.push(SessionDescriptor {
2213 locator: SessionLocator {
2214 harness: HarnessId::from(HarnessId::GROK),
2215 session_id,
2216 storage: StorageLocator::File { path: transcript },
2217 },
2218 cwd: Some(cwd.clone()),
2219 title,
2220 preview_candidates: Vec::new(),
2221 latest_message_candidates: Vec::new(),
2222 updated_at_ms,
2223 message_count,
2224 model,
2225 parent_session_id: None,
2226 child_session_count: 0,
2227 nouns: OrchestrationNouns::default(),
2228 });
2229 }
2230 }
2231}
2232
2233fn discover_opencode(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
2234 let mut dbs = Vec::new();
2235 if root.is_file() {
2236 dbs.push(root.to_path_buf());
2237 } else if let Ok(entries) = fs::read_dir(root) {
2238 dbs.extend(entries.flatten().map(|entry| entry.path()).filter(|path| {
2239 path.file_name()
2240 .and_then(|v| v.to_str())
2241 .is_some_and(|name| name.starts_with("opencode") && name.ends_with(".db"))
2242 }));
2243 }
2244 dbs.sort();
2245 for db in dbs {
2246 let Ok(conn) = Connection::open_with_flags(
2247 &db,
2248 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
2249 ) else {
2250 continue;
2251 };
2252 let has_model = conn.prepare("SELECT model FROM session LIMIT 0").is_ok();
2253 let model_column = if has_model { "s.model" } else { "NULL" };
2254 let query = format!(
2255 "SELECT s.id, s.directory, s.title, s.time_updated, {model_column}, COUNT(m.id) \
2256 FROM session s LEFT JOIN message m ON m.session_id = s.id \
2257 GROUP BY s.id ORDER BY s.time_updated DESC"
2258 );
2259 let Ok(mut stmt) = conn.prepare(&query) else {
2260 continue;
2261 };
2262 let Ok(rows) = stmt.query_map([], |row| {
2263 Ok((
2264 row.get::<_, String>(0)?,
2265 row.get::<_, String>(1)?,
2266 row.get::<_, String>(2)?,
2267 row.get::<_, i64>(3)?,
2268 row.get::<_, Option<String>>(4)?,
2269 row.get::<_, i64>(5)?,
2270 ))
2271 }) else {
2272 continue;
2273 };
2274 for row in rows.flatten() {
2275 let (id, cwd, title, updated, model, messages) = row;
2276 let cwd = PathBuf::from(cwd);
2277 if workspace.is_some_and(|wanted| !recorded_cwd_matches(&cwd, wanted)) {
2278 continue;
2279 }
2280 found.push(SessionDescriptor {
2281 locator: SessionLocator {
2282 harness: HarnessId::from(HarnessId::OPENCODE),
2283 session_id: id.clone(),
2284 storage: StorageLocator::Sqlite {
2285 path: db.clone(),
2286 selector: id,
2287 },
2288 },
2289 cwd: Some(cwd),
2290 title: (!title.is_empty()).then_some(title),
2291 preview_candidates: Vec::new(),
2292 latest_message_candidates: Vec::new(),
2293 updated_at_ms: u64::try_from(updated).ok(),
2294 message_count: usize::try_from(messages).ok(),
2295 model,
2296 parent_session_id: None,
2297 child_session_count: 0,
2298 nouns: OrchestrationNouns::default(),
2299 });
2300 }
2301 }
2302}
2303
2304fn discover_goose(root: &Path, workspace: Option<&Path>, found: &mut Vec<SessionDescriptor>) {
2305 let db = if root.is_file() {
2306 root.to_path_buf()
2307 } else if root.join("sessions.db").is_file() {
2308 root.join("sessions.db")
2309 } else {
2310 root.join("sessions/sessions.db")
2311 };
2312 let Ok(connection) = Connection::open_with_flags(
2313 &db,
2314 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
2315 ) else {
2316 return;
2317 };
2318 let Ok(mut statement) = connection.prepare(
2319 "SELECT s.id, s.working_dir, s.name, s.updated_at, s.model_config_json, \
2320 COUNT(m.id) \
2321 FROM sessions s LEFT JOIN messages m ON m.session_id = s.id \
2322 WHERE s.archived_at IS NULL \
2323 GROUP BY s.id ORDER BY s.updated_at DESC",
2324 ) else {
2325 return;
2326 };
2327 let Ok(rows) = statement.query_map([], |row| {
2328 Ok((
2329 row.get::<_, String>(0)?,
2330 row.get::<_, String>(1)?,
2331 row.get::<_, String>(2)?,
2332 row.get::<_, String>(3)?,
2333 row.get::<_, Option<String>>(4)?,
2334 row.get::<_, i64>(5)?,
2335 ))
2336 }) else {
2337 return;
2338 };
2339 for row in rows.flatten() {
2340 let (id, cwd, title, updated_at, model_config, message_count) = row;
2341 let cwd = PathBuf::from(cwd);
2342 if workspace.is_some_and(|wanted| !recorded_cwd_matches(&cwd, wanted)) {
2343 continue;
2344 }
2345 let model = model_config
2346 .as_deref()
2347 .and_then(|value| serde_json::from_str::<Value>(value).ok())
2348 .and_then(|value| {
2349 value
2350 .get("model_name")
2351 .or_else(|| value.get("modelName"))
2352 .and_then(Value::as_str)
2353 .map(str::to_string)
2354 });
2355 let updated_at_ms = crate::sidecar::rfc3339_to_ms(&updated_at)
2356 .or_else(|| {
2357 crate::sidecar::rfc3339_to_ms(&format!("{}Z", updated_at.replace(' ', "T")))
2359 })
2360 .and_then(|value| u64::try_from(value).ok());
2361 found.push(SessionDescriptor {
2362 locator: SessionLocator {
2363 harness: HarnessId::from(HarnessId::GOOSE),
2364 session_id: id.clone(),
2365 storage: StorageLocator::Sqlite {
2366 path: db.clone(),
2367 selector: id,
2368 },
2369 },
2370 cwd: Some(cwd),
2371 title: (!title.trim().is_empty()).then_some(title),
2372 preview_candidates: Vec::new(),
2373 latest_message_candidates: Vec::new(),
2374 updated_at_ms,
2375 message_count: usize::try_from(message_count).ok(),
2376 model,
2377 parent_session_id: None,
2378 child_session_count: 0,
2379 nouns: OrchestrationNouns::default(),
2380 });
2381 }
2382}
2383
2384const LATEST_PREVIEW_CANDIDATES: usize = 8;
2385const TOPIC_PREVIEW_HEAD_BYTES: u64 = 512 * 1024;
2386const LATEST_PREVIEW_TAIL_BYTES: u64 = 512 * 1024;
2387const LATEST_PREVIEW_MAX_BYTES: u64 = 4 * 1024 * 1024;
2388
2389fn topic_message_candidates(locator: &SessionLocator) -> Result<Vec<SessionPreviewCandidate>> {
2390 match &locator.storage {
2391 StorageLocator::File { path }
2392 if matches!(
2393 locator.harness.as_str(),
2394 HarnessId::CLAUDE_CODE | HarnessId::CODEX
2395 ) =>
2396 {
2397 topic_file_message_candidates(path, locator.harness.as_str())
2398 }
2399 _ => Ok(Vec::new()),
2400 }
2401}
2402
2403fn codex_history_topics(
2404 sessions_root: &Path,
2405 sessions: &[SessionDescriptor],
2406) -> Result<HashMap<String, Vec<SessionPreviewCandidate>>> {
2407 let wanted: HashSet<&str> = sessions
2408 .iter()
2409 .filter(|descriptor| descriptor.locator.harness.as_str() == HarnessId::CODEX)
2410 .map(|descriptor| descriptor.locator.session_id.as_str())
2411 .collect();
2412 if wanted.is_empty() {
2413 return Ok(HashMap::new());
2414 }
2415 let Some(root) = sessions_root.parent() else {
2416 return Ok(HashMap::new());
2417 };
2418 let file = match File::open(root.join("history.jsonl")) {
2419 Ok(file) => file,
2420 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(HashMap::new()),
2421 Err(error) => return Err(error.into()),
2422 };
2423 let mut topics = HashMap::new();
2424 for line in BufReader::new(file).lines() {
2425 let Ok(value) = serde_json::from_str::<Value>(&line?) else {
2426 continue;
2427 };
2428 let Some(session_id) = value.get("session_id").and_then(Value::as_str) else {
2429 continue;
2430 };
2431 if !wanted.contains(session_id) || topics.contains_key(session_id) {
2432 continue;
2433 }
2434 let mut candidates = Vec::new();
2435 push_message_candidate(&mut candidates, "user", value.get("text"), HashMap::new());
2436 if !candidates.is_empty() {
2437 topics.insert(session_id.to_string(), candidates);
2438 if topics.len() == wanted.len() {
2439 break;
2440 }
2441 }
2442 }
2443 Ok(topics)
2444}
2445
2446fn latest_message_candidates(locator: &SessionLocator) -> Result<Vec<SessionPreviewCandidate>> {
2447 match &locator.storage {
2448 StorageLocator::File { path } | StorageLocator::Sqlite { path, .. }
2451 if locator.harness.as_str() == HarnessId::HERMES =>
2452 {
2453 latest_hermes_message_candidates(path, &locator.session_id)
2454 }
2455 StorageLocator::File { path } => {
2456 latest_file_message_candidates(path, locator.harness.as_str())
2457 }
2458 StorageLocator::Sqlite { path, selector }
2459 if locator.harness.as_str() == HarnessId::OPENCODE =>
2460 {
2461 latest_opencode_message_candidates(path, selector)
2462 }
2463 StorageLocator::Sqlite { path, selector }
2464 if locator.harness.as_str() == HarnessId::GOOSE =>
2465 {
2466 latest_goose_message_candidates(path, selector)
2467 }
2468 StorageLocator::Sqlite { .. } => Ok(Vec::new()),
2469 }
2470}
2471
2472fn topic_file_message_candidates(
2473 path: &Path,
2474 harness: &str,
2475) -> Result<Vec<SessionPreviewCandidate>> {
2476 let mut file = File::open(path)?;
2477 let mut bytes = Vec::with_capacity(TOPIC_PREVIEW_HEAD_BYTES as usize);
2478 file.by_ref()
2479 .take(TOPIC_PREVIEW_HEAD_BYTES)
2480 .read_to_end(&mut bytes)?;
2481 if file.metadata()?.len() > TOPIC_PREVIEW_HEAD_BYTES {
2482 if let Some(newline) = bytes.iter().rposition(|byte| *byte == b'\n') {
2483 bytes.truncate(newline);
2484 }
2485 }
2486 let text = String::from_utf8(bytes).map_err(|_| {
2487 Error::Other(format!(
2488 "{} contains non-UTF-8 data in its topic-preview window",
2489 path.display()
2490 ))
2491 })?;
2492 let mut candidates = Vec::new();
2493 for line in text.lines() {
2494 let Ok(value) = serde_json::from_str::<Value>(line) else {
2495 continue;
2496 };
2497 push_topic_message_candidate(&mut candidates, harness, &value);
2498 if candidates.len() >= LATEST_PREVIEW_CANDIDATES {
2499 break;
2500 }
2501 }
2502 Ok(candidates)
2503}
2504
2505fn latest_file_message_candidates(
2506 path: &Path,
2507 harness: &str,
2508) -> Result<Vec<SessionPreviewCandidate>> {
2509 let mut candidates =
2510 latest_file_message_candidates_with_limit(path, harness, LATEST_PREVIEW_TAIL_BYTES)?;
2511 if candidates.is_empty() {
2512 candidates =
2513 latest_file_message_candidates_with_limit(path, harness, LATEST_PREVIEW_MAX_BYTES)?;
2514 }
2515 Ok(candidates)
2516}
2517
2518fn latest_file_message_candidates_with_limit(
2519 path: &Path,
2520 harness: &str,
2521 byte_limit: u64,
2522) -> Result<Vec<SessionPreviewCandidate>> {
2523 let mut file = File::open(path)?;
2524 let file_len = file.metadata()?.len();
2525 let start = file_len.saturating_sub(byte_limit);
2526 file.seek(SeekFrom::Start(start))?;
2527 let mut bytes = Vec::with_capacity((file_len - start) as usize);
2528 file.read_to_end(&mut bytes)?;
2529 if start > 0 {
2530 if let Some(newline) = bytes.iter().position(|byte| *byte == b'\n') {
2531 bytes.drain(..=newline);
2532 } else {
2533 return Ok(Vec::new());
2534 }
2535 }
2536 let text = String::from_utf8(bytes).map_err(|_| {
2537 Error::Other(format!(
2538 "{} contains non-UTF-8 data in its list-preview window",
2539 path.display()
2540 ))
2541 })?;
2542 let mut candidates = Vec::new();
2543 for line in text.lines().rev() {
2544 let Ok(value) = serde_json::from_str::<Value>(line) else {
2545 continue;
2546 };
2547 let (role, content, metadata) = match harness {
2548 HarnessId::CLAUDE_CODE => {
2549 let role = value.get("type").and_then(Value::as_str);
2550 if !matches!(role, Some("user" | "assistant")) {
2551 continue;
2552 }
2553 let metadata = if role == Some("user") {
2554 crate::session::claude_user_provenance(&value)
2555 .into_iter()
2556 .collect()
2557 } else {
2558 HashMap::new()
2559 };
2560 (
2561 role.unwrap_or_default(),
2562 value
2563 .get("message")
2564 .and_then(|message| message.get("content")),
2565 metadata,
2566 )
2567 }
2568 HarnessId::CODEX => {
2569 let payload = value.get("payload").unwrap_or(&Value::Null);
2570 if value.get("type").and_then(Value::as_str) != Some("response_item")
2571 || payload.get("type").and_then(Value::as_str) != Some("message")
2572 {
2573 continue;
2574 }
2575 let Some(role @ ("user" | "assistant")) =
2576 payload.get("role").and_then(Value::as_str)
2577 else {
2578 continue;
2579 };
2580 (role, payload.get("content"), HashMap::new())
2581 }
2582 HarnessId::PI => {
2583 if value.get("type").and_then(Value::as_str) != Some("message") {
2584 continue;
2585 }
2586 let message = value.get("message").unwrap_or(&Value::Null);
2587 let Some(role @ ("user" | "assistant")) =
2588 message.get("role").and_then(Value::as_str)
2589 else {
2590 continue;
2591 };
2592 (role, message.get("content"), HashMap::new())
2593 }
2594 HarnessId::GEMINI => {
2595 let Some(kind @ ("user" | "gemini")) = value.get("type").and_then(Value::as_str)
2596 else {
2597 continue;
2598 };
2599 (
2600 if kind == "gemini" {
2601 "assistant"
2602 } else {
2603 "user"
2604 },
2605 value.get("content"),
2606 HashMap::new(),
2607 )
2608 }
2609 HarnessId::GROK => {
2610 let Some(role @ ("user" | "assistant")) = value.get("type").and_then(Value::as_str)
2611 else {
2612 continue;
2613 };
2614 (role, value.get("content"), HashMap::new())
2615 }
2616 HarnessId::SUPERCODE => {
2617 let Some(role @ ("user" | "assistant")) = value.get("role").and_then(Value::as_str)
2618 else {
2619 continue;
2620 };
2621 (role, value.get("content"), HashMap::new())
2622 }
2623 _ => continue,
2624 };
2625 let mut metadata = metadata;
2626 if matches!(harness, HarnessId::CLAUDE_CODE | HarnessId::CODEX) {
2627 if let Some(timestamp) = value.get("timestamp").and_then(Value::as_str) {
2628 metadata.insert("timestamp".to_string(), timestamp.to_string());
2629 }
2630 }
2631 push_message_candidate_with_cursor(
2632 &mut candidates,
2633 role,
2634 content,
2635 metadata,
2636 Some(message_candidate_cursor(harness, &value)),
2637 );
2638 if candidates.len() >= LATEST_PREVIEW_CANDIDATES {
2639 break;
2640 }
2641 }
2642 Ok(candidates)
2643}
2644
2645fn push_topic_message_candidate(
2646 candidates: &mut Vec<SessionPreviewCandidate>,
2647 harness: &str,
2648 value: &Value,
2649) {
2650 let (role, content, metadata) = match harness {
2651 HarnessId::CLAUDE_CODE => {
2652 let role = value.get("type").and_then(Value::as_str);
2653 if !matches!(role, Some("user" | "assistant")) {
2654 return;
2655 }
2656 let metadata = if role == Some("user") {
2657 crate::session::claude_user_provenance(value)
2658 .into_iter()
2659 .collect()
2660 } else {
2661 HashMap::new()
2662 };
2663 (
2664 role.unwrap_or_default(),
2665 value
2666 .get("message")
2667 .and_then(|message| message.get("content")),
2668 metadata,
2669 )
2670 }
2671 HarnessId::CODEX => {
2672 let payload = value.get("payload").unwrap_or(&Value::Null);
2673 if value.get("type").and_then(Value::as_str) != Some("response_item")
2674 || payload.get("type").and_then(Value::as_str) != Some("message")
2675 {
2676 return;
2677 }
2678 let Some(role @ ("user" | "assistant")) = payload.get("role").and_then(Value::as_str)
2679 else {
2680 return;
2681 };
2682 (role, payload.get("content"), HashMap::new())
2683 }
2684 _ => return,
2685 };
2686 push_message_candidate(candidates, role, content, metadata);
2687}
2688
2689fn latest_opencode_message_candidates(
2690 path: &Path,
2691 session_id: &str,
2692) -> Result<Vec<SessionPreviewCandidate>> {
2693 let connection = Connection::open_with_flags(
2694 path,
2695 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
2696 )
2697 .map_err(|error| Error::Other(format!("failed to open list-preview store: {error}")))?;
2698 let mut statement = connection
2699 .prepare(
2700 "SELECT m.data, p.data FROM message m JOIN part p ON p.message_id = m.id \
2701 WHERE m.session_id = ?1 ORDER BY m.time_created DESC, p.time_created DESC LIMIT 32",
2702 )
2703 .map_err(|error| Error::Other(format!("failed to prepare list-preview query: {error}")))?;
2704 let rows = statement
2705 .query_map([session_id], |row| {
2706 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2707 })
2708 .map_err(|error| Error::Other(format!("failed to read list-preview rows: {error}")))?;
2709 let mut candidates = Vec::new();
2710 for row in rows.flatten() {
2711 let (Ok(message), Ok(part)) = (
2712 serde_json::from_str::<Value>(&row.0),
2713 serde_json::from_str::<Value>(&row.1),
2714 ) else {
2715 continue;
2716 };
2717 let Some(role @ ("user" | "assistant")) = message.get("role").and_then(Value::as_str)
2718 else {
2719 continue;
2720 };
2721 if part.get("type").and_then(Value::as_str) != Some("text") {
2722 continue;
2723 }
2724 push_message_candidate(&mut candidates, role, part.get("text"), HashMap::new());
2725 if candidates.len() >= LATEST_PREVIEW_CANDIDATES {
2726 break;
2727 }
2728 }
2729 Ok(candidates)
2730}
2731
2732fn latest_hermes_message_candidates(
2733 path: &Path,
2734 session_id: &str,
2735) -> Result<Vec<SessionPreviewCandidate>> {
2736 let connection = Connection::open_with_flags(
2737 path,
2738 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
2739 )
2740 .map_err(|error| Error::Other(format!("failed to open list-preview store: {error}")))?;
2741 let mut statement = connection
2742 .prepare(
2743 "SELECT role, content FROM messages WHERE session_id = ?1 AND active = 1 \
2744 AND role IN ('user', 'assistant') AND content IS NOT NULL AND content != '' \
2745 ORDER BY timestamp DESC, id DESC LIMIT 32",
2746 )
2747 .map_err(|error| Error::Other(format!("failed to prepare list-preview query: {error}")))?;
2748 let rows = statement
2749 .query_map([session_id], |row| {
2750 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2751 })
2752 .map_err(|error| Error::Other(format!("failed to read list-preview rows: {error}")))?;
2753 let mut candidates = Vec::new();
2754 for (role, content) in rows.flatten() {
2755 push_message_candidate(
2756 &mut candidates,
2757 &role,
2758 Some(&Value::String(content)),
2759 HashMap::new(),
2760 );
2761 if candidates.len() >= LATEST_PREVIEW_CANDIDATES {
2762 break;
2763 }
2764 }
2765 Ok(candidates)
2766}
2767
2768fn latest_goose_message_candidates(
2769 path: &Path,
2770 session_id: &str,
2771) -> Result<Vec<SessionPreviewCandidate>> {
2772 let connection = Connection::open_with_flags(
2773 path,
2774 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
2775 )
2776 .map_err(|error| Error::Other(format!("failed to open list-preview store: {error}")))?;
2777 let mut statement = connection
2778 .prepare(
2779 "SELECT role, content_json FROM messages WHERE session_id = ?1 \
2780 ORDER BY created_timestamp DESC, id DESC LIMIT 16",
2781 )
2782 .map_err(|error| Error::Other(format!("failed to prepare list-preview query: {error}")))?;
2783 let rows = statement
2784 .query_map([session_id], |row| {
2785 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2786 })
2787 .map_err(|error| Error::Other(format!("failed to read list-preview rows: {error}")))?;
2788 let mut candidates = Vec::new();
2789 for row in rows.flatten() {
2790 let (role, content) = row;
2791 if !matches!(role.as_str(), "user" | "assistant") {
2792 continue;
2793 }
2794 let Ok(content) = serde_json::from_str::<Value>(&content) else {
2795 continue;
2796 };
2797 push_message_candidate(&mut candidates, &role, Some(&content), HashMap::new());
2798 if candidates.len() >= LATEST_PREVIEW_CANDIDATES {
2799 break;
2800 }
2801 }
2802 Ok(candidates)
2803}
2804
2805fn push_message_candidate(
2806 candidates: &mut Vec<SessionPreviewCandidate>,
2807 role: &str,
2808 content: Option<&Value>,
2809 metadata: HashMap<String, String>,
2810) {
2811 push_message_candidate_with_cursor(candidates, role, content, metadata, None);
2812}
2813
2814fn push_message_candidate_with_cursor(
2815 candidates: &mut Vec<SessionPreviewCandidate>,
2816 role: &str,
2817 content: Option<&Value>,
2818 metadata: HashMap<String, String>,
2819 cursor: Option<String>,
2820) {
2821 if candidates.len() >= LATEST_PREVIEW_CANDIDATES {
2822 return;
2823 }
2824 let Some(text) = display_text(content).filter(|text| !text.trim().is_empty()) else {
2825 return;
2826 };
2827 const MAX_CHARS: usize = 4_096;
2828 candidates.push(SessionPreviewCandidate {
2829 cursor,
2830 role: role.to_string(),
2831 content: text.chars().take(MAX_CHARS).collect(),
2832 metadata,
2833 });
2834}
2835
2836fn message_candidate_cursor(harness: &str, value: &Value) -> String {
2837 let native_identity = value
2838 .get("uuid")
2839 .or_else(|| value.get("id"))
2840 .or_else(|| value.pointer("/message/id"))
2841 .or_else(|| value.pointer("/payload/id"))
2842 .and_then(Value::as_str)
2843 .or_else(|| value.get("timestamp").and_then(Value::as_str));
2844 let mut hasher = blake3::Hasher::new();
2845 hasher.update(b"supercode.session-preview-cursor.v1\0");
2846 hasher.update(harness.as_bytes());
2847 hasher.update(b"\0");
2848 if let Some(identity) = native_identity {
2849 hasher.update(identity.as_bytes());
2850 } else {
2851 hasher.update(value.to_string().as_bytes());
2855 }
2856 format!("v1:{}", &hasher.finalize().to_hex()[..24])
2857}
2858
2859fn fill_string(target: &mut Option<String>, value: Option<&Value>) {
2860 if target.is_none() {
2861 *target = value.and_then(Value::as_str).map(str::to_owned);
2862 }
2863}
2864
2865fn fill_path(target: &mut Option<PathBuf>, value: Option<&Value>) {
2866 if target.is_none() {
2867 *target = value.and_then(Value::as_str).map(PathBuf::from);
2868 }
2869}
2870
2871fn modified_ms(path: &Path) -> Option<u64> {
2872 fs::metadata(path)
2873 .ok()?
2874 .modified()
2875 .ok()?
2876 .duration_since(UNIX_EPOCH)
2877 .ok()
2878 .and_then(|duration| u64::try_from(duration.as_millis()).ok())
2879}
2880
2881fn recorded_cwd_matches(recorded: &Path, wanted: &Path) -> bool {
2887 recorded.is_absolute() && same_path(recorded, wanted)
2888}
2889
2890fn same_path(left: &Path, right: &Path) -> bool {
2891 match (fs::canonicalize(left), fs::canonicalize(right)) {
2892 (Ok(left), Ok(right)) => left == right,
2893 _ => normalize_path(left) == normalize_path(right),
2894 }
2895}
2896
2897fn normalize_path(path: &Path) -> PathBuf {
2898 let absolute = if path.is_absolute() {
2899 path.to_path_buf()
2900 } else {
2901 std::env::current_dir()
2902 .unwrap_or_else(|_| PathBuf::from("."))
2903 .join(path)
2904 };
2905 let mut normalized = PathBuf::new();
2906 for component in absolute.components() {
2907 match component {
2908 Component::CurDir => {}
2909 Component::ParentDir => {
2910 normalized.pop();
2911 }
2912 other => normalized.push(other.as_os_str()),
2913 }
2914 }
2915 normalized
2916}
2917
2918#[cfg(test)]
2919mod tests {
2920 use super::*;
2921 use std::io::Write;
2922 use std::time::{SystemTime, UNIX_EPOCH};
2923
2924 fn temp_dir(label: &str) -> PathBuf {
2925 let nonce = SystemTime::now()
2926 .duration_since(UNIX_EPOCH)
2927 .unwrap()
2928 .as_nanos();
2929 let path = std::env::temp_dir().join(format!(
2930 "supercode-catalog-{label}-{}-{nonce}",
2931 std::process::id()
2932 ));
2933 fs::create_dir_all(&path).unwrap();
2934 path
2935 }
2936
2937 #[test]
2938 fn codex_history_index_reads_appends_and_repairs_replacements() {
2939 let root = temp_dir("codex-history-index");
2940 let sessions = root.join("sessions");
2941 fs::create_dir_all(&sessions).unwrap();
2942 let history = root.join("history.jsonl");
2943 fs::write(
2944 &history,
2945 "{\"session_id\":\"alpha\",\"text\":\"first topic\"}\n",
2946 )
2947 .unwrap();
2948
2949 let mut index = CodexHistoryTopicIndex::new(&sessions);
2950 assert_eq!(index.refresh().unwrap(), BTreeSet::from(["alpha".into()]));
2951 assert_eq!(index.topics["alpha"][0].content, "first topic");
2952 assert!(index.refresh().unwrap().is_empty());
2953
2954 let mut file = fs::OpenOptions::new().append(true).open(&history).unwrap();
2955 write!(
2956 file,
2957 "{{\"session_id\":\"alpha\",\"text\":\"later topic\"}}\n\
2958 {{\"session_id\":\"beta\",\"text\":\"second topic\"}}\n"
2959 )
2960 .unwrap();
2961 file.flush().unwrap();
2962 assert_eq!(index.refresh().unwrap(), BTreeSet::from(["beta".into()]));
2963 assert_eq!(index.topics["alpha"][0].content, "first topic");
2964 assert_eq!(index.topics["beta"][0].content, "second topic");
2965
2966 fs::write(
2967 &history,
2968 "{\"session_id\":\"gamma\",\"text\":\"replacement\"}\n",
2969 )
2970 .unwrap();
2971 assert_eq!(
2972 index.refresh().unwrap(),
2973 BTreeSet::from(["alpha".into(), "beta".into(), "gamma".into()])
2974 );
2975 assert!(!index.topics.contains_key("alpha"));
2976 assert_eq!(index.topics["gamma"][0].content, "replacement");
2977
2978 fs::remove_dir_all(root).ok();
2979 }
2980
2981 #[test]
2982 fn codex_history_index_retains_an_incomplete_appended_record() {
2983 let root = temp_dir("codex-history-partial");
2984 let sessions = root.join("sessions");
2985 fs::create_dir_all(&sessions).unwrap();
2986 let history = root.join("history.jsonl");
2987 fs::write(&history, "{\"session_id\":\"partial\",\"text\":\"hel").unwrap();
2988
2989 let mut index = CodexHistoryTopicIndex::new(&sessions);
2990 assert!(index.refresh().unwrap().is_empty());
2991 let mut file = fs::OpenOptions::new().append(true).open(&history).unwrap();
2992 writeln!(file, "lo\"}}").unwrap();
2993 file.flush().unwrap();
2994
2995 assert_eq!(index.refresh().unwrap(), BTreeSet::from(["partial".into()]));
2996 assert_eq!(index.topics["partial"][0].content, "hello");
2997 fs::remove_dir_all(root).ok();
2998 }
2999
3000 #[test]
3001 fn cached_codex_history_enrichment_matches_stateless_discovery() {
3002 let root = temp_dir("codex-history-parity");
3003 let sessions = root.join("sessions");
3004 let workspace = root.join("workspace");
3005 fs::create_dir_all(&sessions).unwrap();
3006 fs::create_dir_all(&workspace).unwrap();
3007 fs::write(
3008 sessions.join("rollout.jsonl"),
3009 format!(
3010 "{{\"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",
3011 serde_json::to_string(&workspace.to_string_lossy()).unwrap(),
3012 serde_json::to_string(&workspace.to_string_lossy()).unwrap(),
3013 ),
3014 )
3015 .unwrap();
3016 fs::write(
3017 root.join("history.jsonl"),
3018 "{\"session_id\":\"alpha\",\"text\":\"history topic\"}\n",
3019 )
3020 .unwrap();
3021 let query = DiscoveryQuery {
3022 harnesses: vec![HarnessId::from(HarnessId::CODEX)],
3023 homes: HarnessHomes {
3024 codex: sessions.clone(),
3025 ..HarnessHomes::default()
3026 },
3027 include_topic_candidates: true,
3028 ..DiscoveryQuery::default()
3029 };
3030 let catalog = HarnessCatalog::new();
3031 let projected = catalog
3032 .project_index(&query, catalog.discover_raw_index(&query))
3033 .unwrap();
3034 let expected = catalog
3035 .enrich_index_page(&query, projected.clone())
3036 .unwrap();
3037 let mut history = CodexHistoryTopicIndex::new(&sessions);
3038 history.refresh().unwrap();
3039 let actual = catalog
3040 .enrich_index_page_with_codex_history(&query, projected, &history)
3041 .unwrap();
3042
3043 assert_eq!(actual, expected);
3044 assert_eq!(actual[0].preview_candidates[0].content, "history topic");
3045 fs::remove_dir_all(root).ok();
3046 }
3047
3048 #[test]
3049 fn locator_json_round_trip_preserves_sqlite_selector() {
3050 let locator = SessionLocator {
3051 harness: HarnessId::from(HarnessId::OPENCODE),
3052 session_id: "ses_123".into(),
3053 storage: StorageLocator::Sqlite {
3054 path: PathBuf::from("/tmp/opencode-dev.db"),
3055 selector: "ses_123".into(),
3056 },
3057 };
3058 let encoded = serde_json::to_string(&locator).unwrap();
3059 assert_eq!(
3060 serde_json::from_str::<SessionLocator>(&encoded).unwrap(),
3061 locator
3062 );
3063 }
3064
3065 #[test]
3066 fn discovers_filters_loads_and_follows_three_jsonl_harnesses() {
3067 let root = temp_dir("jsonl");
3068 let workspace = root.join("workspace");
3069 let other = root.join("other");
3070 fs::create_dir_all(&workspace).unwrap();
3071 fs::create_dir_all(&other).unwrap();
3072
3073 let claude = root.join("claude");
3074 let codex = root.join("codex");
3075 let pi = root.join("pi");
3076 fs::create_dir_all(&claude).unwrap();
3077 fs::create_dir_all(&codex).unwrap();
3078 fs::create_dir_all(&pi).unwrap();
3079 fs::write(
3080 claude.join("claude.jsonl"),
3081 format!(
3082 "{{\"type\":\"user\",\"sessionId\":\"cc-1\",\"cwd\":{},\"timestamp\":\"2026-01-01T00:00:01Z\",\"message\":{{\"role\":\"user\",\"content\":\"hi\"}}}}\n",
3083 serde_json::to_string(&workspace.to_string_lossy()).unwrap()
3084 ),
3085 )
3086 .unwrap();
3087 fs::write(
3088 codex.join("rollout.jsonl"),
3089 format!(
3090 "{{\"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",
3091 serde_json::to_string(&workspace.to_string_lossy()).unwrap()
3092 ),
3093 )
3094 .unwrap();
3095 fs::write(
3096 pi.join("pi.jsonl"),
3097 format!(
3098 "{{\"type\":\"session\",\"version\":3,\"id\":\"pi-1\",\"timestamp\":\"2026-01-01T00:00:00Z\",\"cwd\":{}}}\n{{\"type\":\"message\",\"message\":{{\"role\":\"user\",\"content\":\"inspect pi\"}}}}\n",
3099 serde_json::to_string(&workspace.to_string_lossy()).unwrap()
3100 ),
3101 )
3102 .unwrap();
3103 fs::write(
3104 pi.join("unrelated.jsonl"),
3105 format!(
3106 "{{\"type\":\"session\",\"version\":3,\"id\":\"pi-2\",\"timestamp\":\"2026-01-01T00:00:00Z\",\"cwd\":{}}}\n",
3107 serde_json::to_string(&other.to_string_lossy()).unwrap()
3108 ),
3109 )
3110 .unwrap();
3111 fs::write(claude.join("partial.jsonl"), "{truncated").unwrap();
3112
3113 let query = DiscoveryQuery {
3114 workspace: Some(workspace),
3115 homes: HarnessHomes {
3116 claude_code: claude,
3117 codex,
3118 pi,
3119 opencode: root.join("missing-opencode"),
3120 grok: root.join("missing-grok"),
3121 gemini: root.join("missing-gemini"),
3122 goose: root.join("missing-goose"),
3123 supercode: root.join("missing-supercode"),
3124 openclaw: root.join("missing-openclaw"),
3125 hermes: root.join("missing-hermes"),
3126 orchestrator: root.join("missing-orchestrator"),
3127 },
3128 ..DiscoveryQuery::default()
3129 };
3130 let catalog = HarnessCatalog::new();
3131 let found = catalog.discover(&query).unwrap();
3132 assert_eq!(found.len(), 3);
3133 assert_eq!(
3134 found
3135 .iter()
3136 .map(|item| item.locator.harness.as_str())
3137 .collect::<HashSet<_>>(),
3138 HashSet::from([HarnessId::CLAUDE_CODE, HarnessId::CODEX, HarnessId::PI])
3139 );
3140 for descriptor in found {
3141 assert!(descriptor.preview_candidates.is_empty());
3142 assert_eq!(descriptor.latest_message_candidates.len(), 1);
3143 assert_eq!(descriptor.latest_message_candidates[0].role, "user");
3144 assert!(descriptor.latest_message_candidates[0].cursor.is_some());
3145 if descriptor.locator.harness.as_str() == HarnessId::CLAUDE_CODE {
3146 assert_eq!(
3147 descriptor.latest_message_candidates[0]
3148 .metadata
3149 .get("timestamp")
3150 .map(String::as_str),
3151 Some("2026-01-01T00:00:01Z")
3152 );
3153 } else if descriptor.locator.harness.as_str() == HarnessId::CODEX {
3154 assert_eq!(
3155 descriptor.latest_message_candidates[0]
3156 .metadata
3157 .get("timestamp")
3158 .map(String::as_str),
3159 Some("2026-01-01T00:00:02Z")
3160 );
3161 }
3162 let loaded = catalog.load(&descriptor.locator).unwrap();
3163 assert_eq!(
3164 loaded.meta.session_id.as_deref(),
3165 Some(descriptor.locator.session_id.as_str())
3166 );
3167 let mut follower = catalog.follow(&descriptor.locator).unwrap();
3168 assert!(matches!(
3169 follower.poll().unwrap(),
3170 Some(crate::SessionWatchEvent::SessionSnapshot { .. })
3171 ));
3172 }
3173 fs::remove_dir_all(root).ok();
3174 }
3175
3176 #[test]
3177 fn preview_cursor_tracks_native_boundary_not_growing_text() {
3178 let first = serde_json::json!({
3179 "timestamp": "2026-01-01T00:00:02Z",
3180 "type": "response_item",
3181 "payload": {"type": "message", "role": "assistant", "content": "partial"}
3182 });
3183 let grown = serde_json::json!({
3184 "timestamp": "2026-01-01T00:00:02Z",
3185 "type": "response_item",
3186 "payload": {"type": "message", "role": "assistant", "content": "partial and complete"}
3187 });
3188 let next = serde_json::json!({
3189 "timestamp": "2026-01-01T00:00:03Z",
3190 "type": "response_item",
3191 "payload": {"type": "message", "role": "assistant", "content": "next"}
3192 });
3193
3194 assert_eq!(
3195 message_candidate_cursor(HarnessId::CODEX, &first),
3196 message_candidate_cursor(HarnessId::CODEX, &grown)
3197 );
3198 assert_ne!(
3199 message_candidate_cursor(HarnessId::CODEX, &first),
3200 message_candidate_cursor(HarnessId::CODEX, &next)
3201 );
3202 }
3203
3204 #[test]
3205 fn codex_child_rollouts_roll_into_roots_before_pagination() {
3206 let root = temp_dir("codex-roots");
3207 let codex = root.join("codex");
3208 fs::create_dir_all(&codex).unwrap();
3209 let write_rollout =
3210 |name: &str, payload: Value, modified_seconds: u64| {
3211 let path = codex.join(format!("{name}.jsonl"));
3212 fs::write(
3213 &path,
3214 format!(
3215 "{}\n",
3216 serde_json::json!({
3217 "timestamp": "2026-01-01T00:00:00Z",
3218 "type": "session_meta",
3219 "payload": payload,
3220 })
3221 ),
3222 )
3223 .unwrap();
3224 File::open(&path)
3225 .unwrap()
3226 .set_times(fs::FileTimes::new().set_modified(
3227 UNIX_EPOCH + std::time::Duration::from_secs(modified_seconds),
3228 ))
3229 .unwrap();
3230 };
3231 write_rollout(
3232 "parent",
3233 serde_json::json!({"id":"parent","cwd":"/project","source":"cli"}),
3234 100,
3235 );
3236 write_rollout(
3237 "other",
3238 serde_json::json!({"id":"other","cwd":"/project","source":"cli"}),
3239 200,
3240 );
3241 write_rollout(
3242 "child",
3243 serde_json::json!({
3244 "id": "child",
3245 "cwd": "/project",
3246 "parent_thread_id": "parent",
3247 "source": {"subagent":{"thread_spawn":{
3248 "parent_thread_id":"parent",
3249 "depth":1,
3250 "agent_path":"/root/reviewer"
3251 }}}
3252 }),
3253 300,
3254 );
3255
3256 let catalog = HarnessCatalog::new();
3257 let query = DiscoveryQuery {
3258 harnesses: vec![HarnessId::from(HarnessId::CODEX)],
3259 homes: HarnessHomes {
3260 codex: codex.clone(),
3261 ..HarnessHomes::default()
3262 },
3263 limit: Some(1),
3264 ..DiscoveryQuery::default()
3265 };
3266 let roots = catalog.discover(&query).unwrap();
3267 assert_eq!(roots.len(), 1);
3268 assert_eq!(roots[0].locator.session_id, "parent");
3269 assert_eq!(roots[0].updated_at_ms, Some(300_000));
3270 assert_eq!(roots[0].parent_session_id, None);
3271 assert_eq!(roots[0].child_session_count, 1);
3272
3273 let tree = catalog
3274 .discover(&DiscoveryQuery {
3275 limit: None,
3276 include_child_sessions: true,
3277 root_session_id: Some("parent".into()),
3278 ..query
3279 })
3280 .unwrap();
3281 assert_eq!(tree.len(), 2);
3282 assert!(tree
3283 .iter()
3284 .all(|descriptor| descriptor.locator.session_id != "other"));
3285 let child = tree
3286 .iter()
3287 .find(|descriptor| descriptor.locator.session_id == "child")
3288 .unwrap();
3289 assert_eq!(child.parent_session_id.as_deref(), Some("parent"));
3290 fs::remove_dir_all(root).ok();
3291 }
3292
3293 #[test]
3294 fn discovers_loads_and_follows_opencode_sqlite() {
3295 let db = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
3296 .join("../harness/tests/fixtures/opencode_fixture/opencode.db");
3297 let catalog = HarnessCatalog::new();
3298 let found = catalog
3299 .discover(&DiscoveryQuery {
3300 harnesses: vec![HarnessId::from(HarnessId::OPENCODE)],
3301 homes: HarnessHomes {
3302 opencode: db,
3303 ..HarnessHomes::default()
3304 },
3305 ..DiscoveryQuery::default()
3306 })
3307 .unwrap();
3308 assert!(!found.is_empty());
3309 for descriptor in found {
3310 assert_eq!(descriptor.locator.harness.as_str(), HarnessId::OPENCODE);
3311 assert_eq!(
3312 catalog.load(&descriptor.locator).unwrap().meta.session_id,
3313 Some(descriptor.locator.session_id.clone())
3314 );
3315 assert!(catalog.follow(&descriptor.locator).is_ok());
3316 }
3317 }
3318
3319 #[test]
3320 fn discovers_loads_and_follows_hermes_sqlite_by_session() {
3321 let root = temp_dir("hermes-follow");
3323 fs::create_dir_all(&root).unwrap();
3324 let db = root.join("state.db");
3325 fs::copy(
3326 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
3327 .join("../harness/tests/fixtures/hermes_home/state.db"),
3328 &db,
3329 )
3330 .unwrap();
3331 let catalog = HarnessCatalog::new();
3332 let query = DiscoveryQuery {
3333 harnesses: vec![HarnessId::from(HarnessId::HERMES)],
3334 homes: HarnessHomes {
3335 hermes: db.clone(),
3336 ..HarnessHomes::default()
3337 },
3338 ..DiscoveryQuery::default()
3339 };
3340 let found = catalog.discover(&query).unwrap();
3341 assert!(found.len() >= 2, "{found:#?}");
3342 for descriptor in &found {
3346 assert_eq!(descriptor.locator.harness.as_str(), HarnessId::HERMES);
3347 let loaded = catalog.load(&descriptor.locator).unwrap();
3348 let last_text = loaded.messages.iter().rev().find_map(|message| {
3349 (matches!(message.role, crate::Role::User | crate::Role::Assistant))
3350 .then(|| message.content.clone())
3351 .flatten()
3352 });
3353 assert_eq!(
3355 descriptor
3356 .latest_message_candidates
3357 .first()
3358 .map(|c| c.content.as_str()),
3359 last_text.as_deref(),
3360 "{}",
3361 descriptor.locator.session_id
3362 );
3363 assert_eq!(
3364 catalog.load(&descriptor.locator).unwrap().meta.session_id,
3365 Some(descriptor.locator.session_id.clone())
3366 );
3367 let mut follower = catalog.follow(&descriptor.locator).unwrap();
3368 match follower.poll().unwrap() {
3369 Some(crate::watch::SessionWatchEvent::SessionSnapshot { session, .. }) => {
3370 assert_eq!(
3371 session.meta.session_id,
3372 Some(descriptor.locator.session_id.clone())
3373 );
3374 }
3375 other => panic!("expected an initial snapshot, got {other:?}"),
3376 }
3377 }
3378 let target = &found[0].locator;
3381 let sibling = &found[1].locator;
3382 let mut target_follower = catalog.follow(target).unwrap();
3383 let mut sibling_follower = catalog.follow(sibling).unwrap();
3384 target_follower.poll().unwrap();
3385 sibling_follower.poll().unwrap();
3386 std::thread::sleep(std::time::Duration::from_millis(20));
3387 {
3388 let conn = rusqlite::Connection::open(&db).unwrap();
3389 conn.execute(
3390 "INSERT INTO messages (session_id, role, content, timestamp, active) VALUES (?1, 'assistant', 'appended by the follow test', ?2, 1)",
3391 rusqlite::params![target.session_id, 1_800_000_000.0_f64],
3392 )
3393 .unwrap();
3394 }
3395 match target_follower.poll().unwrap() {
3396 Some(crate::watch::SessionWatchEvent::MessagesAppended {
3397 session_id,
3398 messages,
3399 ..
3400 }) => {
3401 assert_eq!(session_id, Some(target.session_id.clone()));
3402 assert_eq!(messages.len(), 1);
3403 assert_eq!(
3404 messages[0].content.as_deref(),
3405 Some("appended by the follow test")
3406 );
3407 }
3408 other => panic!("expected messages_appended for the target session, got {other:?}"),
3409 }
3410 assert!(
3411 sibling_follower.poll().unwrap().is_none(),
3412 "the sibling session must not wake"
3413 );
3414 fs::remove_dir_all(&root).ok();
3415 }
3416
3417 #[test]
3418 fn discovers_loads_and_follows_gemini_conversation_records() {
3419 let root = temp_dir("gemini");
3420 let workspace = root.join("workspace");
3421 let chats = root.join("gemini/tmp/demo/chats");
3422 fs::create_dir_all(&workspace).unwrap();
3423 fs::create_dir_all(&chats).unwrap();
3424 fs::write(
3425 root.join("gemini/projects.json"),
3426 serde_json::json!({
3427 "projects": {workspace.to_string_lossy(): "demo"}
3428 })
3429 .to_string(),
3430 )
3431 .unwrap();
3432 let transcript = chats.join("gemini-id.jsonl");
3433 fs::write(
3434 &transcript,
3435 include_str!("../../harness/tests/fixtures/gemini_session.jsonl"),
3436 )
3437 .unwrap();
3438
3439 let catalog = HarnessCatalog::new();
3440 let found = catalog
3441 .discover(&DiscoveryQuery {
3442 harnesses: vec![HarnessId::from(HarnessId::GEMINI)],
3443 homes: HarnessHomes {
3444 gemini: root.join("gemini"),
3445 ..HarnessHomes::default()
3446 },
3447 workspace: Some(workspace.clone()),
3448 ..DiscoveryQuery::default()
3449 })
3450 .unwrap();
3451
3452 assert_eq!(found.len(), 1);
3453 assert_eq!(found[0].cwd.as_deref(), Some(workspace.as_path()));
3454 assert_eq!(found[0].message_count, None);
3455 assert_eq!(found[0].model.as_deref(), Some("gemini-2.5-pro"));
3456 assert_eq!(found[0].title, None);
3457 assert!(found[0].preview_candidates.is_empty());
3458 assert_eq!(found[0].latest_message_candidates.len(), 3);
3459 assert_eq!(
3460 found[0].latest_message_candidates[0].content,
3461 "Fixture inspected."
3462 );
3463 let loaded = catalog.load(&found[0].locator).unwrap();
3464 assert_eq!(
3465 loaded.meta.session_id.as_deref(),
3466 Some("11111111-1111-4111-8111-111111111111")
3467 );
3468 assert_eq!(loaded.messages.len(), 4);
3469 assert!(matches!(
3470 catalog.follow(&found[0].locator).unwrap().poll().unwrap(),
3471 Some(crate::SessionWatchEvent::SessionSnapshot { .. })
3472 ));
3473 fs::remove_dir_all(root).ok();
3474 }
3475
3476 #[test]
3477 fn discovers_native_store_and_pages_search_results() {
3478 let root = temp_dir("supercode");
3479 let store_root = root.join("sessions");
3480 fs::create_dir_all(&store_root).unwrap();
3481 for (name, title) in [
3482 ("alpha", "Alpha planning"),
3483 ("beta", "Beta implementation"),
3484 ("gamma", "Gamma review"),
3485 ] {
3486 fs::write(
3487 store_root.join(format!("{name}.jsonl")),
3488 format!("{{\"role\":\"user\",\"content\":\"{title}\"}}\n"),
3489 )
3490 .unwrap();
3491 fs::write(
3492 store_root.join(format!("{name}.meta.json")),
3493 serde_json::json!({"name": name, "title": title}).to_string(),
3494 )
3495 .unwrap();
3496 }
3497 let catalog = HarnessCatalog::new();
3498 let base = DiscoveryQuery {
3499 harnesses: vec![HarnessId::from(HarnessId::SUPERCODE)],
3500 homes: HarnessHomes {
3501 supercode: store_root,
3502 ..HarnessHomes::default()
3503 },
3504 limit: Some(1),
3505 ..DiscoveryQuery::default()
3506 };
3507
3508 let first = catalog.discover_page(&base).unwrap();
3509 assert_eq!(first.sessions.len(), 1);
3510 assert!(first.next_cursor.is_some());
3511 let second = catalog
3512 .discover_page(&DiscoveryQuery {
3513 cursor: first.next_cursor,
3514 ..base.clone()
3515 })
3516 .unwrap();
3517 assert_eq!(second.sessions.len(), 1);
3518 assert_ne!(
3519 first.sessions[0].locator.session_id,
3520 second.sessions[0].locator.session_id
3521 );
3522 let search = catalog
3523 .discover_page(&DiscoveryQuery {
3524 limit: None,
3525 query: Some("implementation".into()),
3526 ..base
3527 })
3528 .unwrap();
3529 assert_eq!(search.sessions.len(), 1);
3530 assert_eq!(search.sessions[0].locator.session_id, "beta");
3531 assert_eq!(search.sessions[0].message_count, None);
3532 assert_eq!(
3533 catalog
3534 .load(&search.sessions[0].locator)
3535 .unwrap()
3536 .messages
3537 .len(),
3538 1
3539 );
3540 fs::remove_dir_all(root).ok();
3541 }
3542
3543 #[test]
3544 fn native_workspace_discovery_reads_bounded_sidecar_headers() {
3545 let root = temp_dir("supercode-bounded-header");
3546 let store_root = root.join("sessions");
3547 let workspace = root.join("project");
3548 fs::create_dir_all(&store_root).unwrap();
3549 fs::create_dir_all(&workspace).unwrap();
3550 let name = "bounded-native";
3551 fs::write(
3552 store_root.join(format!("{name}.meta.json")),
3553 serde_json::json!({"name": name, "title": "Bounded native"}).to_string(),
3554 )
3555 .unwrap();
3556 fs::write(
3557 store_root.join(format!("{name}.jsonl")),
3558 "{\"role\":\"user\",\"content\":\"projected view\"}\n",
3559 )
3560 .unwrap();
3561 let sidecar = [
3562 serde_json::json!({
3563 "supercode_native": 2,
3564 "source": "claude_code",
3565 "session_id": "native-session"
3566 })
3567 .to_string(),
3568 serde_json::json!({
3569 "type": "user",
3570 "sessionId": "native-session",
3571 "cwd": workspace,
3572 "message": {"role": "user", "content": "hello"}
3573 })
3574 .to_string(),
3575 serde_json::json!({
3576 "type": "assistant",
3577 "sessionId": "native-session",
3578 "cwd": workspace,
3579 "message": {"role": "assistant", "model": "claude-sonnet-5", "content": []}
3580 })
3581 .to_string(),
3582 "not-json".into(),
3585 ]
3586 .join("\n");
3587 fs::write(
3588 store_root.join(format!("{name}.sidecar.jsonl")),
3589 format!("{sidecar}\n"),
3590 )
3591 .unwrap();
3592
3593 let found = HarnessCatalog::new()
3594 .discover(&DiscoveryQuery {
3595 workspace: Some(workspace.clone()),
3596 harnesses: vec![HarnessId::from(HarnessId::SUPERCODE)],
3597 homes: HarnessHomes {
3598 supercode: store_root,
3599 ..HarnessHomes::default()
3600 },
3601 ..DiscoveryQuery::default()
3602 })
3603 .unwrap();
3604
3605 assert_eq!(found.len(), 1);
3606 assert_eq!(found[0].cwd.as_deref(), Some(workspace.as_path()));
3607 assert_eq!(found[0].model.as_deref(), Some("claude-sonnet-5"));
3608 assert_eq!(found[0].message_count, None);
3609 fs::remove_dir_all(root).ok();
3610 }
3611
3612 #[test]
3613 fn discovers_current_opencode_schema_without_a_session_model_column() {
3614 let root = temp_dir("opencode-current");
3615 let db = root.join("opencode.db");
3616 let conn = Connection::open(&db).unwrap();
3617 conn.execute_batch(
3618 "CREATE TABLE session (
3619 id TEXT PRIMARY KEY,
3620 directory TEXT NOT NULL,
3621 title TEXT NOT NULL,
3622 time_updated INTEGER NOT NULL
3623 );
3624 CREATE TABLE message (
3625 id TEXT PRIMARY KEY,
3626 session_id TEXT NOT NULL
3627 );
3628 INSERT INTO session VALUES ('ses_current', '/tmp/work', 'Current', 42);
3629 INSERT INTO message VALUES ('msg_current', 'ses_current');",
3630 )
3631 .unwrap();
3632 drop(conn);
3633
3634 let found = HarnessCatalog::new()
3635 .discover(&DiscoveryQuery {
3636 harnesses: vec![HarnessId::from(HarnessId::OPENCODE)],
3637 homes: HarnessHomes {
3638 opencode: db,
3639 ..HarnessHomes::default()
3640 },
3641 ..DiscoveryQuery::default()
3642 })
3643 .unwrap();
3644
3645 assert_eq!(found.len(), 1);
3646 assert_eq!(found[0].locator.session_id, "ses_current");
3647 assert_eq!(found[0].message_count, Some(1));
3648 assert_eq!(found[0].model, None);
3649 fs::remove_dir_all(root).ok();
3650 }
3651
3652 #[test]
3653 fn workspace_filter_never_matches_a_relative_recorded_cwd() {
3654 let root = temp_dir("opencode-relative-cwd");
3659 let db = root.join("opencode.db");
3660 let conn = Connection::open(&db).unwrap();
3661 let here = std::env::current_dir().unwrap();
3662 conn.execute_batch(&format!(
3663 "CREATE TABLE session (
3664 id TEXT PRIMARY KEY,
3665 directory TEXT NOT NULL,
3666 title TEXT NOT NULL,
3667 time_updated INTEGER NOT NULL
3668 );
3669 CREATE TABLE message (
3670 id TEXT PRIMARY KEY,
3671 session_id TEXT NOT NULL
3672 );
3673 INSERT INTO session VALUES ('ses_relative', '.', 'Ghost', 41);
3674 INSERT INTO session VALUES ('ses_here', '{}', 'Real', 42);",
3675 here.display()
3676 ))
3677 .unwrap();
3678 drop(conn);
3679
3680 let found = HarnessCatalog::new()
3681 .discover(&DiscoveryQuery {
3682 workspace: Some(here),
3683 harnesses: vec![HarnessId::from(HarnessId::OPENCODE)],
3684 homes: HarnessHomes {
3685 opencode: db,
3686 ..HarnessHomes::default()
3687 },
3688 ..DiscoveryQuery::default()
3689 })
3690 .unwrap();
3691
3692 assert_eq!(found.len(), 1);
3693 assert_eq!(found[0].locator.session_id, "ses_here");
3694 fs::remove_dir_all(root).ok();
3695 }
3696}