brainwires-relay 0.1.0

MCP server framework, relay client, and agent communication backbone for Brainwires
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
//! IPC Protocol Definitions
//!
//! Defines the message types for communication between the TUI viewer and Agent process.
//! This is the bridge-crate version, using `brainwires_core::ToolMode` instead of
//! CLI-specific types. The `From<ResourceType>` impls live in the CLI adapter.

use serde::{Deserialize, Serialize};
use serde_json::Value;

use brainwires_core::ToolMode;

/// Messages sent from Viewer (TUI) to Agent
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ViewerMessage {
    /// User submitted input text
    UserInput {
        /// The user's message text
        content: String,
        /// Files to include in context (from working set)
        #[serde(default)]
        context_files: Vec<String>,
    },

    /// Cancel the current operation (streaming or tool execution)
    Cancel,

    /// Request full conversation state sync
    SyncRequest,

    /// Viewer is detaching (going to background)
    Detach {
        /// If true, agent should exit when current work completes
        exit_when_done: bool,
    },

    /// Request agent to exit immediately
    Exit,

    /// Execute a slash command
    SlashCommand {
        /// Command name (without /)
        command: String,
        /// Command arguments
        args: Vec<String>,
    },

    /// Change tool mode
    SetToolMode {
        /// The new tool mode to set.
        mode: ToolMode,
    },

    /// Queue a message for injection during agent processing
    QueueMessage {
        /// The message content to queue.
        content: String,
    },

    /// Request to acquire a resource lock
    AcquireLock {
        /// Type of resource to lock
        resource_type: ResourceLockType,
        /// Scope of the lock (global or project path)
        scope: String,
        /// Description of the operation
        description: String,
    },

    /// Release a resource lock
    ReleaseLock {
        /// Type of resource to release
        resource_type: ResourceLockType,
        /// Scope of the lock
        scope: String,
    },

    /// Query lock status
    QueryLocks {
        /// Optional scope filter
        scope: Option<String>,
    },

    /// Update lock status message
    UpdateLockStatus {
        /// Type of resource
        resource_type: ResourceLockType,
        /// Scope of the lock
        scope: String,
        /// New status message
        status: String,
    },

    // ========================================================================
    // Multi-Agent Messages
    // ========================================================================

    /// Request list of all active agents
    ListAgents,

    /// Request to spawn a new child agent
    SpawnAgent {
        /// Model for the new agent (defaults to parent's model)
        model: Option<String>,
        /// Reason for spawning (displayed in agent tree)
        reason: Option<String>,
        /// Working directory for the new agent (defaults to parent's)
        working_directory: Option<String>,
    },

    /// Notify child agents on parent exit
    NotifyChildren {
        /// What action children should take
        action: ChildNotifyAction,
    },

    /// Signal from parent to child agent (via IPC or message queue)
    ParentSignal {
        /// The signal type
        signal: ParentSignalType,
        /// Parent's session ID
        parent_session_id: String,
    },

    /// Viewer is disconnecting (graceful close, different from Detach)
    Disconnect,

    // ========================================================================
    // Plan Mode Messages
    // ========================================================================

    /// Enter plan mode with optional focus/goal
    EnterPlanMode {
        /// Optional focus or goal for the planning session
        focus: Option<String>,
    },

    /// Exit plan mode and return to main context
    ExitPlanMode,

    /// User input while in plan mode
    PlanModeUserInput {
        /// The user's message text
        content: String,
        /// Files to include in context
        #[serde(default)]
        context_files: Vec<String>,
    },

    /// Request plan mode state sync
    PlanModeSyncRequest,
}

