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    #[serde(default)]
54    pub delegations: DelegationSummary,
55}
56
57/// Delegated agent runs of the daemon's SCV instance.
58#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
59pub struct DelegationSummary {
60    /// Running delegations, whichever SCV process of the instance started them.
61    pub active: u64,
62    /// Orphaned delegations the daemon has stopped since it started.
63    pub reaped: u64,
64    /// Listed delegations, for `delegations` and `delegation_kill`.
65    #[serde(default, skip_serializing_if = "Vec::is_empty")]
66    pub entries: Vec<DelegationInfo>,
67    /// Handles this request stopped.
68    #[serde(default, skip_serializing_if = "Vec::is_empty")]
69    pub killed: Vec<String>,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
73pub struct DelegationInfo {
74    pub handle: String,
75    pub agent: String,
76    pub session: String,
77    pub depth: u32,
78    pub pid: u32,
79    /// The SCV process that started it.
80    pub owner_pid: u32,
81    /// Live processes in its group plus tagged processes outside it.
82    pub processes: u32,
83    pub cwd: String,
84    pub started_unix_seconds: u64,
85    /// The owning SCV process is gone; the daemon will stop it.
86    pub orphaned: bool,
87    /// The conversation this run is a turn of, and which turn.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub conversation: Option<String>,
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub turn: Option<u32>,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
95#[serde(tag = "action", rename_all = "snake_case")]
96pub enum DaemonCommand {
97    Status,
98    Reload,
99    ClawbotSet {
100        account: String,
101        enabled: bool,
102        workspace: Option<String>,
103        /// Omitted keeps the saved setting.
104        #[serde(default, skip_serializing_if = "Option::is_none")]
105        remote_tools: Option<RemoteTools>,
106    },
107    ClawbotLogout {
108        account: String,
109    },
110    /// List running delegations; `all` includes orphans awaiting cleanup.
111    Delegations {
112        #[serde(default)]
113        all: bool,
114    },
115    /// Stop one delegation by handle, or every orphaned one.
116    DelegationKill {
117        #[serde(default, skip_serializing_if = "Option::is_none")]
118        handle: Option<String>,
119        #[serde(default)]
120        orphans: bool,
121    },
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
125pub struct QueueEntry {
126    pub queue_id: String,
127    pub revision: u64,
128    pub prompt: String,
129    pub submitter: String,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
133pub struct PeerInfo {
134    pub name: String,
135    pub version: String,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
139pub struct Usage {
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub input_tokens: Option<u64>,
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub output_tokens: Option<u64>,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
147#[serde(tag = "type")]
148pub enum ClientMessage {
149    #[serde(rename = "daemon.control")]
150    DaemonControl {
151        request_id: String,
152        command: DaemonCommand,
153    },
154    #[serde(rename = "initialize")]
155    Initialize {
156        request_id: String,
157        protocol_version: u32,
158        client: PeerInfo,
159    },
160    #[serde(rename = "session.start")]
161    SessionStart {
162        request_id: String,
163        cwd: String,
164        #[serde(default, skip_serializing_if = "Option::is_none")]
165        provider: Option<String>,
166        #[serde(default, skip_serializing_if = "Option::is_none")]
167        model: Option<String>,
168        #[serde(default, skip_serializing_if = "Option::is_none")]
169        base_url: Option<String>,
170        #[serde(default, skip_serializing_if = "Option::is_none")]
171        no_tools: Option<bool>,
172    },
173    #[serde(rename = "session.attach")]
174    SessionAttach {
175        request_id: String,
176        session_id: String,
177        cwd: String,
178    },
179    #[serde(rename = "turn.start")]
180    TurnStart {
181        request_id: String,
182        session_id: String,
183        prompt: String,
184    },
185    #[serde(rename = "queue.update")]
186    QueueUpdate {
187        request_id: String,
188        session_id: String,
189        queue_id: String,
190        revision: u64,
191        prompt: String,
192    },
193    #[serde(rename = "queue.move")]
194    QueueMove {
195        request_id: String,
196        session_id: String,
197        queue_id: String,
198        revision: u64,
199        before_queue_id: Option<String>,
200    },
201    #[serde(rename = "queue.remove")]
202    QueueRemove {
203        request_id: String,
204        session_id: String,
205        queue_id: String,
206        revision: u64,
207    },
208    #[serde(rename = "session.pause")]
209    SessionPause {
210        request_id: String,
211        session_id: String,
212        paused: bool,
213    },
214    #[serde(rename = "turn.cancel")]
215    TurnCancel {
216        request_id: String,
217        session_id: String,
218        turn_id: String,
219    },
220    #[serde(rename = "approval.resolve")]
221    ApprovalResolve {
222        request_id: String,
223        session_id: String,
224        approval_id: String,
225        approved: bool,
226    },
227    #[serde(rename = "session.clear")]
228    SessionClear {
229        request_id: String,
230        session_id: String,
231    },
232}
233
234impl ClientMessage {
235    pub fn request_id(&self) -> &str {
236        match self {
237            Self::Initialize { request_id, .. }
238            | Self::DaemonControl { request_id, .. }
239            | Self::SessionStart { request_id, .. }
240            | Self::SessionAttach { request_id, .. }
241            | Self::TurnStart { request_id, .. }
242            | Self::QueueUpdate { request_id, .. }
243            | Self::QueueMove { request_id, .. }
244            | Self::QueueRemove { request_id, .. }
245            | Self::SessionPause { request_id, .. }
246            | Self::TurnCancel { request_id, .. }
247            | Self::ApprovalResolve { request_id, .. }
248            | Self::SessionClear { request_id, .. } => request_id,
249        }
250    }
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
254#[serde(tag = "type")]
255pub enum ServerEvent {
256    #[serde(rename = "daemon.status")]
257    DaemonStatus {
258        request_id: String,
259        status: DaemonStatus,
260    },
261    #[serde(rename = "initialized")]
262    Initialized {
263        request_id: String,
264        protocol_version: u32,
265        server: PeerInfo,
266    },
267    #[serde(rename = "session.started")]
268    SessionStarted {
269        request_id: String,
270        session_id: String,
271        cwd: String,
272        model: String,
273        context_max_tokens: usize,
274        max_server_frame_bytes: usize,
275        max_transcript_bytes: usize,
276        max_transcript_items: usize,
277        max_prompt_history_bytes: usize,
278        max_prompt_history_items: usize,
279    },
280    #[serde(rename = "queue.snapshot")]
281    QueueSnapshot {
282        request_id: Option<String>,
283        session_id: String,
284        seq: u64,
285        entries: Vec<QueueEntry>,
286        paused: bool,
287    },
288    #[serde(rename = "queue.enqueued")]
289    QueueEnqueued {
290        request_id: String,
291        session_id: String,
292        seq: u64,
293        entry: QueueEntry,
294        position: usize,
295    },
296    #[serde(rename = "queue.updated")]
297    QueueUpdated {
298        request_id: String,
299        session_id: String,
300        seq: u64,
301        entry: QueueEntry,
302    },
303    #[serde(rename = "queue.moved")]
304    QueueMoved {
305        request_id: String,
306        session_id: String,
307        seq: u64,
308        queue_id: String,
309        position: usize,
310        revision: u64,
311    },
312    #[serde(rename = "queue.removed")]
313    QueueRemoved {
314        request_id: String,
315        session_id: String,
316        seq: u64,
317        queue_id: String,
318        revision: u64,
319    },
320    #[serde(rename = "queue.dequeued")]
321    QueueDequeued {
322        request_id: String,
323        session_id: String,
324        seq: u64,
325        queue_id: String,
326        turn_id: String,
327    },
328    #[serde(rename = "session.paused")]
329    SessionPaused {
330        request_id: String,
331        session_id: String,
332        seq: u64,
333        paused: bool,
334    },
335    #[serde(rename = "turn.started")]
336    TurnStarted {
337        request_id: String,
338        session_id: String,
339        turn_id: String,
340        seq: u64,
341    },
342    #[serde(rename = "assistant.delta")]
343    AssistantDelta {
344        request_id: String,
345        session_id: String,
346        turn_id: String,
347        seq: u64,
348        content: String,
349    },
350    #[serde(rename = "assistant.completed")]
351    AssistantCompleted {
352        request_id: String,
353        session_id: String,
354        turn_id: String,
355        seq: u64,
356        content: String,
357    },
358    #[serde(rename = "tool.proposed")]
359    ToolProposed {
360        request_id: String,
361        session_id: String,
362        turn_id: String,
363        seq: u64,
364        call_id: String,
365        name: String,
366        arguments: Value,
367    },
368    #[serde(rename = "approval.requested")]
369    ApprovalRequested {
370        request_id: String,
371        session_id: String,
372        turn_id: String,
373        seq: u64,
374        approval_id: String,
375        call_id: String,
376        name: String,
377        risk: String,
378        cwd: String,
379        summary: String,
380    },
381    #[serde(rename = "tool.started")]
382    ToolStarted {
383        request_id: String,
384        session_id: String,
385        turn_id: String,
386        seq: u64,
387        call_id: String,
388        name: String,
389    },
390    #[serde(rename = "tool.completed")]
391    ToolCompleted {
392        request_id: String,
393        session_id: String,
394        turn_id: String,
395        seq: u64,
396        call_id: String,
397        name: String,
398        success: bool,
399        output: String,
400        truncated: bool,
401    },
402    #[serde(rename = "context.compacted")]
403    ContextCompacted {
404        request_id: String,
405        session_id: String,
406        turn_id: String,
407        seq: u64,
408        before_tokens: usize,
409        after_tokens: usize,
410        removed_messages: usize,
411    },
412    #[serde(rename = "session.trimmed")]
413    SessionTrimmed {
414        request_id: String,
415        session_id: String,
416        seq: u64,
417        removed_messages: usize,
418        history_bytes: usize,
419    },
420    #[serde(rename = "session.cleared")]
421    SessionCleared {
422        request_id: String,
423        session_id: String,
424        seq: u64,
425    },
426    #[serde(rename = "turn.completed")]
427    TurnCompleted {
428        request_id: String,
429        session_id: String,
430        turn_id: String,
431        seq: u64,
432        steps: usize,
433        usage: Usage,
434    },
435    #[serde(rename = "turn.cancelled")]
436    TurnCancelled {
437        request_id: String,
438        session_id: String,
439        turn_id: String,
440        seq: u64,
441    },
442    #[serde(rename = "turn.failed")]
443    TurnFailed {
444        request_id: String,
445        session_id: String,
446        turn_id: String,
447        seq: u64,
448        code: String,
449        message: String,
450    },
451    #[serde(rename = "error")]
452    Error {
453        #[serde(skip_serializing_if = "Option::is_none")]
454        request_id: Option<String>,
455        code: String,
456        message: String,
457        fatal: bool,
458    },
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464
465    #[test]
466    fn client_message_round_trip() {
467        let message = ClientMessage::TurnStart {
468            request_id: "3".into(),
469            session_id: "session".into(),
470            prompt: "hello".into(),
471        };
472        let json = serde_json::to_string(&message).unwrap();
473        assert!(json.contains("\"type\":\"turn.start\""));
474        assert_eq!(
475            serde_json::from_str::<ClientMessage>(&json).unwrap(),
476            message
477        );
478    }
479
480    #[test]
481    fn additive_fields_are_ignored() {
482        let json = r#"{"type":"session.clear","request_id":"1","session_id":"s","future":true}"#;
483        assert!(matches!(
484            serde_json::from_str::<ClientMessage>(json).unwrap(),
485            ClientMessage::SessionClear { .. }
486        ));
487    }
488
489    #[test]
490    fn event_round_trip() {
491        let event = ServerEvent::AssistantDelta {
492            request_id: "1".into(),
493            session_id: "s".into(),
494            turn_id: "t".into(),
495            seq: 4,
496            content: "hello".into(),
497        };
498        let encoded = serde_json::to_string(&event).unwrap();
499        assert_eq!(
500            serde_json::from_str::<ServerEvent>(&encoded).unwrap(),
501            event
502        );
503    }
504
505    #[test]
506    fn queue_messages_and_events_round_trip() {
507        let message = ClientMessage::QueueMove {
508            request_id: "q1".into(),
509            session_id: "s".into(),
510            queue_id: "q".into(),
511            revision: 2,
512            before_queue_id: None,
513        };
514        let encoded = serde_json::to_string(&message).unwrap();
515        assert_eq!(
516            serde_json::from_str::<ClientMessage>(&encoded).unwrap(),
517            message
518        );
519        let event = ServerEvent::QueueSnapshot {
520            request_id: None,
521            session_id: "s".into(),
522            seq: 4,
523            entries: vec![QueueEntry {
524                queue_id: "q".into(),
525                revision: 1,
526                prompt: "hello".into(),
527                submitter: "cli".into(),
528            }],
529            paused: false,
530        };
531        let encoded = serde_json::to_string(&event).unwrap();
532        assert_eq!(
533            serde_json::from_str::<ServerEvent>(&encoded).unwrap(),
534            event
535        );
536    }
537
538    #[test]
539    fn remote_tools_fields_are_additive() {
540        let legacy: DaemonCommand = serde_json::from_str(
541            r#"{"action":"clawbot_set","account":"a","enabled":true,"workspace":null}"#,
542        )
543        .unwrap();
544        assert!(matches!(
545            legacy,
546            DaemonCommand::ClawbotSet {
547                remote_tools: None,
548                ..
549            }
550        ));
551        let owner = DaemonCommand::ClawbotSet {
552            account: "a".into(),
553            enabled: true,
554            workspace: None,
555            remote_tools: Some(RemoteTools::Owner),
556        };
557        let encoded = serde_json::to_string(&owner).unwrap();
558        assert!(encoded.contains(r#""remote_tools":"owner""#));
559        assert_eq!(
560            serde_json::from_str::<DaemonCommand>(&encoded).unwrap(),
561            owner
562        );
563        let health: ComponentHealth = serde_json::from_str(
564            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}"#,
565        )
566        .unwrap();
567        assert_eq!(health.remote_tools, RemoteTools::None);
568    }
569
570    #[test]
571    fn delegation_control_round_trips_and_older_status_still_parses() {
572        for (command, wire) in [
573            (
574                DaemonCommand::Delegations { all: true },
575                r#"{"action":"delegations","all":true}"#,
576            ),
577            (
578                DaemonCommand::DelegationKill {
579                    handle: Some("codex-3f9a2c".into()),
580                    orphans: false,
581                },
582                r#"{"action":"delegation_kill","handle":"codex-3f9a2c","orphans":false}"#,
583            ),
584        ] {
585            assert_eq!(serde_json::to_string(&command).unwrap(), wire);
586            assert_eq!(
587                serde_json::from_str::<DaemonCommand>(wire).unwrap(),
588                command
589            );
590        }
591        assert_eq!(
592            serde_json::from_str::<DaemonCommand>(r#"{"action":"delegation_kill","orphans":true}"#)
593                .unwrap(),
594            DaemonCommand::DelegationKill {
595                handle: None,
596                orphans: true
597            }
598        );
599        // A status from a daemon without delegation tracking.
600        let status: DaemonStatus =
601            serde_json::from_str(r#"{"version":"0.1.23","pid":7,"components":[]}"#).unwrap();
602        assert_eq!(status.delegations, DelegationSummary::default());
603    }
604}