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