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(®ex::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}