agentmux_srv\backend/
subagent_watcher.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Subagent watcher: monitors Claude Code session directories for subagent
5//! JSONL files and broadcasts activity events to WebSocket clients.
6//!
7//! Claude Code spawns "subagents" via the Task tool. Each subagent writes its
8//! conversation to a JSONL file under:
9//!   `<claude-config>/projects/<encoded-workspace>/subagents/agent-<id>.jsonl`
10//!
11//! This module watches those directories and emits:
12//!   - `subagent:spawned`   — new subagent JSONL file detected
13//!   - `subagent:activity`  — new events appended to a subagent file
14//!   - `subagent:completed` — subagent finished (result event seen)
15
16use 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// ── Public types ──────────────────────────────────────────────────────────
30
31#[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
68// ── Internal state ────────────────────────────────────────────────────────
69
70struct 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
87// ── SubagentWatcher ───────────────────────────────────────────────────────
88
89pub 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    /// Create a new SubagentWatcher and return it wrapped in Arc.
105    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    /// Start watching a Claude Code agent's session directory for subagent files.
112    /// Spawns a background tokio task for debounced file event processing.
113    ///
114    /// `parent_block_id` is the pane/block that owns this Claude instance (from
115    /// the reactive register request). It is stamped onto every emitted subagent
116    /// event so the frontend can route the ⚡ panel to the originating pane only,
117    /// instead of every agent pane rendering every subagent globally.
118    ///
119    /// Note: the watcher dedupes by `agent_id`, so if the same agent_id is
120    /// registered from two blocks, events carry the first registrant's block id.
121    /// That edge case (same instance name in two panes) is rare; the common
122    /// leak — a terminal Claude's subagents showing up in unrelated agent panes —
123    /// is fully fixed because the terminal block id never matches an agent pane.
124    pub fn watch_agent(self: &Arc<Self>, agent_id: &str, parent_block_id: &str, config_dir: PathBuf) {
125        // Derive the projects directory where Claude stores session data
126        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        // Check if already watching this agent
136        {
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        // Set up filesystem watcher
147        let tx_clone = tx.clone();
148        let watched_dir = if projects_dir.exists() {
149            projects_dir.clone()
150        } else {
151            // Watch parent (config_dir) until projects/ appears
152            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        // Store the watcher handle to keep it alive
205        {
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        // Scan for any existing subagent files
215        self.scan_existing_subagents(agent_id, parent_block_id, &projects_dir);
216
217        // Spawn async task to process file change notifications
218        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                // Debounce: drain additional events within 200ms
235                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    /// Stop watching an agent: drop its filesystem watcher, which closes the
251    /// debounce channel — so the processing task self-terminates on the next
252    /// `rx.recv()` returning `None`. Idempotent: a no-op if the agent isn't
253    /// currently watched.
254    ///
255    /// Without this, `watched_agents` was push-only: every distinct agent that
256    /// ever ran leaked one OS watch handle + channel + idle task for the rest of
257    /// the process lifetime, even after its pane/agent was deleted. (Session
258    /// records in `sessions` are plain data, not handles, and are left as-is.)
259    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    /// List all subagents across all sessions (sync — safe to call from RPC dispatch).
269    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    /// Get recent events for a specific subagent (sync — safe to call from RPC dispatch).
282    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    // ── Internal methods ──────────────────────────────────────────────────
295
296    /// Scan for existing subagent JSONL files in a projects directory.
297    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    /// Process a changed/new JSONL subagent file. Reads new lines, updates state,
325    /// and broadcasts events via EventBus.
326    fn process_jsonl_change(&self, parent_agent: &str, parent_block_id: &str, jsonl_path: &Path) {
327        // Extract agent ID from filename: agent-<id>.jsonl
328        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        // Derive session_id from the parent directory structure
338        let session_id = jsonl_path
339            .parent() // subagents/
340            .and_then(|p| p.parent()) // project-encoded-dir/
341            .and_then(|p| p.file_name())
342            .and_then(|n| n.to_str())
343            .unwrap_or("unknown")
344            .to_string();
345
346        // Read the current offset before locking (so file I/O is outside the lock)
347        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        // Do file I/O outside the mutex lock
357        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        // Now lock and update state
370        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            // Update metadata from first line if we got it
401            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            // Process events
415            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            // Check last event for result type (completion)
423            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        // Mutex released here — broadcast outside the lock
434
435        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
512// ── JSONL parsing ─────────────────────────────────────────────────────────
513
514/// Metadata extracted from the first JSONL line (the subagent init record).
515struct JsonlMeta {
516    slug: String,
517    model: Option<String>,
518}
519
520/// Read a JSONL file from a byte offset, parsing new subagent events.
521/// Returns (events, new_offset, optional_meta).
522fn 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; // +1 for newline
548
549        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        // Extract metadata from init/config lines
559        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
610/// Parse a JSONL line into a SubagentEventType based on the `type` field.
611fn 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// ── Utility: encode workspace path like Claude Code does ──────────────────
726
727/// Encode a workspace path the same way Claude Code does for its projects dir.
728#[allow(dead_code)]
729pub fn encode_workspace_path(workspace_path: &str) -> String {
730    workspace_path
731        .replace('\\', "-")
732        .replace('/', "-")
733        .replace(':', "")
734}
735
736/// Derive the Claude Code config directory for a host agent.
737pub 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}