/// Messages sent from Agent to Viewer (TUI)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AgentMessage {
    /// Streaming text chunk from AI response
    StreamChunk {
        /// The text delta
        text: String,
    },

    /// Stream completed (full response received)
    StreamEnd {
        /// Optional finish reason
        finish_reason: Option<String>,
    },

    /// Tool call started
    ToolCallStart {
        /// Tool use ID
        id: String,
        /// Tool name
        name: String,
        /// Server name (for MCP tools)
        #[serde(default)]
        server: Option<String>,
        /// Tool input parameters
        input: Value,
    },

    /// Tool execution progress update
    ToolProgress {
        /// Tool name
        name: String,
        /// Progress message
        message: String,
        /// Progress percentage (0.0-1.0) if known
        progress: Option<f64>,
    },

    /// Tool execution completed
    ToolResult {
        /// Tool use ID
        id: String,
        /// Tool name
        name: String,
        /// Result output (if successful)
        output: Option<String>,
        /// Error message (if failed)
        error: Option<String>,
    },

    /// Full conversation state (sent on attach or sync request)
    ConversationSync {
        /// Session ID
        session_id: String,
        /// Current model name
        model: String,
        /// Full conversation history (for display)
        messages: Vec<DisplayMessage>,
        /// Current status message
        status: String,
        /// Whether an operation is in progress
        is_busy: bool,
        /// Current tool mode
        tool_mode: ToolMode,
        /// Connected MCP servers
        mcp_servers: Vec<String>,
    },

    /// New message added to conversation
    MessageAdded {
        /// The message that was added
        message: DisplayMessage,
    },

    /// Status update (e.g., "Working...", "Connected to MCP server X")
    StatusUpdate {
        /// New status message
        status: String,
    },

    /// Task list update
    TaskUpdate {
        /// Formatted task tree for display
        task_tree: String,
        /// Total task count
        task_count: usize,
        /// Completed task count
        completed_count: usize,
    },

    /// Error occurred
    Error {
        /// Error message
        message: String,
        /// Whether this is a fatal error (agent will exit)
        fatal: bool,
    },

    /// Agent is exiting
    Exiting {
        /// Reason for exit
        reason: String,
    },

    /// Acknowledgment of viewer command
    Ack {
        /// Original command type that was acknowledged
        command: String,
    },

    /// Result of a slash command execution (for remote control)
    SlashCommandResult {
        /// The command that was executed (without leading /)
        command: String,
        /// Whether the command executed successfully
        success: bool,
        /// Output/result of the command (for Message or Help results)
        #[serde(skip_serializing_if = "Option::is_none")]
        output: Option<String>,
        /// Description of action taken (for Action results)
        #[serde(skip_serializing_if = "Option::is_none")]
        action_taken: Option<String>,
        /// Error message if command failed
        #[serde(skip_serializing_if = "Option::is_none")]
        error: Option<String>,
        /// Whether the command was blocked by security policy
        #[serde(default)]
        blocked: bool,
    },

    /// Toast notification
    Toast {
        /// Message to display
        message: String,
        /// Duration in milliseconds
        duration_ms: u64,
    },

    /// SEAL status update
    SealStatus {
        /// Whether SEAL is enabled
        enabled: bool,
        /// Entity count
        entity_count: usize,
        /// Last resolution (if any)
        last_resolution: Option<String>,
        /// Quality score (0.0-1.0)
        quality_score: f32,
    },

    /// Lock acquisition result
    LockResult {
        /// Whether the lock was acquired
        success: bool,
        /// Resource type
        resource_type: ResourceLockType,
        /// Scope
        scope: String,
        /// Error message if failed
        error: Option<String>,
        /// Info about blocking lock if failed
        blocking_agent: Option<String>,
    },

    /// Lock released confirmation
    LockReleased {
        /// Resource type
        resource_type: ResourceLockType,
        /// Scope
        scope: String,
    },

    /// Response to QueryLocks
    LockStatus {
        /// All current locks
        locks: Vec<LockInfo>,
    },

    /// Lock state changed notification (for other viewers)
    LockChanged {
        /// The change type
        change: LockChangeType,
        /// Lock info
        lock: LockInfo,
    },

    // ========================================================================
    // Multi-Agent Messages
    // ========================================================================

    /// A new child agent was spawned
    AgentSpawned {
        /// Session ID of the new agent
        new_session_id: String,
        /// Session ID of the parent that spawned it
        parent_session_id: String,
        /// Reason for spawning
        spawn_reason: String,
        /// Model used by the new agent
        model: String,
    },

    /// Response to ListAgents request
    AgentList {
        /// All active agents with their metadata
        agents: Vec<AgentMetadata>,
    },

    /// An agent is exiting (sent to TUI and potentially to children)
    AgentExiting {
        /// Session ID of the exiting agent
        session_id: String,
        /// Reason for exit
        reason: String,
        /// Child agents that were notified
        children_notified: Vec<String>,
    },

    /// Signal received from parent agent
    ParentSignalReceived {
        /// The signal type
        signal: ParentSignalType,
        /// Parent's session ID
        parent_session_id: String,
    },

    // ========================================================================
    // Plan Mode Messages
    // ========================================================================

    /// Plan mode entered successfully
    PlanModeEntered {
        /// Plan session ID
        plan_session_id: String,
        /// Display messages from plan mode
        messages: Vec<DisplayMessage>,
        /// Current status
        status: String,
    },

    /// Plan mode exited
    PlanModeExited {
        /// Optional summary of the planning session
        summary: Option<String>,
    },

    /// Plan mode state sync (response to PlanModeSyncRequest)
    PlanModeSync {
        /// Plan session ID
        plan_session_id: String,
        /// Main session ID
        main_session_id: String,
        /// Display messages from plan mode
        messages: Vec<DisplayMessage>,
        /// Current status
        status: String,
        /// Whether an operation is in progress
        is_busy: bool,
    },

    /// New message added to plan mode conversation
    PlanModeMessageAdded {
        /// The message that was added
        message: DisplayMessage,
    },

    /// Streaming text chunk in plan mode
    PlanModeStreamChunk {
        /// The text delta
        text: String,
    },

    /// Plan mode stream completed
    PlanModeStreamEnd {
        /// Optional finish reason
        finish_reason: Option<String>,
    },
}

