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