Skip to main content

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}