/// Type of resource lock (standalone bridge type)
///
/// The CLI provides `From<ResourceType>` impls to convert between this
/// and the CLI's `agents::resource_locks::ResourceType`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ResourceLockType {
    /// Build lock
    Build,
    /// Test lock
    Test,
    /// Combined build+test lock
    BuildTest,
    /// Git index/staging operations
    GitIndex,
    /// Git commit operations
    GitCommit,
    /// Git remote write (push)
    GitRemoteWrite,
    /// Git remote merge (pull)
    GitRemoteMerge,
    /// Git branch operations
    GitBranch,
    /// Git destructive operations
    GitDestructive,
}

impl ResourceLockType {
    /// Convert to string for LockStore
    pub fn as_lock_type_str(&self) -> &'static str {
        match self {
            ResourceLockType::Build => "build",
            ResourceLockType::Test => "test",
            ResourceLockType::BuildTest => "build_test",
            ResourceLockType::GitIndex => "git_index",
            ResourceLockType::GitCommit => "git_commit",
            ResourceLockType::GitRemoteWrite => "git_remote_write",
            ResourceLockType::GitRemoteMerge => "git_remote_merge",
            ResourceLockType::GitBranch => "git_branch",
            ResourceLockType::GitDestructive => "git_destructive",
        }
    }

    /// Parse from string (from LockStore)
    pub fn from_lock_type_str(s: &str) -> Option<Self> {
        match s {
            "build" => Some(ResourceLockType::Build),
            "test" => Some(ResourceLockType::Test),
            "build_test" => Some(ResourceLockType::BuildTest),
            "git_index" => Some(ResourceLockType::GitIndex),
            "git_commit" => Some(ResourceLockType::GitCommit),
            "git_remote_write" => Some(ResourceLockType::GitRemoteWrite),
            "git_remote_merge" => Some(ResourceLockType::GitRemoteMerge),
            "git_branch" => Some(ResourceLockType::GitBranch),
            "git_destructive" => Some(ResourceLockType::GitDestructive),
            _ => None,
        }
    }
}

