1use 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 #[serde(default)]
34 pub remote_tools: RemoteTools,
35}
36
37#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
39#[serde(rename_all = "snake_case")]
40pub enum RemoteTools {
41 #[default]
43 None,
44 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 #[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}