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