/// Information about a lock (for IPC)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LockInfo {
    /// Agent holding the lock
    pub agent_id: String,
    /// Resource type
    pub resource_type: ResourceLockType,
    /// Scope (global or project path)
    pub scope: String,
    /// Description of operation
    pub description: String,
    /// Current status message
    pub status: String,
    /// Seconds since lock was acquired
    pub held_for_secs: u64,
}

/// Type of lock change notification
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LockChangeType {
    /// Lock was acquired
    Acquired,
    /// Lock was released
    Released,
    /// Lock became stale (holder died)
    Stale,
}

/// Display message format (simplified for TUI rendering)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DisplayMessage {
    /// Role of the sender
    pub role: String,
    /// Message content (rendered text)
    pub content: String,
    /// Timestamp (Unix epoch ms)
    pub created_at: i64,
}

impl DisplayMessage {
    /// Create a new display message
    pub fn new(role: impl Into<String>, content: impl Into<String>, created_at: i64) -> Self {
        Self {
            role: role.into(),
            content: content.into(),
            created_at,
        }
    }
}

/// Agent configuration sent on startup
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentConfig {
    /// Session ID
    pub session_id: String,
    /// Model to use
    pub model: String,
    /// MDAP configuration (if enabled)
    pub mdap_enabled: bool,
    /// SEAL enabled
    pub seal_enabled: bool,
    /// Initial working directory
    pub working_directory: String,
}

/// Handshake message for initial connection
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Handshake {
    /// Protocol version
    pub version: u32,
    /// Whether this is a new session or reattach
    pub is_reattach: bool,
    /// Session ID (for reattach)
    pub session_id: Option<String>,
    /// Session token for authentication (required for reattach)
    /// This is a cryptographically random 64-character hex string
    #[serde(default)]
    pub session_token: Option<String>,
}

impl Handshake {
    /// Current protocol version - bumped to 2 for session token support
    pub const PROTOCOL_VERSION: u32 = 2;

    /// Create a new session handshake
    pub fn new_session() -> Self {
        Self {
            version: Self::PROTOCOL_VERSION,
            is_reattach: false,
            session_id: None,
            session_token: None,
        }
    }

    /// Create a reattach handshake with session token for authentication
    pub fn reattach(session_id: String, session_token: String) -> Self {
        Self {
            version: Self::PROTOCOL_VERSION,
            is_reattach: true,
            session_id: Some(session_id),
            session_token: Some(session_token),
        }
    }


}

/// Handshake response from agent
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HandshakeResponse {
    /// Whether the handshake was accepted
    pub accepted: bool,
    /// Session ID (assigned for new sessions, echoed for reattach)
    pub session_id: String,
    /// Session token for authentication (returned to new sessions for later reattachment)
    /// This should be stored securely by the client
    #[serde(default)]
    pub session_token: Option<String>,
    /// Error message if not accepted
    pub error: Option<String>,
}

// ============================================================================
// Multi-Agent Architecture Types
// ============================================================================

/// Metadata about an agent for registry and discovery
///
/// This is stored alongside the agent socket as a `.meta.json` file
/// to enable agent discovery, tree visualization, and lifecycle management.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentMetadata {
    /// Unique session identifier
    pub session_id: String,
    /// Parent agent that spawned this one (if any)
    pub parent_agent_id: Option<String>,
    /// Reason this agent was spawned (e.g., "Tool call: investigate auth bug")
    pub spawn_reason: Option<String>,
    /// Model being used by this agent
    pub model: String,
    /// Unix timestamp (seconds) when agent was created
    pub created_at: i64,
    /// Unix timestamp (seconds) of last activity
    pub last_activity: i64,
    /// Working directory for this agent
    pub working_directory: String,
    /// Whether the agent is currently busy (processing a request)
    pub is_busy: bool,
    /// Process ID of the agent (for liveness checking)
    #[serde(default)]
    pub pid: Option<u32>,
}

