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 = 3;
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        /// Delegation depth of the client, when it is itself a delegated
173        /// agent (such as a nested SCV). Tools started from the session count
174        /// from it, so the depth limit holds across processes.
175        #[serde(default, skip_serializing_if = "Option::is_none")]
176        delegation_depth: Option<u32>,
177    },
178    #[serde(rename = "session.attach")]
179    SessionAttach {
180        request_id: String,
181        session_id: String,
182        cwd: String,
183    },
184    #[serde(rename = "turn.start")]
185    TurnStart {
186        request_id: String,
187        session_id: String,
188        prompt: String,
189    },
190    #[serde(rename = "queue.update")]
191    QueueUpdate {
192        request_id: String,
193        session_id: String,
194        queue_id: String,
195        revision: u64,
196        prompt: String,
197    },
198    #[serde(rename = "queue.move")]
199    QueueMove {
200        request_id: String,
201        session_id: String,
202        queue_id: String,
203        revision: u64,
204        before_queue_id: Option<String>,
205    },
206    #[serde(rename = "queue.remove")]
207    QueueRemove {
208        request_id: String,
209        session_id: String,
210        queue_id: String,
211        revision: u64,
212    },
213    #[serde(rename = "session.pause")]
214    SessionPause {
215        request_id: String,
216        session_id: String,
217        paused: bool,
218    },
219    #[serde(rename = "turn.cancel")]
220    TurnCancel {
221        request_id: String,
222        session_id: String,
223        turn_id: String,
224    },
225    #[serde(rename = "approval.resolve")]
226    ApprovalResolve {
227        request_id: String,
228        session_id: String,
229        approval_id: String,
230        approved: bool,
231    },
232    #[serde(rename = "session.clear")]
233    SessionClear {
234        request_id: String,
235        session_id: String,
236    },
237}
238
239impl ClientMessage {
240    pub fn request_id(&self) -> &str {
241        match self {
242            Self::Initialize { request_id, .. }
243            | Self::DaemonControl { request_id, .. }
244            | Self::SessionStart { request_id, .. }
245            | Self::SessionAttach { request_id, .. }
246            | Self::TurnStart { request_id, .. }
247            | Self::QueueUpdate { request_id, .. }
248            | Self::QueueMove { request_id, .. }
249            | Self::QueueRemove { request_id, .. }
250            | Self::SessionPause { request_id, .. }
251            | Self::TurnCancel { request_id, .. }
252            | Self::ApprovalResolve { request_id, .. }
253            | Self::SessionClear { request_id, .. } => request_id,
254        }
255    }
256}
257
258#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
259#[serde(tag = "type")]
260pub enum ServerEvent {
261    #[serde(rename = "daemon.status")]
262    DaemonStatus {
263        request_id: String,
264        status: DaemonStatus,
265    },
266    #[serde(rename = "initialized")]
267    Initialized {
268        request_id: String,
269        protocol_version: u32,
270        server: PeerInfo,
271    },
272    #[serde(rename = "session.started")]
273    SessionStarted {
274        request_id: String,
275        session_id: String,
276        cwd: String,
277        model: String,
278        context_max_tokens: usize,
279        max_server_frame_bytes: usize,
280        max_transcript_bytes: usize,
281        max_transcript_items: usize,
282        max_prompt_history_bytes: usize,
283        max_prompt_history_items: usize,
284    },
285    #[serde(rename = "queue.snapshot")]
286    QueueSnapshot {
287        request_id: Option<String>,
288        session_id: String,
289        seq: u64,
290        entries: Vec<QueueEntry>,
291        paused: bool,
292    },
293    #[serde(rename = "queue.enqueued")]
294    QueueEnqueued {
295        request_id: String,
296        session_id: String,
297        seq: u64,
298        entry: QueueEntry,
299        position: usize,
300    },
301    #[serde(rename = "queue.updated")]
302    QueueUpdated {
303        request_id: String,
304        session_id: String,
305        seq: u64,
306        entry: QueueEntry,
307    },
308    #[serde(rename = "queue.moved")]
309    QueueMoved {
310        request_id: String,
311        session_id: String,
312        seq: u64,
313        queue_id: String,
314        position: usize,
315        revision: u64,
316    },
317    #[serde(rename = "queue.removed")]
318    QueueRemoved {
319        request_id: String,
320        session_id: String,
321        seq: u64,
322        queue_id: String,
323        revision: u64,
324    },
325    #[serde(rename = "queue.dequeued")]
326    QueueDequeued {
327        request_id: String,
328        session_id: String,
329        seq: u64,
330        queue_id: String,
331        turn_id: String,
332    },
333    #[serde(rename = "session.paused")]
334    SessionPaused {
335        request_id: String,
336        session_id: String,
337        seq: u64,
338        paused: bool,
339    },
340    #[serde(rename = "turn.started")]
341    TurnStarted {
342        request_id: String,
343        session_id: String,
344        turn_id: String,
345        seq: u64,
346    },
347    #[serde(rename = "assistant.delta")]
348    AssistantDelta {
349        request_id: String,
350        session_id: String,
351        turn_id: String,
352        seq: u64,
353        content: String,
354    },
355    #[serde(rename = "assistant.completed")]
356    AssistantCompleted {
357        request_id: String,
358        session_id: String,
359        turn_id: String,
360        seq: u64,
361        content: String,
362    },
363    #[serde(rename = "tool.proposed")]
364    ToolProposed {
365        request_id: String,
366        session_id: String,
367        turn_id: String,
368        seq: u64,
369        call_id: String,
370        name: String,
371        arguments: Value,
372    },
373    #[serde(rename = "approval.requested")]
374    ApprovalRequested {
375        request_id: String,
376        session_id: String,
377        turn_id: String,
378        seq: u64,
379        approval_id: String,
380        call_id: String,
381        name: String,
382        risk: String,
383        cwd: String,
384        summary: String,
385    },
386    #[serde(rename = "tool.started")]
387    ToolStarted {
388        request_id: String,
389        session_id: String,
390        turn_id: String,
391        seq: u64,
392        call_id: String,
393        name: String,
394    },
395    /// Short status lines from a running tool, at most two events a second
396    /// per call and 512 bytes each. Display only; not part of the history.
397    #[serde(rename = "tool.progress")]
398    ToolProgress {
399        request_id: String,
400        session_id: String,
401        turn_id: String,
402        seq: u64,
403        call_id: String,
404        text: String,
405    },
406    #[serde(rename = "tool.completed")]
407    ToolCompleted {
408        request_id: String,
409        session_id: String,
410        turn_id: String,
411        seq: u64,
412        call_id: String,
413        name: String,
414        success: bool,
415        output: String,
416        truncated: bool,
417    },
418    #[serde(rename = "context.compacted")]
419    ContextCompacted {
420        request_id: String,
421        session_id: String,
422        turn_id: String,
423        seq: u64,
424        before_tokens: usize,
425        after_tokens: usize,
426        removed_messages: usize,
427    },
428    #[serde(rename = "session.trimmed")]
429    SessionTrimmed {
430        request_id: String,
431        session_id: String,
432        seq: u64,
433        removed_messages: usize,
434        history_bytes: usize,
435    },
436    #[serde(rename = "session.cleared")]
437    SessionCleared {
438        request_id: String,
439        session_id: String,
440        seq: u64,
441    },
442    #[serde(rename = "turn.completed")]
443    TurnCompleted {
444        request_id: String,
445        session_id: String,
446        turn_id: String,
447        seq: u64,
448        steps: usize,
449        usage: Usage,
450    },
451    #[serde(rename = "turn.cancelled")]
452    TurnCancelled {
453        request_id: String,
454        session_id: String,
455        turn_id: String,
456        seq: u64,
457    },
458    #[serde(rename = "turn.failed")]
459    TurnFailed {
460        request_id: String,
461        session_id: String,
462        turn_id: String,
463        seq: u64,
464        code: String,
465        message: String,
466    },
467    #[serde(rename = "error")]
468    Error {
469        #[serde(skip_serializing_if = "Option::is_none")]
470        request_id: Option<String>,
471        code: String,
472        message: String,
473        fatal: bool,
474    },
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480
481    #[test]
482    fn client_message_round_trip() {
483        let message = ClientMessage::TurnStart {
484            request_id: "3".into(),
485            session_id: "session".into(),
486            prompt: "hello".into(),
487        };
488        let json = serde_json::to_string(&message).unwrap();
489        assert!(json.contains("\"type\":\"turn.start\""));
490        assert_eq!(
491            serde_json::from_str::<ClientMessage>(&json).unwrap(),
492            message
493        );
494    }
495
496    #[test]
497    fn tool_progress_and_delegation_depth_round_trip() {
498        let event = ServerEvent::ToolProgress {
499            request_id: "r".into(),
500            session_id: "s".into(),
501            turn_id: "t".into(),
502            seq: 4,
503            call_id: "c".into(),
504            text: "$ cargo test\nupdate …/src/lib.rs".into(),
505        };
506        let wire = serde_json::to_string(&event).unwrap();
507        assert!(wire.contains(r#""type":"tool.progress""#));
508        assert_eq!(serde_json::from_str::<ServerEvent>(&wire).unwrap(), event);
509
510        let start = |depth| ClientMessage::SessionStart {
511            request_id: "1".into(),
512            cwd: "/w".into(),
513            provider: None,
514            model: None,
515            base_url: None,
516            no_tools: None,
517            delegation_depth: depth,
518        };
519        let nested = serde_json::to_string(&start(Some(2))).unwrap();
520        assert!(nested.contains(r#""delegation_depth":2"#));
521        assert_eq!(
522            serde_json::from_str::<ClientMessage>(&nested).unwrap(),
523            start(Some(2))
524        );
525        // Omitted when unset, and optional on the wire.
526        let direct = serde_json::to_string(&start(None)).unwrap();
527        assert!(!direct.contains("delegation_depth"));
528        let older = r#"{"type":"session.start","request_id":"1","cwd":"/w"}"#;
529        assert_eq!(
530            serde_json::from_str::<ClientMessage>(older).unwrap(),
531            start(None)
532        );
533        assert_eq!(PROTOCOL_VERSION, 3);
534    }
535
536    #[test]
537    fn additive_fields_are_ignored() {
538        let json = r#"{"type":"session.clear","request_id":"1","session_id":"s","future":true}"#;
539        assert!(matches!(
540            serde_json::from_str::<ClientMessage>(json).unwrap(),
541            ClientMessage::SessionClear { .. }
542        ));
543    }
544
545    #[test]
546    fn event_round_trip() {
547        let event = ServerEvent::AssistantDelta {
548            request_id: "1".into(),
549            session_id: "s".into(),
550            turn_id: "t".into(),
551            seq: 4,
552            content: "hello".into(),
553        };
554        let encoded = serde_json::to_string(&event).unwrap();
555        assert_eq!(
556            serde_json::from_str::<ServerEvent>(&encoded).unwrap(),
557            event
558        );
559    }
560
561    #[test]
562    fn queue_messages_and_events_round_trip() {
563        let message = ClientMessage::QueueMove {
564            request_id: "q1".into(),
565            session_id: "s".into(),
566            queue_id: "q".into(),
567            revision: 2,
568            before_queue_id: None,
569        };
570        let encoded = serde_json::to_string(&message).unwrap();
571        assert_eq!(
572            serde_json::from_str::<ClientMessage>(&encoded).unwrap(),
573            message
574        );
575        let event = ServerEvent::QueueSnapshot {
576            request_id: None,
577            session_id: "s".into(),
578            seq: 4,
579            entries: vec![QueueEntry {
580                queue_id: "q".into(),
581                revision: 1,
582                prompt: "hello".into(),
583                submitter: "cli".into(),
584            }],
585            paused: false,
586        };
587        let encoded = serde_json::to_string(&event).unwrap();
588        assert_eq!(
589            serde_json::from_str::<ServerEvent>(&encoded).unwrap(),
590            event
591        );
592    }
593
594    #[test]
595    fn remote_tools_fields_are_additive() {
596        let legacy: DaemonCommand = serde_json::from_str(
597            r#"{"action":"clawbot_set","account":"a","enabled":true,"workspace":null}"#,
598        )
599        .unwrap();
600        assert!(matches!(
601            legacy,
602            DaemonCommand::ClawbotSet {
603                remote_tools: None,
604                ..
605            }
606        ));
607        let owner = DaemonCommand::ClawbotSet {
608            account: "a".into(),
609            enabled: true,
610            workspace: None,
611            remote_tools: Some(RemoteTools::Owner),
612        };
613        let encoded = serde_json::to_string(&owner).unwrap();
614        assert!(encoded.contains(r#""remote_tools":"owner""#));
615        assert_eq!(
616            serde_json::from_str::<DaemonCommand>(&encoded).unwrap(),
617            owner
618        );
619        let health: ComponentHealth = serde_json::from_str(
620            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}"#,
621        )
622        .unwrap();
623        assert_eq!(health.remote_tools, RemoteTools::None);
624    }
625
626    #[test]
627    fn delegation_control_round_trips_and_older_status_still_parses() {
628        for (command, wire) in [
629            (
630                DaemonCommand::Delegations { all: true },
631                r#"{"action":"delegations","all":true}"#,
632            ),
633            (
634                DaemonCommand::DelegationKill {
635                    handle: Some("codex-3f9a2c".into()),
636                    orphans: false,
637                },
638                r#"{"action":"delegation_kill","handle":"codex-3f9a2c","orphans":false}"#,
639            ),
640        ] {
641            assert_eq!(serde_json::to_string(&command).unwrap(), wire);
642            assert_eq!(
643                serde_json::from_str::<DaemonCommand>(wire).unwrap(),
644                command
645            );
646        }
647        assert_eq!(
648            serde_json::from_str::<DaemonCommand>(r#"{"action":"delegation_kill","orphans":true}"#)
649                .unwrap(),
650            DaemonCommand::DelegationKill {
651                handle: None,
652                orphans: true
653            }
654        );
655        // A status from a daemon without delegation tracking.
656        let status: DaemonStatus =
657            serde_json::from_str(r#"{"version":"0.1.23","pid":7,"components":[]}"#).unwrap();
658        assert_eq!(status.delegations, DelegationSummary::default());
659    }
660}