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 #[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}
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 #[serde(default, skip_serializing_if = "Option::is_none")]
100 remote_tools: Option<RemoteTools>,
101 },
102 ClawbotLogout {
103 account: String,
104 },
105 Delegations {
107 #[serde(default)]
108 all: bool,
109 },
110 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 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}