1use 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 #[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 #[serde(default)]
54 pub delegations: DelegationSummary,
55}
56
57#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
59pub struct DelegationSummary {
60 pub active: u64,
62 pub reaped: u64,
64 #[serde(default, skip_serializing_if = "Vec::is_empty")]
66 pub entries: Vec<DelegationInfo>,
67 #[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 pub owner_pid: u32,
81 pub processes: u32,
83 pub cwd: String,
84 pub started_unix_seconds: u64,
85 pub orphaned: bool,
87 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
105 remote_tools: Option<RemoteTools>,
106 },
107 ClawbotLogout {
108 account: String,
109 },
110 Delegations {
112 #[serde(default)]
113 all: bool,
114 },
115 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 #[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 #[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 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 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}