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, PiEntryListItem, PiEntryListView, PiEntryQuery, ProviderKind, ResolvedSkill,
19    ResolvedThread, SubagentDetailView, SubagentExcerptMessage, SubagentLifecycleEvent,
20    SubagentListItem, SubagentListView, SubagentQuery, SubagentRelation, SubagentThreadRef,
21    SubagentView, ThreadQuery, ThreadQueryItem, ThreadQueryResult, WriteRequest, WriteResult,
22};
23use crate::provider::amp::AmpProvider;
24use crate::provider::claude::ClaudeProvider;
25use crate::provider::codex::CodexProvider;
26use crate::provider::gemini::GeminiProvider;
27use crate::provider::opencode::OpencodeProvider;
28use crate::provider::pi::PiProvider;
29use crate::provider::skills::SkillsProvider;
30use crate::provider::{Provider, ProviderRoots, WriteEventSink};
31use crate::render;
32use crate::uri::{AgentsUri, SkillsUri, is_uuid_session_id};
33
34const STATUS_PENDING_INIT: &str = "pendingInit";
35const STATUS_RUNNING: &str = "running";
36const STATUS_COMPLETED: &str = "completed";
37const STATUS_ERRORED: &str = "errored";
38const STATUS_SHUTDOWN: &str = "shutdown";
39const STATUS_NOT_FOUND: &str = "notFound";
40
41#[derive(Debug, Default, Clone)]
42struct AgentTimeline {
43    events: Vec<SubagentLifecycleEvent>,
44    states: Vec<String>,
45    has_spawn: bool,
46    has_activity: bool,
47    last_update: Option<String>,
48}
49
50#[derive(Debug, Clone)]
51struct ClaudeAgentRecord {
52    agent_id: String,
53    path: PathBuf,
54    status: String,
55    last_update: Option<String>,
56    relation: SubagentRelation,
57    excerpt: Vec<SubagentExcerptMessage>,
58    warnings: Vec<String>,
59}
60
61#[derive(Debug, Clone)]
62struct GeminiChatRecord {
63    session_id: String,
64    path: PathBuf,
65    last_update: Option<String>,
66    status: String,
67    explicit_parent_ids: Vec<String>,
68}
69
70#[derive(Debug, Clone)]
71struct GeminiLogEntry {
72    session_id: String,
73    message: Option<String>,
74    timestamp: Option<String>,
75    entry_type: Option<String>,
76    explicit_parent_ids: Vec<String>,
77}
78
79#[derive(Debug, Clone, Default)]
80struct GeminiChildRecord {
81    relation: SubagentRelation,
82    relation_timestamp: Option<String>,
83}
84
85#[derive(Debug, Clone)]
86struct AmpHandoff {
87    thread_id: String,
88    role: Option<String>,
89    timestamp: Option<String>,
90}
91
92#[derive(Debug, Clone)]
93struct AmpChildAnalysis {
94    thread: SubagentThreadRef,
95    status: String,
96    status_source: String,
97    excerpt: Vec<SubagentExcerptMessage>,
98    lifecycle: Vec<SubagentLifecycleEvent>,
99    relation_evidence: Vec<String>,
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
103enum PiSessionHintKind {
104    Parent,
105    Child,
106}
107
108#[derive(Debug, Clone)]
109struct PiSessionHint {
110    kind: PiSessionHintKind,
111    session_id: String,
112    evidence: String,
113}
114
115#[derive(Debug, Clone)]
116struct PiSessionRecord {
117    session_id: String,
118    path: PathBuf,
119    last_update: Option<String>,
120    hints: Vec<PiSessionHint>,
121}
122
123#[derive(Debug, Clone)]
124struct PiDiscoveredChild {
125    relation: SubagentRelation,
126    status: String,
127    status_source: String,
128    last_update: Option<String>,
129    child_thread: Option<SubagentThreadRef>,
130    excerpt: Vec<SubagentExcerptMessage>,
131    warnings: Vec<String>,
132}
133
134#[derive(Debug, Clone)]
135struct OpencodeAgentRecord {
136    agent_id: String,
137    relation: SubagentRelation,
138    message_count: usize,
139}
140
141#[derive(Debug, Clone)]
142struct OpencodeChildAnalysis {
143    child_thread: Option<SubagentThreadRef>,
144    status: String,
145    status_source: String,
146    last_update: Option<String>,
147    excerpt: Vec<SubagentExcerptMessage>,
148    warnings: Vec<String>,
149}
150
151impl Default for PiDiscoveredChild {
152    fn default() -> Self {
153        Self {
154            relation: SubagentRelation::default(),
155            status: STATUS_NOT_FOUND.to_string(),
156            status_source: "inferred".to_string(),
157            last_update: None,
158            child_thread: None,
159            excerpt: Vec::new(),
160            warnings: Vec::new(),
161        }
162    }
163}
164
165pub fn resolve_thread(uri: &AgentsUri, roots: &ProviderRoots) -> Result<ResolvedThread> {
166    let session_id = uri.require_session_id()?;
167    match uri.provider {
168        ProviderKind::Amp => AmpProvider::new(&roots.amp_root).resolve(session_id),
169        ProviderKind::Codex => CodexProvider::new(&roots.codex_root).resolve(session_id),
170        ProviderKind::Claude => ClaudeProvider::new(&roots.claude_root).resolve(session_id),
171        ProviderKind::Gemini => GeminiProvider::new(&roots.gemini_root).resolve(session_id),
172        ProviderKind::Pi => PiProvider::new(&roots.pi_root).resolve(session_id),
173        ProviderKind::Opencode => OpencodeProvider::new(&roots.opencode_root).resolve(session_id),
174    }
175}
176
177pub fn resolve_skill(uri: &SkillsUri, roots: &ProviderRoots) -> Result<ResolvedSkill> {
178    SkillsProvider::new(&roots.skills_root, &roots.skills_cache_root).resolve(uri)
179}
180
181pub fn write_thread(
182    provider: ProviderKind,
183    roots: &ProviderRoots,
184    req: &WriteRequest,
185    sink: &mut dyn WriteEventSink,
186) -> Result<WriteResult> {
187    match provider {
188        ProviderKind::Amp => AmpProvider::new(&roots.amp_root).write(req, sink),
189        ProviderKind::Codex => CodexProvider::new(&roots.codex_root).write(req, sink),
190        ProviderKind::Claude => ClaudeProvider::new(&roots.claude_root).write(req, sink),
191        ProviderKind::Gemini => GeminiProvider::new(&roots.gemini_root).write(req, sink),
192        ProviderKind::Pi => PiProvider::new(&roots.pi_root).write(req, sink),
193        ProviderKind::Opencode => OpencodeProvider::new(&roots.opencode_root).write(req, sink),
194    }
195}
196
197#[derive(Debug, Clone)]
198enum QuerySearchTarget {
199    File(PathBuf),
200    Text(String),
201}
202
203#[derive(Debug, Clone)]
204struct QueryCandidate {
205    thread_id: String,
206    uri: String,
207    thread_source: String,
208    updated_at: Option<String>,
209    updated_epoch: Option<u64>,
210    search_target: QuerySearchTarget,
211}
212
213pub fn query_threads(query: &ThreadQuery, roots: &ProviderRoots) -> Result<ThreadQueryResult> {
214    let mut warnings = query
215        .ignored_params
216        .iter()
217        .map(|key| format!("ignored query parameter: {key}"))
218        .collect::<Vec<_>>();
219
220    let mut candidates = match query.provider {
221        ProviderKind::Amp => collect_amp_query_candidates(roots, &mut warnings),
222        ProviderKind::Codex => collect_codex_query_candidates(roots, &mut warnings),
223        ProviderKind::Claude => collect_claude_query_candidates(roots, &mut warnings),
224        ProviderKind::Gemini => collect_gemini_query_candidates(roots, &mut warnings),
225        ProviderKind::Pi => collect_pi_query_candidates(roots, &mut warnings),
226        ProviderKind::Opencode => collect_opencode_query_candidates(
227            roots,
228            &mut warnings,
229            query.q.as_deref().is_some_and(|q| !q.trim().is_empty())
230                || query
231                    .role
232                    .as_deref()
233                    .is_some_and(|role| !role.trim().is_empty()),
234        )?,
235    };
236
237    candidates.sort_by_key(|candidate| Reverse(candidate.updated_epoch.unwrap_or(0)));
238
239    if query.limit == 0 {
240        return Ok(ThreadQueryResult {
241            query: query.clone(),
242            items: Vec::new(),
243            warnings,
244        });
245    }
246
247    let role_filter = query
248        .role
249        .as_deref()
250        .map(str::trim)
251        .filter(|q| !q.is_empty());
252    let keyword_filter = query.q.as_deref().map(str::trim).filter(|q| !q.is_empty());
253    let mut items = Vec::new();
254    for candidate in &candidates {
255        if items.len() >= query.limit {
256            break;
257        }
258
259        let mut role_preview = None::<String>;
260        if let Some(role_filter) = role_filter {
261            role_preview = match_candidate_preview(candidate, role_filter)?;
262            if role_preview.is_none() {
263                continue;
264            }
265        }
266
267        let matched_preview = if let Some(keyword_filter) = keyword_filter {
268            let matched_preview = match_candidate_preview(candidate, keyword_filter)?;
269            if matched_preview.is_none() {
270                continue;
271            }
272            matched_preview
273        } else {
274            role_preview
275        };
276
277        items.push(ThreadQueryItem {
278            thread_id: candidate.thread_id.clone(),
279            uri: candidate.uri.clone(),
280            thread_source: candidate.thread_source.clone(),
281            updated_at: candidate.updated_at.clone(),
282            matched_preview,
283        });
284    }
285
286    Ok(ThreadQueryResult {
287        query: query.clone(),
288        items,
289        warnings,
290    })
291}
292
293pub fn render_thread_query_head_markdown(result: &ThreadQueryResult) -> String {
294    let mut output = String::new();
295    output.push_str("---\n");
296    push_yaml_string(&mut output, "uri", &result.query.uri);
297    push_yaml_string(&mut output, "provider", &result.query.provider.to_string());
298    push_yaml_string(&mut output, "mode", "thread_query");
299    push_yaml_string(&mut output, "limit", &result.query.limit.to_string());
300    if let Some(role) = &result.query.role {
301        push_yaml_string(&mut output, "role", role);
302    }
303
304    if let Some(q) = &result.query.q {
305        push_yaml_string(&mut output, "q", q);
306    }
307
308    output.push_str("threads:\n");
309    if result.items.is_empty() {
310        output.push_str("  []\n");
311    } else {
312        for item in &result.items {
313            push_yaml_string_with_indent(&mut output, 2, "thread_id", &item.thread_id);
314            push_yaml_string_with_indent(&mut output, 2, "uri", &item.uri);
315            push_yaml_string_with_indent(&mut output, 2, "thread_source", &item.thread_source);
316            if let Some(updated_at) = &item.updated_at {
317                push_yaml_string_with_indent(&mut output, 2, "updated_at", updated_at);
318            }
319            if let Some(matched_preview) = &item.matched_preview {
320                push_yaml_string_with_indent(&mut output, 2, "matched_preview", matched_preview);
321            }
322        }
323    }
324
325    render_warnings(&mut output, &result.warnings);
326    output.push_str("---\n");
327    output
328}
329
330pub fn render_thread_query_markdown(result: &ThreadQueryResult) -> String {
331    let mut output = render_thread_query_head_markdown(result);
332    output.push('\n');
333    output.push_str("# Threads\n\n");
334    output.push_str(&format!("- Provider: `{}`\n", result.query.provider));
335    if let Some(role) = &result.query.role {
336        output.push_str(&format!("- Role: `{}`\n", role));
337    } else {
338        output.push_str("- Role: `_none_`\n");
339    }
340    output.push_str(&format!("- Limit: `{}`\n", result.query.limit));
341    if let Some(q) = &result.query.q {
342        output.push_str(&format!("- Query: `{}`\n", q));
343    } else {
344        output.push_str("- Query: `_none_`\n");
345    }
346    output.push_str(&format!("- Matched: `{}`\n\n", result.items.len()));
347
348    if result.items.is_empty() {
349        output.push_str("_No threads found._\n");
350        return output;
351    }
352
353    for (index, item) in result.items.iter().enumerate() {
354        output.push_str(&format!("## {}. `{}`\n\n", index + 1, item.uri));
355        output.push_str(&format!("- Thread ID: `{}`\n", item.thread_id));
356        output.push_str(&format!("- Thread Source: `{}`\n", item.thread_source));
357        if let Some(updated_at) = &item.updated_at {
358            output.push_str(&format!("- Updated At: `{}`\n", updated_at));
359        }
360        if let Some(matched_preview) = &item.matched_preview {
361            output.push_str(&format!("- Match: `{}`\n", matched_preview));
362        }
363        output.push('\n');
364    }
365
366    output
367}
368
369fn match_candidate_preview(candidate: &QueryCandidate, keyword: &str) -> Result<Option<String>> {
370    match &candidate.search_target {
371        QuerySearchTarget::File(path) => match_first_preview_in_file(path, keyword),
372        QuerySearchTarget::Text(text) => Ok(match_first_preview_in_text(text, keyword)),
373    }
374}
375
376fn match_first_preview_in_file(path: &Path, keyword: &str) -> Result<Option<String>> {
377    let mut matcher_builder = RegexMatcherBuilder::new();
378    matcher_builder.fixed_strings(true).case_insensitive(true);
379    let matcher = matcher_builder
380        .build(keyword)
381        .map_err(|err| XurlError::InvalidMode(format!("invalid keyword query: {err}")))?;
382    let mut searcher = SearcherBuilder::new()
383        .binary_detection(BinaryDetection::quit(b'\x00'))
384        .line_number(true)
385        .build();
386    let mut preview = None::<String>;
387    searcher
388        .search_path(
389            &matcher,
390            path,
391            Lossy(|_, line| {
392                let line = line.trim();
393                if line.is_empty() {
394                    return Ok(true);
395                }
396                preview = Some(truncate_preview(line, 160));
397                Ok(false)
398            }),
399        )
400        .map_err(|source| XurlError::Io {
401            path: path.to_path_buf(),
402            source,
403        })?;
404    Ok(preview)
405}
406
407fn match_first_preview_in_text(text: &str, keyword: &str) -> Option<String> {
408    let matcher = RegexBuilder::new(&regex::escape(keyword))
409        .case_insensitive(true)
410        .build()
411        .ok()?;
412    let found = matcher.find(text)?;
413    let line_start = text[..found.start()].rfind('\n').map_or(0, |idx| idx + 1);
414    let line_end = text[found.end()..]
415        .find('\n')
416        .map_or(text.len(), |idx| found.end() + idx);
417    let line = text[line_start..line_end].trim();
418    if line.is_empty() {
419        Some(truncate_preview(text, 160))
420    } else {
421        Some(truncate_preview(line, 160))
422    }
423}
424
425fn read_thread_raw(path: &Path) -> Result<String> {
426    let bytes = fs::read(path).map_err(|source| XurlError::Io {
427        path: path.to_path_buf(),
428        source,
429    })?;
430
431    if bytes.is_empty() {
432        return Err(XurlError::EmptyThreadFile {
433            path: path.to_path_buf(),
434        });
435    }
436
437    String::from_utf8(bytes).map_err(|_| XurlError::NonUtf8ThreadFile {
438        path: path.to_path_buf(),
439    })
440}
441
442pub fn render_thread_markdown(uri: &AgentsUri, resolved: &ResolvedThread) -> Result<String> {
443    let raw = read_thread_raw(&resolved.path)?;
444    let markdown = render::render_markdown(uri, &resolved.path, &raw)?;
445    Ok(strip_frontmatter(markdown))
446}
447
448pub fn render_skill_markdown(resolved: &ResolvedSkill) -> String {
449    resolved.content.clone()
450}
451
452pub fn render_skill_head_markdown(resolved: &ResolvedSkill) -> String {
453    let mut output = String::new();
454    output.push_str("---\n");
455    push_yaml_string(&mut output, "uri", &resolved.uri);
456    push_yaml_string(&mut output, "kind", "skill");
457    push_yaml_string(&mut output, "provider", "skills");
458    push_yaml_string(
459        &mut output,
460        "source_kind",
461        &resolved.source_kind.to_string(),
462    );
463    push_yaml_string(&mut output, "skill_name", &resolved.skill_name);
464    push_yaml_string(&mut output, "source", &resolved.source);
465    push_yaml_string(&mut output, "resolved_path", &resolved.resolved_path);
466    render_warnings(&mut output, &resolved.metadata.warnings);
467    if !resolved.metadata.candidates.is_empty() {
468        output.push_str("candidates:\n");
469        for candidate in &resolved.metadata.candidates {
470            output.push_str(&format!("  - '{}'\n", yaml_single_quoted(candidate)));
471        }
472    }
473    output.push_str("---\n");
474    output
475}
476
477pub fn render_thread_head_markdown(uri: &AgentsUri, roots: &ProviderRoots) -> Result<String> {
478    let mut output = String::new();
479    output.push_str("---\n");
480    push_yaml_string(&mut output, "uri", &uri.as_agents_string());
481    push_yaml_string(&mut output, "provider", &uri.provider.to_string());
482    push_yaml_string(&mut output, "session_id", &uri.session_id);
483
484    match (uri.provider, uri.agent_id.as_deref()) {
485        (
486            ProviderKind::Amp
487            | ProviderKind::Codex
488            | ProviderKind::Claude
489            | ProviderKind::Gemini
490            | ProviderKind::Opencode,
491            None,
492        ) => {
493            let resolved_main = resolve_thread(uri, roots)?;
494            push_yaml_string(
495                &mut output,
496                "thread_source",
497                &resolved_main.path.display().to_string(),
498            );
499            push_yaml_string(&mut output, "mode", "subagent_index");
500
501            let view = resolve_subagent_view(uri, roots, true)?;
502            let mut warnings = resolved_main.metadata.warnings.clone();
503
504            if let SubagentView::List(list) = view {
505                render_subagents_head(&mut output, &list);
506                warnings.extend(list.warnings);
507            }
508
509            render_warnings(&mut output, &warnings);
510        }
511        (ProviderKind::Pi, None) => {
512            let resolved = resolve_thread(uri, roots)?;
513            push_yaml_string(
514                &mut output,
515                "thread_source",
516                &resolved.path.display().to_string(),
517            );
518            push_yaml_string(&mut output, "mode", "pi_entry_index");
519
520            let list = resolve_pi_entry_list_view(uri, roots)?;
521            render_pi_entries_head(&mut output, &list);
522            let mut warnings = list.warnings;
523
524            if let SubagentView::List(subagents) = resolve_subagent_view(uri, roots, true)? {
525                render_subagents_head(&mut output, &subagents);
526                warnings.extend(subagents.warnings);
527            }
528
529            render_warnings(&mut output, &warnings);
530        }
531        (
532            ProviderKind::Amp
533            | ProviderKind::Codex
534            | ProviderKind::Claude
535            | ProviderKind::Gemini
536            | ProviderKind::Opencode,
537            Some(_),
538        ) => {
539            let main_uri = main_thread_uri(uri);
540            let resolved_main = resolve_thread(&main_uri, roots)?;
541
542            let view = resolve_subagent_view(uri, roots, false)?;
543            if let SubagentView::Detail(detail) = view {
544                let thread_source = detail
545                    .child_thread
546                    .as_ref()
547                    .and_then(|thread| thread.path.as_deref())
548                    .map(ToString::to_string)
549                    .unwrap_or_else(|| resolved_main.path.display().to_string());
550                push_yaml_string(&mut output, "thread_source", &thread_source);
551                push_yaml_string(&mut output, "mode", "subagent_detail");
552
553                if let Some(agent_id) = &detail.query.agent_id {
554                    push_yaml_string(&mut output, "agent_id", agent_id);
555                    push_yaml_string(
556                        &mut output,
557                        "subagent_uri",
558                        &agents_thread_uri(
559                            &detail.query.provider,
560                            &detail.query.main_thread_id,
561                            Some(agent_id),
562                        ),
563                    );
564                }
565                push_yaml_string(&mut output, "status", &detail.status);
566                push_yaml_string(&mut output, "status_source", &detail.status_source);
567
568                if let Some(child_thread) = &detail.child_thread {
569                    push_yaml_string(&mut output, "child_thread_id", &child_thread.thread_id);
570                    if let Some(path) = &child_thread.path {
571                        push_yaml_string(&mut output, "child_thread_source", path);
572                    }
573                    if let Some(last_updated_at) = &child_thread.last_updated_at {
574                        push_yaml_string(&mut output, "child_last_updated_at", last_updated_at);
575                    }
576                }
577
578                render_warnings(&mut output, &detail.warnings);
579            }
580        }
581        (ProviderKind::Pi, Some(agent_id)) if is_uuid_session_id(agent_id) => {
582            let main_uri = main_thread_uri(uri);
583            let resolved_main = resolve_thread(&main_uri, roots)?;
584
585            let view = resolve_subagent_view(uri, roots, false)?;
586            if let SubagentView::Detail(detail) = view {
587                let thread_source = detail
588                    .child_thread
589                    .as_ref()
590                    .and_then(|thread| thread.path.as_deref())
591                    .map(ToString::to_string)
592                    .unwrap_or_else(|| resolved_main.path.display().to_string());
593                push_yaml_string(&mut output, "thread_source", &thread_source);
594                push_yaml_string(&mut output, "mode", "subagent_detail");
595                push_yaml_string(&mut output, "agent_id", agent_id);
596                push_yaml_string(
597                    &mut output,
598                    "subagent_uri",
599                    &agents_thread_uri("pi", &uri.session_id, Some(agent_id)),
600                );
601                push_yaml_string(&mut output, "status", &detail.status);
602                push_yaml_string(&mut output, "status_source", &detail.status_source);
603
604                if let Some(child_thread) = &detail.child_thread {
605                    push_yaml_string(&mut output, "child_thread_id", &child_thread.thread_id);
606                    if let Some(path) = &child_thread.path {
607                        push_yaml_string(&mut output, "child_thread_source", path);
608                    }
609                    if let Some(last_updated_at) = &child_thread.last_updated_at {
610                        push_yaml_string(&mut output, "child_last_updated_at", last_updated_at);
611                    }
612                }
613
614                render_warnings(&mut output, &detail.warnings);
615            }
616        }
617        (ProviderKind::Pi, Some(entry_id)) => {
618            let resolved = resolve_thread(uri, roots)?;
619            push_yaml_string(
620                &mut output,
621                "thread_source",
622                &resolved.path.display().to_string(),
623            );
624            push_yaml_string(&mut output, "mode", "pi_entry");
625            push_yaml_string(&mut output, "entry_id", entry_id);
626        }
627    }
628
629    output.push_str("---\n");
630    Ok(output)
631}
632
633pub fn resolve_subagent_view(
634    uri: &AgentsUri,
635    roots: &ProviderRoots,
636    list: bool,
637) -> Result<SubagentView> {
638    if list && uri.agent_id.is_some() {
639        return Err(XurlError::InvalidMode(
640            "subagent index mode requires agents://<provider>/<main_thread_id>".to_string(),
641        ));
642    }
643
644    if !list && uri.agent_id.is_none() {
645        return Err(XurlError::InvalidMode(
646            "subagent drill-down requires agents://<provider>/<main_thread_id>/<agent_id>"
647                .to_string(),
648        ));
649    }
650
651    match uri.provider {
652        ProviderKind::Amp => resolve_amp_subagent_view(uri, roots, list),
653        ProviderKind::Codex => resolve_codex_subagent_view(uri, roots, list),
654        ProviderKind::Claude => resolve_claude_subagent_view(uri, roots, list),
655        ProviderKind::Gemini => resolve_gemini_subagent_view(uri, roots, list),
656        ProviderKind::Pi => resolve_pi_subagent_view(uri, roots, list),
657        ProviderKind::Opencode => resolve_opencode_subagent_view(uri, roots, list),
658    }
659}
660
661fn push_yaml_string(output: &mut String, key: &str, value: &str) {
662    output.push_str(&format!("{key}: '{}'\n", yaml_single_quoted(value)));
663}
664
665fn yaml_single_quoted(value: &str) -> String {
666    value.replace('\'', "''")
667}
668
669fn render_warnings(output: &mut String, warnings: &[String]) {
670    let mut unique = BTreeSet::<String>::new();
671    unique.extend(warnings.iter().cloned());
672
673    if unique.is_empty() {
674        return;
675    }
676
677    output.push_str("warnings:\n");
678    for warning in unique {
679        output.push_str(&format!("  - '{}'\n", yaml_single_quoted(&warning)));
680    }
681}
682
683fn render_subagents_head(output: &mut String, list: &SubagentListView) {
684    output.push_str("subagents:\n");
685    if list.agents.is_empty() {
686        output.push_str("  []\n");
687        return;
688    }
689
690    for agent in &list.agents {
691        output.push_str(&format!(
692            "  - agent_id: '{}'\n",
693            yaml_single_quoted(&agent.agent_id)
694        ));
695        output.push_str(&format!(
696            "    uri: '{}'\n",
697            yaml_single_quoted(&agents_thread_uri(
698                &list.query.provider,
699                &list.query.main_thread_id,
700                Some(&agent.agent_id),
701            ))
702        ));
703        push_yaml_string_with_indent(output, 4, "status", &agent.status);
704        push_yaml_string_with_indent(output, 4, "status_source", &agent.status_source);
705        if let Some(last_update) = &agent.last_update {
706            push_yaml_string_with_indent(output, 4, "last_update", last_update);
707        }
708        if let Some(child_thread) = &agent.child_thread
709            && let Some(path) = &child_thread.path
710        {
711            push_yaml_string_with_indent(output, 4, "thread_source", path);
712        }
713    }
714}
715
716fn render_pi_entries_head(output: &mut String, list: &PiEntryListView) {
717    output.push_str("entries:\n");
718    if list.entries.is_empty() {
719        output.push_str("  []\n");
720        return;
721    }
722
723    for entry in &list.entries {
724        output.push_str(&format!(
725            "  - entry_id: '{}'\n",
726            yaml_single_quoted(&entry.entry_id)
727        ));
728        output.push_str(&format!(
729            "    uri: '{}'\n",
730            yaml_single_quoted(&agents_thread_uri(
731                &list.query.provider,
732                &list.query.session_id,
733                Some(&entry.entry_id),
734            ))
735        ));
736        push_yaml_string_with_indent(output, 4, "entry_type", &entry.entry_type);
737        if let Some(parent_id) = &entry.parent_id {
738            push_yaml_string_with_indent(output, 4, "parent_id", parent_id);
739        }
740        if let Some(timestamp) = &entry.timestamp {
741            push_yaml_string_with_indent(output, 4, "timestamp", timestamp);
742        }
743        if let Some(preview) = &entry.preview {
744            push_yaml_string_with_indent(output, 4, "preview", preview);
745        }
746        push_yaml_bool_with_indent(output, 4, "is_leaf", entry.is_leaf);
747    }
748}
749
750fn push_yaml_string_with_indent(output: &mut String, indent: usize, key: &str, value: &str) {
751    output.push_str(&format!(
752        "{}{key}: '{}'\n",
753        " ".repeat(indent),
754        yaml_single_quoted(value)
755    ));
756}
757
758fn push_yaml_bool_with_indent(output: &mut String, indent: usize, key: &str, value: bool) {
759    output.push_str(&format!("{}{key}: {value}\n", " ".repeat(indent)));
760}
761
762fn strip_frontmatter(markdown: String) -> String {
763    let Some(rest) = markdown.strip_prefix("---\n") else {
764        return markdown;
765    };
766    let Some((_, body)) = rest.split_once("\n---\n\n") else {
767        return markdown;
768    };
769    body.to_string()
770}
771
772pub fn render_subagent_view_markdown(view: &SubagentView) -> String {
773    match view {
774        SubagentView::List(list_view) => render_subagent_list_markdown(list_view),
775        SubagentView::Detail(detail_view) => render_subagent_detail_markdown(detail_view),
776    }
777}
778
779pub fn resolve_pi_entry_list_view(
780    uri: &AgentsUri,
781    roots: &ProviderRoots,
782) -> Result<PiEntryListView> {
783    if uri.provider != ProviderKind::Pi {
784        return Err(XurlError::InvalidMode(
785            "pi entry listing requires agents://pi/<session_id> (legacy pi://<session_id> is also supported)".to_string(),
786        ));
787    }
788    if uri.agent_id.is_some() {
789        return Err(XurlError::InvalidMode(
790            "pi entry index mode requires agents://pi/<session_id>".to_string(),
791        ));
792    }
793
794    let resolved = resolve_thread(uri, roots)?;
795    let raw = read_thread_raw(&resolved.path)?;
796
797    let mut warnings = resolved.metadata.warnings;
798    let mut entries = Vec::<PiEntryListItem>::new();
799    let mut parent_ids = BTreeSet::<String>::new();
800
801    for (line_idx, line) in raw.lines().enumerate() {
802        let value = match jsonl::parse_json_line(Path::new("<pi:session>"), line_idx + 1, line) {
803            Ok(Some(value)) => value,
804            Ok(None) => continue,
805            Err(err) => {
806                warnings.push(format!(
807                    "failed to parse pi session line {}: {err}",
808                    line_idx + 1,
809                ));
810                continue;
811            }
812        };
813
814        if value.get("type").and_then(Value::as_str) == Some("session") {
815            continue;
816        }
817
818        let Some(entry_id) = value
819            .get("id")
820            .and_then(Value::as_str)
821            .map(ToString::to_string)
822        else {
823            continue;
824        };
825        let parent_id = value
826            .get("parentId")
827            .and_then(Value::as_str)
828            .map(ToString::to_string);
829        if let Some(parent_id) = &parent_id {
830            parent_ids.insert(parent_id.clone());
831        }
832
833        let entry_type = value
834            .get("type")
835            .and_then(Value::as_str)
836            .unwrap_or("unknown")
837            .to_string();
838
839        let timestamp = value
840            .get("timestamp")
841            .and_then(Value::as_str)
842            .map(ToString::to_string);
843
844        let preview = match entry_type.as_str() {
845            "message" => value
846                .get("message")
847                .and_then(|message| message.get("content"))
848                .map(|content| render_preview_text(content, 96))
849                .filter(|text| !text.is_empty()),
850            "compaction" | "branch_summary" => value
851                .get("summary")
852                .and_then(Value::as_str)
853                .map(|text| truncate_preview(text, 96))
854                .filter(|text| !text.is_empty()),
855            _ => None,
856        };
857
858        entries.push(PiEntryListItem {
859            entry_id,
860            entry_type,
861            parent_id,
862            timestamp,
863            is_leaf: false,
864            preview,
865        });
866    }
867
868    for entry in &mut entries {
869        entry.is_leaf = !parent_ids.contains(&entry.entry_id);
870    }
871
872    Ok(PiEntryListView {
873        query: PiEntryQuery {
874            provider: uri.provider.to_string(),
875            session_id: uri.session_id.clone(),
876            list: true,
877        },
878        entries,
879        warnings,
880    })
881}
882
883pub fn render_pi_entry_list_markdown(view: &PiEntryListView) -> String {
884    let session_uri = agents_thread_uri(&view.query.provider, &view.query.session_id, None);
885    let mut output = String::new();
886    output.push_str("# Pi Session Entries\n\n");
887    output.push_str(&format!("- Provider: `{}`\n", view.query.provider));
888    output.push_str(&format!("- Session: `{}`\n", session_uri));
889    output.push_str("- Mode: `list`\n\n");
890
891    if view.entries.is_empty() {
892        output.push_str("_No entries found in this session._\n");
893        return output;
894    }
895
896    for (index, entry) in view.entries.iter().enumerate() {
897        let entry_uri = format!("{session_uri}/{}", entry.entry_id);
898        output.push_str(&format!("## {}. `{}`\n\n", index + 1, entry_uri));
899        output.push_str(&format!("- Type: `{}`\n", entry.entry_type));
900        output.push_str(&format!(
901            "- Parent: `{}`\n",
902            entry.parent_id.as_deref().unwrap_or("root")
903        ));
904        output.push_str(&format!(
905            "- Timestamp: `{}`\n",
906            entry.timestamp.as_deref().unwrap_or("unknown")
907        ));
908        output.push_str(&format!(
909            "- Leaf: `{}`\n",
910            if entry.is_leaf { "yes" } else { "no" }
911        ));
912        if let Some(preview) = &entry.preview {
913            output.push_str(&format!("- Preview: {}\n", preview));
914        }
915        output.push('\n');
916    }
917
918    output
919}
920
921fn resolve_pi_subagent_view(
922    uri: &AgentsUri,
923    roots: &ProviderRoots,
924    list: bool,
925) -> Result<SubagentView> {
926    if uri.provider != ProviderKind::Pi {
927        return Err(XurlError::InvalidMode(
928            "pi child-session view requires agents://pi/<main_session_id>/<child_session_id>"
929                .to_string(),
930        ));
931    }
932
933    if !list
934        && uri
935            .agent_id
936            .as_deref()
937            .is_some_and(|agent_id| !is_uuid_session_id(agent_id))
938    {
939        return Err(XurlError::InvalidMode(
940            "pi child-session drill-down requires UUID child_session_id".to_string(),
941        ));
942    }
943
944    let main_uri = main_thread_uri(uri);
945    let resolved_main = resolve_thread(&main_uri, roots)?;
946    let mut warnings = resolved_main.metadata.warnings.clone();
947
948    let records = discover_pi_session_records(&roots.pi_root, &mut warnings);
949    let main_record = records.get(&uri.session_id);
950    let mut discovered = discover_pi_children(&uri.session_id, main_record, &records);
951
952    if list {
953        warnings.extend(
954            discovered
955                .values()
956                .flat_map(|child| child.warnings.clone())
957                .collect::<Vec<_>>(),
958        );
959
960        let agents = discovered
961            .into_iter()
962            .map(|(agent_id, child)| SubagentListItem {
963                agent_id: agent_id.clone(),
964                status: child.status,
965                status_source: child.status_source,
966                last_update: child.last_update,
967                relation: child.relation,
968                child_thread: child.child_thread,
969            })
970            .collect();
971
972        return Ok(SubagentView::List(SubagentListView {
973            query: make_query(uri, None, true),
974            agents,
975            warnings,
976        }));
977    }
978
979    let requested_agent = uri
980        .agent_id
981        .clone()
982        .ok_or_else(|| XurlError::InvalidMode("missing child session id".to_string()))?;
983
984    if let Some(child) = discovered.remove(&requested_agent) {
985        warnings.extend(child.warnings.clone());
986        let lifecycle = child
987            .relation
988            .evidence
989            .iter()
990            .map(|evidence| SubagentLifecycleEvent {
991                timestamp: child.last_update.clone(),
992                event: "session_relation_hint".to_string(),
993                detail: evidence.clone(),
994            })
995            .collect::<Vec<_>>();
996
997        return Ok(SubagentView::Detail(SubagentDetailView {
998            query: make_query(uri, Some(requested_agent), false),
999            relation: child.relation,
1000            lifecycle,
1001            status: child.status,
1002            status_source: child.status_source,
1003            child_thread: child.child_thread,
1004            excerpt: child.excerpt,
1005            warnings,
1006        }));
1007    }
1008
1009    if let Some(record) = records.get(&requested_agent) {
1010        warnings.push(format!(
1011            "child session file exists but no relation hint links it to main_session_id={} (child path: {})",
1012            uri.session_id,
1013            record.path.display()
1014        ));
1015    } else {
1016        warnings.push(format!(
1017            "child session not found for main_session_id={} child_session_id={requested_agent}",
1018            uri.session_id
1019        ));
1020    }
1021
1022    Ok(SubagentView::Detail(SubagentDetailView {
1023        query: make_query(uri, Some(requested_agent), false),
1024        relation: SubagentRelation::default(),
1025        lifecycle: Vec::new(),
1026        status: STATUS_NOT_FOUND.to_string(),
1027        status_source: "inferred".to_string(),
1028        child_thread: None,
1029        excerpt: Vec::new(),
1030        warnings,
1031    }))
1032}
1033
1034fn discover_pi_children(
1035    main_session_id: &str,
1036    main_record: Option<&PiSessionRecord>,
1037    records: &BTreeMap<String, PiSessionRecord>,
1038) -> BTreeMap<String, PiDiscoveredChild> {
1039    let mut children = BTreeMap::<String, PiDiscoveredChild>::new();
1040
1041    for record in records.values() {
1042        for hint in record.hints.iter().filter(|hint| {
1043            hint.kind == PiSessionHintKind::Parent && hint.session_id == main_session_id
1044        }) {
1045            let child = children.entry(record.session_id.clone()).or_default();
1046            child.relation.validated = true;
1047            child.relation.evidence.push(format!(
1048                "{} (from {})",
1049                hint.evidence,
1050                record.path.display()
1051            ));
1052            child.last_update = child
1053                .last_update
1054                .clone()
1055                .or_else(|| record.last_update.clone());
1056            child.child_thread = Some(SubagentThreadRef {
1057                thread_id: record.session_id.clone(),
1058                path: Some(record.path.display().to_string()),
1059                last_updated_at: record.last_update.clone(),
1060            });
1061        }
1062    }
1063
1064    if let Some(main_record) = main_record {
1065        for hint in main_record
1066            .hints
1067            .iter()
1068            .filter(|hint| hint.kind == PiSessionHintKind::Child)
1069        {
1070            let child = children.entry(hint.session_id.clone()).or_default();
1071            child.relation.validated = true;
1072            child.relation.evidence.push(format!(
1073                "{} (from {})",
1074                hint.evidence,
1075                main_record.path.display()
1076            ));
1077
1078            if let Some(record) = records.get(&hint.session_id) {
1079                child.last_update = child
1080                    .last_update
1081                    .clone()
1082                    .or_else(|| record.last_update.clone());
1083                child.child_thread = Some(SubagentThreadRef {
1084                    thread_id: record.session_id.clone(),
1085                    path: Some(record.path.display().to_string()),
1086                    last_updated_at: record.last_update.clone(),
1087                });
1088            } else {
1089                child.status = STATUS_NOT_FOUND.to_string();
1090                child.status_source = "inferred".to_string();
1091                child.warnings.push(format!(
1092                    "relation hint references child_session_id={} but transcript file is missing for main_session_id={} ({})",
1093                    hint.session_id, main_session_id, hint.evidence
1094                ));
1095            }
1096        }
1097    }
1098
1099    for (child_id, child) in &mut children {
1100        let Some(path) = child
1101            .child_thread
1102            .as_ref()
1103            .and_then(|thread| thread.path.as_deref())
1104            .map(ToString::to_string)
1105        else {
1106            continue;
1107        };
1108
1109        match read_thread_raw(Path::new(&path)) {
1110            Ok(raw) => {
1111                if child.last_update.is_none() {
1112                    child.last_update = extract_last_timestamp(&raw);
1113                }
1114
1115                let messages = render::extract_messages(ProviderKind::Pi, Path::new(&path), &raw)
1116                    .unwrap_or_default();
1117
1118                let has_assistant = messages
1119                    .iter()
1120                    .any(|message| matches!(message.role, crate::model::MessageRole::Assistant));
1121                let has_user = messages
1122                    .iter()
1123                    .any(|message| matches!(message.role, crate::model::MessageRole::User));
1124
1125                child.status = if has_assistant {
1126                    STATUS_COMPLETED.to_string()
1127                } else if has_user {
1128                    STATUS_RUNNING.to_string()
1129                } else {
1130                    STATUS_PENDING_INIT.to_string()
1131                };
1132                child.status_source = "child_rollout".to_string();
1133                child.excerpt = messages
1134                    .into_iter()
1135                    .rev()
1136                    .take(3)
1137                    .collect::<Vec<_>>()
1138                    .into_iter()
1139                    .rev()
1140                    .map(|message| SubagentExcerptMessage {
1141                        role: message.role,
1142                        text: message.text,
1143                    })
1144                    .collect();
1145            }
1146            Err(err) => {
1147                child.status = STATUS_NOT_FOUND.to_string();
1148                child.status_source = "inferred".to_string();
1149                child.warnings.push(format!(
1150                    "failed to read child session transcript for child_session_id={child_id}: {err}"
1151                ));
1152            }
1153        }
1154    }
1155
1156    children
1157}
1158
1159fn discover_pi_session_records(
1160    pi_root: &Path,
1161    warnings: &mut Vec<String>,
1162) -> BTreeMap<String, PiSessionRecord> {
1163    let sessions_root = pi_root.join("sessions");
1164    if !sessions_root.exists() {
1165        return BTreeMap::new();
1166    }
1167
1168    let mut latest = BTreeMap::<String, (u64, PiSessionRecord)>::new();
1169    for entry in WalkDir::new(&sessions_root)
1170        .into_iter()
1171        .filter_map(std::result::Result::ok)
1172        .filter(|entry| entry.file_type().is_file())
1173        .filter(|entry| {
1174            entry
1175                .path()
1176                .extension()
1177                .and_then(|ext| ext.to_str())
1178                .is_some_and(|ext| ext == "jsonl")
1179        })
1180    {
1181        let path = entry.path();
1182        let Some(record) = parse_pi_session_record(path, warnings) else {
1183            continue;
1184        };
1185
1186        let stamp = file_modified_epoch(path).unwrap_or(0);
1187        match latest.get(&record.session_id) {
1188            Some((existing_stamp, existing)) => {
1189                if stamp > *existing_stamp {
1190                    warnings.push(format!(
1191                        "multiple pi transcripts found for session_id={}; selected latest: {}",
1192                        record.session_id,
1193                        record.path.display()
1194                    ));
1195                    latest.insert(record.session_id.clone(), (stamp, record));
1196                } else {
1197                    warnings.push(format!(
1198                        "multiple pi transcripts found for session_id={}; kept latest: {}",
1199                        existing.session_id,
1200                        existing.path.display()
1201                    ));
1202                }
1203            }
1204            None => {
1205                latest.insert(record.session_id.clone(), (stamp, record));
1206            }
1207        }
1208    }
1209
1210    latest
1211        .into_values()
1212        .map(|(_, record)| (record.session_id.clone(), record))
1213        .collect()
1214}
1215
1216fn parse_pi_session_record(path: &Path, warnings: &mut Vec<String>) -> Option<PiSessionRecord> {
1217    let raw = match read_thread_raw(path) {
1218        Ok(raw) => raw,
1219        Err(err) => {
1220            warnings.push(format!(
1221                "failed to read pi session transcript {}: {err}",
1222                path.display()
1223            ));
1224            return None;
1225        }
1226    };
1227
1228    let first_non_empty = raw.lines().find(|line| !line.trim().is_empty())?;
1229
1230    let header = match serde_json::from_str::<Value>(first_non_empty) {
1231        Ok(value) => value,
1232        Err(err) => {
1233            warnings.push(format!(
1234                "failed to parse pi session header {}: {err}",
1235                path.display()
1236            ));
1237            return None;
1238        }
1239    };
1240
1241    if header.get("type").and_then(Value::as_str) != Some("session") {
1242        return None;
1243    }
1244
1245    let Some(session_id) = header
1246        .get("id")
1247        .and_then(Value::as_str)
1248        .map(str::to_ascii_lowercase)
1249    else {
1250        warnings.push(format!(
1251            "pi session header missing id in {}",
1252            path.display()
1253        ));
1254        return None;
1255    };
1256
1257    if !is_uuid_session_id(&session_id) {
1258        warnings.push(format!(
1259            "pi session header id is not UUID in {}: {}",
1260            path.display(),
1261            session_id
1262        ));
1263        return None;
1264    }
1265
1266    let hints = collect_pi_session_hints(&header);
1267    let last_update = header
1268        .get("timestamp")
1269        .and_then(Value::as_str)
1270        .map(ToString::to_string)
1271        .or_else(|| modified_timestamp_string(path));
1272
1273    Some(PiSessionRecord {
1274        session_id,
1275        path: path.to_path_buf(),
1276        last_update,
1277        hints,
1278    })
1279}
1280
1281fn collect_pi_session_hints(header: &Value) -> Vec<PiSessionHint> {
1282    let mut hints = Vec::new();
1283    collect_pi_session_hints_rec(header, "", &mut hints);
1284
1285    let mut seen = BTreeSet::new();
1286    hints
1287        .into_iter()
1288        .filter(|hint| seen.insert((hint.kind, hint.session_id.clone(), hint.evidence.clone())))
1289        .collect()
1290}
1291
1292fn collect_pi_session_hints_rec(value: &Value, path: &str, out: &mut Vec<PiSessionHint>) {
1293    match value {
1294        Value::Object(map) => {
1295            for (key, child) in map {
1296                let key_path = if path.is_empty() {
1297                    key.clone()
1298                } else {
1299                    format!("{path}.{key}")
1300                };
1301
1302                if let Some(kind) = classify_pi_hint_key(key) {
1303                    let mut ids = Vec::new();
1304                    collect_uuid_strings(child, &mut ids);
1305                    for session_id in ids {
1306                        out.push(PiSessionHint {
1307                            kind,
1308                            session_id,
1309                            evidence: format!("session header key `{key_path}`"),
1310                        });
1311                    }
1312                }
1313
1314                collect_pi_session_hints_rec(child, &key_path, out);
1315            }
1316        }
1317        Value::Array(items) => {
1318            for (index, child) in items.iter().enumerate() {
1319                let key_path = format!("{path}[{index}]");
1320                collect_pi_session_hints_rec(child, &key_path, out);
1321            }
1322        }
1323        _ => {}
1324    }
1325}
1326
1327fn classify_pi_hint_key(key: &str) -> Option<PiSessionHintKind> {
1328    let normalized = normalize_hint_key(key);
1329
1330    const PARENT_HINTS: &[&str] = &[
1331        "parentsessionid",
1332        "parentsessionids",
1333        "parentthreadid",
1334        "parentthreadids",
1335        "mainsessionid",
1336        "rootsessionid",
1337        "parentid",
1338    ];
1339    const CHILD_HINTS: &[&str] = &[
1340        "childsessionid",
1341        "childsessionids",
1342        "childthreadid",
1343        "childthreadids",
1344        "childid",
1345        "subsessionid",
1346        "subsessionids",
1347        "subagentsessionid",
1348        "subagentsessionids",
1349        "subagentthreadid",
1350        "subagentthreadids",
1351    ];
1352
1353    if PARENT_HINTS.contains(&normalized.as_str()) {
1354        return Some(PiSessionHintKind::Parent);
1355    }
1356    if CHILD_HINTS.contains(&normalized.as_str()) {
1357        return Some(PiSessionHintKind::Child);
1358    }
1359
1360    let has_session_scope = normalized.contains("session") || normalized.contains("thread");
1361    if has_session_scope
1362        && (normalized.contains("parent")
1363            || normalized.contains("main")
1364            || normalized.contains("root"))
1365    {
1366        return Some(PiSessionHintKind::Parent);
1367    }
1368    if has_session_scope
1369        && (normalized.contains("child")
1370            || normalized.contains("subagent")
1371            || normalized.contains("subsession"))
1372    {
1373        return Some(PiSessionHintKind::Child);
1374    }
1375
1376    None
1377}
1378
1379fn normalize_hint_key(key: &str) -> String {
1380    key.chars()
1381        .filter(|ch| ch.is_ascii_alphanumeric())
1382        .flat_map(char::to_lowercase)
1383        .collect()
1384}
1385
1386fn collect_uuid_strings(value: &Value, ids: &mut Vec<String>) {
1387    match value {
1388        Value::String(text) => {
1389            if is_uuid_session_id(text) {
1390                ids.push(text.to_ascii_lowercase());
1391            }
1392        }
1393        Value::Array(items) => {
1394            for item in items {
1395                collect_uuid_strings(item, ids);
1396            }
1397        }
1398        Value::Object(map) => {
1399            for item in map.values() {
1400                collect_uuid_strings(item, ids);
1401            }
1402        }
1403        _ => {}
1404    }
1405}
1406
1407fn resolve_amp_subagent_view(
1408    uri: &AgentsUri,
1409    roots: &ProviderRoots,
1410    list: bool,
1411) -> Result<SubagentView> {
1412    let main_uri = main_thread_uri(uri);
1413    let resolved_main = resolve_thread(&main_uri, roots)?;
1414    let main_raw = read_thread_raw(&resolved_main.path)?;
1415    let main_value =
1416        serde_json::from_str::<Value>(&main_raw).map_err(|source| XurlError::InvalidJsonLine {
1417            path: resolved_main.path.clone(),
1418            line: 1,
1419            source,
1420        })?;
1421
1422    let mut warnings = resolved_main.metadata.warnings.clone();
1423    let handoffs = extract_amp_handoffs(&main_value, "main", &mut warnings);
1424
1425    if list {
1426        return Ok(SubagentView::List(build_amp_list_view(
1427            uri, roots, &handoffs, warnings,
1428        )));
1429    }
1430
1431    let agent_id = uri
1432        .agent_id
1433        .clone()
1434        .ok_or_else(|| XurlError::InvalidMode("missing agent id".to_string()))?;
1435
1436    Ok(SubagentView::Detail(build_amp_detail_view(
1437        uri, roots, &agent_id, &handoffs, warnings,
1438    )))
1439}
1440
1441fn build_amp_list_view(
1442    uri: &AgentsUri,
1443    roots: &ProviderRoots,
1444    handoffs: &[AmpHandoff],
1445    mut warnings: Vec<String>,
1446) -> SubagentListView {
1447    let mut grouped = BTreeMap::<String, Vec<&AmpHandoff>>::new();
1448    for handoff in handoffs {
1449        if handoff.thread_id == uri.session_id || handoff.role.as_deref() == Some("child") {
1450            continue;
1451        }
1452        grouped
1453            .entry(handoff.thread_id.clone())
1454            .or_default()
1455            .push(handoff);
1456    }
1457
1458    let mut agents = Vec::new();
1459    for (agent_id, relations) in grouped {
1460        let mut relation = SubagentRelation::default();
1461
1462        for handoff in relations {
1463            match handoff.role.as_deref() {
1464                Some("parent") => {
1465                    relation.validated = true;
1466                    push_unique(
1467                        &mut relation.evidence,
1468                        "main relationships includes handoff(role=parent) to child thread"
1469                            .to_string(),
1470                    );
1471                }
1472                Some(role) => {
1473                    push_unique(
1474                        &mut relation.evidence,
1475                        format!("main relationships includes handoff(role={role}) to child thread"),
1476                    );
1477                }
1478                None => {
1479                    push_unique(
1480                        &mut relation.evidence,
1481                        "main relationships includes handoff(role missing) to child thread"
1482                            .to_string(),
1483                    );
1484                }
1485            }
1486        }
1487
1488        let mut status = if relation.validated {
1489            STATUS_PENDING_INIT.to_string()
1490        } else {
1491            STATUS_NOT_FOUND.to_string()
1492        };
1493        let mut status_source = "inferred".to_string();
1494        let mut last_update = None::<String>;
1495        let mut child_thread = None::<SubagentThreadRef>;
1496
1497        if let Some(analysis) =
1498            analyze_amp_child_thread(&agent_id, &uri.session_id, roots, &mut warnings)
1499        {
1500            for evidence in analysis.relation_evidence {
1501                push_unique(&mut relation.evidence, evidence);
1502            }
1503            if !relation.evidence.is_empty() {
1504                relation.validated = true;
1505            }
1506
1507            status = analysis.status;
1508            status_source = analysis.status_source;
1509            last_update = analysis.thread.last_updated_at.clone();
1510            child_thread = Some(analysis.thread);
1511        }
1512
1513        agents.push(SubagentListItem {
1514            agent_id,
1515            status,
1516            status_source,
1517            last_update,
1518            relation,
1519            child_thread,
1520        });
1521    }
1522
1523    SubagentListView {
1524        query: make_query(uri, None, true),
1525        agents,
1526        warnings,
1527    }
1528}
1529
1530fn build_amp_detail_view(
1531    uri: &AgentsUri,
1532    roots: &ProviderRoots,
1533    agent_id: &str,
1534    handoffs: &[AmpHandoff],
1535    mut warnings: Vec<String>,
1536) -> SubagentDetailView {
1537    let mut relation = SubagentRelation::default();
1538    let mut lifecycle = Vec::<SubagentLifecycleEvent>::new();
1539
1540    let matches = handoffs
1541        .iter()
1542        .filter(|handoff| handoff.thread_id == agent_id)
1543        .collect::<Vec<_>>();
1544
1545    if matches.is_empty() {
1546        warnings.push(format!(
1547            "no handoff relationship found in main thread for child_thread_id={agent_id}"
1548        ));
1549    }
1550
1551    for handoff in matches {
1552        match handoff.role.as_deref() {
1553            Some("parent") => {
1554                relation.validated = true;
1555                push_unique(
1556                    &mut relation.evidence,
1557                    "main relationships includes handoff(role=parent) to child thread".to_string(),
1558                );
1559                lifecycle.push(SubagentLifecycleEvent {
1560                    timestamp: handoff.timestamp.clone(),
1561                    event: "handoff".to_string(),
1562                    detail: "main handoff relationship discovered (role=parent)".to_string(),
1563                });
1564            }
1565            Some(role) => {
1566                push_unique(
1567                    &mut relation.evidence,
1568                    format!("main relationships includes handoff(role={role}) to child thread"),
1569                );
1570                lifecycle.push(SubagentLifecycleEvent {
1571                    timestamp: handoff.timestamp.clone(),
1572                    event: "handoff".to_string(),
1573                    detail: format!("main handoff relationship discovered (role={role})"),
1574                });
1575            }
1576            None => {
1577                push_unique(
1578                    &mut relation.evidence,
1579                    "main relationships includes handoff(role missing) to child thread".to_string(),
1580                );
1581                lifecycle.push(SubagentLifecycleEvent {
1582                    timestamp: handoff.timestamp.clone(),
1583                    event: "handoff".to_string(),
1584                    detail: "main handoff relationship discovered (role missing)".to_string(),
1585                });
1586            }
1587        }
1588    }
1589
1590    let mut child_thread = None::<SubagentThreadRef>;
1591    let mut excerpt = Vec::<SubagentExcerptMessage>::new();
1592    let mut status = if relation.validated {
1593        STATUS_PENDING_INIT.to_string()
1594    } else {
1595        STATUS_NOT_FOUND.to_string()
1596    };
1597    let mut status_source = "inferred".to_string();
1598
1599    if let Some(analysis) =
1600        analyze_amp_child_thread(agent_id, &uri.session_id, roots, &mut warnings)
1601    {
1602        for evidence in analysis.relation_evidence {
1603            push_unique(&mut relation.evidence, evidence);
1604        }
1605        if !relation.evidence.is_empty() {
1606            relation.validated = true;
1607        }
1608        lifecycle.extend(analysis.lifecycle);
1609        status = analysis.status;
1610        status_source = analysis.status_source;
1611        child_thread = Some(analysis.thread);
1612        excerpt = analysis.excerpt;
1613    }
1614
1615    SubagentDetailView {
1616        query: make_query(uri, Some(agent_id.to_string()), false),
1617        relation,
1618        lifecycle,
1619        status,
1620        status_source,
1621        child_thread,
1622        excerpt,
1623        warnings,
1624    }
1625}
1626
1627fn analyze_amp_child_thread(
1628    child_thread_id: &str,
1629    main_thread_id: &str,
1630    roots: &ProviderRoots,
1631    warnings: &mut Vec<String>,
1632) -> Option<AmpChildAnalysis> {
1633    let resolved_child = match AmpProvider::new(&roots.amp_root).resolve(child_thread_id) {
1634        Ok(resolved) => resolved,
1635        Err(err) => {
1636            warnings.push(format!(
1637                "failed resolving amp child thread child_thread_id={child_thread_id}: {err}"
1638            ));
1639            return None;
1640        }
1641    };
1642
1643    let child_raw = match read_thread_raw(&resolved_child.path) {
1644        Ok(raw) => raw,
1645        Err(err) => {
1646            warnings.push(format!(
1647                "failed reading amp child thread child_thread_id={child_thread_id}: {err}"
1648            ));
1649            return None;
1650        }
1651    };
1652
1653    let child_value = match serde_json::from_str::<Value>(&child_raw) {
1654        Ok(value) => value,
1655        Err(err) => {
1656            warnings.push(format!(
1657                "failed parsing amp child thread {}: {err}",
1658                resolved_child.path.display()
1659            ));
1660            return None;
1661        }
1662    };
1663
1664    let mut relation_evidence = Vec::<String>::new();
1665    let mut lifecycle = Vec::<SubagentLifecycleEvent>::new();
1666    for handoff in extract_amp_handoffs(&child_value, "child", warnings) {
1667        if handoff.thread_id != main_thread_id {
1668            continue;
1669        }
1670
1671        match handoff.role.as_deref() {
1672            Some("child") => {
1673                push_unique(
1674                    &mut relation_evidence,
1675                    "child relationships includes handoff(role=child) back to main thread"
1676                        .to_string(),
1677                );
1678                lifecycle.push(SubagentLifecycleEvent {
1679                    timestamp: handoff.timestamp.clone(),
1680                    event: "handoff_backlink".to_string(),
1681                    detail: "child handoff relationship discovered (role=child)".to_string(),
1682                });
1683            }
1684            Some(role) => {
1685                push_unique(
1686                    &mut relation_evidence,
1687                    format!(
1688                        "child relationships includes handoff(role={role}) back to main thread"
1689                    ),
1690                );
1691                lifecycle.push(SubagentLifecycleEvent {
1692                    timestamp: handoff.timestamp.clone(),
1693                    event: "handoff_backlink".to_string(),
1694                    detail: format!("child handoff relationship discovered (role={role})"),
1695                });
1696            }
1697            None => {
1698                push_unique(
1699                    &mut relation_evidence,
1700                    "child relationships includes handoff(role missing) back to main thread"
1701                        .to_string(),
1702                );
1703                lifecycle.push(SubagentLifecycleEvent {
1704                    timestamp: handoff.timestamp.clone(),
1705                    event: "handoff_backlink".to_string(),
1706                    detail: "child handoff relationship discovered (role missing)".to_string(),
1707                });
1708            }
1709        }
1710    }
1711
1712    let messages =
1713        match render::extract_messages(ProviderKind::Amp, &resolved_child.path, &child_raw) {
1714            Ok(messages) => messages,
1715            Err(err) => {
1716                warnings.push(format!(
1717                    "failed extracting amp child messages from {}: {err}",
1718                    resolved_child.path.display()
1719                ));
1720                Vec::new()
1721            }
1722        };
1723    let has_user = messages
1724        .iter()
1725        .any(|message| message.role == MessageRole::User);
1726    let has_assistant = messages
1727        .iter()
1728        .any(|message| message.role == MessageRole::Assistant);
1729
1730    let excerpt = messages
1731        .into_iter()
1732        .rev()
1733        .take(3)
1734        .collect::<Vec<_>>()
1735        .into_iter()
1736        .rev()
1737        .map(|message| SubagentExcerptMessage {
1738            role: message.role,
1739            text: message.text,
1740        })
1741        .collect::<Vec<_>>();
1742
1743    let (status, status_source) = infer_amp_status(&child_value, has_user, has_assistant);
1744    let last_updated_at = extract_amp_last_update(&child_value)
1745        .or_else(|| modified_timestamp_string(&resolved_child.path));
1746
1747    Some(AmpChildAnalysis {
1748        thread: SubagentThreadRef {
1749            thread_id: child_thread_id.to_string(),
1750            path: Some(resolved_child.path.display().to_string()),
1751            last_updated_at,
1752        },
1753        status,
1754        status_source,
1755        excerpt,
1756        lifecycle,
1757        relation_evidence,
1758    })
1759}
1760
1761fn extract_amp_handoffs(
1762    value: &Value,
1763    source: &str,
1764    warnings: &mut Vec<String>,
1765) -> Vec<AmpHandoff> {
1766    let mut handoffs = Vec::new();
1767    for relationship in value
1768        .get("relationships")
1769        .and_then(Value::as_array)
1770        .into_iter()
1771        .flatten()
1772    {
1773        if relationship.get("type").and_then(Value::as_str) != Some("handoff") {
1774            continue;
1775        }
1776
1777        let Some(thread_id_raw) = relationship.get("threadID").and_then(Value::as_str) else {
1778            warnings.push(format!(
1779                "{source} thread handoff relationship missing threadID field"
1780            ));
1781            continue;
1782        };
1783        let Some(thread_id) = normalize_amp_thread_id(thread_id_raw) else {
1784            warnings.push(format!(
1785                "{source} thread handoff relationship has invalid threadID={thread_id_raw}"
1786            ));
1787            continue;
1788        };
1789
1790        let role = relationship
1791            .get("role")
1792            .and_then(Value::as_str)
1793            .map(|role| role.to_ascii_lowercase());
1794        let timestamp = relationship
1795            .get("timestamp")
1796            .or_else(|| relationship.get("updatedAt"))
1797            .or_else(|| relationship.get("createdAt"))
1798            .and_then(Value::as_str)
1799            .map(ToString::to_string);
1800
1801        handoffs.push(AmpHandoff {
1802            thread_id,
1803            role,
1804            timestamp,
1805        });
1806    }
1807
1808    handoffs
1809}
1810
1811fn normalize_amp_thread_id(thread_id: &str) -> Option<String> {
1812    AgentsUri::parse(&format!("amp://{thread_id}"))
1813        .ok()
1814        .map(|uri| uri.session_id)
1815}
1816
1817fn infer_amp_status(value: &Value, has_user: bool, has_assistant: bool) -> (String, String) {
1818    if let Some(status) = extract_amp_status(value) {
1819        return (status, "child_thread".to_string());
1820    }
1821    if has_assistant {
1822        return (STATUS_COMPLETED.to_string(), "inferred".to_string());
1823    }
1824    if has_user {
1825        return (STATUS_RUNNING.to_string(), "inferred".to_string());
1826    }
1827    (STATUS_PENDING_INIT.to_string(), "inferred".to_string())
1828}
1829
1830fn extract_amp_status(value: &Value) -> Option<String> {
1831    let status = value.get("status");
1832    if let Some(status) = status {
1833        if let Some(status_str) = status.as_str() {
1834            return Some(status_str.to_string());
1835        }
1836        if let Some(status_obj) = status.as_object() {
1837            for key in [
1838                STATUS_PENDING_INIT,
1839                STATUS_RUNNING,
1840                STATUS_COMPLETED,
1841                STATUS_ERRORED,
1842                STATUS_SHUTDOWN,
1843                STATUS_NOT_FOUND,
1844            ] {
1845                if status_obj.contains_key(key) {
1846                    return Some(key.to_string());
1847                }
1848            }
1849        }
1850    }
1851
1852    value
1853        .get("state")
1854        .and_then(Value::as_str)
1855        .map(ToString::to_string)
1856}
1857
1858fn extract_amp_last_update(value: &Value) -> Option<String> {
1859    for key in ["lastUpdated", "updatedAt", "timestamp", "createdAt"] {
1860        if let Some(stamp) = value.get(key).and_then(Value::as_str) {
1861            return Some(stamp.to_string());
1862        }
1863    }
1864
1865    for message in value
1866        .get("messages")
1867        .and_then(Value::as_array)
1868        .into_iter()
1869        .flatten()
1870        .rev()
1871    {
1872        if let Some(stamp) = message.get("timestamp").and_then(Value::as_str) {
1873            return Some(stamp.to_string());
1874        }
1875    }
1876
1877    None
1878}
1879
1880fn push_unique(values: &mut Vec<String>, value: String) {
1881    if !values.iter().any(|existing| existing == &value) {
1882        values.push(value);
1883    }
1884}
1885
1886fn resolve_codex_subagent_view(
1887    uri: &AgentsUri,
1888    roots: &ProviderRoots,
1889    list: bool,
1890) -> Result<SubagentView> {
1891    let main_uri = main_thread_uri(uri);
1892    let resolved_main = resolve_thread(&main_uri, roots)?;
1893    let main_raw = read_thread_raw(&resolved_main.path)?;
1894
1895    let mut warnings = resolved_main.metadata.warnings.clone();
1896    let mut timelines = BTreeMap::<String, AgentTimeline>::new();
1897    warnings.extend(parse_codex_parent_lifecycle(&main_raw, &mut timelines));
1898
1899    if list {
1900        return Ok(SubagentView::List(build_codex_list_view(
1901            uri, roots, &timelines, warnings,
1902        )));
1903    }
1904
1905    let agent_id = uri
1906        .agent_id
1907        .clone()
1908        .ok_or_else(|| XurlError::InvalidMode("missing agent id".to_string()))?;
1909
1910    Ok(SubagentView::Detail(build_codex_detail_view(
1911        uri, roots, &agent_id, &timelines, warnings,
1912    )))
1913}
1914
1915fn build_codex_list_view(
1916    uri: &AgentsUri,
1917    roots: &ProviderRoots,
1918    timelines: &BTreeMap<String, AgentTimeline>,
1919    warnings: Vec<String>,
1920) -> SubagentListView {
1921    let mut agents = Vec::new();
1922
1923    for (agent_id, timeline) in timelines {
1924        let mut relation = SubagentRelation::default();
1925        if timeline.has_spawn {
1926            relation.validated = true;
1927            relation
1928                .evidence
1929                .push("parent rollout contains spawn_agent output".to_string());
1930        }
1931
1932        let mut child_ref = None;
1933        let mut last_update = timeline.last_update.clone();
1934        if let Some((thread_ref, relation_evidence, thread_last_update)) =
1935            resolve_codex_child_thread(agent_id, &uri.session_id, roots)
1936        {
1937            if !relation_evidence.is_empty() {
1938                relation.validated = true;
1939                relation.evidence.extend(relation_evidence);
1940            }
1941            if last_update.is_none() {
1942                last_update = thread_last_update;
1943            }
1944            child_ref = Some(thread_ref);
1945        }
1946
1947        let (status, status_source) = infer_status_from_timeline(timeline, child_ref.is_some());
1948
1949        agents.push(SubagentListItem {
1950            agent_id: agent_id.clone(),
1951            status,
1952            status_source,
1953            last_update,
1954            relation,
1955            child_thread: child_ref,
1956        });
1957    }
1958
1959    SubagentListView {
1960        query: make_query(uri, None, true),
1961        agents,
1962        warnings,
1963    }
1964}
1965
1966fn build_codex_detail_view(
1967    uri: &AgentsUri,
1968    roots: &ProviderRoots,
1969    agent_id: &str,
1970    timelines: &BTreeMap<String, AgentTimeline>,
1971    mut warnings: Vec<String>,
1972) -> SubagentDetailView {
1973    let timeline = timelines.get(agent_id).cloned().unwrap_or_default();
1974    let mut relation = SubagentRelation::default();
1975    if timeline.has_spawn {
1976        relation.validated = true;
1977        relation
1978            .evidence
1979            .push("parent rollout contains spawn_agent output".to_string());
1980    }
1981
1982    let mut child_thread = None;
1983    let mut excerpt = Vec::new();
1984    let mut child_status = None;
1985
1986    if let Some((resolved_child, relation_evidence, thread_ref)) =
1987        resolve_codex_child_resolved(agent_id, &uri.session_id, roots)
1988    {
1989        if !relation_evidence.is_empty() {
1990            relation.validated = true;
1991            relation.evidence.extend(relation_evidence);
1992        }
1993
1994        match read_thread_raw(&resolved_child.path) {
1995            Ok(child_raw) => {
1996                if let Some(inferred) = infer_codex_child_status(&child_raw, &resolved_child.path) {
1997                    child_status = Some(inferred);
1998                }
1999
2000                if let Ok(messages) =
2001                    render::extract_messages(ProviderKind::Codex, &resolved_child.path, &child_raw)
2002                {
2003                    excerpt = messages
2004                        .into_iter()
2005                        .rev()
2006                        .take(3)
2007                        .collect::<Vec<_>>()
2008                        .into_iter()
2009                        .rev()
2010                        .map(|message| SubagentExcerptMessage {
2011                            role: message.role,
2012                            text: message.text,
2013                        })
2014                        .collect();
2015                }
2016            }
2017            Err(err) => warnings.push(format!(
2018                "failed reading child thread for agent_id={agent_id}: {err}"
2019            )),
2020        }
2021
2022        child_thread = Some(thread_ref);
2023    }
2024
2025    let (status, status_source) =
2026        infer_status_for_detail(&timeline, child_status, child_thread.is_some());
2027
2028    SubagentDetailView {
2029        query: make_query(uri, Some(agent_id.to_string()), false),
2030        relation,
2031        lifecycle: timeline.events,
2032        status,
2033        status_source,
2034        child_thread,
2035        excerpt,
2036        warnings,
2037    }
2038}
2039
2040fn resolve_codex_child_thread(
2041    agent_id: &str,
2042    main_thread_id: &str,
2043    roots: &ProviderRoots,
2044) -> Option<(SubagentThreadRef, Vec<String>, Option<String>)> {
2045    let resolved = CodexProvider::new(&roots.codex_root)
2046        .resolve(agent_id)
2047        .ok()?;
2048    let raw = read_thread_raw(&resolved.path).ok()?;
2049
2050    let mut evidence = Vec::new();
2051    if extract_codex_parent_thread_id(&raw)
2052        .as_deref()
2053        .is_some_and(|parent| parent == main_thread_id)
2054    {
2055        evidence.push("child session_meta points to main thread".to_string());
2056    }
2057
2058    let last_update = extract_last_timestamp(&raw);
2059    let thread_ref = SubagentThreadRef {
2060        thread_id: agent_id.to_string(),
2061        path: Some(resolved.path.display().to_string()),
2062        last_updated_at: last_update.clone(),
2063    };
2064
2065    Some((thread_ref, evidence, last_update))
2066}
2067
2068fn resolve_codex_child_resolved(
2069    agent_id: &str,
2070    main_thread_id: &str,
2071    roots: &ProviderRoots,
2072) -> Option<(ResolvedThread, Vec<String>, SubagentThreadRef)> {
2073    let resolved = CodexProvider::new(&roots.codex_root)
2074        .resolve(agent_id)
2075        .ok()?;
2076    let raw = read_thread_raw(&resolved.path).ok()?;
2077
2078    let mut evidence = Vec::new();
2079    if extract_codex_parent_thread_id(&raw)
2080        .as_deref()
2081        .is_some_and(|parent| parent == main_thread_id)
2082    {
2083        evidence.push("child session_meta points to main thread".to_string());
2084    }
2085
2086    let thread_ref = SubagentThreadRef {
2087        thread_id: agent_id.to_string(),
2088        path: Some(resolved.path.display().to_string()),
2089        last_updated_at: extract_last_timestamp(&raw),
2090    };
2091
2092    Some((resolved, evidence, thread_ref))
2093}
2094
2095fn infer_codex_child_status(raw: &str, path: &Path) -> Option<String> {
2096    let mut has_assistant_message = false;
2097    let mut has_error = false;
2098
2099    for (line_idx, line) in raw.lines().enumerate() {
2100        let Ok(Some(value)) = jsonl::parse_json_line(path, line_idx + 1, line) else {
2101            continue;
2102        };
2103
2104        if value.get("type").and_then(Value::as_str) == Some("event_msg") {
2105            let payload_type = value
2106                .get("payload")
2107                .and_then(|payload| payload.get("type"))
2108                .and_then(Value::as_str);
2109            if payload_type == Some("turn_aborted") {
2110                has_error = true;
2111            }
2112        }
2113
2114        if render::extract_messages(ProviderKind::Codex, path, line)
2115            .ok()
2116            .is_some_and(|messages| {
2117                messages
2118                    .iter()
2119                    .any(|message| matches!(message.role, crate::model::MessageRole::Assistant))
2120            })
2121        {
2122            has_assistant_message = true;
2123        }
2124    }
2125
2126    if has_error {
2127        Some(STATUS_ERRORED.to_string())
2128    } else if has_assistant_message {
2129        Some(STATUS_COMPLETED.to_string())
2130    } else {
2131        None
2132    }
2133}
2134
2135fn parse_codex_parent_lifecycle(
2136    raw: &str,
2137    timelines: &mut BTreeMap<String, AgentTimeline>,
2138) -> Vec<String> {
2139    let mut warnings = Vec::new();
2140    let mut calls: HashMap<String, (String, Value, Option<String>)> = HashMap::new();
2141
2142    for (line_idx, line) in raw.lines().enumerate() {
2143        let trimmed = line.trim();
2144        if trimmed.is_empty() {
2145            continue;
2146        }
2147
2148        let value = match jsonl::parse_json_line(Path::new("<codex:parent>"), line_idx + 1, trimmed)
2149        {
2150            Ok(Some(value)) => value,
2151            Ok(None) => continue,
2152            Err(err) => {
2153                warnings.push(format!(
2154                    "failed to parse parent rollout line {}: {err}",
2155                    line_idx + 1
2156                ));
2157                continue;
2158            }
2159        };
2160
2161        if value.get("type").and_then(Value::as_str) != Some("response_item") {
2162            continue;
2163        }
2164
2165        let Some(payload) = value.get("payload") else {
2166            continue;
2167        };
2168        let Some(payload_type) = payload.get("type").and_then(Value::as_str) else {
2169            continue;
2170        };
2171
2172        if payload_type == "function_call" {
2173            let call_id = payload
2174                .get("call_id")
2175                .and_then(Value::as_str)
2176                .unwrap_or_default()
2177                .to_string();
2178            if call_id.is_empty() {
2179                continue;
2180            }
2181
2182            let name = payload
2183                .get("name")
2184                .and_then(Value::as_str)
2185                .unwrap_or_default()
2186                .to_string();
2187            if name.is_empty() {
2188                continue;
2189            }
2190
2191            let args = payload
2192                .get("arguments")
2193                .and_then(Value::as_str)
2194                .and_then(|arguments| serde_json::from_str::<Value>(arguments).ok())
2195                .unwrap_or_else(|| Value::Object(Default::default()));
2196
2197            let timestamp = value
2198                .get("timestamp")
2199                .and_then(Value::as_str)
2200                .map(ToString::to_string);
2201
2202            calls.insert(call_id, (name, args, timestamp));
2203            continue;
2204        }
2205
2206        if payload_type != "function_call_output" {
2207            continue;
2208        }
2209
2210        let Some(call_id) = payload.get("call_id").and_then(Value::as_str) else {
2211            continue;
2212        };
2213
2214        let Some((name, args, timestamp)) = calls.remove(call_id) else {
2215            continue;
2216        };
2217
2218        let output_raw = payload
2219            .get("output")
2220            .and_then(Value::as_str)
2221            .unwrap_or_default()
2222            .to_string();
2223        let output_value =
2224            serde_json::from_str::<Value>(&output_raw).unwrap_or(Value::String(output_raw));
2225
2226        match name.as_str() {
2227            "spawn_agent" => {
2228                let Some(agent_id) = output_value
2229                    .get("agent_id")
2230                    .and_then(Value::as_str)
2231                    .map(ToString::to_string)
2232                else {
2233                    warnings.push(
2234                        "spawn_agent output did not include agent_id; skipping subagent mapping"
2235                            .to_string(),
2236                    );
2237                    continue;
2238                };
2239
2240                let timeline = timelines.entry(agent_id).or_default();
2241                timeline.has_spawn = true;
2242                timeline.has_activity = true;
2243                timeline.last_update = timestamp.clone();
2244                timeline.events.push(SubagentLifecycleEvent {
2245                    timestamp,
2246                    event: "spawn_agent".to_string(),
2247                    detail: "subagent spawned".to_string(),
2248                });
2249            }
2250            "wait" => {
2251                let ids = args
2252                    .get("ids")
2253                    .and_then(Value::as_array)
2254                    .into_iter()
2255                    .flatten()
2256                    .filter_map(Value::as_str)
2257                    .map(ToString::to_string)
2258                    .collect::<Vec<_>>();
2259
2260                let timed_out = output_value
2261                    .get("timed_out")
2262                    .and_then(Value::as_bool)
2263                    .unwrap_or(false);
2264
2265                for agent_id in ids {
2266                    let timeline = timelines.entry(agent_id).or_default();
2267                    timeline.has_activity = true;
2268                    timeline.last_update = timestamp.clone();
2269
2270                    let mut detail = if timed_out {
2271                        "wait timed out".to_string()
2272                    } else {
2273                        "wait returned".to_string()
2274                    };
2275
2276                    if let Some(state) = infer_state_from_status_payload(&output_value) {
2277                        timeline.states.push(state.clone());
2278                        detail = format!("wait state={state}");
2279                    } else if timed_out {
2280                        timeline.states.push(STATUS_RUNNING.to_string());
2281                    }
2282
2283                    timeline.events.push(SubagentLifecycleEvent {
2284                        timestamp: timestamp.clone(),
2285                        event: "wait".to_string(),
2286                        detail,
2287                    });
2288                }
2289            }
2290            "send_input" | "resume_agent" | "close_agent" => {
2291                let Some(agent_id) = args
2292                    .get("id")
2293                    .and_then(Value::as_str)
2294                    .map(ToString::to_string)
2295                else {
2296                    continue;
2297                };
2298
2299                let timeline = timelines.entry(agent_id).or_default();
2300                timeline.has_activity = true;
2301                timeline.last_update = timestamp.clone();
2302
2303                if name == "close_agent" {
2304                    if let Some(state) = infer_state_from_status_payload(&output_value) {
2305                        timeline.states.push(state.clone());
2306                    } else {
2307                        timeline.states.push(STATUS_SHUTDOWN.to_string());
2308                    }
2309                }
2310
2311                timeline.events.push(SubagentLifecycleEvent {
2312                    timestamp,
2313                    event: name,
2314                    detail: "agent lifecycle event".to_string(),
2315                });
2316            }
2317            _ => {}
2318        }
2319    }
2320
2321    warnings
2322}
2323
2324fn infer_state_from_status_payload(payload: &Value) -> Option<String> {
2325    let status = payload.get("status")?;
2326
2327    if let Some(object) = status.as_object() {
2328        for key in object.keys() {
2329            if [
2330                STATUS_PENDING_INIT,
2331                STATUS_RUNNING,
2332                STATUS_COMPLETED,
2333                STATUS_ERRORED,
2334                STATUS_SHUTDOWN,
2335                STATUS_NOT_FOUND,
2336            ]
2337            .contains(&key.as_str())
2338            {
2339                return Some(key.clone());
2340            }
2341        }
2342
2343        if object.contains_key("completed") {
2344            return Some(STATUS_COMPLETED.to_string());
2345        }
2346    }
2347
2348    None
2349}
2350
2351fn infer_status_from_timeline(timeline: &AgentTimeline, child_exists: bool) -> (String, String) {
2352    if timeline.states.iter().any(|state| state == STATUS_ERRORED) {
2353        return (STATUS_ERRORED.to_string(), "parent_rollout".to_string());
2354    }
2355    if timeline.states.iter().any(|state| state == STATUS_SHUTDOWN) {
2356        return (STATUS_SHUTDOWN.to_string(), "parent_rollout".to_string());
2357    }
2358    if timeline
2359        .states
2360        .iter()
2361        .any(|state| state == STATUS_COMPLETED)
2362    {
2363        return (STATUS_COMPLETED.to_string(), "parent_rollout".to_string());
2364    }
2365    if timeline.states.iter().any(|state| state == STATUS_RUNNING) || timeline.has_activity {
2366        return (STATUS_RUNNING.to_string(), "parent_rollout".to_string());
2367    }
2368    if timeline.has_spawn {
2369        return (
2370            STATUS_PENDING_INIT.to_string(),
2371            "parent_rollout".to_string(),
2372        );
2373    }
2374    if child_exists {
2375        return (STATUS_RUNNING.to_string(), "child_rollout".to_string());
2376    }
2377
2378    (STATUS_NOT_FOUND.to_string(), "inferred".to_string())
2379}
2380
2381fn infer_status_for_detail(
2382    timeline: &AgentTimeline,
2383    child_status: Option<String>,
2384    child_exists: bool,
2385) -> (String, String) {
2386    let (status, source) = infer_status_from_timeline(timeline, child_exists);
2387    if status == STATUS_NOT_FOUND
2388        && let Some(child_status) = child_status
2389    {
2390        return (child_status, "child_rollout".to_string());
2391    }
2392
2393    (status, source)
2394}
2395
2396fn extract_codex_parent_thread_id(raw: &str) -> Option<String> {
2397    let first = raw.lines().find(|line| !line.trim().is_empty())?;
2398    let value = serde_json::from_str::<Value>(first).ok()?;
2399
2400    value
2401        .get("payload")
2402        .and_then(|payload| payload.get("source"))
2403        .and_then(|source| source.get("subagent"))
2404        .and_then(|subagent| subagent.get("thread_spawn"))
2405        .and_then(|thread_spawn| thread_spawn.get("parent_thread_id"))
2406        .and_then(Value::as_str)
2407        .map(ToString::to_string)
2408}
2409
2410fn resolve_claude_subagent_view(
2411    uri: &AgentsUri,
2412    roots: &ProviderRoots,
2413    list: bool,
2414) -> Result<SubagentView> {
2415    let main_uri = main_thread_uri(uri);
2416    let resolved_main = resolve_thread(&main_uri, roots)?;
2417
2418    let mut warnings = resolved_main.metadata.warnings.clone();
2419    let records = discover_claude_agents(&resolved_main, &uri.session_id, &mut warnings);
2420
2421    if list {
2422        return Ok(SubagentView::List(SubagentListView {
2423            query: make_query(uri, None, true),
2424            agents: records
2425                .iter()
2426                .map(|record| SubagentListItem {
2427                    agent_id: record.agent_id.clone(),
2428                    status: record.status.clone(),
2429                    status_source: "inferred".to_string(),
2430                    last_update: record.last_update.clone(),
2431                    relation: record.relation.clone(),
2432                    child_thread: Some(SubagentThreadRef {
2433                        thread_id: record.agent_id.clone(),
2434                        path: Some(record.path.display().to_string()),
2435                        last_updated_at: record.last_update.clone(),
2436                    }),
2437                })
2438                .collect(),
2439            warnings,
2440        }));
2441    }
2442
2443    let requested_agent = uri
2444        .agent_id
2445        .clone()
2446        .ok_or_else(|| XurlError::InvalidMode("missing agent id".to_string()))?;
2447
2448    let normalized_requested = normalize_agent_id(&requested_agent);
2449
2450    if let Some(record) = records
2451        .into_iter()
2452        .find(|record| normalize_agent_id(&record.agent_id) == normalized_requested)
2453    {
2454        let lifecycle = vec![SubagentLifecycleEvent {
2455            timestamp: record.last_update.clone(),
2456            event: "discovered_agent_file".to_string(),
2457            detail: "agent transcript discovered and analyzed".to_string(),
2458        }];
2459
2460        warnings.extend(record.warnings.clone());
2461
2462        return Ok(SubagentView::Detail(SubagentDetailView {
2463            query: make_query(uri, Some(requested_agent), false),
2464            relation: record.relation.clone(),
2465            lifecycle,
2466            status: record.status.clone(),
2467            status_source: "inferred".to_string(),
2468            child_thread: Some(SubagentThreadRef {
2469                thread_id: record.agent_id.clone(),
2470                path: Some(record.path.display().to_string()),
2471                last_updated_at: record.last_update.clone(),
2472            }),
2473            excerpt: record.excerpt,
2474            warnings,
2475        }));
2476    }
2477
2478    warnings.push(format!(
2479        "agent not found for main_session_id={} agent_id={requested_agent}",
2480        uri.session_id
2481    ));
2482
2483    Ok(SubagentView::Detail(SubagentDetailView {
2484        query: make_query(uri, Some(requested_agent), false),
2485        relation: SubagentRelation::default(),
2486        lifecycle: Vec::new(),
2487        status: STATUS_NOT_FOUND.to_string(),
2488        status_source: "inferred".to_string(),
2489        child_thread: None,
2490        excerpt: Vec::new(),
2491        warnings,
2492    }))
2493}
2494
2495fn resolve_gemini_subagent_view(
2496    uri: &AgentsUri,
2497    roots: &ProviderRoots,
2498    list: bool,
2499) -> Result<SubagentView> {
2500    let main_uri = main_thread_uri(uri);
2501    let resolved_main = resolve_thread(&main_uri, roots)?;
2502    let mut warnings = resolved_main.metadata.warnings.clone();
2503
2504    let (chats, mut children) =
2505        discover_gemini_children(&resolved_main, &uri.session_id, &mut warnings);
2506
2507    if list {
2508        let agents = children
2509            .iter_mut()
2510            .map(|(child_session_id, record)| {
2511                if let Some(chat) = chats.get(child_session_id) {
2512                    return SubagentListItem {
2513                        agent_id: child_session_id.clone(),
2514                        status: chat.status.clone(),
2515                        status_source: "child_rollout".to_string(),
2516                        last_update: chat.last_update.clone(),
2517                        relation: record.relation.clone(),
2518                        child_thread: Some(SubagentThreadRef {
2519                            thread_id: child_session_id.clone(),
2520                            path: Some(chat.path.display().to_string()),
2521                            last_updated_at: chat.last_update.clone(),
2522                        }),
2523                    };
2524                }
2525
2526                let missing_warning = format!(
2527                    "child session {child_session_id} discovered from local Gemini data but chat file was not found in project chats"
2528                );
2529                warnings.push(missing_warning);
2530                let missing_evidence =
2531                    "child session could not be materialized to a chat file".to_string();
2532                if !record.relation.evidence.contains(&missing_evidence) {
2533                    record.relation.evidence.push(missing_evidence);
2534                }
2535
2536                SubagentListItem {
2537                    agent_id: child_session_id.clone(),
2538                    status: STATUS_NOT_FOUND.to_string(),
2539                    status_source: "inferred".to_string(),
2540                    last_update: record.relation_timestamp.clone(),
2541                    relation: record.relation.clone(),
2542                    child_thread: None,
2543                }
2544            })
2545            .collect::<Vec<_>>();
2546
2547        return Ok(SubagentView::List(SubagentListView {
2548            query: make_query(uri, None, true),
2549            agents,
2550            warnings,
2551        }));
2552    }
2553
2554    let requested_child = uri
2555        .agent_id
2556        .clone()
2557        .ok_or_else(|| XurlError::InvalidMode("missing agent id".to_string()))?;
2558
2559    let mut relation = SubagentRelation::default();
2560    let mut lifecycle = Vec::new();
2561    let mut status = STATUS_NOT_FOUND.to_string();
2562    let mut status_source = "inferred".to_string();
2563    let mut child_thread = None;
2564    let mut excerpt = Vec::new();
2565
2566    if let Some(record) = children.get_mut(&requested_child) {
2567        relation = record.relation.clone();
2568        if !relation.evidence.is_empty() {
2569            lifecycle.push(SubagentLifecycleEvent {
2570                timestamp: record.relation_timestamp.clone(),
2571                event: "discover_child".to_string(),
2572                detail: if relation.validated {
2573                    "child relation validated from local Gemini payload".to_string()
2574                } else {
2575                    "child relation inferred from logs.json /resume sequence".to_string()
2576                },
2577            });
2578        }
2579
2580        if let Some(chat) = chats.get(&requested_child) {
2581            status = chat.status.clone();
2582            status_source = "child_rollout".to_string();
2583            child_thread = Some(SubagentThreadRef {
2584                thread_id: requested_child.clone(),
2585                path: Some(chat.path.display().to_string()),
2586                last_updated_at: chat.last_update.clone(),
2587            });
2588            excerpt = extract_child_excerpt(ProviderKind::Gemini, &chat.path, &mut warnings);
2589        } else {
2590            warnings.push(format!(
2591                "child session {requested_child} discovered from local Gemini data but chat file was not found in project chats"
2592            ));
2593            let missing_evidence =
2594                "child session could not be materialized to a chat file".to_string();
2595            if !relation.evidence.contains(&missing_evidence) {
2596                relation.evidence.push(missing_evidence);
2597            }
2598        }
2599    } else if let Some(chat) = chats.get(&requested_child) {
2600        warnings.push(format!(
2601            "unable to validate Gemini parent-child relation for main_session_id={} child_session_id={requested_child}",
2602            uri.session_id
2603        ));
2604        lifecycle.push(SubagentLifecycleEvent {
2605            timestamp: chat.last_update.clone(),
2606            event: "discover_child_chat".to_string(),
2607            detail: "child chat exists but relation to main thread is unknown".to_string(),
2608        });
2609        status = chat.status.clone();
2610        status_source = "child_rollout".to_string();
2611        child_thread = Some(SubagentThreadRef {
2612            thread_id: requested_child.clone(),
2613            path: Some(chat.path.display().to_string()),
2614            last_updated_at: chat.last_update.clone(),
2615        });
2616        excerpt = extract_child_excerpt(ProviderKind::Gemini, &chat.path, &mut warnings);
2617    } else {
2618        warnings.push(format!(
2619            "child session not found for main_session_id={} child_session_id={requested_child}",
2620            uri.session_id
2621        ));
2622    }
2623
2624    Ok(SubagentView::Detail(SubagentDetailView {
2625        query: make_query(uri, Some(requested_child), false),
2626        relation,
2627        lifecycle,
2628        status,
2629        status_source,
2630        child_thread,
2631        excerpt,
2632        warnings,
2633    }))
2634}
2635
2636fn discover_gemini_children(
2637    resolved_main: &ResolvedThread,
2638    main_session_id: &str,
2639    warnings: &mut Vec<String>,
2640) -> (
2641    BTreeMap<String, GeminiChatRecord>,
2642    BTreeMap<String, GeminiChildRecord>,
2643) {
2644    let Some(project_dir) = resolved_main.path.parent().and_then(Path::parent) else {
2645        warnings.push(format!(
2646            "cannot determine Gemini project directory from resolved main thread path: {}",
2647            resolved_main.path.display()
2648        ));
2649        return (BTreeMap::new(), BTreeMap::new());
2650    };
2651
2652    let chats = load_gemini_project_chats(project_dir, warnings);
2653    let logs = read_gemini_log_entries(project_dir, warnings);
2654
2655    let mut children = BTreeMap::<String, GeminiChildRecord>::new();
2656
2657    for chat in chats.values() {
2658        if chat.session_id == main_session_id {
2659            continue;
2660        }
2661        if chat
2662            .explicit_parent_ids
2663            .iter()
2664            .any(|parent_id| parent_id == main_session_id)
2665        {
2666            push_explicit_gemini_relation(
2667                &mut children,
2668                &chat.session_id,
2669                "child chat payload includes explicit parent session reference",
2670                chat.last_update.clone(),
2671            );
2672        }
2673    }
2674
2675    for entry in &logs {
2676        if entry.session_id == main_session_id {
2677            continue;
2678        }
2679        if entry
2680            .explicit_parent_ids
2681            .iter()
2682            .any(|parent_id| parent_id == main_session_id)
2683        {
2684            push_explicit_gemini_relation(
2685                &mut children,
2686                &entry.session_id,
2687                "logs.json entry includes explicit parent session reference",
2688                entry.timestamp.clone(),
2689            );
2690        }
2691    }
2692
2693    for (child_session_id, parent_session_id, timestamp) in infer_gemini_relations_from_logs(&logs)
2694    {
2695        if child_session_id == main_session_id || parent_session_id != main_session_id {
2696            continue;
2697        }
2698        push_inferred_gemini_relation(
2699            &mut children,
2700            &child_session_id,
2701            "logs.json shows child session starts with /resume after main session activity",
2702            timestamp,
2703        );
2704    }
2705
2706    (chats, children)
2707}
2708
2709fn load_gemini_project_chats(
2710    project_dir: &Path,
2711    warnings: &mut Vec<String>,
2712) -> BTreeMap<String, GeminiChatRecord> {
2713    let chats_dir = project_dir.join("chats");
2714    if !chats_dir.exists() {
2715        warnings.push(format!(
2716            "Gemini project chats directory not found: {}",
2717            chats_dir.display()
2718        ));
2719        return BTreeMap::new();
2720    }
2721
2722    let mut chats = BTreeMap::<String, GeminiChatRecord>::new();
2723    let Ok(entries) = fs::read_dir(&chats_dir) else {
2724        warnings.push(format!(
2725            "failed to read Gemini chats directory: {}",
2726            chats_dir.display()
2727        ));
2728        return chats;
2729    };
2730
2731    for entry in entries.filter_map(std::result::Result::ok) {
2732        let path = entry.path();
2733        let is_chat_file = path
2734            .file_name()
2735            .and_then(|name| name.to_str())
2736            .is_some_and(|name| name.starts_with("session-") && name.ends_with(".json"));
2737        if !is_chat_file || !path.is_file() {
2738            continue;
2739        }
2740
2741        let Some(chat) = parse_gemini_chat_file(&path, warnings) else {
2742            continue;
2743        };
2744
2745        match chats.get(&chat.session_id) {
2746            Some(existing) => {
2747                let existing_stamp = file_modified_epoch(&existing.path).unwrap_or(0);
2748                let new_stamp = file_modified_epoch(&chat.path).unwrap_or(0);
2749                if new_stamp > existing_stamp {
2750                    chats.insert(chat.session_id.clone(), chat);
2751                }
2752            }
2753            None => {
2754                chats.insert(chat.session_id.clone(), chat);
2755            }
2756        }
2757    }
2758
2759    chats
2760}
2761
2762fn parse_gemini_chat_file(path: &Path, warnings: &mut Vec<String>) -> Option<GeminiChatRecord> {
2763    let raw = match read_thread_raw(path) {
2764        Ok(raw) => raw,
2765        Err(err) => {
2766            warnings.push(format!(
2767                "failed to read Gemini chat {}: {err}",
2768                path.display()
2769            ));
2770            return None;
2771        }
2772    };
2773
2774    let value = match serde_json::from_str::<Value>(&raw) {
2775        Ok(value) => value,
2776        Err(err) => {
2777            warnings.push(format!(
2778                "failed to parse Gemini chat JSON {}: {err}",
2779                path.display()
2780            ));
2781            return None;
2782        }
2783    };
2784
2785    let Some(session_id) = value
2786        .get("sessionId")
2787        .and_then(Value::as_str)
2788        .and_then(parse_session_id_like)
2789    else {
2790        warnings.push(format!(
2791            "Gemini chat missing valid sessionId: {}",
2792            path.display()
2793        ));
2794        return None;
2795    };
2796
2797    let last_update = value
2798        .get("lastUpdated")
2799        .and_then(Value::as_str)
2800        .map(ToString::to_string)
2801        .or_else(|| {
2802            value
2803                .get("startTime")
2804                .and_then(Value::as_str)
2805                .map(ToString::to_string)
2806        })
2807        .or_else(|| modified_timestamp_string(path));
2808
2809    let status = infer_gemini_chat_status(&value);
2810    let explicit_parent_ids = parse_parent_session_ids(&value);
2811
2812    Some(GeminiChatRecord {
2813        session_id,
2814        path: path.to_path_buf(),
2815        last_update,
2816        status,
2817        explicit_parent_ids,
2818    })
2819}
2820
2821fn infer_gemini_chat_status(value: &Value) -> String {
2822    let Some(messages) = value.get("messages").and_then(Value::as_array) else {
2823        return STATUS_PENDING_INIT.to_string();
2824    };
2825
2826    let mut has_error = false;
2827    let mut has_assistant = false;
2828    let mut has_user = false;
2829
2830    for message in messages {
2831        let message_type = message
2832            .get("type")
2833            .and_then(Value::as_str)
2834            .unwrap_or_default();
2835        if message_type == "error" || !message.get("error").is_none_or(Value::is_null) {
2836            has_error = true;
2837        }
2838        if message_type == "gemini" || message_type == "assistant" {
2839            has_assistant = true;
2840        }
2841        if message_type == "user" {
2842            has_user = true;
2843        }
2844    }
2845
2846    if has_error {
2847        STATUS_ERRORED.to_string()
2848    } else if has_assistant {
2849        STATUS_COMPLETED.to_string()
2850    } else if has_user {
2851        STATUS_RUNNING.to_string()
2852    } else {
2853        STATUS_PENDING_INIT.to_string()
2854    }
2855}
2856
2857fn read_gemini_log_entries(project_dir: &Path, warnings: &mut Vec<String>) -> Vec<GeminiLogEntry> {
2858    let logs_path = project_dir.join("logs.json");
2859    if !logs_path.exists() {
2860        return Vec::new();
2861    }
2862
2863    let raw = match read_thread_raw(&logs_path) {
2864        Ok(raw) => raw,
2865        Err(err) => {
2866            warnings.push(format!(
2867                "failed to read Gemini logs file {}: {err}",
2868                logs_path.display()
2869            ));
2870            return Vec::new();
2871        }
2872    };
2873
2874    if raw.trim().is_empty() {
2875        return Vec::new();
2876    }
2877
2878    if let Ok(value) = serde_json::from_str::<Value>(&raw) {
2879        return parse_gemini_logs_value(&logs_path, value, warnings);
2880    }
2881
2882    let mut parsed = Vec::new();
2883    for (index, line) in raw.lines().enumerate() {
2884        if line.trim().is_empty() {
2885            continue;
2886        }
2887        match serde_json::from_str::<Value>(line) {
2888            Ok(value) => {
2889                if let Some(entry) = parse_gemini_log_entry(&logs_path, index + 1, &value, warnings)
2890                {
2891                    parsed.push(entry);
2892                }
2893            }
2894            Err(err) => warnings.push(format!(
2895                "failed to parse Gemini logs line {} in {}: {err}",
2896                index + 1,
2897                logs_path.display()
2898            )),
2899        }
2900    }
2901    parsed
2902}
2903
2904fn parse_gemini_logs_value(
2905    logs_path: &Path,
2906    value: Value,
2907    warnings: &mut Vec<String>,
2908) -> Vec<GeminiLogEntry> {
2909    match value {
2910        Value::Array(entries) => entries
2911            .into_iter()
2912            .enumerate()
2913            .filter_map(|(index, entry)| {
2914                parse_gemini_log_entry(logs_path, index + 1, &entry, warnings)
2915            })
2916            .collect(),
2917        Value::Object(object) => {
2918            if let Some(entries) = object.get("entries").and_then(Value::as_array) {
2919                return entries
2920                    .iter()
2921                    .enumerate()
2922                    .filter_map(|(index, entry)| {
2923                        parse_gemini_log_entry(logs_path, index + 1, entry, warnings)
2924                    })
2925                    .collect();
2926            }
2927
2928            parse_gemini_log_entry(logs_path, 1, &Value::Object(object), warnings)
2929                .into_iter()
2930                .collect()
2931        }
2932        _ => {
2933            warnings.push(format!(
2934                "unsupported Gemini logs format in {}: expected JSON array or object",
2935                logs_path.display()
2936            ));
2937            Vec::new()
2938        }
2939    }
2940}
2941
2942fn parse_gemini_log_entry(
2943    logs_path: &Path,
2944    line: usize,
2945    value: &Value,
2946    warnings: &mut Vec<String>,
2947) -> Option<GeminiLogEntry> {
2948    let Some(object) = value.as_object() else {
2949        warnings.push(format!(
2950            "invalid Gemini log entry at {} line {}: expected JSON object",
2951            logs_path.display(),
2952            line
2953        ));
2954        return None;
2955    };
2956
2957    let session_id = object
2958        .get("sessionId")
2959        .and_then(Value::as_str)
2960        .or_else(|| object.get("session_id").and_then(Value::as_str))
2961        .and_then(parse_session_id_like)?;
2962
2963    Some(GeminiLogEntry {
2964        session_id,
2965        message: object
2966            .get("message")
2967            .and_then(Value::as_str)
2968            .map(ToString::to_string),
2969        timestamp: object
2970            .get("timestamp")
2971            .and_then(Value::as_str)
2972            .map(ToString::to_string),
2973        entry_type: object
2974            .get("type")
2975            .and_then(Value::as_str)
2976            .map(ToString::to_string),
2977        explicit_parent_ids: parse_parent_session_ids(value),
2978    })
2979}
2980
2981fn infer_gemini_relations_from_logs(
2982    logs: &[GeminiLogEntry],
2983) -> Vec<(String, String, Option<String>)> {
2984    let mut first_user_seen = BTreeSet::<String>::new();
2985    let mut latest_session = None::<String>;
2986    let mut relations = Vec::new();
2987
2988    for entry in logs {
2989        let session_id = entry.session_id.clone();
2990        let is_user_like = entry
2991            .entry_type
2992            .as_deref()
2993            .is_none_or(|kind| kind == "user");
2994
2995        if is_user_like && !first_user_seen.contains(&session_id) {
2996            first_user_seen.insert(session_id.clone());
2997            if entry
2998                .message
2999                .as_deref()
3000                .map(str::trim_start)
3001                .is_some_and(|message| message.starts_with("/resume"))
3002                && let Some(parent_session_id) = latest_session.clone()
3003                && parent_session_id != session_id
3004            {
3005                relations.push((
3006                    session_id.clone(),
3007                    parent_session_id,
3008                    entry.timestamp.clone(),
3009                ));
3010            }
3011        }
3012
3013        latest_session = Some(session_id);
3014    }
3015
3016    relations
3017}
3018
3019fn push_explicit_gemini_relation(
3020    children: &mut BTreeMap<String, GeminiChildRecord>,
3021    child_session_id: &str,
3022    evidence: &str,
3023    timestamp: Option<String>,
3024) {
3025    let record = children.entry(child_session_id.to_string()).or_default();
3026    record.relation.validated = true;
3027    if !record.relation.evidence.iter().any(|item| item == evidence) {
3028        record.relation.evidence.push(evidence.to_string());
3029    }
3030    if record.relation_timestamp.is_none() {
3031        record.relation_timestamp = timestamp;
3032    }
3033}
3034
3035fn push_inferred_gemini_relation(
3036    children: &mut BTreeMap<String, GeminiChildRecord>,
3037    child_session_id: &str,
3038    evidence: &str,
3039    timestamp: Option<String>,
3040) {
3041    let record = children.entry(child_session_id.to_string()).or_default();
3042    if record.relation.validated {
3043        return;
3044    }
3045    if !record.relation.evidence.iter().any(|item| item == evidence) {
3046        record.relation.evidence.push(evidence.to_string());
3047    }
3048    if record.relation_timestamp.is_none() {
3049        record.relation_timestamp = timestamp;
3050    }
3051}
3052
3053fn parse_parent_session_ids(value: &Value) -> Vec<String> {
3054    let mut parent_ids = BTreeSet::new();
3055    collect_parent_session_ids(value, &mut parent_ids);
3056    parent_ids.into_iter().collect()
3057}
3058
3059fn collect_parent_session_ids(value: &Value, parent_ids: &mut BTreeSet<String>) {
3060    match value {
3061        Value::Object(object) => {
3062            for (key, nested) in object {
3063                let normalized_key = key.to_ascii_lowercase();
3064                let is_parent_key = normalized_key.contains("parent")
3065                    && (normalized_key.contains("session")
3066                        || normalized_key.contains("thread")
3067                        || normalized_key.contains("id"));
3068                if is_parent_key {
3069                    maybe_collect_session_id(nested, parent_ids);
3070                }
3071                if normalized_key == "parent" {
3072                    maybe_collect_session_id(nested, parent_ids);
3073                }
3074                collect_parent_session_ids(nested, parent_ids);
3075            }
3076        }
3077        Value::Array(values) => {
3078            for nested in values {
3079                collect_parent_session_ids(nested, parent_ids);
3080            }
3081        }
3082        _ => {}
3083    }
3084}
3085
3086fn maybe_collect_session_id(value: &Value, parent_ids: &mut BTreeSet<String>) {
3087    match value {
3088        Value::String(raw) => {
3089            if let Some(session_id) = parse_session_id_like(raw) {
3090                parent_ids.insert(session_id);
3091            }
3092        }
3093        Value::Object(object) => {
3094            for key in ["sessionId", "session_id", "threadId", "thread_id", "id"] {
3095                if let Some(session_id) = object
3096                    .get(key)
3097                    .and_then(Value::as_str)
3098                    .and_then(parse_session_id_like)
3099                {
3100                    parent_ids.insert(session_id);
3101                }
3102            }
3103        }
3104        _ => {}
3105    }
3106}
3107
3108fn parse_session_id_like(raw: &str) -> Option<String> {
3109    let normalized = raw.trim().to_ascii_lowercase();
3110    if normalized.len() != 36 {
3111        return None;
3112    }
3113
3114    for (index, byte) in normalized.bytes().enumerate() {
3115        if [8, 13, 18, 23].contains(&index) {
3116            if byte != b'-' {
3117                return None;
3118            }
3119            continue;
3120        }
3121
3122        if !byte.is_ascii_hexdigit() {
3123            return None;
3124        }
3125    }
3126
3127    Some(normalized)
3128}
3129
3130fn extract_child_excerpt(
3131    provider: ProviderKind,
3132    path: &Path,
3133    warnings: &mut Vec<String>,
3134) -> Vec<SubagentExcerptMessage> {
3135    let raw = match read_thread_raw(path) {
3136        Ok(raw) => raw,
3137        Err(err) => {
3138            warnings.push(format!(
3139                "failed reading child thread {}: {err}",
3140                path.display()
3141            ));
3142            return Vec::new();
3143        }
3144    };
3145
3146    match render::extract_messages(provider, path, &raw) {
3147        Ok(messages) => messages
3148            .into_iter()
3149            .rev()
3150            .take(3)
3151            .collect::<Vec<_>>()
3152            .into_iter()
3153            .rev()
3154            .map(|message| SubagentExcerptMessage {
3155                role: message.role,
3156                text: message.text,
3157            })
3158            .collect(),
3159        Err(err) => {
3160            warnings.push(format!(
3161                "failed extracting child messages from {}: {err}",
3162                path.display()
3163            ));
3164            Vec::new()
3165        }
3166    }
3167}
3168
3169fn resolve_opencode_subagent_view(
3170    uri: &AgentsUri,
3171    roots: &ProviderRoots,
3172    list: bool,
3173) -> Result<SubagentView> {
3174    let main_uri = main_thread_uri(uri);
3175    let resolved_main = resolve_thread(&main_uri, roots)?;
3176
3177    let mut warnings = resolved_main.metadata.warnings.clone();
3178    let records = discover_opencode_agents(roots, &uri.session_id, &mut warnings)?;
3179
3180    if list {
3181        let mut agents = Vec::new();
3182        for record in records {
3183            let analysis = inspect_opencode_child(&record.agent_id, roots, record.message_count);
3184            warnings.extend(analysis.warnings);
3185
3186            agents.push(SubagentListItem {
3187                agent_id: record.agent_id.clone(),
3188                status: analysis.status,
3189                status_source: analysis.status_source,
3190                last_update: analysis.last_update.clone(),
3191                relation: record.relation,
3192                child_thread: analysis.child_thread,
3193            });
3194        }
3195
3196        return Ok(SubagentView::List(SubagentListView {
3197            query: make_query(uri, None, true),
3198            agents,
3199            warnings,
3200        }));
3201    }
3202
3203    let requested_agent = uri
3204        .agent_id
3205        .clone()
3206        .ok_or_else(|| XurlError::InvalidMode("missing agent id".to_string()))?;
3207
3208    if let Some(record) = records
3209        .into_iter()
3210        .find(|record| record.agent_id == requested_agent)
3211    {
3212        let analysis = inspect_opencode_child(&record.agent_id, roots, record.message_count);
3213        warnings.extend(analysis.warnings);
3214
3215        let lifecycle = vec![SubagentLifecycleEvent {
3216            timestamp: analysis.last_update.clone(),
3217            event: "session_parent_link".to_string(),
3218            detail: "session.parent_id points to main thread".to_string(),
3219        }];
3220
3221        return Ok(SubagentView::Detail(SubagentDetailView {
3222            query: make_query(uri, Some(requested_agent), false),
3223            relation: record.relation,
3224            lifecycle,
3225            status: analysis.status,
3226            status_source: analysis.status_source,
3227            child_thread: analysis.child_thread,
3228            excerpt: analysis.excerpt,
3229            warnings,
3230        }));
3231    }
3232
3233    warnings.push(format!(
3234        "agent not found for main_session_id={} agent_id={requested_agent}",
3235        uri.session_id
3236    ));
3237
3238    Ok(SubagentView::Detail(SubagentDetailView {
3239        query: make_query(uri, Some(requested_agent), false),
3240        relation: SubagentRelation::default(),
3241        lifecycle: Vec::new(),
3242        status: STATUS_NOT_FOUND.to_string(),
3243        status_source: "inferred".to_string(),
3244        child_thread: None,
3245        excerpt: Vec::new(),
3246        warnings,
3247    }))
3248}
3249
3250fn discover_opencode_agents(
3251    roots: &ProviderRoots,
3252    main_session_id: &str,
3253    warnings: &mut Vec<String>,
3254) -> Result<Vec<OpencodeAgentRecord>> {
3255    let db_path = opencode_db_path(roots);
3256    let conn = open_opencode_read_only_db(&db_path)?;
3257
3258    let has_parent_id =
3259        opencode_session_table_has_parent_id(&conn).map_err(|source| XurlError::Sqlite {
3260            path: db_path.clone(),
3261            source,
3262        })?;
3263    if !has_parent_id {
3264        warnings.push(
3265            "opencode sqlite session table does not expose parent_id; cannot discover subagent relations"
3266                .to_string(),
3267        );
3268        return Ok(Vec::new());
3269    }
3270
3271    let rows =
3272        query_opencode_children(&conn, main_session_id).map_err(|source| XurlError::Sqlite {
3273            path: db_path,
3274            source,
3275        })?;
3276
3277    Ok(rows
3278        .into_iter()
3279        .map(|(agent_id, message_count)| {
3280            let mut relation = SubagentRelation {
3281                validated: true,
3282                ..SubagentRelation::default()
3283            };
3284            relation
3285                .evidence
3286                .push("opencode sqlite relation validated via session.parent_id".to_string());
3287
3288            OpencodeAgentRecord {
3289                agent_id,
3290                relation,
3291                message_count,
3292            }
3293        })
3294        .collect())
3295}
3296
3297fn query_opencode_children(
3298    conn: &Connection,
3299    main_session_id: &str,
3300) -> std::result::Result<Vec<(String, usize)>, rusqlite::Error> {
3301    let mut stmt = conn.prepare(
3302        "SELECT s.id, COUNT(m.id) AS message_count
3303         FROM session AS s
3304         LEFT JOIN message AS m ON m.session_id = s.id
3305         WHERE s.parent_id = ?1
3306         GROUP BY s.id
3307         ORDER BY s.id ASC",
3308    )?;
3309
3310    let rows = stmt.query_map([main_session_id], |row| {
3311        let id = row.get::<_, String>(0)?;
3312        let message_count = row.get::<_, i64>(1)?;
3313        Ok((id, usize::try_from(message_count).unwrap_or(0)))
3314    })?;
3315
3316    let mut children = Vec::new();
3317    for row in rows {
3318        children.push(row?);
3319    }
3320    Ok(children)
3321}
3322
3323fn opencode_db_path(roots: &ProviderRoots) -> PathBuf {
3324    roots.opencode_root.join("opencode.db")
3325}
3326
3327fn open_opencode_read_only_db(db_path: &Path) -> Result<Connection> {
3328    Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY).map_err(|source| {
3329        XurlError::Sqlite {
3330            path: db_path.to_path_buf(),
3331            source,
3332        }
3333    })
3334}
3335
3336fn opencode_session_table_has_parent_id(
3337    conn: &Connection,
3338) -> std::result::Result<bool, rusqlite::Error> {
3339    let mut stmt = conn.prepare("PRAGMA table_info(session)")?;
3340    let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
3341
3342    let mut has_parent_id = false;
3343    for row in rows {
3344        if row? == "parent_id" {
3345            has_parent_id = true;
3346            break;
3347        }
3348    }
3349    Ok(has_parent_id)
3350}
3351
3352fn inspect_opencode_child(
3353    child_session_id: &str,
3354    roots: &ProviderRoots,
3355    message_count: usize,
3356) -> OpencodeChildAnalysis {
3357    let mut warnings = Vec::new();
3358    let resolved_child = match OpencodeProvider::new(&roots.opencode_root).resolve(child_session_id)
3359    {
3360        Ok(resolved) => resolved,
3361        Err(err) => {
3362            warnings.push(format!(
3363                "failed to materialize child session_id={child_session_id}: {err}"
3364            ));
3365            return OpencodeChildAnalysis {
3366                child_thread: None,
3367                status: STATUS_NOT_FOUND.to_string(),
3368                status_source: "inferred".to_string(),
3369                last_update: None,
3370                excerpt: Vec::new(),
3371                warnings,
3372            };
3373        }
3374    };
3375
3376    let raw = match read_thread_raw(&resolved_child.path) {
3377        Ok(raw) => raw,
3378        Err(err) => {
3379            warnings.push(format!(
3380                "failed reading child session transcript session_id={child_session_id}: {err}"
3381            ));
3382            return OpencodeChildAnalysis {
3383                child_thread: Some(SubagentThreadRef {
3384                    thread_id: child_session_id.to_string(),
3385                    path: Some(resolved_child.path.display().to_string()),
3386                    last_updated_at: None,
3387                }),
3388                status: STATUS_NOT_FOUND.to_string(),
3389                status_source: "inferred".to_string(),
3390                last_update: None,
3391                excerpt: Vec::new(),
3392                warnings,
3393            };
3394        }
3395    };
3396
3397    let messages =
3398        match render::extract_messages(ProviderKind::Opencode, &resolved_child.path, &raw) {
3399            Ok(messages) => messages,
3400            Err(err) => {
3401                warnings.push(format!(
3402                "failed extracting child transcript messages session_id={child_session_id}: {err}"
3403            ));
3404                Vec::new()
3405            }
3406        };
3407
3408    if message_count == 0 {
3409        warnings.push(format!(
3410            "child session_id={child_session_id} has no materialized messages in sqlite"
3411        ));
3412    }
3413
3414    let (status, status_source) = infer_opencode_status(&messages);
3415    let last_update = extract_opencode_last_update(&raw);
3416
3417    let excerpt = messages
3418        .into_iter()
3419        .rev()
3420        .take(3)
3421        .collect::<Vec<_>>()
3422        .into_iter()
3423        .rev()
3424        .map(|message| SubagentExcerptMessage {
3425            role: message.role,
3426            text: message.text,
3427        })
3428        .collect::<Vec<_>>();
3429
3430    OpencodeChildAnalysis {
3431        child_thread: Some(SubagentThreadRef {
3432            thread_id: child_session_id.to_string(),
3433            path: Some(resolved_child.path.display().to_string()),
3434            last_updated_at: last_update.clone(),
3435        }),
3436        status,
3437        status_source,
3438        last_update,
3439        excerpt,
3440        warnings,
3441    }
3442}
3443
3444fn infer_opencode_status(messages: &[crate::model::ThreadMessage]) -> (String, String) {
3445    let has_assistant = messages
3446        .iter()
3447        .any(|message| message.role == crate::model::MessageRole::Assistant);
3448    if has_assistant {
3449        return (STATUS_COMPLETED.to_string(), "child_rollout".to_string());
3450    }
3451
3452    let has_user = messages
3453        .iter()
3454        .any(|message| message.role == crate::model::MessageRole::User);
3455    if has_user {
3456        return (STATUS_RUNNING.to_string(), "child_rollout".to_string());
3457    }
3458
3459    (STATUS_PENDING_INIT.to_string(), "inferred".to_string())
3460}
3461
3462fn extract_opencode_last_update(raw: &str) -> Option<String> {
3463    for line in raw.lines().rev() {
3464        if line.trim().is_empty() {
3465            continue;
3466        }
3467
3468        let Ok(value) = serde_json::from_str::<Value>(line) else {
3469            continue;
3470        };
3471
3472        if value.get("type").and_then(Value::as_str) != Some("message") {
3473            continue;
3474        }
3475
3476        let Some(message) = value.get("message") else {
3477            continue;
3478        };
3479
3480        let Some(time) = message.get("time") else {
3481            continue;
3482        };
3483
3484        if let Some(completed) = value_to_timestamp_string(time.get("completed")) {
3485            return Some(completed);
3486        }
3487        if let Some(created) = value_to_timestamp_string(time.get("created")) {
3488            return Some(created);
3489        }
3490    }
3491
3492    None
3493}
3494
3495fn value_to_timestamp_string(value: Option<&Value>) -> Option<String> {
3496    let value = value?;
3497    value
3498        .as_str()
3499        .map(ToString::to_string)
3500        .or_else(|| value.as_i64().map(|number| number.to_string()))
3501        .or_else(|| value.as_u64().map(|number| number.to_string()))
3502}
3503
3504fn discover_claude_agents(
3505    resolved_main: &ResolvedThread,
3506    main_session_id: &str,
3507    warnings: &mut Vec<String>,
3508) -> Vec<ClaudeAgentRecord> {
3509    let Some(project_dir) = resolved_main.path.parent() else {
3510        warnings.push(format!(
3511            "cannot determine project directory from resolved main thread path: {}",
3512            resolved_main.path.display()
3513        ));
3514        return Vec::new();
3515    };
3516
3517    let mut candidate_files = BTreeSet::new();
3518
3519    let nested_subagent_dir = project_dir.join(main_session_id).join("subagents");
3520    if nested_subagent_dir.exists()
3521        && let Ok(entries) = fs::read_dir(&nested_subagent_dir)
3522    {
3523        for entry in entries.filter_map(std::result::Result::ok) {
3524            let path = entry.path();
3525            if is_claude_agent_filename(&path) {
3526                candidate_files.insert(path);
3527            }
3528        }
3529    }
3530
3531    if let Ok(entries) = fs::read_dir(project_dir) {
3532        for entry in entries.filter_map(std::result::Result::ok) {
3533            let path = entry.path();
3534            if is_claude_agent_filename(&path) {
3535                candidate_files.insert(path);
3536            }
3537        }
3538    }
3539
3540    let mut latest_by_agent = BTreeMap::<String, ClaudeAgentRecord>::new();
3541
3542    for path in candidate_files {
3543        let Some(record) = analyze_claude_agent_file(&path, main_session_id, warnings) else {
3544            continue;
3545        };
3546
3547        match latest_by_agent.get(&record.agent_id) {
3548            Some(existing) => {
3549                let new_stamp = file_modified_epoch(&record.path).unwrap_or(0);
3550                let old_stamp = file_modified_epoch(&existing.path).unwrap_or(0);
3551                if new_stamp > old_stamp {
3552                    latest_by_agent.insert(record.agent_id.clone(), record);
3553                }
3554            }
3555            None => {
3556                latest_by_agent.insert(record.agent_id.clone(), record);
3557            }
3558        }
3559    }
3560
3561    latest_by_agent.into_values().collect()
3562}
3563
3564fn analyze_claude_agent_file(
3565    path: &Path,
3566    main_session_id: &str,
3567    warnings: &mut Vec<String>,
3568) -> Option<ClaudeAgentRecord> {
3569    let raw = match read_thread_raw(path) {
3570        Ok(raw) => raw,
3571        Err(err) => {
3572            warnings.push(format!(
3573                "failed to read Claude agent transcript {}: {err}",
3574                path.display()
3575            ));
3576            return None;
3577        }
3578    };
3579
3580    let mut agent_id = None::<String>;
3581    let mut is_sidechain = false;
3582    let mut session_matches = false;
3583    let mut has_error = false;
3584    let mut has_assistant = false;
3585    let mut has_user = false;
3586    let mut last_update = None::<String>;
3587
3588    for (line_idx, line) in raw.lines().enumerate() {
3589        if line.trim().is_empty() {
3590            continue;
3591        }
3592
3593        let value = match jsonl::parse_json_line(path, line_idx + 1, line) {
3594            Ok(Some(value)) => value,
3595            Ok(None) => continue,
3596            Err(err) => {
3597                warnings.push(format!(
3598                    "failed to parse Claude agent transcript line {} in {}: {err}",
3599                    line_idx + 1,
3600                    path.display()
3601                ));
3602                continue;
3603            }
3604        };
3605
3606        if line_idx == 0 {
3607            agent_id = value
3608                .get("agentId")
3609                .and_then(Value::as_str)
3610                .map(ToString::to_string);
3611            is_sidechain = value
3612                .get("isSidechain")
3613                .and_then(Value::as_bool)
3614                .unwrap_or(false);
3615            session_matches = value
3616                .get("sessionId")
3617                .and_then(Value::as_str)
3618                .is_some_and(|session_id| session_id == main_session_id);
3619        }
3620
3621        if let Some(timestamp) = value
3622            .get("timestamp")
3623            .and_then(Value::as_str)
3624            .map(ToString::to_string)
3625        {
3626            last_update = Some(timestamp);
3627        }
3628
3629        if value
3630            .get("isApiErrorMessage")
3631            .and_then(Value::as_bool)
3632            .unwrap_or(false)
3633            || !value.get("error").is_none_or(Value::is_null)
3634        {
3635            has_error = true;
3636        }
3637
3638        if let Some(kind) = value.get("type").and_then(Value::as_str) {
3639            if kind == "assistant" {
3640                has_assistant = true;
3641            }
3642            if kind == "user" {
3643                has_user = true;
3644            }
3645        }
3646    }
3647
3648    if !is_sidechain || !session_matches {
3649        return None;
3650    }
3651
3652    let Some(agent_id) = agent_id else {
3653        warnings.push(format!(
3654            "missing agentId in Claude sidechain transcript: {}",
3655            path.display()
3656        ));
3657        return None;
3658    };
3659
3660    let status = if has_error {
3661        STATUS_ERRORED.to_string()
3662    } else if has_assistant {
3663        STATUS_COMPLETED.to_string()
3664    } else if has_user {
3665        STATUS_RUNNING.to_string()
3666    } else {
3667        STATUS_PENDING_INIT.to_string()
3668    };
3669
3670    let excerpt = render::extract_messages(ProviderKind::Claude, path, &raw)
3671        .map(|messages| {
3672            messages
3673                .into_iter()
3674                .rev()
3675                .take(3)
3676                .collect::<Vec<_>>()
3677                .into_iter()
3678                .rev()
3679                .map(|message| SubagentExcerptMessage {
3680                    role: message.role,
3681                    text: message.text,
3682                })
3683                .collect::<Vec<_>>()
3684        })
3685        .unwrap_or_default();
3686
3687    let mut relation = SubagentRelation {
3688        validated: true,
3689        ..SubagentRelation::default()
3690    };
3691    relation
3692        .evidence
3693        .push("agent transcript is sidechain and sessionId matches main thread".to_string());
3694
3695    Some(ClaudeAgentRecord {
3696        agent_id,
3697        path: path.to_path_buf(),
3698        status,
3699        last_update: last_update.or_else(|| modified_timestamp_string(path)),
3700        relation,
3701        excerpt,
3702        warnings: Vec::new(),
3703    })
3704}
3705
3706fn is_claude_agent_filename(path: &Path) -> bool {
3707    path.is_file()
3708        && path
3709            .extension()
3710            .and_then(|ext| ext.to_str())
3711            .is_some_and(|ext| ext == "jsonl")
3712        && path
3713            .file_name()
3714            .and_then(|name| name.to_str())
3715            .is_some_and(|name| name.starts_with("agent-"))
3716}
3717
3718fn file_modified_epoch(path: &Path) -> Option<u64> {
3719    fs::metadata(path)
3720        .ok()
3721        .and_then(|meta| meta.modified().ok())
3722        .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
3723        .map(|duration| duration.as_secs())
3724}
3725
3726fn modified_timestamp_string(path: &Path) -> Option<String> {
3727    file_modified_epoch(path).map(|stamp| stamp.to_string())
3728}
3729
3730fn normalize_agent_id(agent_id: &str) -> String {
3731    agent_id
3732        .strip_prefix("agent-")
3733        .unwrap_or(agent_id)
3734        .to_string()
3735}
3736
3737fn extract_last_timestamp(raw: &str) -> Option<String> {
3738    for line in raw.lines().rev() {
3739        let Ok(Some(value)) = jsonl::parse_json_line(Path::new("<timestamp>"), 1, line) else {
3740            continue;
3741        };
3742        if let Some(timestamp) = value
3743            .get("timestamp")
3744            .and_then(Value::as_str)
3745            .map(ToString::to_string)
3746        {
3747            return Some(timestamp);
3748        }
3749    }
3750
3751    None
3752}
3753fn collect_amp_query_candidates(
3754    roots: &ProviderRoots,
3755    warnings: &mut Vec<String>,
3756) -> Vec<QueryCandidate> {
3757    let threads_root = roots.amp_root.join("threads");
3758    collect_simple_file_candidates(
3759        ProviderKind::Amp,
3760        &threads_root,
3761        |path| {
3762            path.extension()
3763                .and_then(|ext| ext.to_str())
3764                .is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
3765        },
3766        |path| {
3767            path.file_stem()
3768                .and_then(|stem| stem.to_str())
3769                .map(ToString::to_string)
3770        },
3771        warnings,
3772    )
3773}
3774
3775fn collect_codex_query_candidates(
3776    roots: &ProviderRoots,
3777    warnings: &mut Vec<String>,
3778) -> Vec<QueryCandidate> {
3779    let mut candidates = Vec::new();
3780    candidates.extend(collect_simple_file_candidates(
3781        ProviderKind::Codex,
3782        &roots.codex_root.join("sessions"),
3783        |path| {
3784            path.file_name()
3785                .and_then(|name| name.to_str())
3786                .is_some_and(|name| name.starts_with("rollout-") && name.ends_with(".jsonl"))
3787        },
3788        extract_codex_rollout_id,
3789        warnings,
3790    ));
3791    candidates.extend(collect_simple_file_candidates(
3792        ProviderKind::Codex,
3793        &roots.codex_root.join("archived_sessions"),
3794        |path| {
3795            path.file_name()
3796                .and_then(|name| name.to_str())
3797                .is_some_and(|name| name.starts_with("rollout-") && name.ends_with(".jsonl"))
3798        },
3799        extract_codex_rollout_id,
3800        warnings,
3801    ));
3802    candidates
3803}
3804
3805fn collect_claude_query_candidates(
3806    roots: &ProviderRoots,
3807    warnings: &mut Vec<String>,
3808) -> Vec<QueryCandidate> {
3809    let projects_root = roots.claude_root.join("projects");
3810    if !projects_root.exists() {
3811        return Vec::new();
3812    }
3813
3814    let mut candidates = Vec::new();
3815    for entry in WalkDir::new(&projects_root)
3816        .into_iter()
3817        .filter_map(std::result::Result::ok)
3818    {
3819        if !entry.file_type().is_file() {
3820            continue;
3821        }
3822        let path = entry.into_path();
3823        if path.file_name().and_then(|name| name.to_str()) == Some("sessions-index.json") {
3824            continue;
3825        }
3826        if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {
3827            continue;
3828        }
3829
3830        if let Some((thread_id, uri)) = extract_claude_thread_identity(&path) {
3831            candidates.push(make_file_candidate(thread_id, uri, path));
3832        } else {
3833            warnings.push(format!(
3834                "skipped claude transcript with unknown thread identity: {}",
3835                path.display()
3836            ));
3837        }
3838    }
3839
3840    candidates
3841}
3842
3843fn collect_gemini_query_candidates(
3844    roots: &ProviderRoots,
3845    warnings: &mut Vec<String>,
3846) -> Vec<QueryCandidate> {
3847    let tmp_root = roots.gemini_root.join("tmp");
3848    if !tmp_root.exists() {
3849        return Vec::new();
3850    }
3851
3852    let mut candidates = Vec::new();
3853    for entry in WalkDir::new(&tmp_root)
3854        .into_iter()
3855        .filter_map(std::result::Result::ok)
3856    {
3857        if !entry.file_type().is_file() {
3858            continue;
3859        }
3860        let path = entry.into_path();
3861        let is_session_file = path
3862            .file_name()
3863            .and_then(|name| name.to_str())
3864            .is_some_and(|name| name.starts_with("session-") && name.ends_with(".json"));
3865        let in_chats_dir = path
3866            .parent()
3867            .and_then(Path::file_name)
3868            .and_then(|name| name.to_str())
3869            .is_some_and(|name| name == "chats");
3870        if !(is_session_file && in_chats_dir) {
3871            continue;
3872        }
3873
3874        let raw = match fs::read_to_string(&path) {
3875            Ok(raw) => raw,
3876            Err(err) => {
3877                warnings.push(format!(
3878                    "failed reading gemini transcript {}: {err}",
3879                    path.display()
3880                ));
3881                continue;
3882            }
3883        };
3884        let value = match serde_json::from_str::<Value>(&raw) {
3885            Ok(value) => value,
3886            Err(err) => {
3887                warnings.push(format!(
3888                    "failed parsing gemini transcript {} as json: {err}",
3889                    path.display()
3890                ));
3891                continue;
3892            }
3893        };
3894        let Some(session_id) = value.get("sessionId").and_then(Value::as_str) else {
3895            warnings.push(format!(
3896                "gemini transcript does not contain sessionId: {}",
3897                path.display()
3898            ));
3899            continue;
3900        };
3901        if !is_uuid_session_id(session_id) {
3902            warnings.push(format!(
3903                "gemini transcript contains non-uuid sessionId={session_id}: {}",
3904                path.display()
3905            ));
3906            continue;
3907        }
3908        let session_id = session_id.to_ascii_lowercase();
3909        candidates.push(make_file_candidate(
3910            session_id.clone(),
3911            format!("agents://gemini/{session_id}"),
3912            path,
3913        ));
3914    }
3915
3916    candidates
3917}
3918
3919fn collect_pi_query_candidates(
3920    roots: &ProviderRoots,
3921    warnings: &mut Vec<String>,
3922) -> Vec<QueryCandidate> {
3923    let sessions_root = roots.pi_root.join("sessions");
3924    if !sessions_root.exists() {
3925        return Vec::new();
3926    }
3927
3928    let mut candidates = Vec::new();
3929    for entry in WalkDir::new(&sessions_root)
3930        .into_iter()
3931        .filter_map(std::result::Result::ok)
3932    {
3933        if !entry.file_type().is_file() {
3934            continue;
3935        }
3936        let path = entry.into_path();
3937        if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {
3938            continue;
3939        }
3940
3941        match extract_pi_session_id_from_header(&path) {
3942            Ok(Some(session_id)) => {
3943                let session_id = session_id.to_ascii_lowercase();
3944                candidates.push(make_file_candidate(
3945                    session_id.clone(),
3946                    format!("agents://pi/{session_id}"),
3947                    path,
3948                ));
3949            }
3950            Ok(None) => {}
3951            Err(err) => warnings.push(err),
3952        }
3953    }
3954
3955    candidates
3956}
3957
3958fn collect_opencode_query_candidates(
3959    roots: &ProviderRoots,
3960    warnings: &mut Vec<String>,
3961    with_search_text: bool,
3962) -> Result<Vec<QueryCandidate>> {
3963    let db_path = roots.opencode_root.join("opencode.db");
3964    if !db_path.exists() {
3965        return Ok(Vec::new());
3966    }
3967
3968    let conn = Connection::open_with_flags(&db_path, OpenFlags::SQLITE_OPEN_READ_ONLY).map_err(
3969        |source| XurlError::Sqlite {
3970            path: db_path.clone(),
3971            source,
3972        },
3973    )?;
3974
3975    let mut stmt = conn
3976        .prepare(
3977            "SELECT s.id, COALESCE(MAX(m.time_created), 0)
3978             FROM session s
3979             LEFT JOIN message m ON m.session_id = s.id
3980             GROUP BY s.id
3981             ORDER BY COALESCE(MAX(m.time_created), 0) DESC, s.id DESC",
3982        )
3983        .map_err(|source| XurlError::Sqlite {
3984            path: db_path.clone(),
3985            source,
3986        })?;
3987
3988    let rows = stmt
3989        .query_map([], |row| {
3990            Ok((
3991                row.get::<_, String>(0)?,
3992                row.get::<_, i64>(1)
3993                    .ok()
3994                    .and_then(|stamp| u64::try_from(stamp).ok()),
3995            ))
3996        })
3997        .map_err(|source| XurlError::Sqlite {
3998            path: db_path.clone(),
3999            source,
4000        })?;
4001
4002    let mut candidates = Vec::new();
4003    for row in rows {
4004        let (session_id, updated_epoch) = row.map_err(|source| XurlError::Sqlite {
4005            path: db_path.clone(),
4006            source,
4007        })?;
4008        if AgentsUri::parse(&format!("opencode://{session_id}")).is_err() {
4009            warnings.push(format!(
4010                "skipped opencode session with invalid id={session_id} from {}",
4011                db_path.display()
4012            ));
4013            continue;
4014        }
4015        let search_target = if with_search_text {
4016            QuerySearchTarget::Text(fetch_opencode_search_text(&conn, &db_path, &session_id)?)
4017        } else {
4018            QuerySearchTarget::Text(String::new())
4019        };
4020
4021        candidates.push(QueryCandidate {
4022            thread_id: session_id.clone(),
4023            uri: format!("agents://opencode/{session_id}"),
4024            thread_source: format!("{}#session:{session_id}", db_path.display()),
4025            updated_at: updated_epoch.map(|value| value.to_string()),
4026            updated_epoch,
4027            search_target,
4028        });
4029    }
4030
4031    Ok(candidates)
4032}
4033
4034fn fetch_opencode_search_text(
4035    conn: &Connection,
4036    db_path: &Path,
4037    session_id: &str,
4038) -> Result<String> {
4039    let mut chunks = Vec::new();
4040
4041    let mut message_stmt = conn
4042        .prepare(
4043            "SELECT data
4044             FROM message
4045             WHERE session_id = ?1
4046             ORDER BY time_created ASC, id ASC",
4047        )
4048        .map_err(|source| XurlError::Sqlite {
4049            path: db_path.to_path_buf(),
4050            source,
4051        })?;
4052    let message_rows = message_stmt
4053        .query_map([session_id], |row| row.get::<_, String>(0))
4054        .map_err(|source| XurlError::Sqlite {
4055            path: db_path.to_path_buf(),
4056            source,
4057        })?;
4058    for row in message_rows {
4059        let value = row.map_err(|source| XurlError::Sqlite {
4060            path: db_path.to_path_buf(),
4061            source,
4062        })?;
4063        chunks.push(value);
4064    }
4065
4066    let mut part_stmt = conn
4067        .prepare(
4068            "SELECT data
4069             FROM part
4070             WHERE session_id = ?1
4071             ORDER BY time_created ASC, id ASC",
4072        )
4073        .map_err(|source| XurlError::Sqlite {
4074            path: db_path.to_path_buf(),
4075            source,
4076        })?;
4077    let part_rows = part_stmt
4078        .query_map([session_id], |row| row.get::<_, String>(0))
4079        .map_err(|source| XurlError::Sqlite {
4080            path: db_path.to_path_buf(),
4081            source,
4082        })?;
4083    for row in part_rows {
4084        let value = row.map_err(|source| XurlError::Sqlite {
4085            path: db_path.to_path_buf(),
4086            source,
4087        })?;
4088        chunks.push(value);
4089    }
4090
4091    Ok(chunks.join("\n"))
4092}
4093
4094fn collect_simple_file_candidates<F, G>(
4095    provider: ProviderKind,
4096    root: &Path,
4097    path_filter: F,
4098    thread_id_extractor: G,
4099    warnings: &mut Vec<String>,
4100) -> Vec<QueryCandidate>
4101where
4102    F: Fn(&Path) -> bool,
4103    G: Fn(&Path) -> Option<String>,
4104{
4105    if !root.exists() {
4106        return Vec::new();
4107    }
4108
4109    let mut candidates = Vec::new();
4110    for entry in WalkDir::new(root)
4111        .into_iter()
4112        .filter_map(std::result::Result::ok)
4113    {
4114        if !entry.file_type().is_file() {
4115            continue;
4116        }
4117        let path = entry.into_path();
4118        if !path_filter(&path) {
4119            continue;
4120        }
4121        let Some(thread_id) = thread_id_extractor(&path) else {
4122            warnings.push(format!(
4123                "skipped {} transcript with unknown thread id: {}",
4124                provider,
4125                path.display()
4126            ));
4127            continue;
4128        };
4129        candidates.push(make_file_candidate(
4130            thread_id.clone(),
4131            format!("agents://{provider}/{thread_id}"),
4132            path,
4133        ));
4134    }
4135
4136    candidates
4137}
4138
4139fn make_file_candidate(thread_id: String, uri: String, path: PathBuf) -> QueryCandidate {
4140    QueryCandidate {
4141        thread_id,
4142        uri,
4143        thread_source: path.display().to_string(),
4144        updated_at: modified_timestamp_string(&path),
4145        updated_epoch: file_modified_epoch(&path),
4146        search_target: QuerySearchTarget::File(path),
4147    }
4148}
4149
4150fn extract_codex_rollout_id(path: &Path) -> Option<String> {
4151    let name = path.file_name()?.to_str()?;
4152    let stem = name.strip_suffix(".jsonl")?;
4153    if stem.len() < 36 {
4154        return None;
4155    }
4156    let thread_id = &stem[stem.len() - 36..];
4157    if is_uuid_session_id(thread_id) {
4158        Some(thread_id.to_ascii_lowercase())
4159    } else {
4160        None
4161    }
4162}
4163
4164fn extract_claude_thread_identity(path: &Path) -> Option<(String, String)> {
4165    let file_name = path.file_name()?.to_str()?;
4166    if let Some(agent_id) = file_name
4167        .strip_prefix("agent-")
4168        .and_then(|name| name.strip_suffix(".jsonl"))
4169    {
4170        let subagents_dir = path.parent()?;
4171        if subagents_dir.file_name()?.to_str()? != "subagents" {
4172            return None;
4173        }
4174        let main_thread_id = subagents_dir.parent()?.file_name()?.to_str()?.to_string();
4175        return Some((
4176            format!("{main_thread_id}/{agent_id}"),
4177            format!("agents://claude/{main_thread_id}/{agent_id}"),
4178        ));
4179    }
4180
4181    if let Some(session_id) = extract_claude_session_id_from_header(path) {
4182        return Some((session_id.clone(), format!("agents://claude/{session_id}")));
4183    }
4184
4185    let file_stem = path.file_stem()?.to_str()?;
4186    if is_uuid_session_id(file_stem) {
4187        let session_id = file_stem.to_ascii_lowercase();
4188        return Some((session_id.clone(), format!("agents://claude/{session_id}")));
4189    }
4190
4191    None
4192}
4193
4194fn extract_claude_session_id_from_header(path: &Path) -> Option<String> {
4195    let file = fs::File::open(path).ok()?;
4196    let reader = BufReader::new(file);
4197    for line in reader.lines().take(30).flatten() {
4198        if line.trim().is_empty() {
4199            continue;
4200        }
4201        let Ok(value) = serde_json::from_str::<Value>(&line) else {
4202            continue;
4203        };
4204        let session_id = value.get("sessionId").and_then(Value::as_str)?;
4205        if is_uuid_session_id(session_id) {
4206            return Some(session_id.to_ascii_lowercase());
4207        }
4208    }
4209    None
4210}
4211
4212fn extract_pi_session_id_from_header(path: &Path) -> std::result::Result<Option<String>, String> {
4213    let file =
4214        fs::File::open(path).map_err(|err| format!("failed opening {}: {err}", path.display()))?;
4215    let reader = BufReader::new(file);
4216    let Some(first_non_empty) = reader
4217        .lines()
4218        .take(30)
4219        .filter_map(std::result::Result::ok)
4220        .find(|line| !line.trim().is_empty())
4221    else {
4222        return Ok(None);
4223    };
4224    let value = serde_json::from_str::<Value>(&first_non_empty)
4225        .map_err(|err| format!("failed parsing pi header {}: {err}", path.display()))?;
4226    if value.get("type").and_then(Value::as_str) != Some("session") {
4227        return Ok(None);
4228    }
4229    let Some(session_id) = value.get("id").and_then(Value::as_str) else {
4230        return Ok(None);
4231    };
4232    if !is_uuid_session_id(session_id) {
4233        return Err(format!(
4234            "pi session header contains invalid session id={session_id}: {}",
4235            path.display()
4236        ));
4237    }
4238    Ok(Some(session_id.to_ascii_lowercase()))
4239}
4240
4241fn main_thread_uri(uri: &AgentsUri) -> AgentsUri {
4242    AgentsUri {
4243        provider: uri.provider,
4244        session_id: uri.session_id.clone(),
4245        agent_id: None,
4246        query: Vec::new(),
4247    }
4248}
4249
4250fn make_query(uri: &AgentsUri, agent_id: Option<String>, list: bool) -> SubagentQuery {
4251    SubagentQuery {
4252        provider: uri.provider.to_string(),
4253        main_thread_id: uri.session_id.clone(),
4254        agent_id,
4255        list,
4256    }
4257}
4258
4259fn agents_thread_uri(provider: &str, thread_id: &str, agent_id: Option<&str>) -> String {
4260    match agent_id {
4261        Some(agent_id) => format!("agents://{provider}/{thread_id}/{agent_id}"),
4262        None => format!("agents://{provider}/{thread_id}"),
4263    }
4264}
4265
4266fn render_preview_text(content: &Value, max_chars: usize) -> String {
4267    let text = if content.is_string() {
4268        content.as_str().unwrap_or_default().to_string()
4269    } else if let Some(items) = content.as_array() {
4270        items
4271            .iter()
4272            .filter_map(|item| {
4273                item.get("text")
4274                    .and_then(Value::as_str)
4275                    .or_else(|| item.as_str())
4276            })
4277            .collect::<Vec<_>>()
4278            .join(" ")
4279    } else {
4280        String::new()
4281    };
4282
4283    truncate_preview(&text, max_chars)
4284}
4285
4286fn truncate_preview(input: &str, max_chars: usize) -> String {
4287    let normalized = input.split_whitespace().collect::<Vec<_>>().join(" ");
4288    if normalized.chars().count() <= max_chars {
4289        return normalized;
4290    }
4291
4292    let mut out = String::new();
4293    for (idx, ch) in normalized.chars().enumerate() {
4294        if idx >= max_chars.saturating_sub(1) {
4295            break;
4296        }
4297        out.push(ch);
4298    }
4299    out.push('…');
4300    out
4301}
4302
4303fn render_subagent_list_markdown(view: &SubagentListView) -> String {
4304    let main_thread_uri = agents_thread_uri(&view.query.provider, &view.query.main_thread_id, None);
4305    let mut output = String::new();
4306    output.push_str("# Subagent Status\n\n");
4307    output.push_str(&format!("- Provider: `{}`\n", view.query.provider));
4308    output.push_str(&format!("- Main Thread: `{}`\n", main_thread_uri));
4309    output.push_str("- Mode: `list`\n\n");
4310
4311    if view.agents.is_empty() {
4312        output.push_str("_No subagents found for this thread._\n");
4313        return output;
4314    }
4315
4316    for (index, agent) in view.agents.iter().enumerate() {
4317        let agent_uri = format!("{}/{}", main_thread_uri, agent.agent_id);
4318        output.push_str(&format!("## {}. `{}`\n\n", index + 1, agent_uri));
4319        output.push_str(&format!(
4320            "- Status: `{}` (`{}`)\n",
4321            agent.status, agent.status_source
4322        ));
4323        output.push_str(&format!(
4324            "- Last Update: `{}`\n",
4325            agent.last_update.as_deref().unwrap_or("unknown")
4326        ));
4327        output.push_str(&format!(
4328            "- Relation: `{}`\n",
4329            if agent.relation.validated {
4330                "validated"
4331            } else {
4332                "inferred"
4333            }
4334        ));
4335        if let Some(thread) = &agent.child_thread
4336            && let Some(path) = &thread.path
4337        {
4338            output.push_str(&format!("- Thread Path: `{}`\n", path));
4339        }
4340        output.push('\n');
4341    }
4342
4343    output
4344}
4345
4346fn render_subagent_detail_markdown(view: &SubagentDetailView) -> String {
4347    let main_thread_uri = agents_thread_uri(&view.query.provider, &view.query.main_thread_id, None);
4348    let mut output = String::new();
4349    output.push_str("# Subagent Thread\n\n");
4350    output.push_str(&format!("- Provider: `{}`\n", view.query.provider));
4351    output.push_str(&format!("- Main Thread: `{}`\n", main_thread_uri));
4352    if let Some(agent_id) = &view.query.agent_id {
4353        output.push_str(&format!(
4354            "- Subagent Thread: `{}/{}`\n",
4355            main_thread_uri, agent_id
4356        ));
4357    }
4358    output.push_str(&format!(
4359        "- Status: `{}` (`{}`)\n\n",
4360        view.status, view.status_source
4361    ));
4362
4363    output.push_str("## Agent Status Summary\n\n");
4364    output.push_str(&format!(
4365        "- Relation: `{}`\n",
4366        if view.relation.validated {
4367            "validated"
4368        } else {
4369            "inferred"
4370        }
4371    ));
4372    for evidence in &view.relation.evidence {
4373        output.push_str(&format!("- Evidence: {}\n", evidence));
4374    }
4375    if let Some(thread) = &view.child_thread {
4376        if let Some(path) = &thread.path {
4377            output.push_str(&format!("- Child Path: `{}`\n", path));
4378        }
4379        if let Some(last_updated_at) = &thread.last_updated_at {
4380            output.push_str(&format!("- Child Last Update: `{}`\n", last_updated_at));
4381        }
4382    }
4383    output.push('\n');
4384
4385    output.push_str("## Lifecycle (Parent Thread)\n\n");
4386    if view.lifecycle.is_empty() {
4387        output.push_str("_No lifecycle events found in parent thread._\n\n");
4388    } else {
4389        for event in &view.lifecycle {
4390            output.push_str(&format!(
4391                "- `{}` `{}` {}\n",
4392                event.timestamp.as_deref().unwrap_or("unknown"),
4393                event.event,
4394                event.detail
4395            ));
4396        }
4397        output.push('\n');
4398    }
4399
4400    output.push_str("## Thread Excerpt (Child Thread)\n\n");
4401    if view.excerpt.is_empty() {
4402        output.push_str("_No child thread messages found._\n\n");
4403    } else {
4404        for (index, message) in view.excerpt.iter().enumerate() {
4405            let title = match message.role {
4406                crate::model::MessageRole::User => "User",
4407                crate::model::MessageRole::Assistant => "Assistant",
4408            };
4409            output.push_str(&format!("### {}. {}\n\n", index + 1, title));
4410            output.push_str(message.text.trim());
4411            output.push_str("\n\n");
4412        }
4413    }
4414
4415    output
4416}
4417
4418#[cfg(test)]
4419mod tests {
4420    use std::fs;
4421
4422    use tempfile::tempdir;
4423
4424    use crate::service::{extract_last_timestamp, read_thread_raw};
4425
4426    #[test]
4427    fn empty_file_returns_error() {
4428        let temp = tempdir().expect("tempdir");
4429        let path = temp.path().join("thread.jsonl");
4430        fs::write(&path, "").expect("write");
4431
4432        let err = read_thread_raw(&path).expect_err("must fail");
4433        assert!(format!("{err}").contains("thread file is empty"));
4434    }
4435
4436    #[test]
4437    fn extract_last_timestamp_from_jsonl() {
4438        let raw =
4439            "{\"timestamp\":\"2026-02-23T00:00:01Z\"}\n{\"timestamp\":\"2026-02-23T00:00:02Z\"}\n";
4440        let timestamp = extract_last_timestamp(raw).expect("must extract timestamp");
4441        assert_eq!(timestamp, "2026-02-23T00:00:02Z");
4442    }
4443}