Skip to main content

xurl_core/
service.rs

1use std::cmp::Reverse;
2use std::collections::{BTreeMap, BTreeSet, HashMap};
3use std::fs;
4use std::io::{BufRead, BufReader};
5use std::path::{Path, PathBuf};
6use std::time::UNIX_EPOCH;
7
8use grep::regex::RegexMatcherBuilder;
9use grep::searcher::{BinaryDetection, SearcherBuilder, sinks::Lossy};
10use regex::RegexBuilder;
11use rusqlite::{Connection, OpenFlags};
12use serde_json::Value;
13use walkdir::WalkDir;
14
15use crate::error::{Result, XurlError};
16use crate::jsonl;
17use crate::model::{
18    MessageRole, PathThreadQuery, PathThreadQueryResult, PiEntryListItem, PiEntryListView,
19    PiEntryQuery, ProviderKind, ResolvedThread, SubagentDetailView, SubagentExcerptMessage,
20    SubagentLifecycleEvent, SubagentListItem, SubagentListView, SubagentQuery, SubagentRelation,
21    SubagentThreadRef, SubagentView, ThreadQuery, ThreadQueryItem, ThreadQueryResult, WriteRequest,
22    WriteResult,
23};
24use crate::provider::amp::AmpProvider;
25use crate::provider::claude::ClaudeProvider;
26use crate::provider::codex::CodexProvider;
27use crate::provider::copilot::CopilotProvider;
28use crate::provider::cursor::CursorProvider;
29use crate::provider::gemini::GeminiProvider;
30use crate::provider::kimi::KimiProvider;
31use crate::provider::opencode::OpencodeProvider;
32use crate::provider::pi::PiProvider;
33use crate::provider::{Provider, ProviderRoots, WriteEventSink};
34use crate::render;
35use crate::uri::{AgentsUri, is_uuid_session_id};
36
37const STATUS_PENDING_INIT: &str = "pendingInit";
38const STATUS_RUNNING: &str = "running";
39const STATUS_COMPLETED: &str = "completed";
40const STATUS_ERRORED: &str = "errored";
41const STATUS_SHUTDOWN: &str = "shutdown";
42const STATUS_NOT_FOUND: &str = "notFound";
43const QUERY_METADATA_LINE_BUDGET: usize = 64;
44
45#[derive(Debug, Default, Clone)]
46struct AgentTimeline {
47    events: Vec<SubagentLifecycleEvent>,
48    states: Vec<String>,
49    has_spawn: bool,
50    has_activity: bool,
51    last_update: Option<String>,
52}
53
54#[derive(Debug, Clone)]
55struct ClaudeAgentRecord {
56    agent_id: String,
57    path: PathBuf,
58    status: String,
59    last_update: Option<String>,
60    relation: SubagentRelation,
61    excerpt: Vec<SubagentExcerptMessage>,
62    warnings: Vec<String>,
63}
64
65#[derive(Debug, Clone)]
66struct GeminiChatRecord {
67    session_id: String,
68    path: PathBuf,
69    last_update: Option<String>,
70    status: String,
71    explicit_parent_ids: Vec<String>,
72}
73
74#[derive(Debug, Clone)]
75struct GeminiLogEntry {
76    session_id: String,
77    message: Option<String>,
78    timestamp: Option<String>,
79    entry_type: Option<String>,
80    explicit_parent_ids: Vec<String>,
81}
82
83#[derive(Debug, Clone, Default)]
84struct GeminiChildRecord {
85    relation: SubagentRelation,
86    relation_timestamp: Option<String>,
87}
88
89#[derive(Debug, Clone)]
90struct AmpHandoff {
91    thread_id: String,
92    role: Option<String>,
93    timestamp: Option<String>,
94}
95
96#[derive(Debug, Clone)]
97struct AmpChildAnalysis {
98    thread: SubagentThreadRef,
99    status: String,
100    status_source: String,
101    excerpt: Vec<SubagentExcerptMessage>,
102    lifecycle: Vec<SubagentLifecycleEvent>,
103    relation_evidence: Vec<String>,
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
107enum PiSessionHintKind {
108    Parent,
109    Child,
110}
111
112#[derive(Debug, Clone)]
113struct PiSessionHint {
114    kind: PiSessionHintKind,
115    session_id: String,
116    evidence: String,
117}
118
119#[derive(Debug, Clone)]
120struct PiSessionRecord {
121    session_id: String,
122    path: PathBuf,
123    last_update: Option<String>,
124    hints: Vec<PiSessionHint>,
125}
126
127#[derive(Debug, Clone)]
128struct PiDiscoveredChild {
129    relation: SubagentRelation,
130    status: String,
131    status_source: String,
132    last_update: Option<String>,
133    child_thread: Option<SubagentThreadRef>,
134    excerpt: Vec<SubagentExcerptMessage>,
135    warnings: Vec<String>,
136}
137
138#[derive(Debug, Clone)]
139struct OpencodeAgentRecord {
140    agent_id: String,
141    relation: SubagentRelation,
142    message_count: usize,
143}
144
145#[derive(Debug, Clone)]
146struct OpencodeChildAnalysis {
147    child_thread: Option<SubagentThreadRef>,
148    status: String,
149    status_source: String,
150    last_update: Option<String>,
151    excerpt: Vec<SubagentExcerptMessage>,
152    warnings: Vec<String>,
153}
154
155impl Default for PiDiscoveredChild {
156    fn default() -> Self {
157        Self {
158            relation: SubagentRelation::default(),
159            status: STATUS_NOT_FOUND.to_string(),
160            status_source: "inferred".to_string(),
161            last_update: None,
162            child_thread: None,
163            excerpt: Vec::new(),
164            warnings: Vec::new(),
165        }
166    }
167}
168
169pub fn resolve_thread(uri: &AgentsUri, roots: &ProviderRoots) -> Result<ResolvedThread> {
170    let session_id = uri.require_session_id()?;
171    match uri.provider {
172        ProviderKind::Amp => AmpProvider::new(&roots.amp_root).resolve(session_id),
173        ProviderKind::Copilot => CopilotProvider::new(&roots.copilot_root).resolve(session_id),
174        ProviderKind::Codex => CodexProvider::new(&roots.codex_root).resolve(session_id),
175        ProviderKind::Claude => ClaudeProvider::new(&roots.claude_root).resolve(session_id),
176        ProviderKind::Cursor => CursorProvider::new(&roots.cursor_root).resolve(session_id),
177        ProviderKind::Gemini => GeminiProvider::new(&roots.gemini_root).resolve(session_id),
178        ProviderKind::Kimi => KimiProvider::new(&roots.kimi_root).resolve(session_id),
179        ProviderKind::Pi => PiProvider::new(&roots.pi_root).resolve(session_id),
180        ProviderKind::Opencode => OpencodeProvider::new(&roots.opencode_root).resolve(session_id),
181    }
182}
183
184pub fn write_thread(
185    provider: ProviderKind,
186    roots: &ProviderRoots,
187    req: &WriteRequest,
188    sink: &mut dyn WriteEventSink,
189) -> Result<WriteResult> {
190    match provider {
191        ProviderKind::Amp => AmpProvider::new(&roots.amp_root).write(req, sink),
192        ProviderKind::Copilot => CopilotProvider::new(&roots.copilot_root).write(req, sink),
193        ProviderKind::Codex => CodexProvider::new(&roots.codex_root).write(req, sink),
194        ProviderKind::Claude => ClaudeProvider::new(&roots.claude_root).write(req, sink),
195        ProviderKind::Cursor => CursorProvider::new(&roots.cursor_root).write(req, sink),
196        ProviderKind::Gemini => GeminiProvider::new(&roots.gemini_root).write(req, sink),
197        ProviderKind::Kimi => Err(XurlError::UnsupportedProviderWrite("kimi".to_string())),
198        ProviderKind::Pi => PiProvider::new(&roots.pi_root).write(req, sink),
199        ProviderKind::Opencode => OpencodeProvider::new(&roots.opencode_root).write(req, sink),
200    }
201}
202
203#[derive(Debug, Clone)]
204enum QuerySearchTarget {
205    File(PathBuf),
206    Text(String),
207}
208
209#[derive(Debug, Clone)]
210struct QueryCandidate {
211    provider: ProviderKind,
212    thread_id: String,
213    uri: String,
214    thread_source: String,
215    updated_at: Option<String>,
216    updated_epoch: Option<u64>,
217    scope_path: Option<PathBuf>,
218    search_target: QuerySearchTarget,
219}
220
221pub fn query_threads(query: &ThreadQuery, roots: &ProviderRoots) -> Result<ThreadQueryResult> {
222    let mut warnings = query
223        .ignored_params
224        .iter()
225        .map(|key| format!("ignored query parameter: {key}"))
226        .collect::<Vec<_>>();
227
228    let mut candidates = match query.provider {
229        ProviderKind::Amp => collect_amp_query_candidates(roots, &mut warnings),
230        ProviderKind::Copilot => collect_copilot_query_candidates(roots, &mut warnings),
231        ProviderKind::Codex => collect_codex_query_candidates(roots, &mut warnings),
232        ProviderKind::Claude => collect_claude_query_candidates(roots, &mut warnings),
233        ProviderKind::Cursor => collect_cursor_query_candidates(
234            roots,
235            &mut warnings,
236            query.q.as_deref().is_some_and(|q| !q.trim().is_empty())
237                || query
238                    .role
239                    .as_deref()
240                    .is_some_and(|role| !role.trim().is_empty()),
241        )?,
242        ProviderKind::Gemini => collect_gemini_query_candidates(roots, &mut warnings),
243        ProviderKind::Kimi => collect_kimi_query_candidates(roots, &mut warnings),
244        ProviderKind::Pi => collect_pi_query_candidates(roots, &mut warnings),
245        ProviderKind::Opencode => collect_opencode_query_candidates(
246            roots,
247            &mut warnings,
248            query.q.as_deref().is_some_and(|q| !q.trim().is_empty())
249                || query
250                    .role
251                    .as_deref()
252                    .is_some_and(|role| !role.trim().is_empty()),
253        )?,
254    };
255
256    candidates.sort_by_key(|candidate| Reverse(candidate.updated_epoch.unwrap_or(0)));
257
258    if query.limit == 0 {
259        return Ok(ThreadQueryResult {
260            query: query.clone(),
261            items: Vec::new(),
262            warnings,
263        });
264    }
265
266    let role_filter = query
267        .role
268        .as_deref()
269        .map(str::trim)
270        .filter(|q| !q.is_empty());
271    let keyword_filter = query.q.as_deref().map(str::trim).filter(|q| !q.is_empty());
272    let mut items = Vec::new();
273    for candidate in &candidates {
274        if items.len() >= query.limit {
275            break;
276        }
277
278        let mut role_preview = None::<String>;
279        if let Some(role_filter) = role_filter {
280            role_preview = match_candidate_preview(candidate, role_filter)?;
281            if role_preview.is_none() {
282                continue;
283            }
284        }
285
286        let matched_preview = if let Some(keyword_filter) = keyword_filter {
287            let matched_preview = match_candidate_preview(candidate, keyword_filter)?;
288            if matched_preview.is_none() {
289                continue;
290            }
291            matched_preview
292        } else {
293            role_preview
294        };
295
296        items.push(ThreadQueryItem {
297            provider: candidate.provider,
298            thread_id: candidate.thread_id.clone(),
299            uri: candidate.uri.clone(),
300            thread_source: candidate.thread_source.clone(),
301            updated_at: candidate.updated_at.clone(),
302            matched_preview,
303            thread_metadata: match &candidate.search_target {
304                QuerySearchTarget::File(path) => {
305                    collect_query_thread_metadata(query.provider, path)
306                }
307                QuerySearchTarget::Text(_) => None,
308            },
309        });
310    }
311
312    Ok(ThreadQueryResult {
313        query: query.clone(),
314        items,
315        warnings,
316    })
317}
318
319pub fn query_threads_by_path(
320    query: &PathThreadQuery,
321    roots: &ProviderRoots,
322) -> Result<PathThreadQueryResult> {
323    let mut warnings = query
324        .ignored_params
325        .iter()
326        .map(|key| format!("ignored query parameter: {key}"))
327        .collect::<Vec<_>>();
328
329    let providers = query.providers.clone().unwrap_or_else(all_provider_kinds);
330    let keyword_filter = query.q.as_deref().map(str::trim).filter(|q| !q.is_empty());
331    let requested_path = PathBuf::from(&query.scope_path);
332    let mut candidates = Vec::new();
333    for provider in providers.iter().copied() {
334        candidates.extend(collect_candidates_for_provider(
335            provider,
336            roots,
337            &mut warnings,
338            keyword_filter.is_some(),
339        )?);
340    }
341
342    candidates.retain(|candidate| {
343        candidate
344            .scope_path
345            .as_deref()
346            .is_some_and(|scope_path| path_matches_scope(scope_path, &requested_path))
347    });
348    candidates.sort_by_key(|candidate| Reverse(candidate.updated_epoch.unwrap_or(0)));
349
350    if query.limit == 0 {
351        return Ok(PathThreadQueryResult {
352            query: query.clone(),
353            items: Vec::new(),
354            warnings,
355        });
356    }
357
358    let mut items = Vec::new();
359    for candidate in &candidates {
360        if items.len() >= query.limit {
361            break;
362        }
363
364        let matched_preview = if let Some(keyword_filter) = keyword_filter {
365            let matched_preview = match_candidate_preview(candidate, keyword_filter)?;
366            if matched_preview.is_none() {
367                continue;
368            }
369            matched_preview
370        } else {
371            None
372        };
373
374        items.push(ThreadQueryItem {
375            provider: candidate.provider,
376            thread_id: candidate.thread_id.clone(),
377            uri: candidate.uri.clone(),
378            thread_source: candidate.thread_source.clone(),
379            updated_at: candidate.updated_at.clone(),
380            matched_preview,
381            thread_metadata: match &candidate.search_target {
382                QuerySearchTarget::File(path) => {
383                    collect_query_thread_metadata(candidate.provider, path)
384                }
385                QuerySearchTarget::Text(_) => None,
386            },
387        });
388    }
389
390    Ok(PathThreadQueryResult {
391        query: query.clone(),
392        items,
393        warnings,
394    })
395}
396
397pub fn render_thread_query_head_markdown(result: &ThreadQueryResult) -> String {
398    let mut output = String::new();
399    output.push_str("---\n");
400    push_yaml_string(&mut output, "uri", &result.query.uri);
401    push_yaml_string(&mut output, "provider", &result.query.provider.to_string());
402    push_yaml_string(&mut output, "mode", "thread_query");
403    push_yaml_string(&mut output, "limit", &result.query.limit.to_string());
404    if let Some(role) = &result.query.role {
405        push_yaml_string(&mut output, "role", role);
406    }
407
408    if let Some(q) = &result.query.q {
409        push_yaml_string(&mut output, "q", q);
410    }
411
412    output.push_str("threads:\n");
413    if result.items.is_empty() {
414        output.push_str("  []\n");
415    } else {
416        for item in &result.items {
417            push_yaml_string_with_indent(&mut output, 2, "provider", &item.provider.to_string());
418            push_yaml_string_with_indent(&mut output, 2, "thread_id", &item.thread_id);
419            push_yaml_string_with_indent(&mut output, 2, "uri", &item.uri);
420            push_yaml_string_with_indent(&mut output, 2, "thread_source", &item.thread_source);
421            if let Some(updated_at) = &item.updated_at {
422                push_yaml_string_with_indent(&mut output, 2, "updated_at", updated_at);
423            }
424            if let Some(matched_preview) = &item.matched_preview {
425                push_yaml_string_with_indent(&mut output, 2, "matched_preview", matched_preview);
426            }
427            if let Some(thread_metadata) = &item.thread_metadata {
428                render_thread_metadata_with_indent(&mut output, 2, thread_metadata);
429            }
430        }
431    }
432
433    render_warnings(&mut output, &result.warnings);
434    output.push_str("---\n");
435    output
436}
437
438pub fn render_thread_query_markdown(result: &ThreadQueryResult) -> String {
439    let mut output = render_thread_query_head_markdown(result);
440    output.push('\n');
441    output.push_str("# Threads\n\n");
442    output.push_str(&format!("- Provider: `{}`\n", result.query.provider));
443    if let Some(role) = &result.query.role {
444        output.push_str(&format!("- Role: `{}`\n", role));
445    } else {
446        output.push_str("- Role: `_none_`\n");
447    }
448    output.push_str(&format!("- Limit: `{}`\n", result.query.limit));
449    if let Some(q) = &result.query.q {
450        output.push_str(&format!("- Query: `{}`\n", q));
451    } else {
452        output.push_str("- Query: `_none_`\n");
453    }
454    output.push_str(&format!("- Matched: `{}`\n\n", result.items.len()));
455
456    if result.items.is_empty() {
457        output.push_str("_No threads found._\n");
458        return output;
459    }
460
461    for (index, item) in result.items.iter().enumerate() {
462        output.push_str(&format!("## {}. `{}`\n\n", index + 1, item.uri));
463        output.push_str(&format!("- Provider: `{}`\n", item.provider));
464        output.push_str(&format!("- Thread ID: `{}`\n", item.thread_id));
465        output.push_str(&format!("- Thread Source: `{}`\n", item.thread_source));
466        if let Some(updated_at) = &item.updated_at {
467            output.push_str(&format!("- Updated At: `{}`\n", updated_at));
468        }
469        if let Some(matched_preview) = &item.matched_preview {
470            output.push_str(&format!("- Match: `{}`\n", matched_preview));
471        }
472        output.push('\n');
473    }
474
475    output
476}
477
478pub fn render_path_thread_query_head_markdown(result: &PathThreadQueryResult) -> String {
479    let mut output = String::new();
480    output.push_str("---\n");
481    push_yaml_string(&mut output, "uri", &result.query.uri);
482    push_yaml_string(&mut output, "scope_path", &result.query.scope_path);
483    push_yaml_string(&mut output, "mode", "path_thread_query");
484    push_yaml_string(&mut output, "limit", &result.query.limit.to_string());
485    if let Some(q) = &result.query.q {
486        push_yaml_string(&mut output, "q", q);
487    }
488    render_provider_filter(&mut output, result.query.providers.as_deref());
489
490    output.push_str("threads:\n");
491    if result.items.is_empty() {
492        output.push_str("  []\n");
493    } else {
494        for item in &result.items {
495            push_yaml_string_with_indent(&mut output, 2, "provider", &item.provider.to_string());
496            push_yaml_string_with_indent(&mut output, 2, "thread_id", &item.thread_id);
497            push_yaml_string_with_indent(&mut output, 2, "uri", &item.uri);
498            push_yaml_string_with_indent(&mut output, 2, "thread_source", &item.thread_source);
499            if let Some(updated_at) = &item.updated_at {
500                push_yaml_string_with_indent(&mut output, 2, "updated_at", updated_at);
501            }
502            if let Some(matched_preview) = &item.matched_preview {
503                push_yaml_string_with_indent(&mut output, 2, "matched_preview", matched_preview);
504            }
505            if let Some(thread_metadata) = &item.thread_metadata {
506                render_thread_metadata_with_indent(&mut output, 2, thread_metadata);
507            }
508        }
509    }
510
511    render_warnings(&mut output, &result.warnings);
512    output.push_str("---\n");
513    output
514}
515
516pub fn render_path_thread_query_markdown(result: &PathThreadQueryResult) -> String {
517    let mut output = render_path_thread_query_head_markdown(result);
518    output.push('\n');
519    output.push_str("# Threads\n\n");
520    output.push_str(&format!("- Scope Path: `{}`\n", result.query.scope_path));
521    output.push_str(&format!(
522        "- Providers: `{}`\n",
523        format_provider_filter(result.query.providers.as_deref())
524    ));
525    output.push_str(&format!("- Limit: `{}`\n", result.query.limit));
526    if let Some(q) = &result.query.q {
527        output.push_str(&format!("- Query: `{}`\n", q));
528    } else {
529        output.push_str("- Query: `_none_`\n");
530    }
531    output.push_str(&format!("- Matched: `{}`\n\n", result.items.len()));
532
533    if result.items.is_empty() {
534        output.push_str("_No threads found._\n");
535        return output;
536    }
537
538    for (index, item) in result.items.iter().enumerate() {
539        output.push_str(&format!("## {}. `{}`\n\n", index + 1, item.uri));
540        output.push_str(&format!("- Provider: `{}`\n", item.provider));
541        output.push_str(&format!("- Thread ID: `{}`\n", item.thread_id));
542        output.push_str(&format!("- Thread Source: `{}`\n", item.thread_source));
543        if let Some(updated_at) = &item.updated_at {
544            output.push_str(&format!("- Updated At: `{}`\n", updated_at));
545        }
546        if let Some(matched_preview) = &item.matched_preview {
547            output.push_str(&format!("- Match: `{}`\n", matched_preview));
548        }
549        output.push('\n');
550    }
551
552    output
553}
554
555fn match_candidate_preview(candidate: &QueryCandidate, keyword: &str) -> Result<Option<String>> {
556    match &candidate.search_target {
557        QuerySearchTarget::File(path) => match_first_preview_in_file(path, keyword),
558        QuerySearchTarget::Text(text) => Ok(match_first_preview_in_text(text, keyword)),
559    }
560}
561
562fn match_first_preview_in_file(path: &Path, keyword: &str) -> Result<Option<String>> {
563    let mut matcher_builder = RegexMatcherBuilder::new();
564    matcher_builder.fixed_strings(true).case_insensitive(true);
565    let matcher = matcher_builder
566        .build(keyword)
567        .map_err(|err| XurlError::InvalidMode(format!("invalid keyword query: {err}")))?;
568    let mut searcher = SearcherBuilder::new()
569        .binary_detection(BinaryDetection::quit(b'\x00'))
570        .line_number(true)
571        .build();
572    let mut preview = None::<String>;
573    searcher
574        .search_path(
575            &matcher,
576            path,
577            Lossy(|_, line| {
578                let line = line.trim();
579                if line.is_empty() {
580                    return Ok(true);
581                }
582                preview = Some(truncate_preview(line, 160));
583                Ok(false)
584            }),
585        )
586        .map_err(|source| XurlError::Io {
587            path: path.to_path_buf(),
588            source,
589        })?;
590    Ok(preview)
591}
592
593fn match_first_preview_in_text(text: &str, keyword: &str) -> Option<String> {
594    let matcher = RegexBuilder::new(&regex::escape(keyword))
595        .case_insensitive(true)
596        .build()
597        .ok()?;
598    let found = matcher.find(text)?;
599    let line_start = text[..found.start()].rfind('\n').map_or(0, |idx| idx + 1);
600    let line_end = text[found.end()..]
601        .find('\n')
602        .map_or(text.len(), |idx| found.end() + idx);
603    let line = text[line_start..line_end].trim();
604    if line.is_empty() {
605        Some(truncate_preview(text, 160))
606    } else {
607        Some(truncate_preview(line, 160))
608    }
609}
610
611fn read_thread_raw(path: &Path) -> Result<String> {
612    let bytes = fs::read(path).map_err(|source| XurlError::Io {
613        path: path.to_path_buf(),
614        source,
615    })?;
616
617    if bytes.is_empty() {
618        return Err(XurlError::EmptyThreadFile {
619            path: path.to_path_buf(),
620        });
621    }
622
623    String::from_utf8(bytes).map_err(|_| XurlError::NonUtf8ThreadFile {
624        path: path.to_path_buf(),
625    })
626}
627
628pub fn render_thread_markdown(uri: &AgentsUri, resolved: &ResolvedThread) -> Result<String> {
629    let raw = read_thread_raw(&resolved.path)?;
630    let markdown = render::render_markdown(uri, &resolved.path, &raw)?;
631    Ok(strip_frontmatter(markdown))
632}
633
634pub fn render_thread_head_markdown(uri: &AgentsUri, roots: &ProviderRoots) -> Result<String> {
635    let mut output = String::new();
636    output.push_str("---\n");
637    push_yaml_string(&mut output, "uri", &uri.as_agents_string());
638    push_yaml_string(&mut output, "provider", &uri.provider.to_string());
639    push_yaml_string(&mut output, "session_id", &uri.session_id);
640
641    match (uri.provider, uri.agent_id.as_deref()) {
642        (
643            ProviderKind::Amp
644            | ProviderKind::Codex
645            | ProviderKind::Claude
646            | ProviderKind::Gemini
647            | ProviderKind::Opencode,
648            None,
649        ) => {
650            let resolved_main = resolve_thread(uri, roots)?;
651            push_yaml_string(
652                &mut output,
653                "thread_source",
654                &resolved_main.path.display().to_string(),
655            );
656            let (thread_metadata, metadata_warnings) =
657                collect_thread_metadata(uri.provider, &resolved_main.path);
658            render_thread_metadata(&mut output, &thread_metadata);
659            push_yaml_string(&mut output, "mode", "subagent_index");
660
661            let view = resolve_subagent_view(uri, roots, true)?;
662            let mut warnings = resolved_main.metadata.warnings.clone();
663            warnings.extend(metadata_warnings);
664
665            if let SubagentView::List(list) = view {
666                render_subagents_head(&mut output, &list);
667                warnings.extend(list.warnings);
668            }
669
670            render_warnings(&mut output, &warnings);
671        }
672        (ProviderKind::Copilot | ProviderKind::Cursor | ProviderKind::Kimi, None) => {
673            let resolved = resolve_thread(uri, roots)?;
674            push_yaml_string(
675                &mut output,
676                "thread_source",
677                &resolved.path.display().to_string(),
678            );
679            let (thread_metadata, metadata_warnings) =
680                collect_thread_metadata(uri.provider, &resolved.path);
681            render_thread_metadata(&mut output, &thread_metadata);
682            push_yaml_string(&mut output, "mode", "thread");
683            let mut warnings = resolved.metadata.warnings.clone();
684            warnings.extend(metadata_warnings);
685            render_warnings(&mut output, &warnings);
686        }
687        (ProviderKind::Pi, None) => {
688            let resolved = resolve_thread(uri, roots)?;
689            push_yaml_string(
690                &mut output,
691                "thread_source",
692                &resolved.path.display().to_string(),
693            );
694            let (thread_metadata, metadata_warnings) =
695                collect_thread_metadata(uri.provider, &resolved.path);
696            render_thread_metadata(&mut output, &thread_metadata);
697            push_yaml_string(&mut output, "mode", "pi_entry_index");
698
699            let list = resolve_pi_entry_list_view(uri, roots)?;
700            render_pi_entries_head(&mut output, &list);
701            let mut warnings = resolved.metadata.warnings.clone();
702            warnings.extend(metadata_warnings);
703            warnings.extend(list.warnings);
704
705            if let SubagentView::List(subagents) = resolve_subagent_view(uri, roots, true)? {
706                render_subagents_head(&mut output, &subagents);
707                warnings.extend(subagents.warnings);
708            }
709
710            render_warnings(&mut output, &warnings);
711        }
712        (
713            ProviderKind::Amp
714            | ProviderKind::Copilot
715            | ProviderKind::Codex
716            | ProviderKind::Claude
717            | ProviderKind::Cursor
718            | ProviderKind::Gemini
719            | ProviderKind::Kimi
720            | ProviderKind::Opencode,
721            Some(_),
722        ) => {
723            let main_uri = main_thread_uri(uri);
724            let resolved_main = resolve_thread(&main_uri, roots)?;
725
726            let view = resolve_subagent_view(uri, roots, false)?;
727            if let SubagentView::Detail(detail) = view {
728                let thread_source = detail
729                    .child_thread
730                    .as_ref()
731                    .and_then(|thread| thread.path.as_deref())
732                    .map(ToString::to_string)
733                    .unwrap_or_else(|| resolved_main.path.display().to_string());
734                let (thread_metadata, metadata_warnings) =
735                    collect_thread_metadata(uri.provider, Path::new(&thread_source));
736                push_yaml_string(&mut output, "thread_source", &thread_source);
737                render_thread_metadata(&mut output, &thread_metadata);
738                push_yaml_string(&mut output, "mode", "subagent_detail");
739
740                if let Some(agent_id) = &detail.query.agent_id {
741                    push_yaml_string(&mut output, "agent_id", agent_id);
742                    push_yaml_string(
743                        &mut output,
744                        "subagent_uri",
745                        &agents_thread_uri(
746                            &detail.query.provider,
747                            &detail.query.main_thread_id,
748                            Some(agent_id),
749                        ),
750                    );
751                }
752                push_yaml_string(&mut output, "status", &detail.status);
753                push_yaml_string(&mut output, "status_source", &detail.status_source);
754
755                if let Some(child_thread) = &detail.child_thread {
756                    push_yaml_string(&mut output, "child_thread_id", &child_thread.thread_id);
757                    if let Some(path) = &child_thread.path {
758                        push_yaml_string(&mut output, "child_thread_source", path);
759                    }
760                    if let Some(last_updated_at) = &child_thread.last_updated_at {
761                        push_yaml_string(&mut output, "child_last_updated_at", last_updated_at);
762                    }
763                }
764
765                let mut warnings = detail.warnings.clone();
766                warnings.extend(metadata_warnings);
767                render_warnings(&mut output, &warnings);
768            }
769        }
770        (ProviderKind::Pi, Some(agent_id)) if is_uuid_session_id(agent_id) => {
771            let main_uri = main_thread_uri(uri);
772            let resolved_main = resolve_thread(&main_uri, roots)?;
773
774            let view = resolve_subagent_view(uri, roots, false)?;
775            if let SubagentView::Detail(detail) = view {
776                let thread_source = detail
777                    .child_thread
778                    .as_ref()
779                    .and_then(|thread| thread.path.as_deref())
780                    .map(ToString::to_string)
781                    .unwrap_or_else(|| resolved_main.path.display().to_string());
782                let (thread_metadata, metadata_warnings) =
783                    collect_thread_metadata(uri.provider, Path::new(&thread_source));
784                push_yaml_string(&mut output, "thread_source", &thread_source);
785                render_thread_metadata(&mut output, &thread_metadata);
786                push_yaml_string(&mut output, "mode", "subagent_detail");
787                push_yaml_string(&mut output, "agent_id", agent_id);
788                push_yaml_string(
789                    &mut output,
790                    "subagent_uri",
791                    &agents_thread_uri("pi", &uri.session_id, Some(agent_id)),
792                );
793                push_yaml_string(&mut output, "status", &detail.status);
794                push_yaml_string(&mut output, "status_source", &detail.status_source);
795
796                if let Some(child_thread) = &detail.child_thread {
797                    push_yaml_string(&mut output, "child_thread_id", &child_thread.thread_id);
798                    if let Some(path) = &child_thread.path {
799                        push_yaml_string(&mut output, "child_thread_source", path);
800                    }
801                    if let Some(last_updated_at) = &child_thread.last_updated_at {
802                        push_yaml_string(&mut output, "child_last_updated_at", last_updated_at);
803                    }
804                }
805
806                let mut warnings = detail.warnings.clone();
807                warnings.extend(metadata_warnings);
808                render_warnings(&mut output, &warnings);
809            }
810        }
811        (ProviderKind::Pi, Some(entry_id)) => {
812            let resolved = resolve_thread(uri, roots)?;
813            let (thread_metadata, metadata_warnings) =
814                collect_thread_metadata(uri.provider, &resolved.path);
815            push_yaml_string(
816                &mut output,
817                "thread_source",
818                &resolved.path.display().to_string(),
819            );
820            render_thread_metadata(&mut output, &thread_metadata);
821            push_yaml_string(&mut output, "mode", "pi_entry");
822            push_yaml_string(&mut output, "entry_id", entry_id);
823            let mut warnings = resolved.metadata.warnings.clone();
824            warnings.extend(metadata_warnings);
825            render_warnings(&mut output, &warnings);
826        }
827    }
828
829    output.push_str("---\n");
830    Ok(output)
831}
832
833pub fn resolve_subagent_view(
834    uri: &AgentsUri,
835    roots: &ProviderRoots,
836    list: bool,
837) -> Result<SubagentView> {
838    if list && uri.agent_id.is_some() {
839        return Err(XurlError::InvalidMode(
840            "subagent index mode requires agents://<provider>/<main_thread_id>".to_string(),
841        ));
842    }
843
844    if !list && uri.agent_id.is_none() {
845        return Err(XurlError::InvalidMode(
846            "subagent drill-down requires agents://<provider>/<main_thread_id>/<agent_id>"
847                .to_string(),
848        ));
849    }
850
851    match uri.provider {
852        ProviderKind::Amp => resolve_amp_subagent_view(uri, roots, list),
853        ProviderKind::Copilot => Err(XurlError::UnsupportedSubagentProvider(
854            ProviderKind::Copilot.to_string(),
855        )),
856        ProviderKind::Codex => resolve_codex_subagent_view(uri, roots, list),
857        ProviderKind::Claude => resolve_claude_subagent_view(uri, roots, list),
858        ProviderKind::Cursor => Err(XurlError::UnsupportedSubagentProvider("cursor".to_string())),
859        ProviderKind::Gemini => resolve_gemini_subagent_view(uri, roots, list),
860        ProviderKind::Kimi => Ok(SubagentView::List(SubagentListView {
861            query: SubagentQuery {
862                provider: "kimi".to_string(),
863                main_thread_id: uri.session_id.clone(),
864                agent_id: uri.agent_id.clone(),
865                list,
866            },
867            agents: Vec::new(),
868            warnings: Vec::new(),
869        })),
870        ProviderKind::Pi => resolve_pi_subagent_view(uri, roots, list),
871        ProviderKind::Opencode => resolve_opencode_subagent_view(uri, roots, list),
872    }
873}
874
875fn push_yaml_string(output: &mut String, key: &str, value: &str) {
876    output.push_str(&format!("{key}: '{}'\n", yaml_single_quoted(value)));
877}
878
879fn render_provider_filter(output: &mut String, providers: Option<&[ProviderKind]>) {
880    output.push_str("providers:\n");
881    if let Some(providers) = providers {
882        for provider in providers {
883            output.push_str(&format!(
884                "  - '{}'\n",
885                yaml_single_quoted(&provider.to_string())
886            ));
887        }
888    } else {
889        output.push_str("  - 'all'\n");
890    }
891}
892
893fn format_provider_filter(providers: Option<&[ProviderKind]>) -> String {
894    providers.map_or_else(
895        || "all".to_string(),
896        |providers| {
897            providers
898                .iter()
899                .map(ToString::to_string)
900                .collect::<Vec<_>>()
901                .join(", ")
902        },
903    )
904}
905
906fn yaml_single_quoted(value: &str) -> String {
907    value.replace('\'', "''")
908}
909
910fn render_warnings(output: &mut String, warnings: &[String]) {
911    let mut unique = BTreeSet::<String>::new();
912    unique.extend(warnings.iter().cloned());
913
914    if unique.is_empty() {
915        return;
916    }
917
918    output.push_str("warnings:\n");
919    for warning in unique {
920        output.push_str(&format!("  - '{}'\n", yaml_single_quoted(&warning)));
921    }
922}
923
924fn render_thread_metadata(output: &mut String, metadata: &[String]) {
925    if metadata.is_empty() {
926        return;
927    }
928    render_thread_metadata_with_indent(output, 0, metadata);
929}
930
931fn render_thread_metadata_with_indent(output: &mut String, indent: usize, metadata: &[String]) {
932    if metadata.is_empty() {
933        return;
934    }
935
936    let prefix = " ".repeat(indent);
937    output.push_str(&format!("{prefix}thread_metadata:\n"));
938    for value in metadata {
939        output.push_str(&format!("{prefix}  - '{}'\n", yaml_single_quoted(value)));
940    }
941}
942
943fn collect_thread_metadata(provider: ProviderKind, path: &Path) -> (Vec<String>, Vec<String>) {
944    let raw = match read_thread_raw(path) {
945        Ok(raw) => raw,
946        Err(err) => {
947            return (
948                Vec::new(),
949                vec![format!(
950                    "failed reading thread metadata {}: {err}",
951                    path.display()
952                )],
953            );
954        }
955    };
956
957    match provider {
958        ProviderKind::Amp => collect_amp_thread_metadata(path, &raw),
959        ProviderKind::Copilot => collect_copilot_thread_metadata(path, &raw),
960        ProviderKind::Codex => collect_codex_thread_metadata(path, &raw),
961        ProviderKind::Claude => collect_claude_thread_metadata(path, &raw),
962        ProviderKind::Cursor => collect_cursor_thread_metadata(path, &raw),
963        ProviderKind::Gemini => collect_gemini_thread_metadata(path, &raw),
964        ProviderKind::Kimi => (Vec::new(), Vec::new()),
965        ProviderKind::Pi => collect_pi_thread_metadata(path, &raw),
966        ProviderKind::Opencode => collect_opencode_thread_metadata(path, &raw),
967    }
968}
969
970fn collect_query_thread_metadata(provider: ProviderKind, path: &Path) -> Option<Vec<String>> {
971    let metadata = match provider {
972        ProviderKind::Codex => {
973            collect_query_jsonl_thread_metadata(path, |value, metadata, seen| {
974                match value.get("type").and_then(Value::as_str) {
975                    Some("session_meta") | Some("turn_context") => {
976                        push_thread_metadata_record(metadata, seen, &value)
977                    }
978                    _ => false,
979                }
980            })
981        }
982        ProviderKind::Claude => {
983            collect_query_jsonl_thread_metadata(path, |value, metadata, seen| {
984                if looks_like_claude_metadata(&value) {
985                    let mut metadata_value = value;
986                    if let Some(object) = metadata_value.as_object_mut() {
987                        object.remove("message");
988                    }
989                    push_thread_metadata_record(metadata, seen, &metadata_value)
990                } else {
991                    false
992                }
993            })
994        }
995        ProviderKind::Cursor => {
996            collect_query_jsonl_thread_metadata(path, |value, metadata, seen| {
997                if value.get("type").and_then(Value::as_str) == Some("session")
998                    && let Some(session_metadata) = value.get("metadata")
999                {
1000                    push_thread_metadata_record(metadata, seen, session_metadata)
1001                } else {
1002                    false
1003                }
1004            })
1005        }
1006        ProviderKind::Pi => collect_query_jsonl_thread_metadata(path, |value, metadata, seen| {
1007            match value.get("type").and_then(Value::as_str) {
1008                Some("session") | Some("model_change") | Some("thinking_level_change") => {
1009                    push_thread_metadata_record(metadata, seen, &value)
1010                }
1011                _ => false,
1012            }
1013        }),
1014        ProviderKind::Amp
1015        | ProviderKind::Copilot
1016        | ProviderKind::Gemini
1017        | ProviderKind::Kimi
1018        | ProviderKind::Opencode => collect_thread_metadata(provider, path).0,
1019    };
1020
1021    if metadata.is_empty() {
1022        None
1023    } else {
1024        Some(metadata)
1025    }
1026}
1027
1028fn collect_query_jsonl_thread_metadata<F>(path: &Path, mut on_value: F) -> Vec<String>
1029where
1030    F: FnMut(Value, &mut Vec<String>, &mut BTreeSet<String>) -> bool,
1031{
1032    let file = match fs::File::open(path) {
1033        Ok(file) => file,
1034        Err(_) => return Vec::new(),
1035    };
1036
1037    let reader = BufReader::new(file);
1038    let mut metadata = Vec::new();
1039    let mut seen = BTreeSet::<String>::new();
1040
1041    for line in reader.lines().take(QUERY_METADATA_LINE_BUDGET) {
1042        let Ok(line) = line else {
1043            break;
1044        };
1045        let trimmed = line.trim();
1046        if trimmed.is_empty() {
1047            continue;
1048        }
1049
1050        let Ok(value) = serde_json::from_str::<Value>(trimmed) else {
1051            continue;
1052        };
1053
1054        if on_value(value, &mut metadata, &mut seen) {
1055            break;
1056        }
1057    }
1058
1059    metadata
1060}
1061
1062fn collect_codex_thread_metadata(path: &Path, raw: &str) -> (Vec<String>, Vec<String>) {
1063    let mut metadata = Vec::new();
1064    let mut warnings = Vec::new();
1065    let mut seen = BTreeSet::<String>::new();
1066
1067    for (line_idx, line) in raw.lines().enumerate() {
1068        let trimmed = line.trim();
1069        if trimmed.is_empty() {
1070            continue;
1071        }
1072
1073        let value = match serde_json::from_str::<Value>(trimmed) {
1074            Ok(value) => value,
1075            Err(err) => {
1076                warnings.push(format!(
1077                    "failed parsing codex metadata line {} in {}: {err}",
1078                    line_idx + 1,
1079                    path.display()
1080                ));
1081                continue;
1082            }
1083        };
1084
1085        match value.get("type").and_then(Value::as_str) {
1086            Some("session_meta") | Some("turn_context") => {
1087                if push_thread_metadata_record(&mut metadata, &mut seen, &value) {
1088                    break;
1089                }
1090            }
1091            _ => {}
1092        }
1093    }
1094
1095    (metadata, warnings)
1096}
1097
1098fn collect_claude_thread_metadata(path: &Path, raw: &str) -> (Vec<String>, Vec<String>) {
1099    let mut metadata = Vec::new();
1100    let mut warnings = Vec::new();
1101    let mut seen = BTreeSet::<String>::new();
1102
1103    for (line_idx, line) in raw.lines().enumerate() {
1104        let trimmed = line.trim();
1105        if trimmed.is_empty() {
1106            continue;
1107        }
1108
1109        let value = match serde_json::from_str::<Value>(trimmed) {
1110            Ok(value) => value,
1111            Err(err) => {
1112                warnings.push(format!(
1113                    "failed parsing claude metadata line {} in {}: {err}",
1114                    line_idx + 1,
1115                    path.display()
1116                ));
1117                continue;
1118            }
1119        };
1120
1121        if looks_like_claude_metadata(&value) {
1122            let mut metadata_value = value;
1123            if let Some(object) = metadata_value.as_object_mut() {
1124                object.remove("message");
1125            }
1126            if push_thread_metadata_record(&mut metadata, &mut seen, &metadata_value) {
1127                break;
1128            }
1129        }
1130    }
1131
1132    (metadata, warnings)
1133}
1134
1135fn collect_pi_thread_metadata(path: &Path, raw: &str) -> (Vec<String>, Vec<String>) {
1136    let mut metadata = Vec::new();
1137    let mut warnings = Vec::new();
1138    let mut seen = BTreeSet::<String>::new();
1139
1140    for (line_idx, line) in raw.lines().enumerate() {
1141        let trimmed = line.trim();
1142        if trimmed.is_empty() {
1143            continue;
1144        }
1145
1146        let value = match serde_json::from_str::<Value>(trimmed) {
1147            Ok(value) => value,
1148            Err(err) => {
1149                warnings.push(format!(
1150                    "failed parsing pi metadata line {} in {}: {err}",
1151                    line_idx + 1,
1152                    path.display()
1153                ));
1154                continue;
1155            }
1156        };
1157
1158        match value.get("type").and_then(Value::as_str) {
1159            Some("session") | Some("model_change") | Some("thinking_level_change") => {
1160                if push_thread_metadata_record(&mut metadata, &mut seen, &value) {
1161                    break;
1162                }
1163            }
1164            _ => {}
1165        }
1166    }
1167
1168    (metadata, warnings)
1169}
1170
1171fn collect_amp_thread_metadata(path: &Path, raw: &str) -> (Vec<String>, Vec<String>) {
1172    collect_json_object_thread_metadata(path, raw, ProviderKind::Amp, &["messages"])
1173}
1174
1175fn collect_copilot_thread_metadata(path: &Path, raw: &str) -> (Vec<String>, Vec<String>) {
1176    let mut metadata = Vec::new();
1177    let mut warnings = Vec::new();
1178    let mut seen = BTreeSet::<String>::new();
1179
1180    for (line_idx, line) in raw.lines().enumerate() {
1181        let trimmed = line.trim();
1182        if trimmed.is_empty() {
1183            continue;
1184        }
1185
1186        let value = match serde_json::from_str::<Value>(trimmed) {
1187            Ok(value) => value,
1188            Err(err) => {
1189                warnings.push(format!(
1190                    "failed parsing copilot metadata line {} in {}: {err}",
1191                    line_idx + 1,
1192                    path.display()
1193                ));
1194                continue;
1195            }
1196        };
1197
1198        match value.get("type").and_then(Value::as_str) {
1199            Some("session.start") | Some("session.resume") | Some("subagent.selected") => {
1200                if push_thread_metadata_record(&mut metadata, &mut seen, &value) {
1201                    break;
1202                }
1203            }
1204            _ => {}
1205        }
1206    }
1207
1208    (metadata, warnings)
1209}
1210
1211fn collect_gemini_thread_metadata(path: &Path, raw: &str) -> (Vec<String>, Vec<String>) {
1212    collect_json_object_thread_metadata(path, raw, ProviderKind::Gemini, &["messages"])
1213}
1214
1215fn collect_cursor_thread_metadata(path: &Path, raw: &str) -> (Vec<String>, Vec<String>) {
1216    let mut metadata = Vec::new();
1217    let mut warnings = Vec::new();
1218    let mut seen = BTreeSet::<String>::new();
1219
1220    for (line_idx, line) in raw.lines().enumerate() {
1221        let trimmed = line.trim();
1222        if trimmed.is_empty() {
1223            continue;
1224        }
1225
1226        let value = match serde_json::from_str::<Value>(trimmed) {
1227            Ok(value) => value,
1228            Err(err) => {
1229                warnings.push(format!(
1230                    "failed parsing cursor metadata line {} in {}: {err}",
1231                    line_idx + 1,
1232                    path.display()
1233                ));
1234                continue;
1235            }
1236        };
1237
1238        if value.get("type").and_then(Value::as_str) == Some("session")
1239            && let Some(session_metadata) = value.get("metadata")
1240        {
1241            push_thread_metadata_record(&mut metadata, &mut seen, session_metadata);
1242            break;
1243        }
1244    }
1245
1246    (metadata, warnings)
1247}
1248
1249fn collect_opencode_thread_metadata(_path: &Path, raw: &str) -> (Vec<String>, Vec<String>) {
1250    let mut metadata = Vec::new();
1251    let mut seen = BTreeSet::<String>::new();
1252
1253    if let Some(first_non_empty) = raw.lines().find(|line| !line.trim().is_empty())
1254        && let Ok(value) = serde_json::from_str::<Value>(first_non_empty)
1255        && value.get("type").and_then(Value::as_str) == Some("session")
1256    {
1257        let _ = push_thread_metadata_record(&mut metadata, &mut seen, &value);
1258    }
1259
1260    (metadata, Vec::new())
1261}
1262
1263fn collect_json_object_thread_metadata(
1264    path: &Path,
1265    raw: &str,
1266    provider: ProviderKind,
1267    strip_keys: &[&str],
1268) -> (Vec<String>, Vec<String>) {
1269    let mut metadata = Vec::new();
1270    let mut seen = BTreeSet::<String>::new();
1271    let value = match serde_json::from_str::<Value>(raw) {
1272        Ok(value) => value,
1273        Err(err) => {
1274            return (
1275                metadata,
1276                vec![format!(
1277                    "failed parsing {provider} metadata payload {}: {err}",
1278                    path.display()
1279                )],
1280            );
1281        }
1282    };
1283
1284    let mut metadata_value = value;
1285    if let Some(object) = metadata_value.as_object_mut() {
1286        for key in strip_keys {
1287            object.remove(*key);
1288        }
1289    }
1290
1291    if !metadata_value.is_null() {
1292        let should_emit = metadata_value
1293            .as_object()
1294            .is_none_or(|object| !object.is_empty());
1295        if should_emit {
1296            let _ = push_thread_metadata_record(&mut metadata, &mut seen, &metadata_value);
1297        }
1298    }
1299
1300    (metadata, Vec::new())
1301}
1302
1303fn looks_like_claude_metadata(value: &Value) -> bool {
1304    value.get("cwd").is_some()
1305        || value.get("gitBranch").is_some()
1306        || value.get("version").is_some()
1307        || value.get("sessionId").is_some()
1308        || value.get("agentId").is_some()
1309        || value.get("isSidechain").is_some()
1310}
1311
1312fn scope_path_from_str(raw: &str) -> Option<PathBuf> {
1313    let trimmed = raw.trim();
1314    if trimmed.is_empty() {
1315        None
1316    } else {
1317        Some(PathBuf::from(trimmed))
1318    }
1319}
1320
1321fn extract_json_string_at_paths<'a>(value: &'a Value, paths: &[&[&str]]) -> Option<&'a str> {
1322    for path in paths {
1323        let mut current = value;
1324        let mut found = true;
1325        for key in *path {
1326            let Some(next) = current.get(*key) else {
1327                found = false;
1328                break;
1329            };
1330            current = next;
1331        }
1332        if found && let Some(text) = current.as_str() {
1333            return Some(text);
1334        }
1335    }
1336
1337    None
1338}
1339
1340fn find_first_string_by_key<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
1341    match value {
1342        Value::Object(map) => {
1343            if let Some(text) = map.get(key).and_then(Value::as_str) {
1344                return Some(text);
1345            }
1346            for child in map.values() {
1347                if let Some(text) = find_first_string_by_key(child, key) {
1348                    return Some(text);
1349                }
1350            }
1351            None
1352        }
1353        Value::Array(items) => items
1354            .iter()
1355            .find_map(|item| find_first_string_by_key(item, key)),
1356        Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => None,
1357    }
1358}
1359
1360fn extract_json_scope_path(
1361    path: &Path,
1362    field_paths: &[&[&str]],
1363    fallback_keys: &[&str],
1364) -> Option<PathBuf> {
1365    let raw = fs::read_to_string(path).ok()?;
1366    let value = serde_json::from_str::<Value>(&raw).ok()?;
1367    extract_json_string_at_paths(&value, field_paths)
1368        .and_then(scope_path_from_str)
1369        .or_else(|| {
1370            fallback_keys
1371                .iter()
1372                .find_map(|key| find_first_string_by_key(&value, key))
1373                .and_then(scope_path_from_str)
1374        })
1375}
1376
1377fn extract_codex_scope_path(path: &Path) -> Option<PathBuf> {
1378    let file = fs::File::open(path).ok()?;
1379    let reader = BufReader::new(file);
1380    for line in reader.lines().take(QUERY_METADATA_LINE_BUDGET).flatten() {
1381        let trimmed = line.trim();
1382        if trimmed.is_empty() {
1383            continue;
1384        }
1385        let value = serde_json::from_str::<Value>(trimmed).ok()?;
1386        match value.get("type").and_then(Value::as_str) {
1387            Some("session_meta") | Some("turn_context") => {
1388                if let Some(text) =
1389                    extract_json_string_at_paths(&value, &[&["payload", "cwd"], &["cwd"]])
1390                {
1391                    return scope_path_from_str(text);
1392                }
1393            }
1394            _ => {}
1395        }
1396    }
1397    None
1398}
1399
1400fn extract_claude_scope_path(path: &Path) -> Option<PathBuf> {
1401    let file = fs::File::open(path).ok()?;
1402    let reader = BufReader::new(file);
1403    for line in reader.lines().take(QUERY_METADATA_LINE_BUDGET).flatten() {
1404        let trimmed = line.trim();
1405        if trimmed.is_empty() {
1406            continue;
1407        }
1408        let value = serde_json::from_str::<Value>(trimmed).ok()?;
1409        if looks_like_claude_metadata(&value)
1410            && let Some(text) = extract_json_string_at_paths(
1411                &value,
1412                &[&["cwd"], &["projectPath"], &["originalPath"]],
1413            )
1414        {
1415            return scope_path_from_str(text);
1416        }
1417    }
1418    None
1419}
1420
1421fn extract_pi_scope_path(path: &Path) -> Option<PathBuf> {
1422    let file = fs::File::open(path).ok()?;
1423    let mut reader = BufReader::new(file);
1424    let mut line = String::new();
1425    reader.read_line(&mut line).ok()?;
1426    let value = serde_json::from_str::<Value>(line.trim()).ok()?;
1427    value
1428        .get("cwd")
1429        .and_then(Value::as_str)
1430        .and_then(scope_path_from_str)
1431}
1432
1433fn extract_amp_scope_path(path: &Path) -> Option<PathBuf> {
1434    extract_json_scope_path(path, &[&["cwd"]], &["cwd"])
1435}
1436
1437fn extract_copilot_scope_path(path: &Path) -> Option<PathBuf> {
1438    let file = fs::File::open(path).ok()?;
1439    let reader = BufReader::new(file);
1440    let mut latest = None::<PathBuf>;
1441
1442    for line in reader.lines().map_while(std::result::Result::ok) {
1443        let trimmed = line.trim();
1444        if trimmed.is_empty() {
1445            continue;
1446        }
1447
1448        let Ok(value) = serde_json::from_str::<Value>(trimmed) else {
1449            continue;
1450        };
1451        match value.get("type").and_then(Value::as_str) {
1452            Some("session.start") | Some("session.resume") => {
1453                if let Some(text) =
1454                    extract_json_string_at_paths(&value, &[&["data", "context", "cwd"]])
1455                {
1456                    latest = scope_path_from_str(text);
1457                }
1458            }
1459            _ => {}
1460        }
1461    }
1462
1463    latest
1464}
1465
1466fn extract_gemini_scope_path(path: &Path) -> Option<PathBuf> {
1467    if let Some(project_root_marker_path) = path
1468        .ancestors()
1469        .skip(1)
1470        .map(|ancestor| ancestor.join(".project_root"))
1471        .find(|candidate| candidate.exists())
1472        && let Ok(contents) = fs::read_to_string(project_root_marker_path)
1473        && let Some(scope_path) = scope_path_from_str(contents.trim())
1474    {
1475        return Some(scope_path);
1476    }
1477
1478    extract_json_scope_path(path, &[&["projectRoot"], &["cwd"]], &["projectRoot", "cwd"])
1479}
1480
1481fn push_thread_metadata_record(
1482    metadata: &mut Vec<String>,
1483    seen: &mut BTreeSet<String>,
1484    value: &Value,
1485) -> bool {
1486    let before = metadata.len();
1487    flatten_thread_metadata_value(metadata, seen, None, value);
1488    metadata.len() > before
1489}
1490
1491fn flatten_thread_metadata_value(
1492    metadata: &mut Vec<String>,
1493    seen: &mut BTreeSet<String>,
1494    path: Option<&str>,
1495    value: &Value,
1496) {
1497    if let Some(path) = path
1498        && should_ignore_thread_metadata_path(path)
1499    {
1500        return;
1501    }
1502    match value {
1503        Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {
1504            let Some(path) = path else {
1505                return;
1506            };
1507            let entry = format!("{path} = {}", format_thread_metadata_value(value));
1508            if seen.insert(entry.clone()) {
1509                metadata.push(entry);
1510            }
1511        }
1512        Value::Array(items) => {
1513            let Some(path) = path else {
1514                return;
1515            };
1516            if items.is_empty() {
1517                let entry = format!("{path} = []");
1518                if seen.insert(entry.clone()) {
1519                    metadata.push(entry);
1520                }
1521                return;
1522            }
1523
1524            for (index, item) in items.iter().enumerate() {
1525                let child_path = format!("{path}[{index}]");
1526                flatten_thread_metadata_value(metadata, seen, Some(&child_path), item);
1527            }
1528        }
1529        Value::Object(map) => {
1530            if map.is_empty() {
1531                if let Some(path) = path {
1532                    let entry = format!("{path} = {{}}");
1533                    if seen.insert(entry.clone()) {
1534                        metadata.push(entry);
1535                    }
1536                }
1537                return;
1538            }
1539
1540            for (key, child) in map {
1541                let child_path = match path {
1542                    Some(path) => format!("{path}.{key}"),
1543                    None => key.clone(),
1544                };
1545                flatten_thread_metadata_value(metadata, seen, Some(&child_path), child);
1546            }
1547        }
1548    }
1549}
1550
1551fn should_ignore_thread_metadata_path(path: &str) -> bool {
1552    const IGNORED_PREFIXES: &[&str] = &[
1553        "base_instructions",
1554        "user_instructions",
1555        "developer_instructions",
1556        "payload.base_instructions",
1557        "payload.user_instructions",
1558        "payload.developer_instructions",
1559    ];
1560
1561    IGNORED_PREFIXES.iter().any(|prefix| {
1562        path == *prefix
1563            || path.starts_with(&format!("{prefix}."))
1564            || path.starts_with(&format!("{prefix}["))
1565    })
1566}
1567fn format_thread_metadata_value(value: &Value) -> String {
1568    match value {
1569        Value::Null => "null".to_string(),
1570        Value::Bool(flag) => flag.to_string(),
1571        Value::Number(number) => number.to_string(),
1572        Value::String(text) => format_thread_metadata_string(text),
1573        Value::Array(_) | Value::Object(_) => serde_json::to_string(value).unwrap_or_default(),
1574    }
1575}
1576
1577fn all_provider_kinds() -> Vec<ProviderKind> {
1578    vec![
1579        ProviderKind::Amp,
1580        ProviderKind::Copilot,
1581        ProviderKind::Codex,
1582        ProviderKind::Claude,
1583        ProviderKind::Cursor,
1584        ProviderKind::Gemini,
1585        ProviderKind::Kimi,
1586        ProviderKind::Pi,
1587        ProviderKind::Opencode,
1588    ]
1589}
1590
1591fn path_matches_scope(scope_path: &Path, requested_path: &Path) -> bool {
1592    scope_path == requested_path || scope_path.starts_with(requested_path)
1593}
1594
1595fn collect_candidates_for_provider(
1596    provider: ProviderKind,
1597    roots: &ProviderRoots,
1598    warnings: &mut Vec<String>,
1599    with_search_text: bool,
1600) -> Result<Vec<QueryCandidate>> {
1601    match provider {
1602        ProviderKind::Amp => Ok(collect_amp_query_candidates(roots, warnings)),
1603        ProviderKind::Copilot => Ok(collect_copilot_query_candidates(roots, warnings)),
1604        ProviderKind::Codex => Ok(collect_codex_query_candidates(roots, warnings)),
1605        ProviderKind::Claude => Ok(collect_claude_query_candidates(roots, warnings)),
1606        ProviderKind::Cursor => collect_cursor_query_candidates(roots, warnings, with_search_text),
1607        ProviderKind::Gemini => Ok(collect_gemini_query_candidates(roots, warnings)),
1608        ProviderKind::Kimi => Ok(collect_kimi_query_candidates(roots, warnings)),
1609        ProviderKind::Pi => Ok(collect_pi_query_candidates(roots, warnings)),
1610        ProviderKind::Opencode => {
1611            collect_opencode_query_candidates(roots, warnings, with_search_text)
1612        }
1613    }
1614}
1615
1616fn format_thread_metadata_string(text: &str) -> String {
1617    if text.is_empty()
1618        || text.contains('\n')
1619        || text.starts_with(char::is_whitespace)
1620        || text.ends_with(char::is_whitespace)
1621    {
1622        serde_json::to_string(text).unwrap_or_else(|_| text.to_string())
1623    } else {
1624        text.to_string()
1625    }
1626}
1627
1628fn render_subagents_head(output: &mut String, list: &SubagentListView) {
1629    output.push_str("subagents:\n");
1630    if list.agents.is_empty() {
1631        output.push_str("  []\n");
1632        return;
1633    }
1634
1635    for agent in &list.agents {
1636        output.push_str(&format!(
1637            "  - agent_id: '{}'\n",
1638            yaml_single_quoted(&agent.agent_id)
1639        ));
1640        output.push_str(&format!(
1641            "    uri: '{}'\n",
1642            yaml_single_quoted(&agents_thread_uri(
1643                &list.query.provider,
1644                &list.query.main_thread_id,
1645                Some(&agent.agent_id),
1646            ))
1647        ));
1648        push_yaml_string_with_indent(output, 4, "status", &agent.status);
1649        push_yaml_string_with_indent(output, 4, "status_source", &agent.status_source);
1650        if let Some(last_update) = &agent.last_update {
1651            push_yaml_string_with_indent(output, 4, "last_update", last_update);
1652        }
1653        if let Some(child_thread) = &agent.child_thread
1654            && let Some(path) = &child_thread.path
1655        {
1656            push_yaml_string_with_indent(output, 4, "thread_source", path);
1657        }
1658    }
1659}
1660
1661fn render_pi_entries_head(output: &mut String, list: &PiEntryListView) {
1662    output.push_str("entries:\n");
1663    if list.entries.is_empty() {
1664        output.push_str("  []\n");
1665        return;
1666    }
1667
1668    for entry in &list.entries {
1669        output.push_str(&format!(
1670            "  - entry_id: '{}'\n",
1671            yaml_single_quoted(&entry.entry_id)
1672        ));
1673        output.push_str(&format!(
1674            "    uri: '{}'\n",
1675            yaml_single_quoted(&agents_thread_uri(
1676                &list.query.provider,
1677                &list.query.session_id,
1678                Some(&entry.entry_id),
1679            ))
1680        ));
1681        push_yaml_string_with_indent(output, 4, "entry_type", &entry.entry_type);
1682        if let Some(parent_id) = &entry.parent_id {
1683            push_yaml_string_with_indent(output, 4, "parent_id", parent_id);
1684        }
1685        if let Some(timestamp) = &entry.timestamp {
1686            push_yaml_string_with_indent(output, 4, "timestamp", timestamp);
1687        }
1688        if let Some(preview) = &entry.preview {
1689            push_yaml_string_with_indent(output, 4, "preview", preview);
1690        }
1691        push_yaml_bool_with_indent(output, 4, "is_leaf", entry.is_leaf);
1692    }
1693}
1694
1695fn push_yaml_string_with_indent(output: &mut String, indent: usize, key: &str, value: &str) {
1696    output.push_str(&format!(
1697        "{}{key}: '{}'\n",
1698        " ".repeat(indent),
1699        yaml_single_quoted(value)
1700    ));
1701}
1702
1703fn push_yaml_bool_with_indent(output: &mut String, indent: usize, key: &str, value: bool) {
1704    output.push_str(&format!("{}{key}: {value}\n", " ".repeat(indent)));
1705}
1706
1707fn strip_frontmatter(markdown: String) -> String {
1708    let Some(rest) = markdown.strip_prefix("---\n") else {
1709        return markdown;
1710    };
1711    let Some((_, body)) = rest.split_once("\n---\n\n") else {
1712        return markdown;
1713    };
1714    body.to_string()
1715}
1716
1717pub fn render_subagent_view_markdown(view: &SubagentView) -> String {
1718    match view {
1719        SubagentView::List(list_view) => render_subagent_list_markdown(list_view),
1720        SubagentView::Detail(detail_view) => render_subagent_detail_markdown(detail_view),
1721    }
1722}
1723
1724pub fn resolve_pi_entry_list_view(
1725    uri: &AgentsUri,
1726    roots: &ProviderRoots,
1727) -> Result<PiEntryListView> {
1728    if uri.provider != ProviderKind::Pi {
1729        return Err(XurlError::InvalidMode(
1730            "pi entry listing requires agents://pi/<session_id> (legacy pi://<session_id> is also supported)".to_string(),
1731        ));
1732    }
1733    if uri.agent_id.is_some() {
1734        return Err(XurlError::InvalidMode(
1735            "pi entry index mode requires agents://pi/<session_id>".to_string(),
1736        ));
1737    }
1738
1739    let resolved = resolve_thread(uri, roots)?;
1740    let raw = read_thread_raw(&resolved.path)?;
1741
1742    let mut warnings = resolved.metadata.warnings;
1743    let mut entries = Vec::<PiEntryListItem>::new();
1744    let mut parent_ids = BTreeSet::<String>::new();
1745
1746    for (line_idx, line) in raw.lines().enumerate() {
1747        let value = match jsonl::parse_json_line(Path::new("<pi:session>"), line_idx + 1, line) {
1748            Ok(Some(value)) => value,
1749            Ok(None) => continue,
1750            Err(err) => {
1751                warnings.push(format!(
1752                    "failed to parse pi session line {}: {err}",
1753                    line_idx + 1,
1754                ));
1755                continue;
1756            }
1757        };
1758
1759        if value.get("type").and_then(Value::as_str) == Some("session") {
1760            continue;
1761        }
1762
1763        let Some(entry_id) = value
1764            .get("id")
1765            .and_then(Value::as_str)
1766            .map(ToString::to_string)
1767        else {
1768            continue;
1769        };
1770        let parent_id = value
1771            .get("parentId")
1772            .and_then(Value::as_str)
1773            .map(ToString::to_string);
1774        if let Some(parent_id) = &parent_id {
1775            parent_ids.insert(parent_id.clone());
1776        }
1777
1778        let entry_type = value
1779            .get("type")
1780            .and_then(Value::as_str)
1781            .unwrap_or("unknown")
1782            .to_string();
1783
1784        let timestamp = value
1785            .get("timestamp")
1786            .and_then(Value::as_str)
1787            .map(ToString::to_string);
1788
1789        let preview = match entry_type.as_str() {
1790            "message" => value
1791                .get("message")
1792                .and_then(|message| message.get("content"))
1793                .map(|content| render_preview_text(content, 96))
1794                .filter(|text| !text.is_empty()),
1795            "compaction" | "branch_summary" => value
1796                .get("summary")
1797                .and_then(Value::as_str)
1798                .map(|text| truncate_preview(text, 96))
1799                .filter(|text| !text.is_empty()),
1800            _ => None,
1801        };
1802
1803        entries.push(PiEntryListItem {
1804            entry_id,
1805            entry_type,
1806            parent_id,
1807            timestamp,
1808            is_leaf: false,
1809            preview,
1810        });
1811    }
1812
1813    for entry in &mut entries {
1814        entry.is_leaf = !parent_ids.contains(&entry.entry_id);
1815    }
1816
1817    Ok(PiEntryListView {
1818        query: PiEntryQuery {
1819            provider: uri.provider.to_string(),
1820            session_id: uri.session_id.clone(),
1821            list: true,
1822        },
1823        entries,
1824        warnings,
1825    })
1826}
1827
1828pub fn render_pi_entry_list_markdown(view: &PiEntryListView) -> String {
1829    let session_uri = agents_thread_uri(&view.query.provider, &view.query.session_id, None);
1830    let mut output = String::new();
1831    output.push_str("# Pi Session Entries\n\n");
1832    output.push_str(&format!("- Provider: `{}`\n", view.query.provider));
1833    output.push_str(&format!("- Session: `{}`\n", session_uri));
1834    output.push_str("- Mode: `list`\n\n");
1835
1836    if view.entries.is_empty() {
1837        output.push_str("_No entries found in this session._\n");
1838        return output;
1839    }
1840
1841    for (index, entry) in view.entries.iter().enumerate() {
1842        let entry_uri = format!("{session_uri}/{}", entry.entry_id);
1843        output.push_str(&format!("## {}. `{}`\n\n", index + 1, entry_uri));
1844        output.push_str(&format!("- Type: `{}`\n", entry.entry_type));
1845        output.push_str(&format!(
1846            "- Parent: `{}`\n",
1847            entry.parent_id.as_deref().unwrap_or("root")
1848        ));
1849        output.push_str(&format!(
1850            "- Timestamp: `{}`\n",
1851            entry.timestamp.as_deref().unwrap_or("unknown")
1852        ));
1853        output.push_str(&format!(
1854            "- Leaf: `{}`\n",
1855            if entry.is_leaf { "yes" } else { "no" }
1856        ));
1857        if let Some(preview) = &entry.preview {
1858            output.push_str(&format!("- Preview: {}\n", preview));
1859        }
1860        output.push('\n');
1861    }
1862
1863    output
1864}
1865
1866fn resolve_pi_subagent_view(
1867    uri: &AgentsUri,
1868    roots: &ProviderRoots,
1869    list: bool,
1870) -> Result<SubagentView> {
1871    if uri.provider != ProviderKind::Pi {
1872        return Err(XurlError::InvalidMode(
1873            "pi child-session view requires agents://pi/<main_session_id>/<child_session_id>"
1874                .to_string(),
1875        ));
1876    }
1877
1878    if !list
1879        && uri
1880            .agent_id
1881            .as_deref()
1882            .is_some_and(|agent_id| !is_uuid_session_id(agent_id))
1883    {
1884        return Err(XurlError::InvalidMode(
1885            "pi child-session drill-down requires UUID child_session_id".to_string(),
1886        ));
1887    }
1888
1889    let main_uri = main_thread_uri(uri);
1890    let resolved_main = resolve_thread(&main_uri, roots)?;
1891    let mut warnings = resolved_main.metadata.warnings.clone();
1892
1893    let records = discover_pi_session_records(&roots.pi_root, &mut warnings);
1894    let main_record = records.get(&uri.session_id);
1895    let mut discovered = discover_pi_children(&uri.session_id, main_record, &records);
1896
1897    if list {
1898        warnings.extend(
1899            discovered
1900                .values()
1901                .flat_map(|child| child.warnings.clone())
1902                .collect::<Vec<_>>(),
1903        );
1904
1905        let agents = discovered
1906            .into_iter()
1907            .map(|(agent_id, child)| SubagentListItem {
1908                agent_id: agent_id.clone(),
1909                status: child.status,
1910                status_source: child.status_source,
1911                last_update: child.last_update,
1912                relation: child.relation,
1913                child_thread: child.child_thread,
1914            })
1915            .collect();
1916
1917        return Ok(SubagentView::List(SubagentListView {
1918            query: make_query(uri, None, true),
1919            agents,
1920            warnings,
1921        }));
1922    }
1923
1924    let requested_agent = uri
1925        .agent_id
1926        .clone()
1927        .ok_or_else(|| XurlError::InvalidMode("missing child session id".to_string()))?;
1928
1929    if let Some(child) = discovered.remove(&requested_agent) {
1930        warnings.extend(child.warnings.clone());
1931        let lifecycle = child
1932            .relation
1933            .evidence
1934            .iter()
1935            .map(|evidence| SubagentLifecycleEvent {
1936                timestamp: child.last_update.clone(),
1937                event: "session_relation_hint".to_string(),
1938                detail: evidence.clone(),
1939            })
1940            .collect::<Vec<_>>();
1941
1942        return Ok(SubagentView::Detail(SubagentDetailView {
1943            query: make_query(uri, Some(requested_agent), false),
1944            relation: child.relation,
1945            lifecycle,
1946            status: child.status,
1947            status_source: child.status_source,
1948            child_thread: child.child_thread,
1949            excerpt: child.excerpt,
1950            warnings,
1951        }));
1952    }
1953
1954    if let Some(record) = records.get(&requested_agent) {
1955        warnings.push(format!(
1956            "child session file exists but no relation hint links it to main_session_id={} (child path: {})",
1957            uri.session_id,
1958            record.path.display()
1959        ));
1960    } else {
1961        warnings.push(format!(
1962            "child session not found for main_session_id={} child_session_id={requested_agent}",
1963            uri.session_id
1964        ));
1965    }
1966
1967    Ok(SubagentView::Detail(SubagentDetailView {
1968        query: make_query(uri, Some(requested_agent), false),
1969        relation: SubagentRelation::default(),
1970        lifecycle: Vec::new(),
1971        status: STATUS_NOT_FOUND.to_string(),
1972        status_source: "inferred".to_string(),
1973        child_thread: None,
1974        excerpt: Vec::new(),
1975        warnings,
1976    }))
1977}
1978
1979fn discover_pi_children(
1980    main_session_id: &str,
1981    main_record: Option<&PiSessionRecord>,
1982    records: &BTreeMap<String, PiSessionRecord>,
1983) -> BTreeMap<String, PiDiscoveredChild> {
1984    let mut children = BTreeMap::<String, PiDiscoveredChild>::new();
1985
1986    for record in records.values() {
1987        for hint in record.hints.iter().filter(|hint| {
1988            hint.kind == PiSessionHintKind::Parent && hint.session_id == main_session_id
1989        }) {
1990            let child = children.entry(record.session_id.clone()).or_default();
1991            child.relation.validated = true;
1992            child.relation.evidence.push(format!(
1993                "{} (from {})",
1994                hint.evidence,
1995                record.path.display()
1996            ));
1997            child.last_update = child
1998                .last_update
1999                .clone()
2000                .or_else(|| record.last_update.clone());
2001            child.child_thread = Some(SubagentThreadRef {
2002                thread_id: record.session_id.clone(),
2003                path: Some(record.path.display().to_string()),
2004                last_updated_at: record.last_update.clone(),
2005            });
2006        }
2007    }
2008
2009    if let Some(main_record) = main_record {
2010        for hint in main_record
2011            .hints
2012            .iter()
2013            .filter(|hint| hint.kind == PiSessionHintKind::Child)
2014        {
2015            let child = children.entry(hint.session_id.clone()).or_default();
2016            child.relation.validated = true;
2017            child.relation.evidence.push(format!(
2018                "{} (from {})",
2019                hint.evidence,
2020                main_record.path.display()
2021            ));
2022
2023            if let Some(record) = records.get(&hint.session_id) {
2024                child.last_update = child
2025                    .last_update
2026                    .clone()
2027                    .or_else(|| record.last_update.clone());
2028                child.child_thread = Some(SubagentThreadRef {
2029                    thread_id: record.session_id.clone(),
2030                    path: Some(record.path.display().to_string()),
2031                    last_updated_at: record.last_update.clone(),
2032                });
2033            } else {
2034                child.status = STATUS_NOT_FOUND.to_string();
2035                child.status_source = "inferred".to_string();
2036                child.warnings.push(format!(
2037                    "relation hint references child_session_id={} but transcript file is missing for main_session_id={} ({})",
2038                    hint.session_id, main_session_id, hint.evidence
2039                ));
2040            }
2041        }
2042    }
2043
2044    for (child_id, child) in &mut children {
2045        let Some(path) = child
2046            .child_thread
2047            .as_ref()
2048            .and_then(|thread| thread.path.as_deref())
2049            .map(ToString::to_string)
2050        else {
2051            continue;
2052        };
2053
2054        match read_thread_raw(Path::new(&path)) {
2055            Ok(raw) => {
2056                if child.last_update.is_none() {
2057                    child.last_update = extract_last_timestamp(&raw);
2058                }
2059
2060                let messages = render::extract_messages(ProviderKind::Pi, Path::new(&path), &raw)
2061                    .unwrap_or_default();
2062
2063                let has_assistant = messages
2064                    .iter()
2065                    .any(|message| matches!(message.role, crate::model::MessageRole::Assistant));
2066                let has_user = messages
2067                    .iter()
2068                    .any(|message| matches!(message.role, crate::model::MessageRole::User));
2069
2070                child.status = if has_assistant {
2071                    STATUS_COMPLETED.to_string()
2072                } else if has_user {
2073                    STATUS_RUNNING.to_string()
2074                } else {
2075                    STATUS_PENDING_INIT.to_string()
2076                };
2077                child.status_source = "child_rollout".to_string();
2078                child.excerpt = messages
2079                    .into_iter()
2080                    .rev()
2081                    .take(3)
2082                    .collect::<Vec<_>>()
2083                    .into_iter()
2084                    .rev()
2085                    .map(|message| SubagentExcerptMessage {
2086                        role: message.role,
2087                        text: message.text,
2088                    })
2089                    .collect();
2090            }
2091            Err(err) => {
2092                child.status = STATUS_NOT_FOUND.to_string();
2093                child.status_source = "inferred".to_string();
2094                child.warnings.push(format!(
2095                    "failed to read child session transcript for child_session_id={child_id}: {err}"
2096                ));
2097            }
2098        }
2099    }
2100
2101    children
2102}
2103
2104fn discover_pi_session_records(
2105    pi_root: &Path,
2106    warnings: &mut Vec<String>,
2107) -> BTreeMap<String, PiSessionRecord> {
2108    let sessions_root = pi_root.join("sessions");
2109    if !sessions_root.exists() {
2110        return BTreeMap::new();
2111    }
2112
2113    let mut latest = BTreeMap::<String, (u64, PiSessionRecord)>::new();
2114    for entry in WalkDir::new(&sessions_root)
2115        .into_iter()
2116        .filter_map(std::result::Result::ok)
2117        .filter(|entry| entry.file_type().is_file())
2118        .filter(|entry| {
2119            entry
2120                .path()
2121                .extension()
2122                .and_then(|ext| ext.to_str())
2123                .is_some_and(|ext| ext == "jsonl")
2124        })
2125    {
2126        let path = entry.path();
2127        let Some(record) = parse_pi_session_record(path, warnings) else {
2128            continue;
2129        };
2130
2131        let stamp = file_modified_epoch(path).unwrap_or(0);
2132        match latest.get(&record.session_id) {
2133            Some((existing_stamp, existing)) => {
2134                if stamp > *existing_stamp {
2135                    warnings.push(format!(
2136                        "multiple pi transcripts found for session_id={}; selected latest: {}",
2137                        record.session_id,
2138                        record.path.display()
2139                    ));
2140                    latest.insert(record.session_id.clone(), (stamp, record));
2141                } else {
2142                    warnings.push(format!(
2143                        "multiple pi transcripts found for session_id={}; kept latest: {}",
2144                        existing.session_id,
2145                        existing.path.display()
2146                    ));
2147                }
2148            }
2149            None => {
2150                latest.insert(record.session_id.clone(), (stamp, record));
2151            }
2152        }
2153    }
2154
2155    latest
2156        .into_values()
2157        .map(|(_, record)| (record.session_id.clone(), record))
2158        .collect()
2159}
2160
2161fn parse_pi_session_record(path: &Path, warnings: &mut Vec<String>) -> Option<PiSessionRecord> {
2162    let raw = match read_thread_raw(path) {
2163        Ok(raw) => raw,
2164        Err(err) => {
2165            warnings.push(format!(
2166                "failed to read pi session transcript {}: {err}",
2167                path.display()
2168            ));
2169            return None;
2170        }
2171    };
2172
2173    let first_non_empty = raw.lines().find(|line| !line.trim().is_empty())?;
2174
2175    let header = match serde_json::from_str::<Value>(first_non_empty) {
2176        Ok(value) => value,
2177        Err(err) => {
2178            warnings.push(format!(
2179                "failed to parse pi session header {}: {err}",
2180                path.display()
2181            ));
2182            return None;
2183        }
2184    };
2185
2186    if header.get("type").and_then(Value::as_str) != Some("session") {
2187        return None;
2188    }
2189
2190    let Some(session_id) = header
2191        .get("id")
2192        .and_then(Value::as_str)
2193        .map(str::to_ascii_lowercase)
2194    else {
2195        warnings.push(format!(
2196            "pi session header missing id in {}",
2197            path.display()
2198        ));
2199        return None;
2200    };
2201
2202    if !is_uuid_session_id(&session_id) {
2203        warnings.push(format!(
2204            "pi session header id is not UUID in {}: {}",
2205            path.display(),
2206            session_id
2207        ));
2208        return None;
2209    }
2210
2211    let hints = collect_pi_session_hints(&header);
2212    let last_update = header
2213        .get("timestamp")
2214        .and_then(Value::as_str)
2215        .map(ToString::to_string)
2216        .or_else(|| modified_timestamp_string(path));
2217
2218    Some(PiSessionRecord {
2219        session_id,
2220        path: path.to_path_buf(),
2221        last_update,
2222        hints,
2223    })
2224}
2225
2226fn collect_pi_session_hints(header: &Value) -> Vec<PiSessionHint> {
2227    let mut hints = Vec::new();
2228    collect_pi_session_hints_rec(header, "", &mut hints);
2229
2230    let mut seen = BTreeSet::new();
2231    hints
2232        .into_iter()
2233        .filter(|hint| seen.insert((hint.kind, hint.session_id.clone(), hint.evidence.clone())))
2234        .collect()
2235}
2236
2237fn collect_pi_session_hints_rec(value: &Value, path: &str, out: &mut Vec<PiSessionHint>) {
2238    match value {
2239        Value::Object(map) => {
2240            for (key, child) in map {
2241                let key_path = if path.is_empty() {
2242                    key.clone()
2243                } else {
2244                    format!("{path}.{key}")
2245                };
2246
2247                if let Some(kind) = classify_pi_hint_key(key) {
2248                    let mut ids = Vec::new();
2249                    collect_uuid_strings(child, &mut ids);
2250                    for session_id in ids {
2251                        out.push(PiSessionHint {
2252                            kind,
2253                            session_id,
2254                            evidence: format!("session header key `{key_path}`"),
2255                        });
2256                    }
2257                }
2258
2259                collect_pi_session_hints_rec(child, &key_path, out);
2260            }
2261        }
2262        Value::Array(items) => {
2263            for (index, child) in items.iter().enumerate() {
2264                let key_path = format!("{path}[{index}]");
2265                collect_pi_session_hints_rec(child, &key_path, out);
2266            }
2267        }
2268        _ => {}
2269    }
2270}
2271
2272fn classify_pi_hint_key(key: &str) -> Option<PiSessionHintKind> {
2273    let normalized = normalize_hint_key(key);
2274
2275    const PARENT_HINTS: &[&str] = &[
2276        "parentsessionid",
2277        "parentsessionids",
2278        "parentthreadid",
2279        "parentthreadids",
2280        "mainsessionid",
2281        "rootsessionid",
2282        "parentid",
2283    ];
2284    const CHILD_HINTS: &[&str] = &[
2285        "childsessionid",
2286        "childsessionids",
2287        "childthreadid",
2288        "childthreadids",
2289        "childid",
2290        "subsessionid",
2291        "subsessionids",
2292        "subagentsessionid",
2293        "subagentsessionids",
2294        "subagentthreadid",
2295        "subagentthreadids",
2296    ];
2297
2298    if PARENT_HINTS.contains(&normalized.as_str()) {
2299        return Some(PiSessionHintKind::Parent);
2300    }
2301    if CHILD_HINTS.contains(&normalized.as_str()) {
2302        return Some(PiSessionHintKind::Child);
2303    }
2304
2305    let has_session_scope = normalized.contains("session") || normalized.contains("thread");
2306    if has_session_scope
2307        && (normalized.contains("parent")
2308            || normalized.contains("main")
2309            || normalized.contains("root"))
2310    {
2311        return Some(PiSessionHintKind::Parent);
2312    }
2313    if has_session_scope
2314        && (normalized.contains("child")
2315            || normalized.contains("subagent")
2316            || normalized.contains("subsession"))
2317    {
2318        return Some(PiSessionHintKind::Child);
2319    }
2320
2321    None
2322}
2323
2324fn normalize_hint_key(key: &str) -> String {
2325    key.chars()
2326        .filter(|ch| ch.is_ascii_alphanumeric())
2327        .flat_map(char::to_lowercase)
2328        .collect()
2329}
2330
2331fn collect_uuid_strings(value: &Value, ids: &mut Vec<String>) {
2332    match value {
2333        Value::String(text) => {
2334            if is_uuid_session_id(text) {
2335                ids.push(text.to_ascii_lowercase());
2336            }
2337        }
2338        Value::Array(items) => {
2339            for item in items {
2340                collect_uuid_strings(item, ids);
2341            }
2342        }
2343        Value::Object(map) => {
2344            for item in map.values() {
2345                collect_uuid_strings(item, ids);
2346            }
2347        }
2348        _ => {}
2349    }
2350}
2351
2352fn resolve_amp_subagent_view(
2353    uri: &AgentsUri,
2354    roots: &ProviderRoots,
2355    list: bool,
2356) -> Result<SubagentView> {
2357    let main_uri = main_thread_uri(uri);
2358    let resolved_main = resolve_thread(&main_uri, roots)?;
2359    let main_raw = read_thread_raw(&resolved_main.path)?;
2360    let main_value =
2361        serde_json::from_str::<Value>(&main_raw).map_err(|source| XurlError::InvalidJsonLine {
2362            path: resolved_main.path.clone(),
2363            line: 1,
2364            source,
2365        })?;
2366
2367    let mut warnings = resolved_main.metadata.warnings.clone();
2368    let handoffs = extract_amp_handoffs(&main_value, "main", &mut warnings);
2369
2370    if list {
2371        return Ok(SubagentView::List(build_amp_list_view(
2372            uri, roots, &handoffs, warnings,
2373        )));
2374    }
2375
2376    let agent_id = uri
2377        .agent_id
2378        .clone()
2379        .ok_or_else(|| XurlError::InvalidMode("missing agent id".to_string()))?;
2380
2381    Ok(SubagentView::Detail(build_amp_detail_view(
2382        uri, roots, &agent_id, &handoffs, warnings,
2383    )))
2384}
2385
2386fn build_amp_list_view(
2387    uri: &AgentsUri,
2388    roots: &ProviderRoots,
2389    handoffs: &[AmpHandoff],
2390    mut warnings: Vec<String>,
2391) -> SubagentListView {
2392    let mut grouped = BTreeMap::<String, Vec<&AmpHandoff>>::new();
2393    for handoff in handoffs {
2394        if handoff.thread_id == uri.session_id || handoff.role.as_deref() == Some("child") {
2395            continue;
2396        }
2397        grouped
2398            .entry(handoff.thread_id.clone())
2399            .or_default()
2400            .push(handoff);
2401    }
2402
2403    let mut agents = Vec::new();
2404    for (agent_id, relations) in grouped {
2405        let mut relation = SubagentRelation::default();
2406
2407        for handoff in relations {
2408            match handoff.role.as_deref() {
2409                Some("parent") => {
2410                    relation.validated = true;
2411                    push_unique(
2412                        &mut relation.evidence,
2413                        "main relationships includes handoff(role=parent) to child thread"
2414                            .to_string(),
2415                    );
2416                }
2417                Some(role) => {
2418                    push_unique(
2419                        &mut relation.evidence,
2420                        format!("main relationships includes handoff(role={role}) to child thread"),
2421                    );
2422                }
2423                None => {
2424                    push_unique(
2425                        &mut relation.evidence,
2426                        "main relationships includes handoff(role missing) to child thread"
2427                            .to_string(),
2428                    );
2429                }
2430            }
2431        }
2432
2433        let mut status = if relation.validated {
2434            STATUS_PENDING_INIT.to_string()
2435        } else {
2436            STATUS_NOT_FOUND.to_string()
2437        };
2438        let mut status_source = "inferred".to_string();
2439        let mut last_update = None::<String>;
2440        let mut child_thread = None::<SubagentThreadRef>;
2441
2442        if let Some(analysis) =
2443            analyze_amp_child_thread(&agent_id, &uri.session_id, roots, &mut warnings)
2444        {
2445            for evidence in analysis.relation_evidence {
2446                push_unique(&mut relation.evidence, evidence);
2447            }
2448            if !relation.evidence.is_empty() {
2449                relation.validated = true;
2450            }
2451
2452            status = analysis.status;
2453            status_source = analysis.status_source;
2454            last_update = analysis.thread.last_updated_at.clone();
2455            child_thread = Some(analysis.thread);
2456        }
2457
2458        agents.push(SubagentListItem {
2459            agent_id,
2460            status,
2461            status_source,
2462            last_update,
2463            relation,
2464            child_thread,
2465        });
2466    }
2467
2468    SubagentListView {
2469        query: make_query(uri, None, true),
2470        agents,
2471        warnings,
2472    }
2473}
2474
2475fn build_amp_detail_view(
2476    uri: &AgentsUri,
2477    roots: &ProviderRoots,
2478    agent_id: &str,
2479    handoffs: &[AmpHandoff],
2480    mut warnings: Vec<String>,
2481) -> SubagentDetailView {
2482    let mut relation = SubagentRelation::default();
2483    let mut lifecycle = Vec::<SubagentLifecycleEvent>::new();
2484
2485    let matches = handoffs
2486        .iter()
2487        .filter(|handoff| handoff.thread_id == agent_id)
2488        .collect::<Vec<_>>();
2489
2490    if matches.is_empty() {
2491        warnings.push(format!(
2492            "no handoff relationship found in main thread for child_thread_id={agent_id}"
2493        ));
2494    }
2495
2496    for handoff in matches {
2497        match handoff.role.as_deref() {
2498            Some("parent") => {
2499                relation.validated = true;
2500                push_unique(
2501                    &mut relation.evidence,
2502                    "main relationships includes handoff(role=parent) to child thread".to_string(),
2503                );
2504                lifecycle.push(SubagentLifecycleEvent {
2505                    timestamp: handoff.timestamp.clone(),
2506                    event: "handoff".to_string(),
2507                    detail: "main handoff relationship discovered (role=parent)".to_string(),
2508                });
2509            }
2510            Some(role) => {
2511                push_unique(
2512                    &mut relation.evidence,
2513                    format!("main relationships includes handoff(role={role}) to child thread"),
2514                );
2515                lifecycle.push(SubagentLifecycleEvent {
2516                    timestamp: handoff.timestamp.clone(),
2517                    event: "handoff".to_string(),
2518                    detail: format!("main handoff relationship discovered (role={role})"),
2519                });
2520            }
2521            None => {
2522                push_unique(
2523                    &mut relation.evidence,
2524                    "main relationships includes handoff(role missing) to child thread".to_string(),
2525                );
2526                lifecycle.push(SubagentLifecycleEvent {
2527                    timestamp: handoff.timestamp.clone(),
2528                    event: "handoff".to_string(),
2529                    detail: "main handoff relationship discovered (role missing)".to_string(),
2530                });
2531            }
2532        }
2533    }
2534
2535    let mut child_thread = None::<SubagentThreadRef>;
2536    let mut excerpt = Vec::<SubagentExcerptMessage>::new();
2537    let mut status = if relation.validated {
2538        STATUS_PENDING_INIT.to_string()
2539    } else {
2540        STATUS_NOT_FOUND.to_string()
2541    };
2542    let mut status_source = "inferred".to_string();
2543
2544    if let Some(analysis) =
2545        analyze_amp_child_thread(agent_id, &uri.session_id, roots, &mut warnings)
2546    {
2547        for evidence in analysis.relation_evidence {
2548            push_unique(&mut relation.evidence, evidence);
2549        }
2550        if !relation.evidence.is_empty() {
2551            relation.validated = true;
2552        }
2553        lifecycle.extend(analysis.lifecycle);
2554        status = analysis.status;
2555        status_source = analysis.status_source;
2556        child_thread = Some(analysis.thread);
2557        excerpt = analysis.excerpt;
2558    }
2559
2560    SubagentDetailView {
2561        query: make_query(uri, Some(agent_id.to_string()), false),
2562        relation,
2563        lifecycle,
2564        status,
2565        status_source,
2566        child_thread,
2567        excerpt,
2568        warnings,
2569    }
2570}
2571
2572fn analyze_amp_child_thread(
2573    child_thread_id: &str,
2574    main_thread_id: &str,
2575    roots: &ProviderRoots,
2576    warnings: &mut Vec<String>,
2577) -> Option<AmpChildAnalysis> {
2578    let resolved_child = match AmpProvider::new(&roots.amp_root).resolve(child_thread_id) {
2579        Ok(resolved) => resolved,
2580        Err(err) => {
2581            warnings.push(format!(
2582                "failed resolving amp child thread child_thread_id={child_thread_id}: {err}"
2583            ));
2584            return None;
2585        }
2586    };
2587
2588    let child_raw = match read_thread_raw(&resolved_child.path) {
2589        Ok(raw) => raw,
2590        Err(err) => {
2591            warnings.push(format!(
2592                "failed reading amp child thread child_thread_id={child_thread_id}: {err}"
2593            ));
2594            return None;
2595        }
2596    };
2597
2598    let child_value = match serde_json::from_str::<Value>(&child_raw) {
2599        Ok(value) => value,
2600        Err(err) => {
2601            warnings.push(format!(
2602                "failed parsing amp child thread {}: {err}",
2603                resolved_child.path.display()
2604            ));
2605            return None;
2606        }
2607    };
2608
2609    let mut relation_evidence = Vec::<String>::new();
2610    let mut lifecycle = Vec::<SubagentLifecycleEvent>::new();
2611    for handoff in extract_amp_handoffs(&child_value, "child", warnings) {
2612        if handoff.thread_id != main_thread_id {
2613            continue;
2614        }
2615
2616        match handoff.role.as_deref() {
2617            Some("child") => {
2618                push_unique(
2619                    &mut relation_evidence,
2620                    "child relationships includes handoff(role=child) back to main thread"
2621                        .to_string(),
2622                );
2623                lifecycle.push(SubagentLifecycleEvent {
2624                    timestamp: handoff.timestamp.clone(),
2625                    event: "handoff_backlink".to_string(),
2626                    detail: "child handoff relationship discovered (role=child)".to_string(),
2627                });
2628            }
2629            Some(role) => {
2630                push_unique(
2631                    &mut relation_evidence,
2632                    format!(
2633                        "child relationships includes handoff(role={role}) back to main thread"
2634                    ),
2635                );
2636                lifecycle.push(SubagentLifecycleEvent {
2637                    timestamp: handoff.timestamp.clone(),
2638                    event: "handoff_backlink".to_string(),
2639                    detail: format!("child handoff relationship discovered (role={role})"),
2640                });
2641            }
2642            None => {
2643                push_unique(
2644                    &mut relation_evidence,
2645                    "child relationships includes handoff(role missing) back to main thread"
2646                        .to_string(),
2647                );
2648                lifecycle.push(SubagentLifecycleEvent {
2649                    timestamp: handoff.timestamp.clone(),
2650                    event: "handoff_backlink".to_string(),
2651                    detail: "child handoff relationship discovered (role missing)".to_string(),
2652                });
2653            }
2654        }
2655    }
2656
2657    let messages =
2658        match render::extract_messages(ProviderKind::Amp, &resolved_child.path, &child_raw) {
2659            Ok(messages) => messages,
2660            Err(err) => {
2661                warnings.push(format!(
2662                    "failed extracting amp child messages from {}: {err}",
2663                    resolved_child.path.display()
2664                ));
2665                Vec::new()
2666            }
2667        };
2668    let has_user = messages
2669        .iter()
2670        .any(|message| message.role == MessageRole::User);
2671    let has_assistant = messages
2672        .iter()
2673        .any(|message| message.role == MessageRole::Assistant);
2674
2675    let excerpt = messages
2676        .into_iter()
2677        .rev()
2678        .take(3)
2679        .collect::<Vec<_>>()
2680        .into_iter()
2681        .rev()
2682        .map(|message| SubagentExcerptMessage {
2683            role: message.role,
2684            text: message.text,
2685        })
2686        .collect::<Vec<_>>();
2687
2688    let (status, status_source) = infer_amp_status(&child_value, has_user, has_assistant);
2689    let last_updated_at = extract_amp_last_update(&child_value)
2690        .or_else(|| modified_timestamp_string(&resolved_child.path));
2691
2692    Some(AmpChildAnalysis {
2693        thread: SubagentThreadRef {
2694            thread_id: child_thread_id.to_string(),
2695            path: Some(resolved_child.path.display().to_string()),
2696            last_updated_at,
2697        },
2698        status,
2699        status_source,
2700        excerpt,
2701        lifecycle,
2702        relation_evidence,
2703    })
2704}
2705
2706fn extract_amp_handoffs(
2707    value: &Value,
2708    source: &str,
2709    warnings: &mut Vec<String>,
2710) -> Vec<AmpHandoff> {
2711    let mut handoffs = Vec::new();
2712    for relationship in value
2713        .get("relationships")
2714        .and_then(Value::as_array)
2715        .into_iter()
2716        .flatten()
2717    {
2718        if relationship.get("type").and_then(Value::as_str) != Some("handoff") {
2719            continue;
2720        }
2721
2722        let Some(thread_id_raw) = relationship.get("threadID").and_then(Value::as_str) else {
2723            warnings.push(format!(
2724                "{source} thread handoff relationship missing threadID field"
2725            ));
2726            continue;
2727        };
2728        let Some(thread_id) = normalize_amp_thread_id(thread_id_raw) else {
2729            warnings.push(format!(
2730                "{source} thread handoff relationship has invalid threadID={thread_id_raw}"
2731            ));
2732            continue;
2733        };
2734
2735        let role = relationship
2736            .get("role")
2737            .and_then(Value::as_str)
2738            .map(|role| role.to_ascii_lowercase());
2739        let timestamp = relationship
2740            .get("timestamp")
2741            .or_else(|| relationship.get("updatedAt"))
2742            .or_else(|| relationship.get("createdAt"))
2743            .and_then(Value::as_str)
2744            .map(ToString::to_string);
2745
2746        handoffs.push(AmpHandoff {
2747            thread_id,
2748            role,
2749            timestamp,
2750        });
2751    }
2752
2753    handoffs
2754}
2755
2756fn normalize_amp_thread_id(thread_id: &str) -> Option<String> {
2757    AgentsUri::parse(&format!("amp://{thread_id}"))
2758        .ok()
2759        .map(|uri| uri.session_id)
2760}
2761
2762fn infer_amp_status(value: &Value, has_user: bool, has_assistant: bool) -> (String, String) {
2763    if let Some(status) = extract_amp_status(value) {
2764        return (status, "child_thread".to_string());
2765    }
2766    if has_assistant {
2767        return (STATUS_COMPLETED.to_string(), "inferred".to_string());
2768    }
2769    if has_user {
2770        return (STATUS_RUNNING.to_string(), "inferred".to_string());
2771    }
2772    (STATUS_PENDING_INIT.to_string(), "inferred".to_string())
2773}
2774
2775fn extract_amp_status(value: &Value) -> Option<String> {
2776    let status = value.get("status");
2777    if let Some(status) = status {
2778        if let Some(status_str) = status.as_str() {
2779            return Some(status_str.to_string());
2780        }
2781        if let Some(status_obj) = status.as_object() {
2782            for key in [
2783                STATUS_PENDING_INIT,
2784                STATUS_RUNNING,
2785                STATUS_COMPLETED,
2786                STATUS_ERRORED,
2787                STATUS_SHUTDOWN,
2788                STATUS_NOT_FOUND,
2789            ] {
2790                if status_obj.contains_key(key) {
2791                    return Some(key.to_string());
2792                }
2793            }
2794        }
2795    }
2796
2797    value
2798        .get("state")
2799        .and_then(Value::as_str)
2800        .map(ToString::to_string)
2801}
2802
2803fn extract_amp_last_update(value: &Value) -> Option<String> {
2804    for key in ["lastUpdated", "updatedAt", "timestamp", "createdAt"] {
2805        if let Some(stamp) = value.get(key).and_then(Value::as_str) {
2806            return Some(stamp.to_string());
2807        }
2808    }
2809
2810    for message in value
2811        .get("messages")
2812        .and_then(Value::as_array)
2813        .into_iter()
2814        .flatten()
2815        .rev()
2816    {
2817        if let Some(stamp) = message.get("timestamp").and_then(Value::as_str) {
2818            return Some(stamp.to_string());
2819        }
2820    }
2821
2822    None
2823}
2824
2825fn push_unique(values: &mut Vec<String>, value: String) {
2826    if !values.iter().any(|existing| existing == &value) {
2827        values.push(value);
2828    }
2829}
2830
2831fn resolve_codex_subagent_view(
2832    uri: &AgentsUri,
2833    roots: &ProviderRoots,
2834    list: bool,
2835) -> Result<SubagentView> {
2836    let main_uri = main_thread_uri(uri);
2837    let resolved_main = resolve_thread(&main_uri, roots)?;
2838    let main_raw = read_thread_raw(&resolved_main.path)?;
2839
2840    let mut warnings = resolved_main.metadata.warnings.clone();
2841    let mut timelines = BTreeMap::<String, AgentTimeline>::new();
2842    warnings.extend(parse_codex_parent_lifecycle(&main_raw, &mut timelines));
2843
2844    if list {
2845        return Ok(SubagentView::List(build_codex_list_view(
2846            uri, roots, &timelines, warnings,
2847        )));
2848    }
2849
2850    let agent_id = uri
2851        .agent_id
2852        .clone()
2853        .ok_or_else(|| XurlError::InvalidMode("missing agent id".to_string()))?;
2854
2855    Ok(SubagentView::Detail(build_codex_detail_view(
2856        uri, roots, &agent_id, &timelines, warnings,
2857    )))
2858}
2859
2860fn build_codex_list_view(
2861    uri: &AgentsUri,
2862    roots: &ProviderRoots,
2863    timelines: &BTreeMap<String, AgentTimeline>,
2864    warnings: Vec<String>,
2865) -> SubagentListView {
2866    let mut agents = Vec::new();
2867
2868    for (agent_id, timeline) in timelines {
2869        let mut relation = SubagentRelation::default();
2870        if timeline.has_spawn {
2871            relation.validated = true;
2872            relation
2873                .evidence
2874                .push("parent rollout contains spawn_agent output".to_string());
2875        }
2876
2877        let mut child_ref = None;
2878        let mut last_update = timeline.last_update.clone();
2879        if let Some((thread_ref, relation_evidence, thread_last_update)) =
2880            resolve_codex_child_thread(agent_id, &uri.session_id, roots)
2881        {
2882            if !relation_evidence.is_empty() {
2883                relation.validated = true;
2884                relation.evidence.extend(relation_evidence);
2885            }
2886            if last_update.is_none() {
2887                last_update = thread_last_update;
2888            }
2889            child_ref = Some(thread_ref);
2890        }
2891
2892        let (status, status_source) = infer_status_from_timeline(timeline, child_ref.is_some());
2893
2894        agents.push(SubagentListItem {
2895            agent_id: agent_id.clone(),
2896            status,
2897            status_source,
2898            last_update,
2899            relation,
2900            child_thread: child_ref,
2901        });
2902    }
2903
2904    SubagentListView {
2905        query: make_query(uri, None, true),
2906        agents,
2907        warnings,
2908    }
2909}
2910
2911fn build_codex_detail_view(
2912    uri: &AgentsUri,
2913    roots: &ProviderRoots,
2914    agent_id: &str,
2915    timelines: &BTreeMap<String, AgentTimeline>,
2916    mut warnings: Vec<String>,
2917) -> SubagentDetailView {
2918    let timeline = timelines.get(agent_id).cloned().unwrap_or_default();
2919    let mut relation = SubagentRelation::default();
2920    if timeline.has_spawn {
2921        relation.validated = true;
2922        relation
2923            .evidence
2924            .push("parent rollout contains spawn_agent output".to_string());
2925    }
2926
2927    let mut child_thread = None;
2928    let mut excerpt = Vec::new();
2929    let mut child_status = None;
2930
2931    if let Some((resolved_child, relation_evidence, thread_ref)) =
2932        resolve_codex_child_resolved(agent_id, &uri.session_id, roots)
2933    {
2934        if !relation_evidence.is_empty() {
2935            relation.validated = true;
2936            relation.evidence.extend(relation_evidence);
2937        }
2938
2939        match read_thread_raw(&resolved_child.path) {
2940            Ok(child_raw) => {
2941                if let Some(inferred) = infer_codex_child_status(&child_raw, &resolved_child.path) {
2942                    child_status = Some(inferred);
2943                }
2944
2945                if let Ok(messages) =
2946                    render::extract_messages(ProviderKind::Codex, &resolved_child.path, &child_raw)
2947                {
2948                    excerpt = messages
2949                        .into_iter()
2950                        .rev()
2951                        .take(3)
2952                        .collect::<Vec<_>>()
2953                        .into_iter()
2954                        .rev()
2955                        .map(|message| SubagentExcerptMessage {
2956                            role: message.role,
2957                            text: message.text,
2958                        })
2959                        .collect();
2960                }
2961            }
2962            Err(err) => warnings.push(format!(
2963                "failed reading child thread for agent_id={agent_id}: {err}"
2964            )),
2965        }
2966
2967        child_thread = Some(thread_ref);
2968    }
2969
2970    let (status, status_source) =
2971        infer_status_for_detail(&timeline, child_status, child_thread.is_some());
2972
2973    SubagentDetailView {
2974        query: make_query(uri, Some(agent_id.to_string()), false),
2975        relation,
2976        lifecycle: timeline.events,
2977        status,
2978        status_source,
2979        child_thread,
2980        excerpt,
2981        warnings,
2982    }
2983}
2984
2985fn resolve_codex_child_thread(
2986    agent_id: &str,
2987    main_thread_id: &str,
2988    roots: &ProviderRoots,
2989) -> Option<(SubagentThreadRef, Vec<String>, Option<String>)> {
2990    let resolved = CodexProvider::new(&roots.codex_root)
2991        .resolve(agent_id)
2992        .ok()?;
2993    let raw = read_thread_raw(&resolved.path).ok()?;
2994
2995    let mut evidence = Vec::new();
2996    if extract_codex_parent_thread_id(&raw)
2997        .as_deref()
2998        .is_some_and(|parent| parent == main_thread_id)
2999    {
3000        evidence.push("child session_meta points to main thread".to_string());
3001    }
3002
3003    let last_update = extract_last_timestamp(&raw);
3004    let thread_ref = SubagentThreadRef {
3005        thread_id: agent_id.to_string(),
3006        path: Some(resolved.path.display().to_string()),
3007        last_updated_at: last_update.clone(),
3008    };
3009
3010    Some((thread_ref, evidence, last_update))
3011}
3012
3013fn resolve_codex_child_resolved(
3014    agent_id: &str,
3015    main_thread_id: &str,
3016    roots: &ProviderRoots,
3017) -> Option<(ResolvedThread, Vec<String>, SubagentThreadRef)> {
3018    let resolved = CodexProvider::new(&roots.codex_root)
3019        .resolve(agent_id)
3020        .ok()?;
3021    let raw = read_thread_raw(&resolved.path).ok()?;
3022
3023    let mut evidence = Vec::new();
3024    if extract_codex_parent_thread_id(&raw)
3025        .as_deref()
3026        .is_some_and(|parent| parent == main_thread_id)
3027    {
3028        evidence.push("child session_meta points to main thread".to_string());
3029    }
3030
3031    let thread_ref = SubagentThreadRef {
3032        thread_id: agent_id.to_string(),
3033        path: Some(resolved.path.display().to_string()),
3034        last_updated_at: extract_last_timestamp(&raw),
3035    };
3036
3037    Some((resolved, evidence, thread_ref))
3038}
3039
3040fn infer_codex_child_status(raw: &str, path: &Path) -> Option<String> {
3041    let mut has_assistant_message = false;
3042    let mut has_error = false;
3043
3044    for (line_idx, line) in raw.lines().enumerate() {
3045        let Ok(Some(value)) = jsonl::parse_json_line(path, line_idx + 1, line) else {
3046            continue;
3047        };
3048
3049        if value.get("type").and_then(Value::as_str) == Some("event_msg") {
3050            let payload_type = value
3051                .get("payload")
3052                .and_then(|payload| payload.get("type"))
3053                .and_then(Value::as_str);
3054            if payload_type == Some("turn_aborted") {
3055                has_error = true;
3056            }
3057        }
3058
3059        if render::extract_messages(ProviderKind::Codex, path, line)
3060            .ok()
3061            .is_some_and(|messages| {
3062                messages
3063                    .iter()
3064                    .any(|message| matches!(message.role, crate::model::MessageRole::Assistant))
3065            })
3066        {
3067            has_assistant_message = true;
3068        }
3069    }
3070
3071    if has_error {
3072        Some(STATUS_ERRORED.to_string())
3073    } else if has_assistant_message {
3074        Some(STATUS_COMPLETED.to_string())
3075    } else {
3076        None
3077    }
3078}
3079
3080fn parse_codex_parent_lifecycle(
3081    raw: &str,
3082    timelines: &mut BTreeMap<String, AgentTimeline>,
3083) -> Vec<String> {
3084    let mut warnings = Vec::new();
3085    let mut calls: HashMap<String, (String, Value, Option<String>)> = HashMap::new();
3086
3087    for (line_idx, line) in raw.lines().enumerate() {
3088        let trimmed = line.trim();
3089        if trimmed.is_empty() {
3090            continue;
3091        }
3092
3093        let value = match jsonl::parse_json_line(Path::new("<codex:parent>"), line_idx + 1, trimmed)
3094        {
3095            Ok(Some(value)) => value,
3096            Ok(None) => continue,
3097            Err(err) => {
3098                warnings.push(format!(
3099                    "failed to parse parent rollout line {}: {err}",
3100                    line_idx + 1
3101                ));
3102                continue;
3103            }
3104        };
3105
3106        if value.get("type").and_then(Value::as_str) != Some("response_item") {
3107            continue;
3108        }
3109
3110        let Some(payload) = value.get("payload") else {
3111            continue;
3112        };
3113        let Some(payload_type) = payload.get("type").and_then(Value::as_str) else {
3114            continue;
3115        };
3116
3117        if payload_type == "function_call" {
3118            let call_id = payload
3119                .get("call_id")
3120                .and_then(Value::as_str)
3121                .unwrap_or_default()
3122                .to_string();
3123            if call_id.is_empty() {
3124                continue;
3125            }
3126
3127            let name = payload
3128                .get("name")
3129                .and_then(Value::as_str)
3130                .unwrap_or_default()
3131                .to_string();
3132            if name.is_empty() {
3133                continue;
3134            }
3135
3136            let args = payload
3137                .get("arguments")
3138                .and_then(Value::as_str)
3139                .and_then(|arguments| serde_json::from_str::<Value>(arguments).ok())
3140                .unwrap_or_else(|| Value::Object(Default::default()));
3141
3142            let timestamp = value
3143                .get("timestamp")
3144                .and_then(Value::as_str)
3145                .map(ToString::to_string);
3146
3147            calls.insert(call_id, (name, args, timestamp));
3148            continue;
3149        }
3150
3151        if payload_type != "function_call_output" {
3152            continue;
3153        }
3154
3155        let Some(call_id) = payload.get("call_id").and_then(Value::as_str) else {
3156            continue;
3157        };
3158
3159        let Some((name, args, timestamp)) = calls.remove(call_id) else {
3160            continue;
3161        };
3162
3163        let output_raw = payload
3164            .get("output")
3165            .and_then(Value::as_str)
3166            .unwrap_or_default()
3167            .to_string();
3168        let output_value =
3169            serde_json::from_str::<Value>(&output_raw).unwrap_or(Value::String(output_raw));
3170
3171        match name.as_str() {
3172            "spawn_agent" => {
3173                let Some(agent_id) = output_value
3174                    .get("agent_id")
3175                    .and_then(Value::as_str)
3176                    .map(ToString::to_string)
3177                else {
3178                    warnings.push(
3179                        "spawn_agent output did not include agent_id; skipping subagent mapping"
3180                            .to_string(),
3181                    );
3182                    continue;
3183                };
3184
3185                let timeline = timelines.entry(agent_id).or_default();
3186                timeline.has_spawn = true;
3187                timeline.has_activity = true;
3188                timeline.last_update = timestamp.clone();
3189                timeline.events.push(SubagentLifecycleEvent {
3190                    timestamp,
3191                    event: "spawn_agent".to_string(),
3192                    detail: "subagent spawned".to_string(),
3193                });
3194            }
3195            "wait" => {
3196                let ids = args
3197                    .get("ids")
3198                    .and_then(Value::as_array)
3199                    .into_iter()
3200                    .flatten()
3201                    .filter_map(Value::as_str)
3202                    .map(ToString::to_string)
3203                    .collect::<Vec<_>>();
3204
3205                let timed_out = output_value
3206                    .get("timed_out")
3207                    .and_then(Value::as_bool)
3208                    .unwrap_or(false);
3209
3210                for agent_id in ids {
3211                    let timeline = timelines.entry(agent_id).or_default();
3212                    timeline.has_activity = true;
3213                    timeline.last_update = timestamp.clone();
3214
3215                    let mut detail = if timed_out {
3216                        "wait timed out".to_string()
3217                    } else {
3218                        "wait returned".to_string()
3219                    };
3220
3221                    if let Some(state) = infer_state_from_status_payload(&output_value) {
3222                        timeline.states.push(state.clone());
3223                        detail = format!("wait state={state}");
3224                    } else if timed_out {
3225                        timeline.states.push(STATUS_RUNNING.to_string());
3226                    }
3227
3228                    timeline.events.push(SubagentLifecycleEvent {
3229                        timestamp: timestamp.clone(),
3230                        event: "wait".to_string(),
3231                        detail,
3232                    });
3233                }
3234            }
3235            "send_input" | "resume_agent" | "close_agent" => {
3236                let Some(agent_id) = args
3237                    .get("id")
3238                    .and_then(Value::as_str)
3239                    .map(ToString::to_string)
3240                else {
3241                    continue;
3242                };
3243
3244                let timeline = timelines.entry(agent_id).or_default();
3245                timeline.has_activity = true;
3246                timeline.last_update = timestamp.clone();
3247
3248                if name == "close_agent" {
3249                    if let Some(state) = infer_state_from_status_payload(&output_value) {
3250                        timeline.states.push(state.clone());
3251                    } else {
3252                        timeline.states.push(STATUS_SHUTDOWN.to_string());
3253                    }
3254                }
3255
3256                timeline.events.push(SubagentLifecycleEvent {
3257                    timestamp,
3258                    event: name,
3259                    detail: "agent lifecycle event".to_string(),
3260                });
3261            }
3262            _ => {}
3263        }
3264    }
3265
3266    warnings
3267}
3268
3269fn infer_state_from_status_payload(payload: &Value) -> Option<String> {
3270    let status = payload.get("status")?;
3271
3272    if let Some(object) = status.as_object() {
3273        for key in object.keys() {
3274            if [
3275                STATUS_PENDING_INIT,
3276                STATUS_RUNNING,
3277                STATUS_COMPLETED,
3278                STATUS_ERRORED,
3279                STATUS_SHUTDOWN,
3280                STATUS_NOT_FOUND,
3281            ]
3282            .contains(&key.as_str())
3283            {
3284                return Some(key.clone());
3285            }
3286        }
3287
3288        if object.contains_key("completed") {
3289            return Some(STATUS_COMPLETED.to_string());
3290        }
3291    }
3292
3293    None
3294}
3295
3296fn infer_status_from_timeline(timeline: &AgentTimeline, child_exists: bool) -> (String, String) {
3297    if timeline.states.iter().any(|state| state == STATUS_ERRORED) {
3298        return (STATUS_ERRORED.to_string(), "parent_rollout".to_string());
3299    }
3300    if timeline.states.iter().any(|state| state == STATUS_SHUTDOWN) {
3301        return (STATUS_SHUTDOWN.to_string(), "parent_rollout".to_string());
3302    }
3303    if timeline
3304        .states
3305        .iter()
3306        .any(|state| state == STATUS_COMPLETED)
3307    {
3308        return (STATUS_COMPLETED.to_string(), "parent_rollout".to_string());
3309    }
3310    if timeline.states.iter().any(|state| state == STATUS_RUNNING) || timeline.has_activity {
3311        return (STATUS_RUNNING.to_string(), "parent_rollout".to_string());
3312    }
3313    if timeline.has_spawn {
3314        return (
3315            STATUS_PENDING_INIT.to_string(),
3316            "parent_rollout".to_string(),
3317        );
3318    }
3319    if child_exists {
3320        return (STATUS_RUNNING.to_string(), "child_rollout".to_string());
3321    }
3322
3323    (STATUS_NOT_FOUND.to_string(), "inferred".to_string())
3324}
3325
3326fn infer_status_for_detail(
3327    timeline: &AgentTimeline,
3328    child_status: Option<String>,
3329    child_exists: bool,
3330) -> (String, String) {
3331    let (status, source) = infer_status_from_timeline(timeline, child_exists);
3332    if status == STATUS_NOT_FOUND
3333        && let Some(child_status) = child_status
3334    {
3335        return (child_status, "child_rollout".to_string());
3336    }
3337
3338    (status, source)
3339}
3340
3341fn extract_codex_parent_thread_id(raw: &str) -> Option<String> {
3342    let first = raw.lines().find(|line| !line.trim().is_empty())?;
3343    let value = serde_json::from_str::<Value>(first).ok()?;
3344
3345    value
3346        .get("payload")
3347        .and_then(|payload| payload.get("source"))
3348        .and_then(|source| source.get("subagent"))
3349        .and_then(|subagent| subagent.get("thread_spawn"))
3350        .and_then(|thread_spawn| thread_spawn.get("parent_thread_id"))
3351        .and_then(Value::as_str)
3352        .map(ToString::to_string)
3353}
3354
3355fn resolve_claude_subagent_view(
3356    uri: &AgentsUri,
3357    roots: &ProviderRoots,
3358    list: bool,
3359) -> Result<SubagentView> {
3360    let main_uri = main_thread_uri(uri);
3361    let resolved_main = resolve_thread(&main_uri, roots)?;
3362
3363    let mut warnings = resolved_main.metadata.warnings.clone();
3364    let records = discover_claude_agents(&resolved_main, &uri.session_id, &mut warnings);
3365
3366    if list {
3367        return Ok(SubagentView::List(SubagentListView {
3368            query: make_query(uri, None, true),
3369            agents: records
3370                .iter()
3371                .map(|record| SubagentListItem {
3372                    agent_id: record.agent_id.clone(),
3373                    status: record.status.clone(),
3374                    status_source: "inferred".to_string(),
3375                    last_update: record.last_update.clone(),
3376                    relation: record.relation.clone(),
3377                    child_thread: Some(SubagentThreadRef {
3378                        thread_id: record.agent_id.clone(),
3379                        path: Some(record.path.display().to_string()),
3380                        last_updated_at: record.last_update.clone(),
3381                    }),
3382                })
3383                .collect(),
3384            warnings,
3385        }));
3386    }
3387
3388    let requested_agent = uri
3389        .agent_id
3390        .clone()
3391        .ok_or_else(|| XurlError::InvalidMode("missing agent id".to_string()))?;
3392
3393    let normalized_requested = normalize_agent_id(&requested_agent);
3394
3395    if let Some(record) = records
3396        .into_iter()
3397        .find(|record| normalize_agent_id(&record.agent_id) == normalized_requested)
3398    {
3399        let lifecycle = vec![SubagentLifecycleEvent {
3400            timestamp: record.last_update.clone(),
3401            event: "discovered_agent_file".to_string(),
3402            detail: "agent transcript discovered and analyzed".to_string(),
3403        }];
3404
3405        warnings.extend(record.warnings.clone());
3406
3407        return Ok(SubagentView::Detail(SubagentDetailView {
3408            query: make_query(uri, Some(requested_agent), false),
3409            relation: record.relation.clone(),
3410            lifecycle,
3411            status: record.status.clone(),
3412            status_source: "inferred".to_string(),
3413            child_thread: Some(SubagentThreadRef {
3414                thread_id: record.agent_id.clone(),
3415                path: Some(record.path.display().to_string()),
3416                last_updated_at: record.last_update.clone(),
3417            }),
3418            excerpt: record.excerpt,
3419            warnings,
3420        }));
3421    }
3422
3423    warnings.push(format!(
3424        "agent not found for main_session_id={} agent_id={requested_agent}",
3425        uri.session_id
3426    ));
3427
3428    Ok(SubagentView::Detail(SubagentDetailView {
3429        query: make_query(uri, Some(requested_agent), false),
3430        relation: SubagentRelation::default(),
3431        lifecycle: Vec::new(),
3432        status: STATUS_NOT_FOUND.to_string(),
3433        status_source: "inferred".to_string(),
3434        child_thread: None,
3435        excerpt: Vec::new(),
3436        warnings,
3437    }))
3438}
3439
3440fn resolve_gemini_subagent_view(
3441    uri: &AgentsUri,
3442    roots: &ProviderRoots,
3443    list: bool,
3444) -> Result<SubagentView> {
3445    let main_uri = main_thread_uri(uri);
3446    let resolved_main = resolve_thread(&main_uri, roots)?;
3447    let mut warnings = resolved_main.metadata.warnings.clone();
3448
3449    let (chats, mut children) =
3450        discover_gemini_children(&resolved_main, &uri.session_id, &mut warnings);
3451
3452    if list {
3453        let agents = children
3454            .iter_mut()
3455            .map(|(child_session_id, record)| {
3456                if let Some(chat) = chats.get(child_session_id) {
3457                    return SubagentListItem {
3458                        agent_id: child_session_id.clone(),
3459                        status: chat.status.clone(),
3460                        status_source: "child_rollout".to_string(),
3461                        last_update: chat.last_update.clone(),
3462                        relation: record.relation.clone(),
3463                        child_thread: Some(SubagentThreadRef {
3464                            thread_id: child_session_id.clone(),
3465                            path: Some(chat.path.display().to_string()),
3466                            last_updated_at: chat.last_update.clone(),
3467                        }),
3468                    };
3469                }
3470
3471                let missing_warning = format!(
3472                    "child session {child_session_id} discovered from local Gemini data but chat file was not found in project chats"
3473                );
3474                warnings.push(missing_warning);
3475                let missing_evidence =
3476                    "child session could not be materialized to a chat file".to_string();
3477                if !record.relation.evidence.contains(&missing_evidence) {
3478                    record.relation.evidence.push(missing_evidence);
3479                }
3480
3481                SubagentListItem {
3482                    agent_id: child_session_id.clone(),
3483                    status: STATUS_NOT_FOUND.to_string(),
3484                    status_source: "inferred".to_string(),
3485                    last_update: record.relation_timestamp.clone(),
3486                    relation: record.relation.clone(),
3487                    child_thread: None,
3488                }
3489            })
3490            .collect::<Vec<_>>();
3491
3492        return Ok(SubagentView::List(SubagentListView {
3493            query: make_query(uri, None, true),
3494            agents,
3495            warnings,
3496        }));
3497    }
3498
3499    let requested_child = uri
3500        .agent_id
3501        .clone()
3502        .ok_or_else(|| XurlError::InvalidMode("missing agent id".to_string()))?;
3503
3504    let mut relation = SubagentRelation::default();
3505    let mut lifecycle = Vec::new();
3506    let mut status = STATUS_NOT_FOUND.to_string();
3507    let mut status_source = "inferred".to_string();
3508    let mut child_thread = None;
3509    let mut excerpt = Vec::new();
3510
3511    if let Some(record) = children.get_mut(&requested_child) {
3512        relation = record.relation.clone();
3513        if !relation.evidence.is_empty() {
3514            lifecycle.push(SubagentLifecycleEvent {
3515                timestamp: record.relation_timestamp.clone(),
3516                event: "discover_child".to_string(),
3517                detail: if relation.validated {
3518                    "child relation validated from local Gemini payload".to_string()
3519                } else {
3520                    "child relation inferred from logs.json /resume sequence".to_string()
3521                },
3522            });
3523        }
3524
3525        if let Some(chat) = chats.get(&requested_child) {
3526            status = chat.status.clone();
3527            status_source = "child_rollout".to_string();
3528            child_thread = Some(SubagentThreadRef {
3529                thread_id: requested_child.clone(),
3530                path: Some(chat.path.display().to_string()),
3531                last_updated_at: chat.last_update.clone(),
3532            });
3533            excerpt = extract_child_excerpt(ProviderKind::Gemini, &chat.path, &mut warnings);
3534        } else {
3535            warnings.push(format!(
3536                "child session {requested_child} discovered from local Gemini data but chat file was not found in project chats"
3537            ));
3538            let missing_evidence =
3539                "child session could not be materialized to a chat file".to_string();
3540            if !relation.evidence.contains(&missing_evidence) {
3541                relation.evidence.push(missing_evidence);
3542            }
3543        }
3544    } else if let Some(chat) = chats.get(&requested_child) {
3545        warnings.push(format!(
3546            "unable to validate Gemini parent-child relation for main_session_id={} child_session_id={requested_child}",
3547            uri.session_id
3548        ));
3549        lifecycle.push(SubagentLifecycleEvent {
3550            timestamp: chat.last_update.clone(),
3551            event: "discover_child_chat".to_string(),
3552            detail: "child chat exists but relation to main thread is unknown".to_string(),
3553        });
3554        status = chat.status.clone();
3555        status_source = "child_rollout".to_string();
3556        child_thread = Some(SubagentThreadRef {
3557            thread_id: requested_child.clone(),
3558            path: Some(chat.path.display().to_string()),
3559            last_updated_at: chat.last_update.clone(),
3560        });
3561        excerpt = extract_child_excerpt(ProviderKind::Gemini, &chat.path, &mut warnings);
3562    } else {
3563        warnings.push(format!(
3564            "child session not found for main_session_id={} child_session_id={requested_child}",
3565            uri.session_id
3566        ));
3567    }
3568
3569    Ok(SubagentView::Detail(SubagentDetailView {
3570        query: make_query(uri, Some(requested_child), false),
3571        relation,
3572        lifecycle,
3573        status,
3574        status_source,
3575        child_thread,
3576        excerpt,
3577        warnings,
3578    }))
3579}
3580
3581fn discover_gemini_children(
3582    resolved_main: &ResolvedThread,
3583    main_session_id: &str,
3584    warnings: &mut Vec<String>,
3585) -> (
3586    BTreeMap<String, GeminiChatRecord>,
3587    BTreeMap<String, GeminiChildRecord>,
3588) {
3589    let Some(project_dir) = resolved_main.path.parent().and_then(Path::parent) else {
3590        warnings.push(format!(
3591            "cannot determine Gemini project directory from resolved main thread path: {}",
3592            resolved_main.path.display()
3593        ));
3594        return (BTreeMap::new(), BTreeMap::new());
3595    };
3596
3597    let chats = load_gemini_project_chats(project_dir, warnings);
3598    let logs = read_gemini_log_entries(project_dir, warnings);
3599
3600    let mut children = BTreeMap::<String, GeminiChildRecord>::new();
3601
3602    for chat in chats.values() {
3603        if chat.session_id == main_session_id {
3604            continue;
3605        }
3606        if chat
3607            .explicit_parent_ids
3608            .iter()
3609            .any(|parent_id| parent_id == main_session_id)
3610        {
3611            push_explicit_gemini_relation(
3612                &mut children,
3613                &chat.session_id,
3614                "child chat payload includes explicit parent session reference",
3615                chat.last_update.clone(),
3616            );
3617        }
3618    }
3619
3620    for entry in &logs {
3621        if entry.session_id == main_session_id {
3622            continue;
3623        }
3624        if entry
3625            .explicit_parent_ids
3626            .iter()
3627            .any(|parent_id| parent_id == main_session_id)
3628        {
3629            push_explicit_gemini_relation(
3630                &mut children,
3631                &entry.session_id,
3632                "logs.json entry includes explicit parent session reference",
3633                entry.timestamp.clone(),
3634            );
3635        }
3636    }
3637
3638    for (child_session_id, parent_session_id, timestamp) in infer_gemini_relations_from_logs(&logs)
3639    {
3640        if child_session_id == main_session_id || parent_session_id != main_session_id {
3641            continue;
3642        }
3643        push_inferred_gemini_relation(
3644            &mut children,
3645            &child_session_id,
3646            "logs.json shows child session starts with /resume after main session activity",
3647            timestamp,
3648        );
3649    }
3650
3651    (chats, children)
3652}
3653
3654fn load_gemini_project_chats(
3655    project_dir: &Path,
3656    warnings: &mut Vec<String>,
3657) -> BTreeMap<String, GeminiChatRecord> {
3658    let chats_dir = project_dir.join("chats");
3659    if !chats_dir.exists() {
3660        warnings.push(format!(
3661            "Gemini project chats directory not found: {}",
3662            chats_dir.display()
3663        ));
3664        return BTreeMap::new();
3665    }
3666
3667    let mut chats = BTreeMap::<String, GeminiChatRecord>::new();
3668    let Ok(entries) = fs::read_dir(&chats_dir) else {
3669        warnings.push(format!(
3670            "failed to read Gemini chats directory: {}",
3671            chats_dir.display()
3672        ));
3673        return chats;
3674    };
3675
3676    for entry in entries.filter_map(std::result::Result::ok) {
3677        let path = entry.path();
3678        let is_chat_file = path
3679            .file_name()
3680            .and_then(|name| name.to_str())
3681            .is_some_and(|name| name.starts_with("session-") && name.ends_with(".json"));
3682        if !is_chat_file || !path.is_file() {
3683            continue;
3684        }
3685
3686        let Some(chat) = parse_gemini_chat_file(&path, warnings) else {
3687            continue;
3688        };
3689
3690        match chats.get(&chat.session_id) {
3691            Some(existing) => {
3692                let existing_stamp = file_modified_epoch(&existing.path).unwrap_or(0);
3693                let new_stamp = file_modified_epoch(&chat.path).unwrap_or(0);
3694                if new_stamp > existing_stamp {
3695                    chats.insert(chat.session_id.clone(), chat);
3696                }
3697            }
3698            None => {
3699                chats.insert(chat.session_id.clone(), chat);
3700            }
3701        }
3702    }
3703
3704    chats
3705}
3706
3707fn parse_gemini_chat_file(path: &Path, warnings: &mut Vec<String>) -> Option<GeminiChatRecord> {
3708    let raw = match read_thread_raw(path) {
3709        Ok(raw) => raw,
3710        Err(err) => {
3711            warnings.push(format!(
3712                "failed to read Gemini chat {}: {err}",
3713                path.display()
3714            ));
3715            return None;
3716        }
3717    };
3718
3719    let value = match serde_json::from_str::<Value>(&raw) {
3720        Ok(value) => value,
3721        Err(err) => {
3722            warnings.push(format!(
3723                "failed to parse Gemini chat JSON {}: {err}",
3724                path.display()
3725            ));
3726            return None;
3727        }
3728    };
3729
3730    let Some(session_id) = value
3731        .get("sessionId")
3732        .and_then(Value::as_str)
3733        .and_then(parse_session_id_like)
3734    else {
3735        warnings.push(format!(
3736            "Gemini chat missing valid sessionId: {}",
3737            path.display()
3738        ));
3739        return None;
3740    };
3741
3742    let last_update = value
3743        .get("lastUpdated")
3744        .and_then(Value::as_str)
3745        .map(ToString::to_string)
3746        .or_else(|| {
3747            value
3748                .get("startTime")
3749                .and_then(Value::as_str)
3750                .map(ToString::to_string)
3751        })
3752        .or_else(|| modified_timestamp_string(path));
3753
3754    let status = infer_gemini_chat_status(&value);
3755    let explicit_parent_ids = parse_parent_session_ids(&value);
3756
3757    Some(GeminiChatRecord {
3758        session_id,
3759        path: path.to_path_buf(),
3760        last_update,
3761        status,
3762        explicit_parent_ids,
3763    })
3764}
3765
3766fn infer_gemini_chat_status(value: &Value) -> String {
3767    let Some(messages) = value.get("messages").and_then(Value::as_array) else {
3768        return STATUS_PENDING_INIT.to_string();
3769    };
3770
3771    let mut has_error = false;
3772    let mut has_assistant = false;
3773    let mut has_user = false;
3774
3775    for message in messages {
3776        let message_type = message
3777            .get("type")
3778            .and_then(Value::as_str)
3779            .unwrap_or_default();
3780        if message_type == "error" || !message.get("error").is_none_or(Value::is_null) {
3781            has_error = true;
3782        }
3783        if message_type == "gemini" || message_type == "assistant" {
3784            has_assistant = true;
3785        }
3786        if message_type == "user" {
3787            has_user = true;
3788        }
3789    }
3790
3791    if has_error {
3792        STATUS_ERRORED.to_string()
3793    } else if has_assistant {
3794        STATUS_COMPLETED.to_string()
3795    } else if has_user {
3796        STATUS_RUNNING.to_string()
3797    } else {
3798        STATUS_PENDING_INIT.to_string()
3799    }
3800}
3801
3802fn read_gemini_log_entries(project_dir: &Path, warnings: &mut Vec<String>) -> Vec<GeminiLogEntry> {
3803    let logs_path = project_dir.join("logs.json");
3804    if !logs_path.exists() {
3805        return Vec::new();
3806    }
3807
3808    let raw = match read_thread_raw(&logs_path) {
3809        Ok(raw) => raw,
3810        Err(err) => {
3811            warnings.push(format!(
3812                "failed to read Gemini logs file {}: {err}",
3813                logs_path.display()
3814            ));
3815            return Vec::new();
3816        }
3817    };
3818
3819    if raw.trim().is_empty() {
3820        return Vec::new();
3821    }
3822
3823    if let Ok(value) = serde_json::from_str::<Value>(&raw) {
3824        return parse_gemini_logs_value(&logs_path, value, warnings);
3825    }
3826
3827    let mut parsed = Vec::new();
3828    for (index, line) in raw.lines().enumerate() {
3829        if line.trim().is_empty() {
3830            continue;
3831        }
3832        match serde_json::from_str::<Value>(line) {
3833            Ok(value) => {
3834                if let Some(entry) = parse_gemini_log_entry(&logs_path, index + 1, &value, warnings)
3835                {
3836                    parsed.push(entry);
3837                }
3838            }
3839            Err(err) => warnings.push(format!(
3840                "failed to parse Gemini logs line {} in {}: {err}",
3841                index + 1,
3842                logs_path.display()
3843            )),
3844        }
3845    }
3846    parsed
3847}
3848
3849fn parse_gemini_logs_value(
3850    logs_path: &Path,
3851    value: Value,
3852    warnings: &mut Vec<String>,
3853) -> Vec<GeminiLogEntry> {
3854    match value {
3855        Value::Array(entries) => entries
3856            .into_iter()
3857            .enumerate()
3858            .filter_map(|(index, entry)| {
3859                parse_gemini_log_entry(logs_path, index + 1, &entry, warnings)
3860            })
3861            .collect(),
3862        Value::Object(object) => {
3863            if let Some(entries) = object.get("entries").and_then(Value::as_array) {
3864                return entries
3865                    .iter()
3866                    .enumerate()
3867                    .filter_map(|(index, entry)| {
3868                        parse_gemini_log_entry(logs_path, index + 1, entry, warnings)
3869                    })
3870                    .collect();
3871            }
3872
3873            parse_gemini_log_entry(logs_path, 1, &Value::Object(object), warnings)
3874                .into_iter()
3875                .collect()
3876        }
3877        _ => {
3878            warnings.push(format!(
3879                "unsupported Gemini logs format in {}: expected JSON array or object",
3880                logs_path.display()
3881            ));
3882            Vec::new()
3883        }
3884    }
3885}
3886
3887fn parse_gemini_log_entry(
3888    logs_path: &Path,
3889    line: usize,
3890    value: &Value,
3891    warnings: &mut Vec<String>,
3892) -> Option<GeminiLogEntry> {
3893    let Some(object) = value.as_object() else {
3894        warnings.push(format!(
3895            "invalid Gemini log entry at {} line {}: expected JSON object",
3896            logs_path.display(),
3897            line
3898        ));
3899        return None;
3900    };
3901
3902    let session_id = object
3903        .get("sessionId")
3904        .and_then(Value::as_str)
3905        .or_else(|| object.get("session_id").and_then(Value::as_str))
3906        .and_then(parse_session_id_like)?;
3907
3908    Some(GeminiLogEntry {
3909        session_id,
3910        message: object
3911            .get("message")
3912            .and_then(Value::as_str)
3913            .map(ToString::to_string),
3914        timestamp: object
3915            .get("timestamp")
3916            .and_then(Value::as_str)
3917            .map(ToString::to_string),
3918        entry_type: object
3919            .get("type")
3920            .and_then(Value::as_str)
3921            .map(ToString::to_string),
3922        explicit_parent_ids: parse_parent_session_ids(value),
3923    })
3924}
3925
3926fn infer_gemini_relations_from_logs(
3927    logs: &[GeminiLogEntry],
3928) -> Vec<(String, String, Option<String>)> {
3929    let mut first_user_seen = BTreeSet::<String>::new();
3930    let mut latest_session = None::<String>;
3931    let mut relations = Vec::new();
3932
3933    for entry in logs {
3934        let session_id = entry.session_id.clone();
3935        let is_user_like = entry
3936            .entry_type
3937            .as_deref()
3938            .is_none_or(|kind| kind == "user");
3939
3940        if is_user_like && !first_user_seen.contains(&session_id) {
3941            first_user_seen.insert(session_id.clone());
3942            if entry
3943                .message
3944                .as_deref()
3945                .map(str::trim_start)
3946                .is_some_and(|message| message.starts_with("/resume"))
3947                && let Some(parent_session_id) = latest_session.clone()
3948                && parent_session_id != session_id
3949            {
3950                relations.push((
3951                    session_id.clone(),
3952                    parent_session_id,
3953                    entry.timestamp.clone(),
3954                ));
3955            }
3956        }
3957
3958        latest_session = Some(session_id);
3959    }
3960
3961    relations
3962}
3963
3964fn push_explicit_gemini_relation(
3965    children: &mut BTreeMap<String, GeminiChildRecord>,
3966    child_session_id: &str,
3967    evidence: &str,
3968    timestamp: Option<String>,
3969) {
3970    let record = children.entry(child_session_id.to_string()).or_default();
3971    record.relation.validated = true;
3972    if !record.relation.evidence.iter().any(|item| item == evidence) {
3973        record.relation.evidence.push(evidence.to_string());
3974    }
3975    if record.relation_timestamp.is_none() {
3976        record.relation_timestamp = timestamp;
3977    }
3978}
3979
3980fn push_inferred_gemini_relation(
3981    children: &mut BTreeMap<String, GeminiChildRecord>,
3982    child_session_id: &str,
3983    evidence: &str,
3984    timestamp: Option<String>,
3985) {
3986    let record = children.entry(child_session_id.to_string()).or_default();
3987    if record.relation.validated {
3988        return;
3989    }
3990    if !record.relation.evidence.iter().any(|item| item == evidence) {
3991        record.relation.evidence.push(evidence.to_string());
3992    }
3993    if record.relation_timestamp.is_none() {
3994        record.relation_timestamp = timestamp;
3995    }
3996}
3997
3998fn parse_parent_session_ids(value: &Value) -> Vec<String> {
3999    let mut parent_ids = BTreeSet::new();
4000    collect_parent_session_ids(value, &mut parent_ids);
4001    parent_ids.into_iter().collect()
4002}
4003
4004fn collect_parent_session_ids(value: &Value, parent_ids: &mut BTreeSet<String>) {
4005    match value {
4006        Value::Object(object) => {
4007            for (key, nested) in object {
4008                let normalized_key = key.to_ascii_lowercase();
4009                let is_parent_key = normalized_key.contains("parent")
4010                    && (normalized_key.contains("session")
4011                        || normalized_key.contains("thread")
4012                        || normalized_key.contains("id"));
4013                if is_parent_key {
4014                    maybe_collect_session_id(nested, parent_ids);
4015                }
4016                if normalized_key == "parent" {
4017                    maybe_collect_session_id(nested, parent_ids);
4018                }
4019                collect_parent_session_ids(nested, parent_ids);
4020            }
4021        }
4022        Value::Array(values) => {
4023            for nested in values {
4024                collect_parent_session_ids(nested, parent_ids);
4025            }
4026        }
4027        _ => {}
4028    }
4029}
4030
4031fn maybe_collect_session_id(value: &Value, parent_ids: &mut BTreeSet<String>) {
4032    match value {
4033        Value::String(raw) => {
4034            if let Some(session_id) = parse_session_id_like(raw) {
4035                parent_ids.insert(session_id);
4036            }
4037        }
4038        Value::Object(object) => {
4039            for key in ["sessionId", "session_id", "threadId", "thread_id", "id"] {
4040                if let Some(session_id) = object
4041                    .get(key)
4042                    .and_then(Value::as_str)
4043                    .and_then(parse_session_id_like)
4044                {
4045                    parent_ids.insert(session_id);
4046                }
4047            }
4048        }
4049        _ => {}
4050    }
4051}
4052
4053fn parse_session_id_like(raw: &str) -> Option<String> {
4054    let normalized = raw.trim().to_ascii_lowercase();
4055    if normalized.len() != 36 {
4056        return None;
4057    }
4058
4059    for (index, byte) in normalized.bytes().enumerate() {
4060        if [8, 13, 18, 23].contains(&index) {
4061            if byte != b'-' {
4062                return None;
4063            }
4064            continue;
4065        }
4066
4067        if !byte.is_ascii_hexdigit() {
4068            return None;
4069        }
4070    }
4071
4072    Some(normalized)
4073}
4074
4075fn extract_child_excerpt(
4076    provider: ProviderKind,
4077    path: &Path,
4078    warnings: &mut Vec<String>,
4079) -> Vec<SubagentExcerptMessage> {
4080    let raw = match read_thread_raw(path) {
4081        Ok(raw) => raw,
4082        Err(err) => {
4083            warnings.push(format!(
4084                "failed reading child thread {}: {err}",
4085                path.display()
4086            ));
4087            return Vec::new();
4088        }
4089    };
4090
4091    match render::extract_messages(provider, path, &raw) {
4092        Ok(messages) => messages
4093            .into_iter()
4094            .rev()
4095            .take(3)
4096            .collect::<Vec<_>>()
4097            .into_iter()
4098            .rev()
4099            .map(|message| SubagentExcerptMessage {
4100                role: message.role,
4101                text: message.text,
4102            })
4103            .collect(),
4104        Err(err) => {
4105            warnings.push(format!(
4106                "failed extracting child messages from {}: {err}",
4107                path.display()
4108            ));
4109            Vec::new()
4110        }
4111    }
4112}
4113
4114fn resolve_opencode_subagent_view(
4115    uri: &AgentsUri,
4116    roots: &ProviderRoots,
4117    list: bool,
4118) -> Result<SubagentView> {
4119    let main_uri = main_thread_uri(uri);
4120    let resolved_main = resolve_thread(&main_uri, roots)?;
4121
4122    let mut warnings = resolved_main.metadata.warnings.clone();
4123    let records = discover_opencode_agents(roots, &uri.session_id, &mut warnings)?;
4124
4125    if list {
4126        let mut agents = Vec::new();
4127        for record in records {
4128            let analysis = inspect_opencode_child(&record.agent_id, roots, record.message_count);
4129            warnings.extend(analysis.warnings);
4130
4131            agents.push(SubagentListItem {
4132                agent_id: record.agent_id.clone(),
4133                status: analysis.status,
4134                status_source: analysis.status_source,
4135                last_update: analysis.last_update.clone(),
4136                relation: record.relation,
4137                child_thread: analysis.child_thread,
4138            });
4139        }
4140
4141        return Ok(SubagentView::List(SubagentListView {
4142            query: make_query(uri, None, true),
4143            agents,
4144            warnings,
4145        }));
4146    }
4147
4148    let requested_agent = uri
4149        .agent_id
4150        .clone()
4151        .ok_or_else(|| XurlError::InvalidMode("missing agent id".to_string()))?;
4152
4153    if let Some(record) = records
4154        .into_iter()
4155        .find(|record| record.agent_id == requested_agent)
4156    {
4157        let analysis = inspect_opencode_child(&record.agent_id, roots, record.message_count);
4158        warnings.extend(analysis.warnings);
4159
4160        let lifecycle = vec![SubagentLifecycleEvent {
4161            timestamp: analysis.last_update.clone(),
4162            event: "session_parent_link".to_string(),
4163            detail: "session.parent_id points to main thread".to_string(),
4164        }];
4165
4166        return Ok(SubagentView::Detail(SubagentDetailView {
4167            query: make_query(uri, Some(requested_agent), false),
4168            relation: record.relation,
4169            lifecycle,
4170            status: analysis.status,
4171            status_source: analysis.status_source,
4172            child_thread: analysis.child_thread,
4173            excerpt: analysis.excerpt,
4174            warnings,
4175        }));
4176    }
4177
4178    warnings.push(format!(
4179        "agent not found for main_session_id={} agent_id={requested_agent}",
4180        uri.session_id
4181    ));
4182
4183    Ok(SubagentView::Detail(SubagentDetailView {
4184        query: make_query(uri, Some(requested_agent), false),
4185        relation: SubagentRelation::default(),
4186        lifecycle: Vec::new(),
4187        status: STATUS_NOT_FOUND.to_string(),
4188        status_source: "inferred".to_string(),
4189        child_thread: None,
4190        excerpt: Vec::new(),
4191        warnings,
4192    }))
4193}
4194
4195fn discover_opencode_agents(
4196    roots: &ProviderRoots,
4197    main_session_id: &str,
4198    warnings: &mut Vec<String>,
4199) -> Result<Vec<OpencodeAgentRecord>> {
4200    let db_path = opencode_db_path(roots);
4201    let conn = open_opencode_read_only_db(&db_path)?;
4202
4203    let has_parent_id =
4204        opencode_session_table_has_parent_id(&conn).map_err(|source| XurlError::Sqlite {
4205            path: db_path.clone(),
4206            source,
4207        })?;
4208    if !has_parent_id {
4209        warnings.push(
4210            "opencode sqlite session table does not expose parent_id; cannot discover subagent relations"
4211                .to_string(),
4212        );
4213        return Ok(Vec::new());
4214    }
4215
4216    let rows =
4217        query_opencode_children(&conn, main_session_id).map_err(|source| XurlError::Sqlite {
4218            path: db_path,
4219            source,
4220        })?;
4221
4222    Ok(rows
4223        .into_iter()
4224        .map(|(agent_id, message_count)| {
4225            let mut relation = SubagentRelation {
4226                validated: true,
4227                ..SubagentRelation::default()
4228            };
4229            relation
4230                .evidence
4231                .push("opencode sqlite relation validated via session.parent_id".to_string());
4232
4233            OpencodeAgentRecord {
4234                agent_id,
4235                relation,
4236                message_count,
4237            }
4238        })
4239        .collect())
4240}
4241
4242fn query_opencode_children(
4243    conn: &Connection,
4244    main_session_id: &str,
4245) -> std::result::Result<Vec<(String, usize)>, rusqlite::Error> {
4246    let mut stmt = conn.prepare(
4247        "SELECT s.id, COUNT(m.id) AS message_count
4248         FROM session AS s
4249         LEFT JOIN message AS m ON m.session_id = s.id
4250         WHERE s.parent_id = ?1
4251         GROUP BY s.id
4252         ORDER BY s.id ASC",
4253    )?;
4254
4255    let rows = stmt.query_map([main_session_id], |row| {
4256        let id = row.get::<_, String>(0)?;
4257        let message_count = row.get::<_, i64>(1)?;
4258        Ok((id, usize::try_from(message_count).unwrap_or(0)))
4259    })?;
4260
4261    let mut children = Vec::new();
4262    for row in rows {
4263        children.push(row?);
4264    }
4265    Ok(children)
4266}
4267
4268fn opencode_db_path(roots: &ProviderRoots) -> PathBuf {
4269    roots.opencode_root.join("opencode.db")
4270}
4271
4272fn open_opencode_read_only_db(db_path: &Path) -> Result<Connection> {
4273    Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY).map_err(|source| {
4274        XurlError::Sqlite {
4275            path: db_path.to_path_buf(),
4276            source,
4277        }
4278    })
4279}
4280
4281fn opencode_session_table_has_parent_id(
4282    conn: &Connection,
4283) -> std::result::Result<bool, rusqlite::Error> {
4284    let mut stmt = conn.prepare("PRAGMA table_info(session)")?;
4285    let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
4286
4287    let mut has_parent_id = false;
4288    for row in rows {
4289        if row? == "parent_id" {
4290            has_parent_id = true;
4291            break;
4292        }
4293    }
4294    Ok(has_parent_id)
4295}
4296
4297fn inspect_opencode_child(
4298    child_session_id: &str,
4299    roots: &ProviderRoots,
4300    message_count: usize,
4301) -> OpencodeChildAnalysis {
4302    let mut warnings = Vec::new();
4303    let resolved_child = match OpencodeProvider::new(&roots.opencode_root).resolve(child_session_id)
4304    {
4305        Ok(resolved) => resolved,
4306        Err(err) => {
4307            warnings.push(format!(
4308                "failed to materialize child session_id={child_session_id}: {err}"
4309            ));
4310            return OpencodeChildAnalysis {
4311                child_thread: None,
4312                status: STATUS_NOT_FOUND.to_string(),
4313                status_source: "inferred".to_string(),
4314                last_update: None,
4315                excerpt: Vec::new(),
4316                warnings,
4317            };
4318        }
4319    };
4320
4321    let raw = match read_thread_raw(&resolved_child.path) {
4322        Ok(raw) => raw,
4323        Err(err) => {
4324            warnings.push(format!(
4325                "failed reading child session transcript session_id={child_session_id}: {err}"
4326            ));
4327            return OpencodeChildAnalysis {
4328                child_thread: Some(SubagentThreadRef {
4329                    thread_id: child_session_id.to_string(),
4330                    path: Some(resolved_child.path.display().to_string()),
4331                    last_updated_at: None,
4332                }),
4333                status: STATUS_NOT_FOUND.to_string(),
4334                status_source: "inferred".to_string(),
4335                last_update: None,
4336                excerpt: Vec::new(),
4337                warnings,
4338            };
4339        }
4340    };
4341
4342    let messages =
4343        match render::extract_messages(ProviderKind::Opencode, &resolved_child.path, &raw) {
4344            Ok(messages) => messages,
4345            Err(err) => {
4346                warnings.push(format!(
4347                "failed extracting child transcript messages session_id={child_session_id}: {err}"
4348            ));
4349                Vec::new()
4350            }
4351        };
4352
4353    if message_count == 0 {
4354        warnings.push(format!(
4355            "child session_id={child_session_id} has no materialized messages in sqlite"
4356        ));
4357    }
4358
4359    let (status, status_source) = infer_opencode_status(&messages);
4360    let last_update = extract_opencode_last_update(&raw);
4361
4362    let excerpt = messages
4363        .into_iter()
4364        .rev()
4365        .take(3)
4366        .collect::<Vec<_>>()
4367        .into_iter()
4368        .rev()
4369        .map(|message| SubagentExcerptMessage {
4370            role: message.role,
4371            text: message.text,
4372        })
4373        .collect::<Vec<_>>();
4374
4375    OpencodeChildAnalysis {
4376        child_thread: Some(SubagentThreadRef {
4377            thread_id: child_session_id.to_string(),
4378            path: Some(resolved_child.path.display().to_string()),
4379            last_updated_at: last_update.clone(),
4380        }),
4381        status,
4382        status_source,
4383        last_update,
4384        excerpt,
4385        warnings,
4386    }
4387}
4388
4389fn infer_opencode_status(messages: &[crate::model::ThreadMessage]) -> (String, String) {
4390    let has_assistant = messages
4391        .iter()
4392        .any(|message| message.role == crate::model::MessageRole::Assistant);
4393    if has_assistant {
4394        return (STATUS_COMPLETED.to_string(), "child_rollout".to_string());
4395    }
4396
4397    let has_user = messages
4398        .iter()
4399        .any(|message| message.role == crate::model::MessageRole::User);
4400    if has_user {
4401        return (STATUS_RUNNING.to_string(), "child_rollout".to_string());
4402    }
4403
4404    (STATUS_PENDING_INIT.to_string(), "inferred".to_string())
4405}
4406
4407fn extract_opencode_last_update(raw: &str) -> Option<String> {
4408    for line in raw.lines().rev() {
4409        if line.trim().is_empty() {
4410            continue;
4411        }
4412
4413        let Ok(value) = serde_json::from_str::<Value>(line) else {
4414            continue;
4415        };
4416
4417        if value.get("type").and_then(Value::as_str) != Some("message") {
4418            continue;
4419        }
4420
4421        let Some(message) = value.get("message") else {
4422            continue;
4423        };
4424
4425        let Some(time) = message.get("time") else {
4426            continue;
4427        };
4428
4429        if let Some(completed) = value_to_timestamp_string(time.get("completed")) {
4430            return Some(completed);
4431        }
4432        if let Some(created) = value_to_timestamp_string(time.get("created")) {
4433            return Some(created);
4434        }
4435    }
4436
4437    None
4438}
4439
4440fn value_to_timestamp_string(value: Option<&Value>) -> Option<String> {
4441    let value = value?;
4442    value
4443        .as_str()
4444        .map(ToString::to_string)
4445        .or_else(|| value.as_i64().map(|number| number.to_string()))
4446        .or_else(|| value.as_u64().map(|number| number.to_string()))
4447}
4448
4449fn discover_claude_agents(
4450    resolved_main: &ResolvedThread,
4451    main_session_id: &str,
4452    warnings: &mut Vec<String>,
4453) -> Vec<ClaudeAgentRecord> {
4454    let Some(project_dir) = resolved_main.path.parent() else {
4455        warnings.push(format!(
4456            "cannot determine project directory from resolved main thread path: {}",
4457            resolved_main.path.display()
4458        ));
4459        return Vec::new();
4460    };
4461
4462    let mut candidate_files = BTreeSet::new();
4463
4464    let nested_subagent_dir = project_dir.join(main_session_id).join("subagents");
4465    if nested_subagent_dir.exists()
4466        && let Ok(entries) = fs::read_dir(&nested_subagent_dir)
4467    {
4468        for entry in entries.filter_map(std::result::Result::ok) {
4469            let path = entry.path();
4470            if is_claude_agent_filename(&path) {
4471                candidate_files.insert(path);
4472            }
4473        }
4474    }
4475
4476    if let Ok(entries) = fs::read_dir(project_dir) {
4477        for entry in entries.filter_map(std::result::Result::ok) {
4478            let path = entry.path();
4479            if is_claude_agent_filename(&path) {
4480                candidate_files.insert(path);
4481            }
4482        }
4483    }
4484
4485    let mut latest_by_agent = BTreeMap::<String, ClaudeAgentRecord>::new();
4486
4487    for path in candidate_files {
4488        let Some(record) = analyze_claude_agent_file(&path, main_session_id, warnings) else {
4489            continue;
4490        };
4491
4492        match latest_by_agent.get(&record.agent_id) {
4493            Some(existing) => {
4494                let new_stamp = file_modified_epoch(&record.path).unwrap_or(0);
4495                let old_stamp = file_modified_epoch(&existing.path).unwrap_or(0);
4496                if new_stamp > old_stamp {
4497                    latest_by_agent.insert(record.agent_id.clone(), record);
4498                }
4499            }
4500            None => {
4501                latest_by_agent.insert(record.agent_id.clone(), record);
4502            }
4503        }
4504    }
4505
4506    latest_by_agent.into_values().collect()
4507}
4508
4509fn analyze_claude_agent_file(
4510    path: &Path,
4511    main_session_id: &str,
4512    warnings: &mut Vec<String>,
4513) -> Option<ClaudeAgentRecord> {
4514    let raw = match read_thread_raw(path) {
4515        Ok(raw) => raw,
4516        Err(err) => {
4517            warnings.push(format!(
4518                "failed to read Claude agent transcript {}: {err}",
4519                path.display()
4520            ));
4521            return None;
4522        }
4523    };
4524
4525    let mut agent_id = None::<String>;
4526    let mut is_sidechain = false;
4527    let mut session_matches = false;
4528    let mut has_error = false;
4529    let mut has_assistant = false;
4530    let mut has_user = false;
4531    let mut last_update = None::<String>;
4532
4533    for (line_idx, line) in raw.lines().enumerate() {
4534        if line.trim().is_empty() {
4535            continue;
4536        }
4537
4538        let value = match jsonl::parse_json_line(path, line_idx + 1, line) {
4539            Ok(Some(value)) => value,
4540            Ok(None) => continue,
4541            Err(err) => {
4542                warnings.push(format!(
4543                    "failed to parse Claude agent transcript line {} in {}: {err}",
4544                    line_idx + 1,
4545                    path.display()
4546                ));
4547                continue;
4548            }
4549        };
4550
4551        if line_idx == 0 {
4552            agent_id = value
4553                .get("agentId")
4554                .and_then(Value::as_str)
4555                .map(ToString::to_string);
4556            is_sidechain = value
4557                .get("isSidechain")
4558                .and_then(Value::as_bool)
4559                .unwrap_or(false);
4560            session_matches = value
4561                .get("sessionId")
4562                .and_then(Value::as_str)
4563                .is_some_and(|session_id| session_id == main_session_id);
4564        }
4565
4566        if let Some(timestamp) = value
4567            .get("timestamp")
4568            .and_then(Value::as_str)
4569            .map(ToString::to_string)
4570        {
4571            last_update = Some(timestamp);
4572        }
4573
4574        if value
4575            .get("isApiErrorMessage")
4576            .and_then(Value::as_bool)
4577            .unwrap_or(false)
4578            || !value.get("error").is_none_or(Value::is_null)
4579        {
4580            has_error = true;
4581        }
4582
4583        if let Some(kind) = value.get("type").and_then(Value::as_str) {
4584            if kind == "assistant" {
4585                has_assistant = true;
4586            }
4587            if kind == "user" {
4588                has_user = true;
4589            }
4590        }
4591    }
4592
4593    if !is_sidechain || !session_matches {
4594        return None;
4595    }
4596
4597    let Some(agent_id) = agent_id else {
4598        warnings.push(format!(
4599            "missing agentId in Claude sidechain transcript: {}",
4600            path.display()
4601        ));
4602        return None;
4603    };
4604
4605    let status = if has_error {
4606        STATUS_ERRORED.to_string()
4607    } else if has_assistant {
4608        STATUS_COMPLETED.to_string()
4609    } else if has_user {
4610        STATUS_RUNNING.to_string()
4611    } else {
4612        STATUS_PENDING_INIT.to_string()
4613    };
4614
4615    let excerpt = render::extract_messages(ProviderKind::Claude, path, &raw)
4616        .map(|messages| {
4617            messages
4618                .into_iter()
4619                .rev()
4620                .take(3)
4621                .collect::<Vec<_>>()
4622                .into_iter()
4623                .rev()
4624                .map(|message| SubagentExcerptMessage {
4625                    role: message.role,
4626                    text: message.text,
4627                })
4628                .collect::<Vec<_>>()
4629        })
4630        .unwrap_or_default();
4631
4632    let mut relation = SubagentRelation {
4633        validated: true,
4634        ..SubagentRelation::default()
4635    };
4636    relation
4637        .evidence
4638        .push("agent transcript is sidechain and sessionId matches main thread".to_string());
4639
4640    Some(ClaudeAgentRecord {
4641        agent_id,
4642        path: path.to_path_buf(),
4643        status,
4644        last_update: last_update.or_else(|| modified_timestamp_string(path)),
4645        relation,
4646        excerpt,
4647        warnings: Vec::new(),
4648    })
4649}
4650
4651fn is_claude_agent_filename(path: &Path) -> bool {
4652    path.is_file()
4653        && path
4654            .extension()
4655            .and_then(|ext| ext.to_str())
4656            .is_some_and(|ext| ext == "jsonl")
4657        && path
4658            .file_name()
4659            .and_then(|name| name.to_str())
4660            .is_some_and(|name| name.starts_with("agent-"))
4661}
4662
4663fn file_modified_epoch(path: &Path) -> Option<u64> {
4664    fs::metadata(path)
4665        .ok()
4666        .and_then(|meta| meta.modified().ok())
4667        .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
4668        .map(|duration| duration.as_secs())
4669}
4670
4671fn modified_timestamp_string(path: &Path) -> Option<String> {
4672    file_modified_epoch(path).map(|stamp| stamp.to_string())
4673}
4674
4675fn normalize_agent_id(agent_id: &str) -> String {
4676    agent_id
4677        .strip_prefix("agent-")
4678        .unwrap_or(agent_id)
4679        .to_string()
4680}
4681
4682fn extract_last_timestamp(raw: &str) -> Option<String> {
4683    for line in raw.lines().rev() {
4684        let Ok(Some(value)) = jsonl::parse_json_line(Path::new("<timestamp>"), 1, line) else {
4685            continue;
4686        };
4687        if let Some(timestamp) = value
4688            .get("timestamp")
4689            .and_then(Value::as_str)
4690            .map(ToString::to_string)
4691        {
4692            return Some(timestamp);
4693        }
4694    }
4695
4696    None
4697}
4698fn collect_amp_query_candidates(
4699    roots: &ProviderRoots,
4700    warnings: &mut Vec<String>,
4701) -> Vec<QueryCandidate> {
4702    let threads_root = roots.amp_root.join("threads");
4703    collect_simple_file_candidates(
4704        ProviderKind::Amp,
4705        &threads_root,
4706        |path| {
4707            path.extension()
4708                .and_then(|ext| ext.to_str())
4709                .is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
4710        },
4711        |path| {
4712            path.file_stem()
4713                .and_then(|stem| stem.to_str())
4714                .map(ToString::to_string)
4715        },
4716        extract_amp_scope_path,
4717        warnings,
4718    )
4719}
4720
4721fn collect_copilot_query_candidates(
4722    roots: &ProviderRoots,
4723    warnings: &mut Vec<String>,
4724) -> Vec<QueryCandidate> {
4725    let sessions_root = roots.copilot_root.join("session-state");
4726    if !sessions_root.exists() {
4727        return Vec::new();
4728    }
4729
4730    let mut candidates = Vec::new();
4731    for entry in WalkDir::new(&sessions_root)
4732        .into_iter()
4733        .filter_map(std::result::Result::ok)
4734    {
4735        if !entry.file_type().is_file() {
4736            continue;
4737        }
4738        let path = entry.into_path();
4739        let thread_id = if path.file_name().and_then(|name| name.to_str()) == Some("events.jsonl") {
4740            path.parent()
4741                .and_then(Path::file_name)
4742                .and_then(|name| name.to_str())
4743                .map(ToString::to_string)
4744        } else if path
4745            .extension()
4746            .and_then(|ext| ext.to_str())
4747            .is_some_and(|ext| ext.eq_ignore_ascii_case("jsonl"))
4748        {
4749            path.file_stem()
4750                .and_then(|stem| stem.to_str())
4751                .map(ToString::to_string)
4752        } else {
4753            None
4754        };
4755
4756        let Some(thread_id) = thread_id else {
4757            continue;
4758        };
4759        if !is_uuid_session_id(&thread_id) {
4760            warnings.push(format!(
4761                "skipped copilot transcript with invalid thread id={thread_id}: {}",
4762                path.display()
4763            ));
4764            continue;
4765        }
4766
4767        let thread_id = thread_id.to_ascii_lowercase();
4768        let scope_path = extract_copilot_scope_path(&path);
4769        candidates.push(make_file_candidate(
4770            ProviderKind::Copilot,
4771            thread_id.clone(),
4772            format!("agents://copilot/{thread_id}"),
4773            path,
4774            scope_path,
4775        ));
4776    }
4777
4778    candidates
4779}
4780
4781fn collect_codex_query_candidates(
4782    roots: &ProviderRoots,
4783    warnings: &mut Vec<String>,
4784) -> Vec<QueryCandidate> {
4785    let mut candidates = Vec::new();
4786    candidates.extend(collect_simple_file_candidates(
4787        ProviderKind::Codex,
4788        &roots.codex_root.join("sessions"),
4789        |path| {
4790            path.file_name()
4791                .and_then(|name| name.to_str())
4792                .is_some_and(|name| name.starts_with("rollout-") && name.ends_with(".jsonl"))
4793        },
4794        extract_codex_rollout_id,
4795        extract_codex_scope_path,
4796        warnings,
4797    ));
4798    candidates.extend(collect_simple_file_candidates(
4799        ProviderKind::Codex,
4800        &roots.codex_root.join("archived_sessions"),
4801        |path| {
4802            path.file_name()
4803                .and_then(|name| name.to_str())
4804                .is_some_and(|name| name.starts_with("rollout-") && name.ends_with(".jsonl"))
4805        },
4806        extract_codex_rollout_id,
4807        extract_codex_scope_path,
4808        warnings,
4809    ));
4810    candidates
4811}
4812
4813fn collect_claude_query_candidates(
4814    roots: &ProviderRoots,
4815    warnings: &mut Vec<String>,
4816) -> Vec<QueryCandidate> {
4817    let projects_root = roots.claude_root.join("projects");
4818    if !projects_root.exists() {
4819        return Vec::new();
4820    }
4821
4822    let mut candidates = Vec::new();
4823    for entry in WalkDir::new(&projects_root)
4824        .into_iter()
4825        .filter_map(std::result::Result::ok)
4826    {
4827        if !entry.file_type().is_file() {
4828            continue;
4829        }
4830        let path = entry.into_path();
4831        if path.file_name().and_then(|name| name.to_str()) == Some("sessions-index.json") {
4832            continue;
4833        }
4834        if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {
4835            continue;
4836        }
4837
4838        if let Some((thread_id, uri)) = extract_claude_thread_identity(&path) {
4839            let scope_path = extract_claude_scope_path(&path);
4840            candidates.push(make_file_candidate(
4841                ProviderKind::Claude,
4842                thread_id,
4843                uri,
4844                path,
4845                scope_path,
4846            ));
4847        } else {
4848            warnings.push(format!(
4849                "skipped claude transcript with unknown thread identity: {}",
4850                path.display()
4851            ));
4852        }
4853    }
4854
4855    candidates
4856}
4857
4858fn collect_cursor_query_candidates(
4859    roots: &ProviderRoots,
4860    warnings: &mut Vec<String>,
4861    with_search_text: bool,
4862) -> Result<Vec<QueryCandidate>> {
4863    let provider = CursorProvider::new(&roots.cursor_root);
4864    let chats_root = roots.cursor_root.join("chats");
4865    if !chats_root.exists() {
4866        return Ok(Vec::new());
4867    }
4868
4869    let mut candidates = Vec::new();
4870    for entry in WalkDir::new(&chats_root)
4871        .min_depth(3)
4872        .max_depth(3)
4873        .into_iter()
4874        .filter_map(std::result::Result::ok)
4875    {
4876        if !entry.file_type().is_file() {
4877            continue;
4878        }
4879
4880        let path = entry.into_path();
4881        if path.file_name().and_then(|name| name.to_str()) != Some("store.db") {
4882            continue;
4883        }
4884
4885        let Some(session_id) = path
4886            .parent()
4887            .and_then(Path::file_name)
4888            .and_then(|name| name.to_str())
4889            .map(str::to_ascii_lowercase)
4890        else {
4891            warnings.push(format!(
4892                "skipped cursor store with invalid session directory: {}",
4893                path.display()
4894            ));
4895            continue;
4896        };
4897
4898        if AgentsUri::parse(&format!("cursor://{session_id}")).is_err() {
4899            warnings.push(format!(
4900                "skipped cursor store with invalid id={session_id} from {}",
4901                path.display()
4902            ));
4903            continue;
4904        }
4905
4906        let materialized = match provider.materialize_store(&path, &session_id) {
4907            Ok(materialized) => materialized,
4908            Err(err) => {
4909                warnings.push(format!(
4910                    "failed materializing cursor store {}: {err}",
4911                    path.display()
4912                ));
4913                continue;
4914            }
4915        };
4916
4917        let search_target = if with_search_text {
4918            QuerySearchTarget::Text(materialized.search_text)
4919        } else {
4920            QuerySearchTarget::File(materialized.path)
4921        };
4922
4923        candidates.push(QueryCandidate {
4924            provider: ProviderKind::Cursor,
4925            thread_id: session_id.clone(),
4926            uri: format!("agents://cursor/{session_id}"),
4927            thread_source: path.display().to_string(),
4928            updated_at: modified_timestamp_string(&path),
4929            updated_epoch: file_modified_epoch(&path),
4930            scope_path: materialized
4931                .metadata
4932                .workspace_path
4933                .as_deref()
4934                .and_then(scope_path_from_str),
4935            search_target,
4936        });
4937    }
4938
4939    Ok(candidates)
4940}
4941
4942fn collect_gemini_query_candidates(
4943    roots: &ProviderRoots,
4944    warnings: &mut Vec<String>,
4945) -> Vec<QueryCandidate> {
4946    let tmp_root = roots.gemini_root.join("tmp");
4947    if !tmp_root.exists() {
4948        return Vec::new();
4949    }
4950
4951    let mut candidates = Vec::new();
4952    for entry in WalkDir::new(&tmp_root)
4953        .into_iter()
4954        .filter_map(std::result::Result::ok)
4955    {
4956        if !entry.file_type().is_file() {
4957            continue;
4958        }
4959        let path = entry.into_path();
4960        let is_session_file = path
4961            .file_name()
4962            .and_then(|name| name.to_str())
4963            .is_some_and(|name| name.starts_with("session-") && name.ends_with(".json"));
4964        let in_chats_dir = path
4965            .parent()
4966            .and_then(Path::file_name)
4967            .and_then(|name| name.to_str())
4968            .is_some_and(|name| name == "chats");
4969        if !(is_session_file && in_chats_dir) {
4970            continue;
4971        }
4972
4973        let raw = match fs::read_to_string(&path) {
4974            Ok(raw) => raw,
4975            Err(err) => {
4976                warnings.push(format!(
4977                    "failed reading gemini transcript {}: {err}",
4978                    path.display()
4979                ));
4980                continue;
4981            }
4982        };
4983        let value = match serde_json::from_str::<Value>(&raw) {
4984            Ok(value) => value,
4985            Err(err) => {
4986                warnings.push(format!(
4987                    "failed parsing gemini transcript {} as json: {err}",
4988                    path.display()
4989                ));
4990                continue;
4991            }
4992        };
4993        let Some(session_id) = value.get("sessionId").and_then(Value::as_str) else {
4994            warnings.push(format!(
4995                "gemini transcript does not contain sessionId: {}",
4996                path.display()
4997            ));
4998            continue;
4999        };
5000        if !is_uuid_session_id(session_id) {
5001            warnings.push(format!(
5002                "gemini transcript contains non-uuid sessionId={session_id}: {}",
5003                path.display()
5004            ));
5005            continue;
5006        }
5007        let session_id = session_id.to_ascii_lowercase();
5008        let scope_path = extract_gemini_scope_path(&path);
5009        candidates.push(make_file_candidate(
5010            ProviderKind::Gemini,
5011            session_id.clone(),
5012            format!("agents://gemini/{session_id}"),
5013            path,
5014            scope_path,
5015        ));
5016    }
5017
5018    candidates
5019}
5020
5021fn collect_kimi_query_candidates(
5022    roots: &ProviderRoots,
5023    _warnings: &mut Vec<String>,
5024) -> Vec<QueryCandidate> {
5025    let sessions_root = roots.kimi_root.join("sessions");
5026    if !sessions_root.exists() {
5027        return Vec::new();
5028    }
5029
5030    let mut candidates = Vec::new();
5031    for entry in WalkDir::new(&sessions_root)
5032        .min_depth(2)
5033        .max_depth(2)
5034        .into_iter()
5035        .filter_map(std::result::Result::ok)
5036    {
5037        if !entry.file_type().is_dir() {
5038            continue;
5039        }
5040        let dir_name = entry.file_name().to_string_lossy().to_string();
5041        if !is_uuid_session_id(&dir_name) {
5042            continue;
5043        }
5044        let context_path = entry.path().join("context.jsonl");
5045        if !context_path.exists() {
5046            continue;
5047        }
5048        let session_id = dir_name.to_ascii_lowercase();
5049        candidates.push(make_file_candidate(
5050            ProviderKind::Kimi,
5051            session_id.clone(),
5052            format!("agents://kimi/{session_id}"),
5053            context_path,
5054            None,
5055        ));
5056    }
5057
5058    candidates
5059}
5060
5061fn collect_pi_query_candidates(
5062    roots: &ProviderRoots,
5063    warnings: &mut Vec<String>,
5064) -> Vec<QueryCandidate> {
5065    let sessions_root = roots.pi_root.join("sessions");
5066    if !sessions_root.exists() {
5067        return Vec::new();
5068    }
5069
5070    let mut candidates = Vec::new();
5071    for entry in WalkDir::new(&sessions_root)
5072        .into_iter()
5073        .filter_map(std::result::Result::ok)
5074    {
5075        if !entry.file_type().is_file() {
5076            continue;
5077        }
5078        let path = entry.into_path();
5079        if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {
5080            continue;
5081        }
5082
5083        match extract_pi_session_id_from_header(&path) {
5084            Ok(Some(session_id)) => {
5085                let session_id = session_id.to_ascii_lowercase();
5086                let scope_path = extract_pi_scope_path(&path);
5087                candidates.push(make_file_candidate(
5088                    ProviderKind::Pi,
5089                    session_id.clone(),
5090                    format!("agents://pi/{session_id}"),
5091                    path,
5092                    scope_path,
5093                ));
5094            }
5095            Ok(None) => {}
5096            Err(err) => warnings.push(err),
5097        }
5098    }
5099
5100    candidates
5101}
5102
5103fn collect_opencode_query_candidates(
5104    roots: &ProviderRoots,
5105    warnings: &mut Vec<String>,
5106    with_search_text: bool,
5107) -> Result<Vec<QueryCandidate>> {
5108    let db_path = roots.opencode_root.join("opencode.db");
5109    if !db_path.exists() {
5110        return Ok(Vec::new());
5111    }
5112
5113    let conn = Connection::open_with_flags(&db_path, OpenFlags::SQLITE_OPEN_READ_ONLY).map_err(
5114        |source| XurlError::Sqlite {
5115            path: db_path.clone(),
5116            source,
5117        },
5118    )?;
5119
5120    let mut stmt = conn
5121        .prepare(
5122            "SELECT s.id, s.directory, COALESCE(MAX(m.time_created), 0)
5123             FROM session s
5124             LEFT JOIN message m ON m.session_id = s.id
5125             GROUP BY s.id, s.directory
5126             ORDER BY COALESCE(MAX(m.time_created), 0) DESC, s.id DESC",
5127        )
5128        .map_err(|source| XurlError::Sqlite {
5129            path: db_path.clone(),
5130            source,
5131        })?;
5132
5133    let rows = stmt
5134        .query_map([], |row| {
5135            Ok((
5136                row.get::<_, String>(0)?,
5137                row.get::<_, Option<String>>(1)?,
5138                row.get::<_, i64>(2)
5139                    .ok()
5140                    .and_then(|stamp| u64::try_from(stamp).ok()),
5141            ))
5142        })
5143        .map_err(|source| XurlError::Sqlite {
5144            path: db_path.clone(),
5145            source,
5146        })?;
5147
5148    let mut candidates = Vec::new();
5149    for row in rows {
5150        let (session_id, directory, updated_epoch) = row.map_err(|source| XurlError::Sqlite {
5151            path: db_path.clone(),
5152            source,
5153        })?;
5154        if AgentsUri::parse(&format!("opencode://{session_id}")).is_err() {
5155            warnings.push(format!(
5156                "skipped opencode session with invalid id={session_id} from {}",
5157                db_path.display()
5158            ));
5159            continue;
5160        }
5161        let search_target = if with_search_text {
5162            QuerySearchTarget::Text(fetch_opencode_search_text(&conn, &db_path, &session_id)?)
5163        } else {
5164            QuerySearchTarget::Text(String::new())
5165        };
5166
5167        candidates.push(QueryCandidate {
5168            provider: ProviderKind::Opencode,
5169            thread_id: session_id.clone(),
5170            uri: format!("agents://opencode/{session_id}"),
5171            thread_source: format!("{}#session:{session_id}", db_path.display()),
5172            updated_at: updated_epoch.map(|value| value.to_string()),
5173            updated_epoch,
5174            scope_path: directory.as_deref().and_then(scope_path_from_str),
5175            search_target,
5176        });
5177    }
5178
5179    Ok(candidates)
5180}
5181
5182fn fetch_opencode_search_text(
5183    conn: &Connection,
5184    db_path: &Path,
5185    session_id: &str,
5186) -> Result<String> {
5187    let mut chunks = Vec::new();
5188
5189    let mut message_stmt = conn
5190        .prepare(
5191            "SELECT data
5192             FROM message
5193             WHERE session_id = ?1
5194             ORDER BY time_created ASC, id ASC",
5195        )
5196        .map_err(|source| XurlError::Sqlite {
5197            path: db_path.to_path_buf(),
5198            source,
5199        })?;
5200    let message_rows = message_stmt
5201        .query_map([session_id], |row| row.get::<_, String>(0))
5202        .map_err(|source| XurlError::Sqlite {
5203            path: db_path.to_path_buf(),
5204            source,
5205        })?;
5206    for row in message_rows {
5207        let value = row.map_err(|source| XurlError::Sqlite {
5208            path: db_path.to_path_buf(),
5209            source,
5210        })?;
5211        chunks.push(value);
5212    }
5213
5214    let mut part_stmt = conn
5215        .prepare(
5216            "SELECT data
5217             FROM part
5218             WHERE session_id = ?1
5219             ORDER BY time_created ASC, id ASC",
5220        )
5221        .map_err(|source| XurlError::Sqlite {
5222            path: db_path.to_path_buf(),
5223            source,
5224        })?;
5225    let part_rows = part_stmt
5226        .query_map([session_id], |row| row.get::<_, String>(0))
5227        .map_err(|source| XurlError::Sqlite {
5228            path: db_path.to_path_buf(),
5229            source,
5230        })?;
5231    for row in part_rows {
5232        let value = row.map_err(|source| XurlError::Sqlite {
5233            path: db_path.to_path_buf(),
5234            source,
5235        })?;
5236        chunks.push(value);
5237    }
5238
5239    Ok(chunks.join("\n"))
5240}
5241
5242fn collect_simple_file_candidates<F, G, H>(
5243    provider: ProviderKind,
5244    root: &Path,
5245    path_filter: F,
5246    thread_id_extractor: G,
5247    scope_path_extractor: H,
5248    warnings: &mut Vec<String>,
5249) -> Vec<QueryCandidate>
5250where
5251    F: Fn(&Path) -> bool,
5252    G: Fn(&Path) -> Option<String>,
5253    H: Fn(&Path) -> Option<PathBuf>,
5254{
5255    if !root.exists() {
5256        return Vec::new();
5257    }
5258
5259    let mut candidates = Vec::new();
5260    for entry in WalkDir::new(root)
5261        .into_iter()
5262        .filter_map(std::result::Result::ok)
5263    {
5264        if !entry.file_type().is_file() {
5265            continue;
5266        }
5267        let path = entry.into_path();
5268        if !path_filter(&path) {
5269            continue;
5270        }
5271        let Some(thread_id) = thread_id_extractor(&path) else {
5272            warnings.push(format!(
5273                "skipped {} transcript with unknown thread id: {}",
5274                provider,
5275                path.display()
5276            ));
5277            continue;
5278        };
5279        candidates.push(make_file_candidate(
5280            provider,
5281            thread_id.clone(),
5282            format!("agents://{provider}/{thread_id}"),
5283            path.clone(),
5284            scope_path_extractor(&path),
5285        ));
5286    }
5287
5288    candidates
5289}
5290
5291fn make_file_candidate(
5292    provider: ProviderKind,
5293    thread_id: String,
5294    uri: String,
5295    path: PathBuf,
5296    scope_path: Option<PathBuf>,
5297) -> QueryCandidate {
5298    QueryCandidate {
5299        provider,
5300        thread_id,
5301        uri,
5302        thread_source: path.display().to_string(),
5303        updated_at: modified_timestamp_string(&path),
5304        updated_epoch: file_modified_epoch(&path),
5305        scope_path,
5306        search_target: QuerySearchTarget::File(path),
5307    }
5308}
5309
5310fn extract_codex_rollout_id(path: &Path) -> Option<String> {
5311    let name = path.file_name()?.to_str()?;
5312    let stem = name.strip_suffix(".jsonl")?;
5313    if stem.len() < 36 {
5314        return None;
5315    }
5316    let thread_id = &stem[stem.len() - 36..];
5317    if is_uuid_session_id(thread_id) {
5318        Some(thread_id.to_ascii_lowercase())
5319    } else {
5320        None
5321    }
5322}
5323
5324fn extract_claude_thread_identity(path: &Path) -> Option<(String, String)> {
5325    let file_name = path.file_name()?.to_str()?;
5326    if let Some(agent_id) = file_name
5327        .strip_prefix("agent-")
5328        .and_then(|name| name.strip_suffix(".jsonl"))
5329    {
5330        let subagents_dir = path.parent()?;
5331        if subagents_dir.file_name()?.to_str()? != "subagents" {
5332            return None;
5333        }
5334        let main_thread_id = subagents_dir.parent()?.file_name()?.to_str()?.to_string();
5335        return Some((
5336            format!("{main_thread_id}/{agent_id}"),
5337            format!("agents://claude/{main_thread_id}/{agent_id}"),
5338        ));
5339    }
5340
5341    if let Some(session_id) = extract_claude_session_id_from_header(path) {
5342        return Some((session_id.clone(), format!("agents://claude/{session_id}")));
5343    }
5344
5345    let file_stem = path.file_stem()?.to_str()?;
5346    if is_uuid_session_id(file_stem) {
5347        let session_id = file_stem.to_ascii_lowercase();
5348        return Some((session_id.clone(), format!("agents://claude/{session_id}")));
5349    }
5350
5351    None
5352}
5353
5354fn extract_claude_session_id_from_header(path: &Path) -> Option<String> {
5355    let file = fs::File::open(path).ok()?;
5356    let reader = BufReader::new(file);
5357    for line in reader.lines().take(30).flatten() {
5358        if line.trim().is_empty() {
5359            continue;
5360        }
5361        let Ok(value) = serde_json::from_str::<Value>(&line) else {
5362            continue;
5363        };
5364        let session_id = value.get("sessionId").and_then(Value::as_str)?;
5365        if is_uuid_session_id(session_id) {
5366            return Some(session_id.to_ascii_lowercase());
5367        }
5368    }
5369    None
5370}
5371
5372fn extract_pi_session_id_from_header(path: &Path) -> std::result::Result<Option<String>, String> {
5373    let file =
5374        fs::File::open(path).map_err(|err| format!("failed opening {}: {err}", path.display()))?;
5375    let reader = BufReader::new(file);
5376    let Some(first_non_empty) = reader
5377        .lines()
5378        .take(30)
5379        .filter_map(std::result::Result::ok)
5380        .find(|line| !line.trim().is_empty())
5381    else {
5382        return Ok(None);
5383    };
5384    let value = serde_json::from_str::<Value>(&first_non_empty)
5385        .map_err(|err| format!("failed parsing pi header {}: {err}", path.display()))?;
5386    if value.get("type").and_then(Value::as_str) != Some("session") {
5387        return Ok(None);
5388    }
5389    let Some(session_id) = value.get("id").and_then(Value::as_str) else {
5390        return Ok(None);
5391    };
5392    if !is_uuid_session_id(session_id) {
5393        return Err(format!(
5394            "pi session header contains invalid session id={session_id}: {}",
5395            path.display()
5396        ));
5397    }
5398    Ok(Some(session_id.to_ascii_lowercase()))
5399}
5400
5401fn main_thread_uri(uri: &AgentsUri) -> AgentsUri {
5402    AgentsUri {
5403        provider: uri.provider,
5404        session_id: uri.session_id.clone(),
5405        agent_id: None,
5406        query: Vec::new(),
5407    }
5408}
5409
5410fn make_query(uri: &AgentsUri, agent_id: Option<String>, list: bool) -> SubagentQuery {
5411    SubagentQuery {
5412        provider: uri.provider.to_string(),
5413        main_thread_id: uri.session_id.clone(),
5414        agent_id,
5415        list,
5416    }
5417}
5418
5419fn agents_thread_uri(provider: &str, thread_id: &str, agent_id: Option<&str>) -> String {
5420    match agent_id {
5421        Some(agent_id) => format!("agents://{provider}/{thread_id}/{agent_id}"),
5422        None => format!("agents://{provider}/{thread_id}"),
5423    }
5424}
5425
5426fn render_preview_text(content: &Value, max_chars: usize) -> String {
5427    let text = if content.is_string() {
5428        content.as_str().unwrap_or_default().to_string()
5429    } else if let Some(items) = content.as_array() {
5430        items
5431            .iter()
5432            .filter_map(|item| {
5433                item.get("text")
5434                    .and_then(Value::as_str)
5435                    .or_else(|| item.as_str())
5436            })
5437            .collect::<Vec<_>>()
5438            .join(" ")
5439    } else {
5440        String::new()
5441    };
5442
5443    truncate_preview(&text, max_chars)
5444}
5445
5446fn truncate_preview(input: &str, max_chars: usize) -> String {
5447    let normalized = input.split_whitespace().collect::<Vec<_>>().join(" ");
5448    if normalized.chars().count() <= max_chars {
5449        return normalized;
5450    }
5451
5452    let mut out = String::new();
5453    for (idx, ch) in normalized.chars().enumerate() {
5454        if idx >= max_chars.saturating_sub(1) {
5455            break;
5456        }
5457        out.push(ch);
5458    }
5459    out.push('…');
5460    out
5461}
5462
5463fn render_subagent_list_markdown(view: &SubagentListView) -> String {
5464    let main_thread_uri = agents_thread_uri(&view.query.provider, &view.query.main_thread_id, None);
5465    let mut output = String::new();
5466    output.push_str("# Subagent Status\n\n");
5467    output.push_str(&format!("- Provider: `{}`\n", view.query.provider));
5468    output.push_str(&format!("- Main Thread: `{}`\n", main_thread_uri));
5469    output.push_str("- Mode: `list`\n\n");
5470
5471    if view.agents.is_empty() {
5472        output.push_str("_No subagents found for this thread._\n");
5473        return output;
5474    }
5475
5476    for (index, agent) in view.agents.iter().enumerate() {
5477        let agent_uri = format!("{}/{}", main_thread_uri, agent.agent_id);
5478        output.push_str(&format!("## {}. `{}`\n\n", index + 1, agent_uri));
5479        output.push_str(&format!(
5480            "- Status: `{}` (`{}`)\n",
5481            agent.status, agent.status_source
5482        ));
5483        output.push_str(&format!(
5484            "- Last Update: `{}`\n",
5485            agent.last_update.as_deref().unwrap_or("unknown")
5486        ));
5487        output.push_str(&format!(
5488            "- Relation: `{}`\n",
5489            if agent.relation.validated {
5490                "validated"
5491            } else {
5492                "inferred"
5493            }
5494        ));
5495        if let Some(thread) = &agent.child_thread
5496            && let Some(path) = &thread.path
5497        {
5498            output.push_str(&format!("- Thread Path: `{}`\n", path));
5499        }
5500        output.push('\n');
5501    }
5502
5503    output
5504}
5505
5506fn render_subagent_detail_markdown(view: &SubagentDetailView) -> String {
5507    let main_thread_uri = agents_thread_uri(&view.query.provider, &view.query.main_thread_id, None);
5508    let mut output = String::new();
5509    output.push_str("# Subagent Thread\n\n");
5510    output.push_str(&format!("- Provider: `{}`\n", view.query.provider));
5511    output.push_str(&format!("- Main Thread: `{}`\n", main_thread_uri));
5512    if let Some(agent_id) = &view.query.agent_id {
5513        output.push_str(&format!(
5514            "- Subagent Thread: `{}/{}`\n",
5515            main_thread_uri, agent_id
5516        ));
5517    }
5518    output.push_str(&format!(
5519        "- Status: `{}` (`{}`)\n\n",
5520        view.status, view.status_source
5521    ));
5522
5523    output.push_str("## Agent Status Summary\n\n");
5524    output.push_str(&format!(
5525        "- Relation: `{}`\n",
5526        if view.relation.validated {
5527            "validated"
5528        } else {
5529            "inferred"
5530        }
5531    ));
5532    for evidence in &view.relation.evidence {
5533        output.push_str(&format!("- Evidence: {}\n", evidence));
5534    }
5535    if let Some(thread) = &view.child_thread {
5536        if let Some(path) = &thread.path {
5537            output.push_str(&format!("- Child Path: `{}`\n", path));
5538        }
5539        if let Some(last_updated_at) = &thread.last_updated_at {
5540            output.push_str(&format!("- Child Last Update: `{}`\n", last_updated_at));
5541        }
5542    }
5543    output.push('\n');
5544
5545    output.push_str("## Lifecycle (Parent Thread)\n\n");
5546    if view.lifecycle.is_empty() {
5547        output.push_str("_No lifecycle events found in parent thread._\n\n");
5548    } else {
5549        for event in &view.lifecycle {
5550            output.push_str(&format!(
5551                "- `{}` `{}` {}\n",
5552                event.timestamp.as_deref().unwrap_or("unknown"),
5553                event.event,
5554                event.detail
5555            ));
5556        }
5557        output.push('\n');
5558    }
5559
5560    output.push_str("## Thread Excerpt (Child Thread)\n\n");
5561    if view.excerpt.is_empty() {
5562        output.push_str("_No child thread messages found._\n\n");
5563    } else {
5564        for (index, message) in view.excerpt.iter().enumerate() {
5565            let title = match message.role {
5566                crate::model::MessageRole::User => "User",
5567                crate::model::MessageRole::Assistant => "Assistant",
5568            };
5569            output.push_str(&format!("### {}. {}\n\n", index + 1, title));
5570            output.push_str(message.text.trim());
5571            output.push_str("\n\n");
5572        }
5573    }
5574
5575    output
5576}
5577
5578#[cfg(test)]
5579mod tests {
5580    use std::fs;
5581    use std::path::Path;
5582
5583    use tempfile::tempdir;
5584
5585    use crate::service::{
5586        collect_claude_thread_metadata, collect_codex_thread_metadata, collect_pi_thread_metadata,
5587        extract_last_timestamp, read_thread_raw,
5588    };
5589    use crate::{
5590        ProviderKind, ThreadQuery, ThreadQueryItem, ThreadQueryResult,
5591        render_thread_query_head_markdown,
5592    };
5593
5594    #[test]
5595    fn empty_file_returns_error() {
5596        let temp = tempdir().expect("tempdir");
5597        let path = temp.path().join("thread.jsonl");
5598        fs::write(&path, "").expect("write");
5599
5600        let err = read_thread_raw(&path).expect_err("must fail");
5601        assert!(format!("{err}").contains("thread file is empty"));
5602    }
5603
5604    #[test]
5605    fn extract_last_timestamp_from_jsonl() {
5606        let raw =
5607            "{\"timestamp\":\"2026-02-23T00:00:01Z\"}\n{\"timestamp\":\"2026-02-23T00:00:02Z\"}\n";
5608        let timestamp = extract_last_timestamp(raw).expect("must extract timestamp");
5609        assert_eq!(timestamp, "2026-02-23T00:00:02Z");
5610    }
5611
5612    #[test]
5613    fn codex_thread_metadata_flattens_records_to_key_value_lines() {
5614        let raw = concat!(
5615            "{\"type\":\"session_meta\",\"payload\":{\"cwd\":\"/tmp/project\",\"model_provider\":\"openai\",\"base_instructions\":{\"text\":\"very long\"},\"git\":{\"branch\":\"main\",\"commit_hash\":\"deadbeef\"}}}\n",
5616            "{\"type\":\"turn_context\",\"payload\":{\"model\":\"gpt-5.3-codex\",\"approval_policy\":\"never\",\"sandbox_policy\":{\"type\":\"danger-full-access\"}}}\n",
5617        );
5618
5619        let (metadata, warnings) = collect_codex_thread_metadata(Path::new("/tmp/mock"), raw);
5620        assert!(warnings.is_empty());
5621        assert!(metadata.iter().any(|item| item == "type = session_meta"));
5622        assert!(
5623            metadata
5624                .iter()
5625                .any(|item| item == "payload.cwd = /tmp/project")
5626        );
5627        assert!(
5628            metadata
5629                .iter()
5630                .any(|item| item == "payload.git.branch = main")
5631        );
5632        assert!(
5633            metadata
5634                .iter()
5635                .any(|item| item == "payload.git.commit_hash = deadbeef")
5636        );
5637        assert!(
5638            !metadata
5639                .iter()
5640                .any(|item| item.contains("base_instructions"))
5641        );
5642        assert!(!metadata.iter().any(|item| item.contains("payload.model =")));
5643    }
5644
5645    #[test]
5646    fn claude_thread_metadata_flattens_raw_keys() {
5647        let raw = "{\"type\":\"user\",\"cwd\":\"/tmp/project\",\"gitBranch\":\"feature/x\",\"version\":\"1.2.3\"}\n";
5648
5649        let (metadata, warnings) = collect_claude_thread_metadata(Path::new("/tmp/mock"), raw);
5650        assert!(warnings.is_empty());
5651        assert!(metadata.iter().any(|item| item == "type = user"));
5652        assert!(metadata.iter().any(|item| item == "cwd = /tmp/project"));
5653        assert!(metadata.iter().any(|item| item == "gitBranch = feature/x"));
5654        assert!(metadata.iter().any(|item| item == "version = 1.2.3"));
5655    }
5656
5657    #[test]
5658    fn pi_thread_metadata_flattens_raw_records() {
5659        let raw = concat!(
5660            "{\"type\":\"session\",\"id\":\"12cb4c19-2774-4de4-a0d0-9fa32fbae29f\",\"cwd\":\"/tmp/project\"}\n",
5661            "{\"type\":\"model_change\",\"modelId\":\"gpt-5.3-codex\"}\n",
5662            "{\"type\":\"thinking_level_change\",\"thinkingLevel\":\"medium\"}\n",
5663        );
5664
5665        let (metadata, warnings) = collect_pi_thread_metadata(Path::new("/tmp/mock"), raw);
5666        assert!(warnings.is_empty());
5667        assert!(metadata.iter().any(|item| item == "type = session"));
5668        assert!(
5669            metadata
5670                .iter()
5671                .any(|item| item == "id = 12cb4c19-2774-4de4-a0d0-9fa32fbae29f")
5672        );
5673        assert!(metadata.iter().any(|item| item == "cwd = /tmp/project"));
5674        assert!(!metadata.iter().any(|item| item.contains("model_change")));
5675        assert!(
5676            !metadata
5677                .iter()
5678                .any(|item| item.contains("thinking_level_change"))
5679        );
5680    }
5681
5682    #[test]
5683    fn render_thread_query_head_renders_metadata_entries() {
5684        let result = ThreadQueryResult {
5685            query: ThreadQuery {
5686                uri: "agents://codex?limit=1".to_string(),
5687                provider: ProviderKind::Codex,
5688                role: None,
5689                q: None,
5690                limit: 1,
5691                ignored_params: Vec::new(),
5692            },
5693            items: vec![ThreadQueryItem {
5694                provider: ProviderKind::Codex,
5695                thread_id: "019c871c-b1f9-7f60-9c4f-87ed09f13592".to_string(),
5696                uri: "agents://codex/019c871c-b1f9-7f60-9c4f-87ed09f13592".to_string(),
5697                thread_source: "/tmp/mock.jsonl".to_string(),
5698                updated_at: Some("123".to_string()),
5699                matched_preview: None,
5700                thread_metadata: Some(vec![
5701                    "type = session_meta".to_string(),
5702                    "payload.cwd = /tmp/project".to_string(),
5703                ]),
5704            }],
5705            warnings: Vec::new(),
5706        };
5707
5708        let output = render_thread_query_head_markdown(&result);
5709        assert!(output.contains("thread_metadata:"));
5710        assert!(output.contains("payload.cwd = /tmp/project"));
5711    }
5712}