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