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}