Skip to main content

tmai_core/api/
events.rs

1//! Core event system for push-based change notification.
2//!
3//! The event system supports two modes:
4//! - **Bridge mode**: `start_monitoring()` spawns a Poller internally and
5//!   bridges `PollMessage` → `CoreEvent` automatically (for headless/webui).
6//! - **External mode**: The consumer (TUI) runs its own Poller and calls
7//!   `notify_agents_updated()` / `notify_teams_updated()` to emit events.
8
9use tokio::sync::broadcast;
10
11use crate::hooks::WorktreeInfo;
12use crate::review::ReviewRequest;
13
14use super::core::TmaiCore;
15
16/// Events emitted by the core when state changes occur.
17///
18/// Consumers call [`TmaiCore::subscribe()`] to receive these events
19/// via a `broadcast::Receiver`.
20#[derive(Debug, Clone, serde::Serialize)]
21#[serde(tag = "type")]
22pub enum CoreEvent {
23    /// The full agent list was refreshed (after a poll cycle)
24    AgentsUpdated,
25
26    /// A single agent changed status
27    AgentStatusChanged {
28        /// Agent target ID
29        target: String,
30        /// Previous status description
31        old_status: String,
32        /// New status description
33        new_status: String,
34    },
35
36    /// A new agent appeared
37    AgentAppeared {
38        /// Agent target ID
39        target: String,
40    },
41
42    /// An agent disappeared
43    AgentDisappeared {
44        /// Agent target ID
45        target: String,
46    },
47
48    /// Team data was refreshed
49    TeamsUpdated,
50
51    /// A team member became idle (waiting for next task)
52    TeammateIdle {
53        /// Agent target ID
54        target: String,
55        /// Team name
56        team_name: String,
57        /// Member name
58        member_name: String,
59    },
60
61    /// A task was completed
62    TaskCompleted {
63        /// Team name
64        team_name: String,
65        /// Task ID
66        task_id: String,
67        /// Task subject
68        task_subject: String,
69    },
70
71    /// Claude Code configuration file was changed
72    ConfigChanged {
73        /// Agent target ID
74        target: String,
75        /// Config source (e.g., "user_settings", "project_settings")
76        source: String,
77        /// Changed file path
78        file_path: String,
79    },
80
81    /// A git worktree was created
82    WorktreeCreated {
83        /// Agent target ID
84        target: String,
85        /// Worktree details (name, path, branch, original_repo)
86        worktree: Option<WorktreeInfo>,
87    },
88
89    /// A git worktree was removed
90    WorktreeRemoved {
91        /// Agent target ID
92        target: String,
93        /// Worktree details (name, path, branch, original_repo)
94        worktree: Option<WorktreeInfo>,
95    },
96
97    /// CLAUDE.md or `.claude/rules/*.md` files were loaded into context
98    ///
99    /// Added in Claude Code v2.1.69. Fires when instruction files are loaded.
100    InstructionsLoaded {
101        /// Agent target ID
102        target: String,
103    },
104
105    /// An agent stopped (completed or paused), emitted by hook handler
106    AgentStopped {
107        /// Agent target ID
108        target: String,
109        /// Working directory
110        cwd: String,
111        /// Last assistant message (if available)
112        last_assistant_message: Option<String>,
113    },
114
115    /// Context compaction started (PreCompact hook event)
116    ContextCompacting {
117        /// Agent target ID
118        target: String,
119        /// How many compactions have occurred in this session
120        compaction_count: u32,
121    },
122
123    /// An agent completed work and is ready for fresh-session review
124    ReviewReady {
125        /// Review request with context for launching a review session
126        request: ReviewRequest,
127    },
128
129    /// A review session was successfully launched
130    ReviewLaunched {
131        /// Original agent target that was reviewed
132        source_target: String,
133        /// tmux target of the review pane
134        review_target: String,
135    },
136
137    /// A review session completed and produced results
138    ReviewCompleted {
139        /// Original agent target that was reviewed
140        source_target: String,
141        /// One-line summary (first line of review output)
142        summary: String,
143    },
144
145    /// Worktree setup commands completed successfully
146    WorktreeSetupCompleted {
147        /// Absolute path to the worktree
148        worktree_path: String,
149        /// Branch name
150        branch: String,
151    },
152
153    /// Worktree setup commands failed
154    WorktreeSetupFailed {
155        /// Absolute path to the worktree
156        worktree_path: String,
157        /// Branch name
158        branch: String,
159        /// Error message
160        error: String,
161    },
162
163    /// Usage data was updated (after a fetch cycle)
164    UsageUpdated,
165
166    /// A tool call was deferred for external resolution
167    ToolCallDeferred {
168        /// Unique deferred call ID
169        defer_id: u64,
170        /// Agent target/pane ID
171        target: String,
172        /// Tool name
173        tool_name: String,
174    },
175
176    /// A deferred tool call was resolved (approved/denied)
177    ToolCallResolved {
178        /// Unique deferred call ID
179        defer_id: u64,
180        /// Agent target/pane ID
181        target: String,
182        /// Resolution: "allow" or "deny"
183        decision: String,
184        /// Who resolved it (e.g., "human", "ai:haiku", "timeout")
185        resolved_by: String,
186    },
187}
188
189impl TmaiCore {
190    /// Subscribe to core events.
191    ///
192    /// Returns a broadcast receiver that will receive [`CoreEvent`]s.
193    /// If the receiver falls behind, older events are dropped (lagged).
194    pub fn subscribe(&self) -> broadcast::Receiver<CoreEvent> {
195        self.event_sender().subscribe()
196    }
197
198    /// Notify subscribers that the agent list was updated.
199    ///
200    /// Called by external consumers (e.g. TUI main loop) after processing
201    /// `PollMessage::AgentsUpdated`. Ignored if no subscribers are listening.
202    pub fn notify_agents_updated(&self) {
203        let _ = self.event_sender().send(CoreEvent::AgentsUpdated);
204    }
205
206    /// Notify subscribers that team data was updated.
207    ///
208    /// Called by external consumers after team scan completes.
209    pub fn notify_teams_updated(&self) {
210        let _ = self.event_sender().send(CoreEvent::TeamsUpdated);
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use crate::api::builder::TmaiCoreBuilder;
218    use crate::config::Settings;
219
220    #[tokio::test]
221    async fn test_subscribe_receives_events() {
222        let core = TmaiCoreBuilder::new(Settings::default()).build();
223        let mut rx = core.subscribe();
224
225        // Send an event via the internal sender
226        let tx = core.event_sender();
227        tx.send(CoreEvent::AgentsUpdated).unwrap();
228
229        let event = rx.recv().await.unwrap();
230        assert!(matches!(event, CoreEvent::AgentsUpdated));
231    }
232
233    #[tokio::test]
234    async fn test_subscribe_multiple_receivers() {
235        let core = TmaiCoreBuilder::new(Settings::default()).build();
236        let mut rx1 = core.subscribe();
237        let mut rx2 = core.subscribe();
238
239        let tx = core.event_sender();
240        tx.send(CoreEvent::TeamsUpdated).unwrap();
241
242        let e1 = rx1.recv().await.unwrap();
243        let e2 = rx2.recv().await.unwrap();
244        assert!(matches!(e1, CoreEvent::TeamsUpdated));
245        assert!(matches!(e2, CoreEvent::TeamsUpdated));
246    }
247
248    #[tokio::test]
249    async fn test_notify_agents_updated() {
250        let core = TmaiCoreBuilder::new(Settings::default()).build();
251        let mut rx = core.subscribe();
252
253        core.notify_agents_updated();
254
255        let event = rx.recv().await.unwrap();
256        assert!(matches!(event, CoreEvent::AgentsUpdated));
257    }
258
259    #[tokio::test]
260    async fn test_notify_teams_updated() {
261        let core = TmaiCoreBuilder::new(Settings::default()).build();
262        let mut rx = core.subscribe();
263
264        core.notify_teams_updated();
265
266        let event = rx.recv().await.unwrap();
267        assert!(matches!(event, CoreEvent::TeamsUpdated));
268    }
269
270    #[test]
271    fn test_notify_no_subscribers() {
272        let core = TmaiCoreBuilder::new(Settings::default()).build();
273        // Should not panic even with no subscribers
274        core.notify_agents_updated();
275        core.notify_teams_updated();
276    }
277}