1use std::collections::HashMap;
17use std::io::{BufRead, BufReader, Seek, SeekFrom};
18use std::path::{Path, PathBuf};
19use std::sync::{Arc, Mutex};
20use std::time::Duration;
21
22use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
23use serde::{Deserialize, Serialize};
24use serde_json::json;
25use tokio::sync::mpsc;
26
27use super::eventbus::{EventBus, WSEventType, WS_EVENT_RPC};
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct SubagentInfo {
33 pub agent_id: String,
34 pub slug: String,
35 pub jsonl_path: String,
36 pub parent_agent: String,
37 pub parent_block_id: String,
38 pub session_id: String,
39 pub last_event_at: u64,
40 pub status: SubagentStatus,
41 pub event_count: usize,
42 pub model: Option<String>,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
46#[serde(rename_all = "lowercase")]
47pub enum SubagentStatus {
48 Active,
49 Completed,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct SubagentEvent {
54 pub agent_id: String,
55 pub event_type: SubagentEventType,
56 pub timestamp: u64,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60#[serde(tag = "type", rename_all = "snake_case")]
61pub enum SubagentEventType {
62 Text { content: String },
63 ToolUse { name: String, input_summary: String },
64 ToolResult { is_error: bool, preview: String },
65 Progress { output: String },
66}
67
68struct SessionWatch {
71 subagents: HashMap<String, SubagentState>,
72}
73
74struct SubagentState {
75 info: SubagentInfo,
76 file_offset: u64,
77 events: Vec<SubagentEvent>,
78}
79
80#[allow(dead_code)]
81struct WatchedAgent {
82 agent_id: String,
83 config_dir: PathBuf,
84 _watcher: RecommendedWatcher,
85}
86
87pub struct SubagentWatcher {
90 event_bus: Arc<EventBus>,
91 sessions: Mutex<HashMap<String, SessionWatch>>,
92 watched_agents: Mutex<Vec<WatchedAgent>>,
93}
94
95impl SubagentWatcher {
96 pub fn new(event_bus: Arc<EventBus>) -> Self {
97 Self {
98 event_bus,
99 sessions: Mutex::new(HashMap::new()),
100 watched_agents: Mutex::new(Vec::new()),
101 }
102 }
103
104 pub fn spawn(event_bus: Arc<EventBus>) -> Arc<Self> {
106 let watcher = Arc::new(Self::new(event_bus));
107 tracing::info!("subagent watcher initialized");
108 watcher
109 }
110
111 pub fn watch_agent(self: &Arc<Self>, agent_id: &str, parent_block_id: &str, config_dir: PathBuf) {
125 let projects_dir = config_dir.join("projects");
127 if !projects_dir.exists() {
128 tracing::debug!(
129 agent = %agent_id,
130 dir = %projects_dir.display(),
131 "projects dir does not exist yet, will watch when created"
132 );
133 }
134
135 {
137 let watched = self.watched_agents.lock().unwrap();
138 if watched.iter().any(|w| w.agent_id == agent_id) {
139 tracing::debug!(agent = %agent_id, "already watching this agent");
140 return;
141 }
142 }
143
144 let (tx, mut rx) = mpsc::unbounded_channel::<PathBuf>();
145
146 let tx_clone = tx.clone();
148 let watched_dir = if projects_dir.exists() {
149 projects_dir.clone()
150 } else {
151 config_dir.clone()
153 };
154
155 let mut watcher = match notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
156 match res {
157 Ok(event) => {
158 let dominated = matches!(
159 event.kind,
160 EventKind::Modify(_) | EventKind::Create(_)
161 );
162 if dominated {
163 for path in event.paths {
164 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
165 if name.starts_with("agent-") && name.ends_with(".jsonl") {
166 let _ = tx_clone.send(path);
167 }
168 }
169 }
170 }
171 }
172 Err(e) => {
173 tracing::warn!(error = %e, "subagent filesystem watcher error");
174 }
175 }
176 }) {
177 Ok(w) => w,
178 Err(e) => {
179 tracing::warn!(
180 agent = %agent_id,
181 error = %e,
182 "failed to create subagent file watcher"
183 );
184 return;
185 }
186 };
187
188 if let Err(e) = watcher.watch(&watched_dir, RecursiveMode::Recursive) {
189 tracing::warn!(
190 agent = %agent_id,
191 dir = %watched_dir.display(),
192 error = %e,
193 "failed to watch directory for subagents"
194 );
195 return;
196 }
197
198 tracing::info!(
199 agent = %agent_id,
200 dir = %watched_dir.display(),
201 "watching for subagent JSONL files"
202 );
203
204 {
206 let mut watched = self.watched_agents.lock().unwrap();
207 watched.push(WatchedAgent {
208 agent_id: agent_id.to_string(),
209 config_dir: config_dir.clone(),
210 _watcher: watcher,
211 });
212 }
213
214 self.scan_existing_subagents(agent_id, parent_block_id, &projects_dir);
216
217 let self_clone = Arc::clone(self);
219 let parent_agent = agent_id.to_string();
220 let parent_block_id = parent_block_id.to_string();
221 tokio::spawn(async move {
222 loop {
223 let path = match rx.recv().await {
224 Some(p) => p,
225 None => {
226 tracing::info!(
227 agent = %parent_agent,
228 "subagent watcher channel closed"
229 );
230 break;
231 }
232 };
233
234 tokio::time::sleep(Duration::from_millis(200)).await;
236 let mut paths = vec![path];
237 while let Ok(p) = rx.try_recv() {
238 if !paths.contains(&p) {
239 paths.push(p);
240 }
241 }
242
243 for changed_path in paths {
244 self_clone.process_jsonl_change(&parent_agent, &parent_block_id, &changed_path);
245 }
246 }
247 });
248 }
249
250 pub fn unwatch_agent(&self, agent_id: &str) {
260 let mut watched = self.watched_agents.lock().unwrap();
261 let before = watched.len();
262 watched.retain(|w| w.agent_id != agent_id);
263 if watched.len() != before {
264 tracing::info!(agent = %agent_id, "stopped watching subagent dir");
265 }
266 }
267
268 pub fn list_active(&self) -> Vec<SubagentInfo> {
270 let sessions = self.sessions.lock().unwrap();
271 let mut result = Vec::new();
272 for session in sessions.values() {
273 for state in session.subagents.values() {
274 result.push(state.info.clone());
275 }
276 }
277 result.sort_by(|a, b| b.last_event_at.cmp(&a.last_event_at));
278 result
279 }
280
281 pub fn get_history(&self, agent_id: &str, limit: usize) -> Vec<SubagentEvent> {
283 let sessions = self.sessions.lock().unwrap();
284 for session in sessions.values() {
285 if let Some(state) = session.subagents.get(agent_id) {
286 let events = &state.events;
287 let start = events.len().saturating_sub(limit);
288 return events[start..].to_vec();
289 }
290 }
291 Vec::new()
292 }
293
294 fn scan_existing_subagents(&self, parent_agent: &str, parent_block_id: &str, projects_dir: &Path) {
298 if !projects_dir.exists() {
299 return;
300 }
301
302 let walker = match std::fs::read_dir(projects_dir) {
303 Ok(w) => w,
304 Err(_) => return,
305 };
306
307 for entry in walker.flatten() {
308 let subagents_dir = entry.path().join("subagents");
309 if subagents_dir.is_dir() {
310 if let Ok(files) = std::fs::read_dir(&subagents_dir) {
311 for file in files.flatten() {
312 let path = file.path();
313 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
314 if name.starts_with("agent-") && name.ends_with(".jsonl") {
315 self.process_jsonl_change(parent_agent, parent_block_id, &path);
316 }
317 }
318 }
319 }
320 }
321 }
322 }
323
324 fn process_jsonl_change(&self, parent_agent: &str, parent_block_id: &str, jsonl_path: &Path) {
327 let agent_id = match jsonl_path
329 .file_stem()
330 .and_then(|s| s.to_str())
331 .and_then(|s| s.strip_prefix("agent-"))
332 {
333 Some(id) => id.to_string(),
334 None => return,
335 };
336
337 let session_id = jsonl_path
339 .parent() .and_then(|p| p.parent()) .and_then(|p| p.file_name())
342 .and_then(|n| n.to_str())
343 .unwrap_or("unknown")
344 .to_string();
345
346 let current_offset = {
348 let sessions = self.sessions.lock().unwrap();
349 sessions
350 .get(&session_id)
351 .and_then(|s| s.subagents.get(&agent_id))
352 .map(|s| s.file_offset)
353 .unwrap_or(0)
354 };
355
356 let (new_events, new_offset, meta) = match read_jsonl_from_offset(jsonl_path, current_offset) {
358 Ok(result) => result,
359 Err(e) => {
360 tracing::debug!(
361 agent_id = %agent_id,
362 error = %e,
363 "failed to read subagent JSONL"
364 );
365 return;
366 }
367 };
368
369 let (is_new, info_snapshot, completed) = {
371 let mut sessions = self.sessions.lock().unwrap();
372 let session = sessions
373 .entry(session_id.clone())
374 .or_insert_with(|| SessionWatch {
375 subagents: HashMap::new(),
376 });
377
378 let is_new = !session.subagents.contains_key(&agent_id);
379 let state = session.subagents.entry(agent_id.clone()).or_insert_with(|| {
380 SubagentState {
381 info: SubagentInfo {
382 agent_id: agent_id.clone(),
383 slug: String::new(),
384 jsonl_path: jsonl_path.to_string_lossy().to_string(),
385 parent_agent: parent_agent.to_string(),
386 parent_block_id: parent_block_id.to_string(),
387 session_id: session_id.clone(),
388 last_event_at: now_millis(),
389 status: SubagentStatus::Active,
390 event_count: 0,
391 model: None,
392 },
393 file_offset: 0,
394 events: Vec::new(),
395 }
396 });
397
398 state.file_offset = new_offset;
399
400 if let Some(m) = meta {
402 if !m.slug.is_empty() {
403 state.info.slug = m.slug;
404 }
405 if let Some(model) = m.model {
406 state.info.model = Some(model);
407 }
408 }
409
410 if new_events.is_empty() && !is_new {
411 return;
412 }
413
414 let mut completed = false;
416 for event in &new_events {
417 state.info.event_count += 1;
418 state.info.last_event_at = event.timestamp;
419 state.events.push(event.clone());
420 }
421
422 if let Some(last) = new_events.last() {
424 if matches!(&last.event_type, SubagentEventType::Text { content } if content == "Subagent completed") {
425 completed = true;
426 state.info.status = SubagentStatus::Completed;
427 }
428 }
429
430 let info_snapshot = state.info.clone();
431 (is_new, info_snapshot, completed)
432 };
433 if is_new {
436 let spawned_event = WSEventType {
437 eventtype: WS_EVENT_RPC.to_string(),
438 oref: String::new(),
439 data: Some(json!({
440 "command": "eventrecv",
441 "data": {
442 "event": "subagent:spawned",
443 "data": {
444 "agentId": info_snapshot.agent_id,
445 "slug": info_snapshot.slug,
446 "parentAgent": parent_agent,
447 "parentBlockId": parent_block_id,
448 "sessionId": session_id,
449 "model": info_snapshot.model,
450 }
451 }
452 })),
453 };
454 self.event_bus.broadcast_event(&spawned_event);
455 tracing::info!(
456 agent_id = %agent_id,
457 slug = %info_snapshot.slug,
458 parent = %parent_agent,
459 "subagent spawned"
460 );
461 }
462
463 if !new_events.is_empty() {
464 let activity_event = WSEventType {
465 eventtype: WS_EVENT_RPC.to_string(),
466 oref: String::new(),
467 data: Some(json!({
468 "command": "eventrecv",
469 "data": {
470 "event": "subagent:activity",
471 "data": {
472 "agentId": agent_id,
473 "parentAgent": parent_agent,
474 "parentBlockId": parent_block_id,
475 "newEvents": new_events.len(),
476 "totalEvents": info_snapshot.event_count,
477 "events": new_events,
478 }
479 }
480 })),
481 };
482 self.event_bus.broadcast_event(&activity_event);
483 }
484
485 if completed {
486 let completed_event = WSEventType {
487 eventtype: WS_EVENT_RPC.to_string(),
488 oref: String::new(),
489 data: Some(json!({
490 "command": "eventrecv",
491 "data": {
492 "event": "subagent:completed",
493 "data": {
494 "agentId": agent_id,
495 "parentAgent": parent_agent,
496 "parentBlockId": parent_block_id,
497 "totalEvents": info_snapshot.event_count,
498 }
499 }
500 })),
501 };
502 self.event_bus.broadcast_event(&completed_event);
503 tracing::info!(
504 agent_id = %agent_id,
505 total_events = info_snapshot.event_count,
506 "subagent completed"
507 );
508 }
509 }
510}
511
512struct JsonlMeta {
516 slug: String,
517 model: Option<String>,
518}
519
520fn read_jsonl_from_offset(
523 path: &Path,
524 offset: u64,
525) -> Result<(Vec<SubagentEvent>, u64, Option<JsonlMeta>), String> {
526 let file = std::fs::File::open(path).map_err(|e| format!("open: {e}"))?;
527 let file_len = file.metadata().map_err(|e| format!("metadata: {e}"))?.len();
528
529 if file_len <= offset {
530 return Ok((Vec::new(), offset, None));
531 }
532
533 let mut reader = BufReader::new(file);
534 reader
535 .seek(SeekFrom::Start(offset))
536 .map_err(|e| format!("seek: {e}"))?;
537
538 let mut events = Vec::new();
539 let mut meta = None;
540 let mut current_offset = offset;
541
542 for line_result in reader.lines() {
543 let line = match line_result {
544 Ok(l) => l,
545 Err(_) => break,
546 };
547 current_offset += line.len() as u64 + 1; if line.trim().is_empty() {
550 continue;
551 }
552
553 let value: serde_json::Value = match serde_json::from_str(&line) {
554 Ok(v) => v,
555 Err(_) => continue,
556 };
557
558 if offset == 0 && meta.is_none() {
560 if let Some(slug) = value.get("slug").and_then(|v| v.as_str()) {
561 meta = Some(JsonlMeta {
562 slug: slug.to_string(),
563 model: value
564 .get("model")
565 .and_then(|v| v.as_str())
566 .map(|s| s.to_string()),
567 });
568 }
569 if meta.is_none() {
570 if let Some(agent_id) = value.get("agentId").and_then(|v| v.as_str()) {
571 meta = Some(JsonlMeta {
572 slug: value
573 .get("slug")
574 .and_then(|v| v.as_str())
575 .unwrap_or(agent_id)
576 .to_string(),
577 model: value
578 .get("model")
579 .and_then(|v| v.as_str())
580 .map(|s| s.to_string()),
581 });
582 }
583 }
584 }
585
586 let timestamp = value
587 .get("timestamp")
588 .and_then(|v| v.as_u64())
589 .unwrap_or_else(now_millis);
590
591 let event_type = parse_event_type(&value);
592 if let Some(et) = event_type {
593 let line_agent_id = value
594 .get("agentId")
595 .and_then(|v| v.as_str())
596 .unwrap_or("")
597 .to_string();
598
599 events.push(SubagentEvent {
600 agent_id: line_agent_id,
601 event_type: et,
602 timestamp,
603 });
604 }
605 }
606
607 Ok((events, current_offset, meta))
608}
609
610fn parse_event_type(value: &serde_json::Value) -> Option<SubagentEventType> {
612 let event_type = value.get("type").and_then(|v| v.as_str())?;
613
614 match event_type {
615 "assistant" => {
616 let content = value
617 .get("message")
618 .and_then(|m| m.get("content"))
619 .and_then(|c| {
620 if let Some(arr) = c.as_array() {
621 let texts: Vec<&str> = arr
622 .iter()
623 .filter_map(|block| {
624 if block.get("type").and_then(|t| t.as_str()) == Some("text") {
625 block.get("text").and_then(|t| t.as_str())
626 } else {
627 None
628 }
629 })
630 .collect();
631 if texts.is_empty() {
632 None
633 } else {
634 Some(texts.join("\n"))
635 }
636 } else {
637 c.as_str().map(|s| s.to_string())
638 }
639 })
640 .unwrap_or_default();
641 Some(SubagentEventType::Text { content })
642 }
643 "tool_use" => {
644 let name = value
645 .get("name")
646 .or_else(|| value.get("tool_name"))
647 .and_then(|v| v.as_str())
648 .unwrap_or("unknown")
649 .to_string();
650 let input_summary = value
651 .get("input")
652 .map(|v| {
653 let s = v.to_string();
654 if s.len() > 200 {
655 let end = s.char_indices().nth(200).map_or(s.len(), |(i, _)| i);
656 format!("{}...", &s[..end])
657 } else {
658 s
659 }
660 })
661 .unwrap_or_default();
662 Some(SubagentEventType::ToolUse {
663 name,
664 input_summary,
665 })
666 }
667 "tool_result" => {
668 let is_error = value
669 .get("is_error")
670 .and_then(|v| v.as_bool())
671 .unwrap_or(false);
672 let preview = value
673 .get("content")
674 .or_else(|| value.get("output"))
675 .map(|v| {
676 let s = if let Some(s) = v.as_str() {
677 s.to_string()
678 } else {
679 v.to_string()
680 };
681 if s.len() > 500 {
682 let end = s.char_indices().nth(500).map_or(s.len(), |(i, _)| i);
683 format!("{}...", &s[..end])
684 } else {
685 s
686 }
687 })
688 .unwrap_or_default();
689 Some(SubagentEventType::ToolResult { is_error, preview })
690 }
691 "progress" => {
692 let output = value
693 .get("output")
694 .or_else(|| value.get("content"))
695 .and_then(|v| v.as_str())
696 .unwrap_or("")
697 .to_string();
698 Some(SubagentEventType::Progress { output })
699 }
700 "result" => {
701 let content = value
702 .get("result")
703 .or_else(|| value.get("content"))
704 .map(|v| {
705 if let Some(s) = v.as_str() {
706 s.to_string()
707 } else {
708 v.to_string()
709 }
710 })
711 .unwrap_or_else(|| "Subagent completed".to_string());
712 Some(SubagentEventType::Text { content })
713 }
714 _ => None,
715 }
716}
717
718fn now_millis() -> u64 {
719 std::time::SystemTime::now()
720 .duration_since(std::time::UNIX_EPOCH)
721 .map(|d| d.as_millis() as u64)
722 .unwrap_or(0)
723}
724
725#[allow(dead_code)]
729pub fn encode_workspace_path(workspace_path: &str) -> String {
730 workspace_path
731 .replace('\\', "-")
732 .replace('/', "-")
733 .replace(':', "")
734}
735
736pub fn derive_claude_config_dir(agent_id: &str) -> Option<PathBuf> {
738 let home = dirs::home_dir()?;
739 let config_dir = home
740 .join(".config")
741 .join(format!("claude-{}", agent_id.to_lowercase()));
742 Some(config_dir)
743}