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