impl AgentMetadata {
    /// Create new metadata for an agent
    pub fn new(
        session_id: String,
        model: String,
        working_directory: String,
    ) -> Self {
        let now = chrono::Utc::now().timestamp();
        Self {
            session_id,
            parent_agent_id: None,
            spawn_reason: None,
            model,
            created_at: now,
            last_activity: now,
            working_directory,
            is_busy: false,
            pid: None,
        }
    }

    /// Set parent agent info
    pub fn with_parent(mut self, parent_id: String, reason: Option<String>) -> Self {
        self.parent_agent_id = Some(parent_id);
        self.spawn_reason = reason;
        self
    }

    /// Set the process ID
    pub fn with_pid(mut self, pid: u32) -> Self {
        self.pid = Some(pid);
        self
    }

    /// Update last activity timestamp
    pub fn touch(&mut self) {
        self.last_activity = chrono::Utc::now().timestamp();
    }

    /// Set busy state
    pub fn set_busy(&mut self, busy: bool) {
        self.is_busy = busy;
        self.touch();
    }
}

/// Action to take when notifying child agents (on parent exit)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChildNotifyAction {
    /// Shut down if idle, otherwise set exit_when_done
    ShutdownIfIdle,
    /// Force immediate shutdown
    ForceShutdown,
    /// Detach from parent (become orphan, keep running)
    Detach,
}

/// Signal types from parent to child agent
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ParentSignalType {
    /// Parent is exiting, child should decide based on busy state
    ParentExiting,
    /// Parent requests child to shutdown immediately
    Shutdown,
    /// Parent is detaching, child becomes orphan
    Detached,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_viewer_message_serialization() {
        let msg = ViewerMessage::UserInput {
            content: "Hello".to_string(),
            context_files: vec!["main.rs".to_string()],
        };
        let json = serde_json::to_string(&msg).unwrap();
        assert!(json.contains("user_input"));
        assert!(json.contains("Hello"));
    }

    #[test]
    fn test_agent_message_serialization() {
        let msg = AgentMessage::StreamChunk {
            text: "World".to_string(),
        };
        let json = serde_json::to_string(&msg).unwrap();
        assert!(json.contains("stream_chunk"));
        assert!(json.contains("World"));
    }

    #[test]
    fn test_handshake_new_session() {
        let hs = Handshake::new_session();
        assert!(!hs.is_reattach);
        assert!(hs.session_id.is_none());
        assert!(hs.session_token.is_none());
        assert_eq!(hs.version, Handshake::PROTOCOL_VERSION);
    }

    #[test]
    fn test_handshake_reattach() {
        let token = "abc123def456".to_string();
        let hs = Handshake::reattach("session-123".to_string(), token.clone());
        assert!(hs.is_reattach);
        assert_eq!(hs.session_id, Some("session-123".to_string()));
        assert_eq!(hs.session_token, Some(token));
    }

    #[test]
    fn test_viewer_message_cancel() {
        let msg = ViewerMessage::Cancel;
        let json = serde_json::to_string(&msg).unwrap();
        let parsed: ViewerMessage = serde_json::from_str(&json).unwrap();
        assert!(matches!(parsed, ViewerMessage::Cancel));
    }

    #[test]
    fn test_agent_message_tool_result() {
        let msg = AgentMessage::ToolResult {
            id: "tool-1".to_string(),
            name: "read_file".to_string(),
            output: Some("content".to_string()),
            error: None,
        };
        let json = serde_json::to_string(&msg).unwrap();
        let parsed: AgentMessage = serde_json::from_str(&json).unwrap();
        match parsed {
            AgentMessage::ToolResult { id, name, output, error } => {
                assert_eq!(id, "tool-1");
                assert_eq!(name, "read_file");
                assert_eq!(output, Some("content".to_string()));
                assert!(error.is_none());
            }
            _ => panic!("Expected ToolResult"),
        }
    }
}