brainwires_network/ipc/protocol.rs
1//! IPC Protocol Definitions
2//!
3//! Defines the message types for communication between the TUI viewer and Agent process.
4//! This is the bridge-crate version, using `brainwires_core::ToolMode` instead of
5//! CLI-specific types. The `From<ResourceType>` impls live in the CLI adapter.
6
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10use brainwires_core::ToolMode;
11
12/// Messages sent from Viewer (TUI) to Agent
13#[derive(Debug, Clone, Serialize, Deserialize)]
14#[serde(tag = "type", rename_all = "snake_case")]
15pub enum ViewerMessage {
16 /// User submitted input text
17 UserInput {
18 /// The user's message text
19 content: String,
20 /// Files to include in context (from working set)
21 #[serde(default)]
22 context_files: Vec<String>,
23 },
24
25 /// Cancel the current operation (streaming or tool execution)
26 Cancel,
27
28 /// Request full conversation state sync
29 SyncRequest,
30
31 /// Viewer is detaching (going to background)
32 Detach {
33 /// If true, agent should exit when current work completes
34 exit_when_done: bool,
35 },
36
37 /// Request agent to exit immediately
38 Exit,
39
40 /// Execute a slash command
41 SlashCommand {
42 /// Command name (without /)
43 command: String,
44 /// Command arguments
45 args: Vec<String>,
46 },
47
48 /// Change tool mode
49 SetToolMode {
50 /// The new tool mode to set.
51 mode: ToolMode,
52 },
53
54 /// Queue a message for injection during agent processing
55 QueueMessage {
56 /// The message content to queue.
57 content: String,
58 },
59
60 /// Request to acquire a resource lock
61 AcquireLock {
62 /// Type of resource to lock
63 resource_type: ResourceLockType,
64 /// Scope of the lock (global or project path)
65 scope: String,
66 /// Description of the operation
67 description: String,
68 },
69
70 /// Release a resource lock
71 ReleaseLock {
72 /// Type of resource to release
73 resource_type: ResourceLockType,
74 /// Scope of the lock
75 scope: String,
76 },
77
78 /// Query lock status
79 QueryLocks {
80 /// Optional scope filter
81 scope: Option<String>,
82 },
83
84 /// Update lock status message
85 UpdateLockStatus {
86 /// Type of resource
87 resource_type: ResourceLockType,
88 /// Scope of the lock
89 scope: String,
90 /// New status message
91 status: String,
92 },
93
94 // ========================================================================
95 // Multi-Agent Messages
96 // ========================================================================
97 /// Request list of all active agents
98 ListAgents,
99
100 /// Request to spawn a new child agent
101 SpawnAgent {
102 /// Model for the new agent (defaults to parent's model)
103 model: Option<String>,
104 /// Reason for spawning (displayed in agent tree)
105 reason: Option<String>,
106 /// Working directory for the new agent (defaults to parent's)
107 working_directory: Option<String>,
108 },
109
110 /// Notify child agents on parent exit
111 NotifyChildren {
112 /// What action children should take
113 action: ChildNotifyAction,
114 },
115
116 /// Signal from parent to child agent (via IPC or message queue)
117 ParentSignal {
118 /// The signal type
119 signal: ParentSignalType,
120 /// Parent's session ID
121 parent_session_id: String,
122 },
123
124 /// Viewer is disconnecting (graceful close, different from Detach)
125 Disconnect,
126
127 // ========================================================================
128 // Plan Mode Messages
129 // ========================================================================
130 /// Enter plan mode with optional focus/goal
131 EnterPlanMode {
132 /// Optional focus or goal for the planning session
133 focus: Option<String>,
134 },
135
136 /// Exit plan mode and return to main context
137 ExitPlanMode,
138
139 /// User input while in plan mode
140 PlanModeUserInput {
141 /// The user's message text
142 content: String,
143 /// Files to include in context
144 #[serde(default)]
145 context_files: Vec<String>,
146 },
147
148 /// Request plan mode state sync
149 PlanModeSyncRequest,
150}
151
152/// Messages sent from Agent to Viewer (TUI)
153#[derive(Debug, Clone, Serialize, Deserialize)]
154#[serde(tag = "type", rename_all = "snake_case")]
155pub enum AgentMessage {
156 /// Streaming text chunk from AI response
157 StreamChunk {
158 /// The text delta
159 text: String,
160 },
161
162 /// Stream completed (full response received)
163 StreamEnd {
164 /// Optional finish reason
165 finish_reason: Option<String>,
166 },
167
168 /// Tool call started
169 ToolCallStart {
170 /// Tool use ID
171 id: String,
172 /// Tool name
173 name: String,
174 /// Server name (for MCP tools)
175 #[serde(default)]
176 server: Option<String>,
177 /// Tool input parameters
178 input: Value,
179 },
180
181 /// Tool execution progress update
182 ToolProgress {
183 /// Tool name
184 name: String,
185 /// Progress message
186 message: String,
187 /// Progress percentage (0.0-1.0) if known
188 progress: Option<f64>,
189 },
190
191 /// Tool execution completed
192 ToolResult {
193 /// Tool use ID
194 id: String,
195 /// Tool name
196 name: String,
197 /// Result output (if successful)
198 output: Option<String>,
199 /// Error message (if failed)
200 error: Option<String>,
201 },
202
203 /// Full conversation state (sent on attach or sync request)
204 ConversationSync {
205 /// Session ID
206 session_id: String,
207 /// Current model name
208 model: String,
209 /// Full conversation history (for display)
210 messages: Vec<DisplayMessage>,
211 /// Current status message
212 status: String,
213 /// Whether an operation is in progress
214 is_busy: bool,
215 /// Current tool mode
216 tool_mode: ToolMode,
217 /// Connected MCP servers
218 mcp_servers: Vec<String>,
219 },
220
221 /// New message added to conversation
222 MessageAdded {
223 /// The message that was added
224 message: DisplayMessage,
225 },
226
227 /// Status update (e.g., "Working...", "Connected to MCP server X")
228 StatusUpdate {
229 /// New status message
230 status: String,
231 },
232
233 /// Task list update
234 TaskUpdate {
235 /// Formatted task tree for display
236 task_tree: String,
237 /// Total task count
238 task_count: usize,
239 /// Completed task count
240 completed_count: usize,
241 },
242
243 /// Error occurred
244 Error {
245 /// Error message
246 message: String,
247 /// Whether this is a fatal error (agent will exit)
248 fatal: bool,
249 },
250
251 /// Agent is exiting
252 Exiting {
253 /// Reason for exit
254 reason: String,
255 },
256
257 /// Acknowledgment of viewer command
258 Ack {
259 /// Original command type that was acknowledged
260 command: String,
261 },
262
263 /// Result of a slash command execution (for remote control)
264 SlashCommandResult {
265 /// The command that was executed (without leading /)
266 command: String,
267 /// Whether the command executed successfully
268 success: bool,
269 /// Output/result of the command (for Message or Help results)
270 #[serde(skip_serializing_if = "Option::is_none")]
271 output: Option<String>,
272 /// Description of action taken (for Action results)
273 #[serde(skip_serializing_if = "Option::is_none")]
274 action_taken: Option<String>,
275 /// Error message if command failed
276 #[serde(skip_serializing_if = "Option::is_none")]
277 error: Option<String>,
278 /// Whether the command was blocked by security policy
279 #[serde(default)]
280 blocked: bool,
281 },
282
283 /// Toast notification
284 Toast {
285 /// Message to display
286 message: String,
287 /// Duration in milliseconds
288 duration_ms: u64,
289 },
290
291 /// SEAL status update
292 SealStatus {
293 /// Whether SEAL is enabled
294 enabled: bool,
295 /// Entity count
296 entity_count: usize,
297 /// Last resolution (if any)
298 last_resolution: Option<String>,
299 /// Quality score (0.0-1.0)
300 quality_score: f32,
301 },
302
303 /// Lock acquisition result
304 LockResult {
305 /// Whether the lock was acquired
306 success: bool,
307 /// Resource type
308 resource_type: ResourceLockType,
309 /// Scope
310 scope: String,
311 /// Error message if failed
312 error: Option<String>,
313 /// Info about blocking lock if failed
314 blocking_agent: Option<String>,
315 },
316
317 /// Lock released confirmation
318 LockReleased {
319 /// Resource type
320 resource_type: ResourceLockType,
321 /// Scope
322 scope: String,
323 },
324
325 /// Response to QueryLocks
326 LockStatus {
327 /// All current locks
328 locks: Vec<LockInfo>,
329 },
330
331 /// Lock state changed notification (for other viewers)
332 LockChanged {
333 /// The change type
334 change: LockChangeType,
335 /// Lock info
336 lock: LockInfo,
337 },
338
339 // ========================================================================
340 // Multi-Agent Messages
341 // ========================================================================
342 /// A new child agent was spawned
343 AgentSpawned {
344 /// Session ID of the new agent
345 new_session_id: String,
346 /// Session ID of the parent that spawned it
347 parent_session_id: String,
348 /// Reason for spawning
349 spawn_reason: String,
350 /// Model used by the new agent
351 model: String,
352 },
353
354 /// Response to ListAgents request
355 AgentList {
356 /// All active agents with their metadata
357 agents: Vec<AgentMetadata>,
358 },
359
360 /// An agent is exiting (sent to TUI and potentially to children)
361 AgentExiting {
362 /// Session ID of the exiting agent
363 session_id: String,
364 /// Reason for exit
365 reason: String,
366 /// Child agents that were notified
367 children_notified: Vec<String>,
368 },
369
370 /// Signal received from parent agent
371 ParentSignalReceived {
372 /// The signal type
373 signal: ParentSignalType,
374 /// Parent's session ID
375 parent_session_id: String,
376 },
377
378 // ========================================================================
379 // Plan Mode Messages
380 // ========================================================================
381 /// Plan mode entered successfully
382 PlanModeEntered {
383 /// Plan session ID
384 plan_session_id: String,
385 /// Display messages from plan mode
386 messages: Vec<DisplayMessage>,
387 /// Current status
388 status: String,
389 },
390
391 /// Plan mode exited
392 PlanModeExited {
393 /// Optional summary of the planning session
394 summary: Option<String>,
395 },
396
397 /// Plan mode state sync (response to PlanModeSyncRequest)
398 PlanModeSync {
399 /// Plan session ID
400 plan_session_id: String,
401 /// Main session ID
402 main_session_id: String,
403 /// Display messages from plan mode
404 messages: Vec<DisplayMessage>,
405 /// Current status
406 status: String,
407 /// Whether an operation is in progress
408 is_busy: bool,
409 },
410
411 /// New message added to plan mode conversation
412 PlanModeMessageAdded {
413 /// The message that was added
414 message: DisplayMessage,
415 },
416
417 /// Streaming text chunk in plan mode
418 PlanModeStreamChunk {
419 /// The text delta
420 text: String,
421 },
422
423 /// Plan mode stream completed
424 PlanModeStreamEnd {
425 /// Optional finish reason
426 finish_reason: Option<String>,
427 },
428}
429
430/// Type of resource lock (standalone bridge type)
431///
432/// The CLI provides `From<ResourceType>` impls to convert between this
433/// and the CLI's `agents::resource_locks::ResourceType`.
434#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
435#[serde(rename_all = "snake_case")]
436pub enum ResourceLockType {
437 /// Build lock
438 Build,
439 /// Test lock
440 Test,
441 /// Combined build+test lock
442 BuildTest,
443 /// Git index/staging operations
444 GitIndex,
445 /// Git commit operations
446 GitCommit,
447 /// Git remote write (push)
448 GitRemoteWrite,
449 /// Git remote merge (pull)
450 GitRemoteMerge,
451 /// Git branch operations
452 GitBranch,
453 /// Git destructive operations
454 GitDestructive,
455}
456
457impl ResourceLockType {
458 /// Convert to string for LockStore
459 pub fn as_lock_type_str(&self) -> &'static str {
460 match self {
461 ResourceLockType::Build => "build",
462 ResourceLockType::Test => "test",
463 ResourceLockType::BuildTest => "build_test",
464 ResourceLockType::GitIndex => "git_index",
465 ResourceLockType::GitCommit => "git_commit",
466 ResourceLockType::GitRemoteWrite => "git_remote_write",
467 ResourceLockType::GitRemoteMerge => "git_remote_merge",
468 ResourceLockType::GitBranch => "git_branch",
469 ResourceLockType::GitDestructive => "git_destructive",
470 }
471 }
472
473 /// Parse from string (from LockStore)
474 pub fn from_lock_type_str(s: &str) -> Option<Self> {
475 match s {
476 "build" => Some(ResourceLockType::Build),
477 "test" => Some(ResourceLockType::Test),
478 "build_test" => Some(ResourceLockType::BuildTest),
479 "git_index" => Some(ResourceLockType::GitIndex),
480 "git_commit" => Some(ResourceLockType::GitCommit),
481 "git_remote_write" => Some(ResourceLockType::GitRemoteWrite),
482 "git_remote_merge" => Some(ResourceLockType::GitRemoteMerge),
483 "git_branch" => Some(ResourceLockType::GitBranch),
484 "git_destructive" => Some(ResourceLockType::GitDestructive),
485 _ => None,
486 }
487 }
488}
489
490/// Information about a lock (for IPC)
491#[derive(Debug, Clone, Serialize, Deserialize)]
492pub struct LockInfo {
493 /// Agent holding the lock
494 pub agent_id: String,
495 /// Resource type
496 pub resource_type: ResourceLockType,
497 /// Scope (global or project path)
498 pub scope: String,
499 /// Description of operation
500 pub description: String,
501 /// Current status message
502 pub status: String,
503 /// Seconds since lock was acquired
504 pub held_for_secs: u64,
505}
506
507/// Type of lock change notification
508#[derive(Debug, Clone, Serialize, Deserialize)]
509#[serde(rename_all = "snake_case")]
510pub enum LockChangeType {
511 /// Lock was acquired
512 Acquired,
513 /// Lock was released
514 Released,
515 /// Lock became stale (holder died)
516 Stale,
517}
518
519/// Display message format (simplified for TUI rendering)
520#[derive(Debug, Clone, Serialize, Deserialize)]
521pub struct DisplayMessage {
522 /// Role of the sender
523 pub role: String,
524 /// Message content (rendered text)
525 pub content: String,
526 /// Timestamp (Unix epoch ms)
527 pub created_at: i64,
528}
529
530impl DisplayMessage {
531 /// Create a new display message
532 pub fn new(role: impl Into<String>, content: impl Into<String>, created_at: i64) -> Self {
533 Self {
534 role: role.into(),
535 content: content.into(),
536 created_at,
537 }
538 }
539}
540
541/// Agent configuration sent on startup
542#[derive(Debug, Clone, Serialize, Deserialize)]
543pub struct AgentConfig {
544 /// Session ID
545 pub session_id: String,
546 /// Model to use
547 pub model: String,
548 /// MDAP configuration (if enabled)
549 pub mdap_enabled: bool,
550 /// SEAL enabled
551 pub seal_enabled: bool,
552 /// Initial working directory
553 pub working_directory: String,
554}
555
556/// Handshake message for initial connection
557#[derive(Debug, Clone, Serialize, Deserialize)]
558pub struct Handshake {
559 /// Protocol version
560 pub version: u32,
561 /// Whether this is a new session or reattach
562 pub is_reattach: bool,
563 /// Session ID (for reattach)
564 pub session_id: Option<String>,
565 /// Session token for authentication (required for reattach)
566 /// This is a cryptographically random 64-character hex string
567 #[serde(default)]
568 pub session_token: Option<String>,
569}
570
571impl Handshake {
572 /// Current protocol version - bumped to 2 for session token support
573 pub const PROTOCOL_VERSION: u32 = 2;
574
575 /// Create a new session handshake
576 pub fn new_session() -> Self {
577 Self {
578 version: Self::PROTOCOL_VERSION,
579 is_reattach: false,
580 session_id: None,
581 session_token: None,
582 }
583 }
584
585 /// Create a reattach handshake with session token for authentication
586 pub fn reattach(session_id: String, session_token: String) -> Self {
587 Self {
588 version: Self::PROTOCOL_VERSION,
589 is_reattach: true,
590 session_id: Some(session_id),
591 session_token: Some(session_token),
592 }
593 }
594}
595
596/// Handshake response from agent
597#[derive(Debug, Clone, Serialize, Deserialize)]
598pub struct HandshakeResponse {
599 /// Whether the handshake was accepted
600 pub accepted: bool,
601 /// Session ID (assigned for new sessions, echoed for reattach)
602 pub session_id: String,
603 /// Session token for authentication (returned to new sessions for later reattachment)
604 /// This should be stored securely by the client
605 #[serde(default)]
606 pub session_token: Option<String>,
607 /// Error message if not accepted
608 pub error: Option<String>,
609}
610
611// ============================================================================
612// Multi-Agent Architecture Types
613// ============================================================================
614
615/// Metadata about an agent for registry and discovery
616///
617/// This is stored alongside the agent socket as a `.meta.json` file
618/// to enable agent discovery, tree visualization, and lifecycle management.
619#[derive(Debug, Clone, Serialize, Deserialize)]
620pub struct AgentMetadata {
621 /// Unique session identifier
622 pub session_id: String,
623 /// Parent agent that spawned this one (if any)
624 pub parent_agent_id: Option<String>,
625 /// Reason this agent was spawned (e.g., "Tool call: investigate auth bug")
626 pub spawn_reason: Option<String>,
627 /// Model being used by this agent
628 pub model: String,
629 /// Unix timestamp (seconds) when agent was created
630 pub created_at: i64,
631 /// Unix timestamp (seconds) of last activity
632 pub last_activity: i64,
633 /// Working directory for this agent
634 pub working_directory: String,
635 /// Whether the agent is currently busy (processing a request)
636 pub is_busy: bool,
637 /// Process ID of the agent (for liveness checking)
638 #[serde(default)]
639 pub pid: Option<u32>,
640}
641
642impl AgentMetadata {
643 /// Create new metadata for an agent
644 pub fn new(session_id: String, model: String, working_directory: String) -> Self {
645 let now = chrono::Utc::now().timestamp();
646 Self {
647 session_id,
648 parent_agent_id: None,
649 spawn_reason: None,
650 model,
651 created_at: now,
652 last_activity: now,
653 working_directory,
654 is_busy: false,
655 pid: None,
656 }
657 }
658
659 /// Set parent agent info
660 pub fn with_parent(mut self, parent_id: String, reason: Option<String>) -> Self {
661 self.parent_agent_id = Some(parent_id);
662 self.spawn_reason = reason;
663 self
664 }
665
666 /// Set the process ID
667 pub fn with_pid(mut self, pid: u32) -> Self {
668 self.pid = Some(pid);
669 self
670 }
671
672 /// Update last activity timestamp
673 pub fn touch(&mut self) {
674 self.last_activity = chrono::Utc::now().timestamp();
675 }
676
677 /// Set busy state
678 pub fn set_busy(&mut self, busy: bool) {
679 self.is_busy = busy;
680 self.touch();
681 }
682}
683
684/// Action to take when notifying child agents (on parent exit)
685#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
686#[serde(rename_all = "snake_case")]
687pub enum ChildNotifyAction {
688 /// Shut down if idle, otherwise set exit_when_done
689 ShutdownIfIdle,
690 /// Force immediate shutdown
691 ForceShutdown,
692 /// Detach from parent (become orphan, keep running)
693 Detach,
694}
695
696/// Signal types from parent to child agent
697#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
698#[serde(rename_all = "snake_case")]
699pub enum ParentSignalType {
700 /// Parent is exiting, child should decide based on busy state
701 ParentExiting,
702 /// Parent requests child to shutdown immediately
703 Shutdown,
704 /// Parent is detaching, child becomes orphan
705 Detached,
706}
707
708#[cfg(test)]
709mod tests {
710 use super::*;
711
712 #[test]
713 fn test_viewer_message_serialization() {
714 let msg = ViewerMessage::UserInput {
715 content: "Hello".to_string(),
716 context_files: vec!["main.rs".to_string()],
717 };
718 let json = serde_json::to_string(&msg).unwrap();
719 assert!(json.contains("user_input"));
720 assert!(json.contains("Hello"));
721 }
722
723 #[test]
724 fn test_agent_message_serialization() {
725 let msg = AgentMessage::StreamChunk {
726 text: "World".to_string(),
727 };
728 let json = serde_json::to_string(&msg).unwrap();
729 assert!(json.contains("stream_chunk"));
730 assert!(json.contains("World"));
731 }
732
733 #[test]
734 fn test_handshake_new_session() {
735 let hs = Handshake::new_session();
736 assert!(!hs.is_reattach);
737 assert!(hs.session_id.is_none());
738 assert!(hs.session_token.is_none());
739 assert_eq!(hs.version, Handshake::PROTOCOL_VERSION);
740 }
741
742 #[test]
743 fn test_handshake_reattach() {
744 let token = "abc123def456".to_string();
745 let hs = Handshake::reattach("session-123".to_string(), token.clone());
746 assert!(hs.is_reattach);
747 assert_eq!(hs.session_id, Some("session-123".to_string()));
748 assert_eq!(hs.session_token, Some(token));
749 }
750
751 #[test]
752 fn test_viewer_message_cancel() {
753 let msg = ViewerMessage::Cancel;
754 let json = serde_json::to_string(&msg).unwrap();
755 let parsed: ViewerMessage = serde_json::from_str(&json).unwrap();
756 assert!(matches!(parsed, ViewerMessage::Cancel));
757 }
758
759 #[test]
760 fn test_agent_message_tool_result() {
761 let msg = AgentMessage::ToolResult {
762 id: "tool-1".to_string(),
763 name: "read_file".to_string(),
764 output: Some("content".to_string()),
765 error: None,
766 };
767 let json = serde_json::to_string(&msg).unwrap();
768 let parsed: AgentMessage = serde_json::from_str(&json).unwrap();
769 match parsed {
770 AgentMessage::ToolResult {
771 id,
772 name,
773 output,
774 error,
775 } => {
776 assert_eq!(id, "tool-1");
777 assert_eq!(name, "read_file");
778 assert_eq!(output, Some("content".to_string()));
779 assert!(error.is_none());
780 }
781 _ => panic!("Expected ToolResult"),
782 }
783 }
784}