Skip to main content

scv_protocol/
lib.rs

1//! Dependency-light wire types shared by SCV clients and the server.
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6pub const PROTOCOL_VERSION: u32 = 2;
7
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
9#[serde(rename_all = "snake_case")]
10pub enum ComponentState {
11    Disabled,
12    Starting,
13    Connected,
14    Disconnected,
15    Backoff,
16    Stopping,
17    Stopped,
18    Failed,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22pub struct ComponentHealth {
23    pub id: String,
24    pub account: String,
25    pub bot_id: Option<String>,
26    pub user_id: Option<String>,
27    pub enabled: bool,
28    pub state: ComponentState,
29    pub last_success_unix_seconds: Option<u64>,
30    pub error: Option<String>,
31    pub restarts: u64,
32    /// Effective remote tool authority; `owner` only when the owner ID is known.
33    #[serde(default)]
34    pub remote_tools: RemoteTools,
35}
36
37/// Who may use tools through a remote bridge account.
38#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
39#[serde(rename_all = "snake_case")]
40pub enum RemoteTools {
41    /// Every remote session is tool-free (the default).
42    #[default]
43    None,
44    /// The account's authenticated owner gets full, auto-approved tools.
45    Owner,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
49pub struct DaemonStatus {
50    pub version: String,
51    pub pid: u32,
52    pub components: Vec<ComponentHealth>,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
56#[serde(tag = "action", rename_all = "snake_case")]
57pub enum DaemonCommand {
58    Status,
59    Reload,
60    ClawbotSet {
61        account: String,
62        enabled: bool,
63        workspace: Option<String>,
64        /// Omitted keeps the saved setting.
65        #[serde(default, skip_serializing_if = "Option::is_none")]
66        remote_tools: Option<RemoteTools>,
67    },
68    ClawbotLogout {
69        account: String,
70    },
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
74pub struct QueueEntry {
75    pub queue_id: String,
76    pub revision: u64,
77    pub prompt: String,
78    pub submitter: String,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
82pub struct PeerInfo {
83    pub name: String,
84    pub version: String,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
88pub struct Usage {
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub input_tokens: Option<u64>,
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub output_tokens: Option<u64>,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
96#[serde(tag = "type")]
97pub enum ClientMessage {
98    #[serde(rename = "daemon.control")]
99    DaemonControl {
100        request_id: String,
101        command: DaemonCommand,
102    },
103    #[serde(rename = "initialize")]
104    Initialize {
105        request_id: String,
106        protocol_version: u32,
107        client: PeerInfo,
108    },
109    #[serde(rename = "session.start")]
110    SessionStart {
111        request_id: String,
112        cwd: String,
113        #[serde(default, skip_serializing_if = "Option::is_none")]
114        provider: Option<String>,
115        #[serde(default, skip_serializing_if = "Option::is_none")]
116        model: Option<String>,
117        #[serde(default, skip_serializing_if = "Option::is_none")]
118        base_url: Option<String>,
119        #[serde(default, skip_serializing_if = "Option::is_none")]
120        no_tools: Option<bool>,
121    },
122    #[serde(rename = "session.attach")]
123    SessionAttach {
124        request_id: String,
125        session_id: String,
126        cwd: String,
127    },
128    #[serde(rename = "turn.start")]
129    TurnStart {
130        request_id: String,
131        session_id: String,
132        prompt: String,
133    },
134    #[serde(rename = "queue.update")]
135    QueueUpdate {
136        request_id: String,
137        session_id: String,
138        queue_id: String,
139        revision: u64,
140        prompt: String,
141    },
142    #[serde(rename = "queue.move")]
143    QueueMove {
144        request_id: String,
145        session_id: String,
146        queue_id: String,
147        revision: u64,
148        before_queue_id: Option<String>,
149    },
150    #[serde(rename = "queue.remove")]
151    QueueRemove {
152        request_id: String,
153        session_id: String,
154        queue_id: String,
155        revision: u64,
156    },
157    #[serde(rename = "session.pause")]
158    SessionPause {
159        request_id: String,
160        session_id: String,
161        paused: bool,
162    },
163    #[serde(rename = "turn.cancel")]
164    TurnCancel {
165        request_id: String,
166        session_id: String,
167        turn_id: String,
168    },
169    #[serde(rename = "approval.resolve")]
170    ApprovalResolve {
171        request_id: String,
172        session_id: String,
173        approval_id: String,
174        approved: bool,
175    },
176    #[serde(rename = "session.clear")]
177    SessionClear {
178        request_id: String,
179        session_id: String,
180    },
181}
182
183impl ClientMessage {
184    pub fn request_id(&self) -> &str {
185        match self {
186            Self::Initialize { request_id, .. }
187            | Self::DaemonControl { request_id, .. }
188            | Self::SessionStart { request_id, .. }
189            | Self::SessionAttach { request_id, .. }
190            | Self::TurnStart { request_id, .. }
191            | Self::QueueUpdate { request_id, .. }
192            | Self::QueueMove { request_id, .. }
193            | Self::QueueRemove { request_id, .. }
194            | Self::SessionPause { request_id, .. }
195            | Self::TurnCancel { request_id, .. }
196            | Self::ApprovalResolve { request_id, .. }
197            | Self::SessionClear { request_id, .. } => request_id,
198        }
199    }
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
203#[serde(tag = "type")]
204pub enum ServerEvent {
205    #[serde(rename = "daemon.status")]
206    DaemonStatus {
207        request_id: String,
208        status: DaemonStatus,
209    },
210    #[serde(rename = "initialized")]
211    Initialized {
212        request_id: String,
213        protocol_version: u32,
214        server: PeerInfo,
215    },
216    #[serde(rename = "session.started")]
217    SessionStarted {
218        request_id: String,
219        session_id: String,
220        cwd: String,
221        model: String,
222        context_max_tokens: usize,
223        max_server_frame_bytes: usize,
224        max_transcript_bytes: usize,
225        max_transcript_items: usize,
226        max_prompt_history_bytes: usize,
227        max_prompt_history_items: usize,
228    },
229    #[serde(rename = "queue.snapshot")]
230    QueueSnapshot {
231        request_id: Option<String>,
232        session_id: String,
233        seq: u64,
234        entries: Vec<QueueEntry>,
235        paused: bool,
236    },
237    #[serde(rename = "queue.enqueued")]
238    QueueEnqueued {
239        request_id: String,
240        session_id: String,
241        seq: u64,
242        entry: QueueEntry,
243        position: usize,
244    },
245    #[serde(rename = "queue.updated")]
246    QueueUpdated {
247        request_id: String,
248        session_id: String,
249        seq: u64,
250        entry: QueueEntry,
251    },
252    #[serde(rename = "queue.moved")]
253    QueueMoved {
254        request_id: String,
255        session_id: String,
256        seq: u64,
257        queue_id: String,
258        position: usize,
259        revision: u64,
260    },
261    #[serde(rename = "queue.removed")]
262    QueueRemoved {
263        request_id: String,
264        session_id: String,
265        seq: u64,
266        queue_id: String,
267        revision: u64,
268    },
269    #[serde(rename = "queue.dequeued")]
270    QueueDequeued {
271        request_id: String,
272        session_id: String,
273        seq: u64,
274        queue_id: String,
275        turn_id: String,
276    },
277    #[serde(rename = "session.paused")]
278    SessionPaused {
279        request_id: String,
280        session_id: String,
281        seq: u64,
282        paused: bool,
283    },
284    #[serde(rename = "turn.started")]
285    TurnStarted {
286        request_id: String,
287        session_id: String,
288        turn_id: String,
289        seq: u64,
290    },
291    #[serde(rename = "assistant.delta")]
292    AssistantDelta {
293        request_id: String,
294        session_id: String,
295        turn_id: String,
296        seq: u64,
297        content: String,
298    },
299    #[serde(rename = "assistant.completed")]
300    AssistantCompleted {
301        request_id: String,
302        session_id: String,
303        turn_id: String,
304        seq: u64,
305        content: String,
306    },
307    #[serde(rename = "tool.proposed")]
308    ToolProposed {
309        request_id: String,
310        session_id: String,
311        turn_id: String,
312        seq: u64,
313        call_id: String,
314        name: String,
315        arguments: Value,
316    },
317    #[serde(rename = "approval.requested")]
318    ApprovalRequested {
319        request_id: String,
320        session_id: String,
321        turn_id: String,
322        seq: u64,
323        approval_id: String,
324        call_id: String,
325        name: String,
326        risk: String,
327        cwd: String,
328        summary: String,
329    },
330    #[serde(rename = "tool.started")]
331    ToolStarted {
332        request_id: String,
333        session_id: String,
334        turn_id: String,
335        seq: u64,
336        call_id: String,
337        name: String,
338    },
339    #[serde(rename = "tool.completed")]
340    ToolCompleted {
341        request_id: String,
342        session_id: String,
343        turn_id: String,
344        seq: u64,
345        call_id: String,
346        name: String,
347        success: bool,
348        output: String,
349        truncated: bool,
350    },
351    #[serde(rename = "context.compacted")]
352    ContextCompacted {
353        request_id: String,
354        session_id: String,
355        turn_id: String,
356        seq: u64,
357        before_tokens: usize,
358        after_tokens: usize,
359        removed_messages: usize,
360    },
361    #[serde(rename = "session.trimmed")]
362    SessionTrimmed {
363        request_id: String,
364        session_id: String,
365        seq: u64,
366        removed_messages: usize,
367        history_bytes: usize,
368    },
369    #[serde(rename = "session.cleared")]
370    SessionCleared {
371        request_id: String,
372        session_id: String,
373        seq: u64,
374    },
375    #[serde(rename = "turn.completed")]
376    TurnCompleted {
377        request_id: String,
378        session_id: String,
379        turn_id: String,
380        seq: u64,
381        steps: usize,
382        usage: Usage,
383    },
384    #[serde(rename = "turn.cancelled")]
385    TurnCancelled {
386        request_id: String,
387        session_id: String,
388        turn_id: String,
389        seq: u64,
390    },
391    #[serde(rename = "turn.failed")]
392    TurnFailed {
393        request_id: String,
394        session_id: String,
395        turn_id: String,
396        seq: u64,
397        code: String,
398        message: String,
399    },
400    #[serde(rename = "error")]
401    Error {
402        #[serde(skip_serializing_if = "Option::is_none")]
403        request_id: Option<String>,
404        code: String,
405        message: String,
406        fatal: bool,
407    },
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413
414    #[test]
415    fn client_message_round_trip() {
416        let message = ClientMessage::TurnStart {
417            request_id: "3".into(),
418            session_id: "session".into(),
419            prompt: "hello".into(),
420        };
421        let json = serde_json::to_string(&message).unwrap();
422        assert!(json.contains("\"type\":\"turn.start\""));
423        assert_eq!(
424            serde_json::from_str::<ClientMessage>(&json).unwrap(),
425            message
426        );
427    }
428
429    #[test]
430    fn additive_fields_are_ignored() {
431        let json = r#"{"type":"session.clear","request_id":"1","session_id":"s","future":true}"#;
432        assert!(matches!(
433            serde_json::from_str::<ClientMessage>(json).unwrap(),
434            ClientMessage::SessionClear { .. }
435        ));
436    }
437
438    #[test]
439    fn event_round_trip() {
440        let event = ServerEvent::AssistantDelta {
441            request_id: "1".into(),
442            session_id: "s".into(),
443            turn_id: "t".into(),
444            seq: 4,
445            content: "hello".into(),
446        };
447        let encoded = serde_json::to_string(&event).unwrap();
448        assert_eq!(
449            serde_json::from_str::<ServerEvent>(&encoded).unwrap(),
450            event
451        );
452    }
453
454    #[test]
455    fn queue_messages_and_events_round_trip() {
456        let message = ClientMessage::QueueMove {
457            request_id: "q1".into(),
458            session_id: "s".into(),
459            queue_id: "q".into(),
460            revision: 2,
461            before_queue_id: None,
462        };
463        let encoded = serde_json::to_string(&message).unwrap();
464        assert_eq!(
465            serde_json::from_str::<ClientMessage>(&encoded).unwrap(),
466            message
467        );
468        let event = ServerEvent::QueueSnapshot {
469            request_id: None,
470            session_id: "s".into(),
471            seq: 4,
472            entries: vec![QueueEntry {
473                queue_id: "q".into(),
474                revision: 1,
475                prompt: "hello".into(),
476                submitter: "cli".into(),
477            }],
478            paused: false,
479        };
480        let encoded = serde_json::to_string(&event).unwrap();
481        assert_eq!(
482            serde_json::from_str::<ServerEvent>(&encoded).unwrap(),
483            event
484        );
485    }
486
487    #[test]
488    fn remote_tools_fields_are_additive() {
489        let legacy: DaemonCommand = serde_json::from_str(
490            r#"{"action":"clawbot_set","account":"a","enabled":true,"workspace":null}"#,
491        )
492        .unwrap();
493        assert!(matches!(
494            legacy,
495            DaemonCommand::ClawbotSet {
496                remote_tools: None,
497                ..
498            }
499        ));
500        let owner = DaemonCommand::ClawbotSet {
501            account: "a".into(),
502            enabled: true,
503            workspace: None,
504            remote_tools: Some(RemoteTools::Owner),
505        };
506        let encoded = serde_json::to_string(&owner).unwrap();
507        assert!(encoded.contains(r#""remote_tools":"owner""#));
508        assert_eq!(
509            serde_json::from_str::<DaemonCommand>(&encoded).unwrap(),
510            owner
511        );
512        let health: ComponentHealth = serde_json::from_str(
513            r#"{"id":"clawbot:a","account":"a","bot_id":null,"user_id":null,"enabled":true,"state":"connected","last_success_unix_seconds":null,"error":null,"restarts":0}"#,
514        )
515        .unwrap();
516        assert_eq!(health.remote_tools, RemoteTools::None);
517    }
518}