Skip to main content

codeoid_protocol/
daemon.rs

1//! Daemon → Client messages.
2//!
3//! Everything the daemon can push over an attached WebSocket. Includes
4//! solicited responses (correlated by `request_id`) and unsolicited events
5//! (session messages, deltas, scrollback replay).
6//!
7//! # Wire format
8//!
9//! Per-variant `rename_all = "camelCase"` keeps field names in sync with
10//! the TS `protocol/types.ts` shape without relying on per-field
11//! `#[serde(rename = "…")]` hints. If you add a new variant, copy the
12//! attribute — the `wire_no_snake_case` test will fail CI otherwise.
13//!
14//! # Forward compatibility
15//!
16//! The trailing `Unknown` variant is a sink for any `type` field the daemon
17//! introduces that this crate doesn't know about. The TUI logs + ignores it,
18//! matching the daemon's "frontends ignore unknown kinds" design.
19
20use serde::{Deserialize, Serialize};
21use serde_json::Value;
22
23use crate::message::{MessageIdentity, SessionMessage, SessionMessageDelta};
24use crate::session::{SessionInfo, SessionStatus};
25
26/// Tagged union of every message the daemon can push to a client.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28#[serde(tag = "type")]
29pub enum DaemonMessage {
30    #[serde(rename = "auth.ok")]
31    AuthOk(AuthOkMsg),
32
33    #[serde(rename = "response.ok", rename_all = "camelCase")]
34    ResponseOk {
35        request_id: String,
36        #[serde(default, skip_serializing_if = "Option::is_none")]
37        data: Option<Value>,
38    },
39
40    #[serde(rename = "response.error", rename_all = "camelCase")]
41    ResponseError {
42        request_id: String,
43        error: String,
44        code: ErrorCode,
45    },
46
47    #[serde(rename = "session.list.result", rename_all = "camelCase")]
48    SessionListResult {
49        request_id: String,
50        sessions: Vec<SessionInfo>,
51    },
52
53    #[serde(rename = "models.list.result", rename_all = "camelCase")]
54    ModelsListResult {
55        request_id: String,
56        models: Vec<ModelInfo>,
57        /// True when these came from the live backend; false = built-in fallback.
58        live: bool,
59    },
60
61    #[serde(rename = "session.message")]
62    SessionMessage(SessionMessage),
63
64    #[serde(rename = "session.message.delta")]
65    SessionMessageDelta(SessionMessageDelta),
66
67    #[serde(rename = "session.status_change", rename_all = "camelCase")]
68    SessionStatusChange {
69        session_id: String,
70        status: SessionStatus,
71        timestamp: String,
72    },
73
74    #[serde(rename = "session.info_update", rename_all = "camelCase")]
75    SessionInfoUpdate {
76        session: SessionInfo,
77        timestamp: String,
78    },
79
80    #[serde(rename = "scrollback.replay", rename_all = "camelCase")]
81    ScrollbackReplay {
82        session_id: String,
83        messages: Vec<SessionMessage>,
84    },
85
86    #[serde(rename = "session.search.result", rename_all = "camelCase")]
87    SessionSearchResult {
88        request_id: String,
89        query: String,
90        sessions: Vec<SessionSearchHit>,
91        workspace_id: String,
92        limit: u32,
93    },
94
95    #[serde(rename = "claude.config.result", rename_all = "camelCase")]
96    ClaudeConfigResult {
97        request_id: String,
98        workdir: String,
99        agents: Vec<ClaudeConfigAgent>,
100        skills: Vec<ClaudeConfigSkill>,
101        mcp_servers: Vec<ClaudeConfigMcpServer>,
102        hooks: Vec<ClaudeConfigHook>,
103    },
104
105    #[serde(rename = "session.export.result", rename_all = "camelCase")]
106    SessionExportResult {
107        request_id: String,
108        manifest: SessionExportManifest,
109        payload: SessionExportPayload,
110    },
111
112    #[serde(rename = "session.import.result", rename_all = "camelCase")]
113    SessionImportResult {
114        request_id: String,
115        new_session_id: String,
116        imported_messages: u32,
117        imported_episodes: u32,
118        imported_turns: u32,
119        pinned_files_written: u32,
120        warnings: Vec<String>,
121    },
122
123    /// Forward-compat sink. Preserves raw JSON so the TUI can log it.
124    #[serde(other)]
125    Unknown,
126}
127
128/// Where the config entry was loaded from.
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(rename_all = "snake_case")]
131pub enum ClaudeConfigScope {
132    Global,
133    Workdir,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
137#[serde(rename_all = "camelCase")]
138pub struct ClaudeConfigAgent {
139    pub name: String,
140    pub description: Option<String>,
141    pub path: String,
142    pub scope: ClaudeConfigScope,
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub tools: Option<Vec<String>>,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
148#[serde(rename_all = "camelCase")]
149pub struct ClaudeConfigSkill {
150    pub name: String,
151    pub description: Option<String>,
152    pub path: String,
153    pub scope: ClaudeConfigScope,
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize)]
157#[serde(rename_all = "camelCase")]
158pub struct ClaudeConfigMcpServer {
159    pub name: String,
160    pub scope: ClaudeConfigScope,
161    pub path: String,
162    pub command: Option<String>,
163    pub args: Vec<String>,
164    pub env_keys: Vec<String>,
165    pub url: Option<String>,
166    #[serde(rename = "type")]
167    pub server_type: Option<String>,
168    /// HTTP-type MCP servers' header keys (values redacted at the daemon).
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub header_keys: Option<Vec<String>>,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
174#[serde(rename_all = "camelCase")]
175pub struct SessionExportManifest {
176    pub exported_at: String,
177    pub session: SessionExportMetaSlim,
178    pub workdir: SessionExportWorkdir,
179    pub counts: SessionExportCounts,
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize)]
183#[serde(rename_all = "camelCase")]
184pub struct SessionExportMetaSlim {
185    pub id: String,
186    pub name: String,
187    pub created_at: String,
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub model: Option<String>,
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub mode: Option<String>,
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize)]
195#[serde(rename_all = "camelCase")]
196pub struct SessionExportWorkdir {
197    pub alias: String,
198    pub alias_source: String,
199    pub original_absolute: String,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
203#[serde(rename_all = "camelCase")]
204pub struct SessionExportCounts {
205    pub messages: u32,
206    pub episodes: u32,
207    pub turns: u32,
208    pub pinned_files: u32,
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize)]
212#[serde(tag = "kind", rename_all = "camelCase")]
213pub enum SessionExportPayload {
214    Inline {
215        bundle: serde_json::Value,
216        size_bytes: u64,
217    },
218    File {
219        path: String,
220        size_bytes: u64,
221    },
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize)]
225#[serde(rename_all = "camelCase")]
226pub struct ClaudeConfigHook {
227    pub event: String,
228    pub scope: ClaudeConfigScope,
229    pub path: String,
230    pub matcher: Option<String>,
231    pub kind: String,
232    pub command: String,
233}
234
235/// One selectable model as reported by the Claude Code backend. Mirrors
236/// `ModelInfo` in `codeoid/src/protocol/types.ts`.
237#[derive(Debug, Clone, Serialize, Deserialize)]
238#[serde(rename_all = "camelCase")]
239pub struct ModelInfo {
240    /// Value passed to `/model` and forwarded to the SDK (e.g. `"opus[1m]"`).
241    pub value: String,
242    /// Human label (e.g. `"Opus"`).
243    pub display_name: String,
244    /// Optional one-line description from the backend.
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub description: Option<String>,
247    /// True for the backend's recommended default.
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub is_default: Option<bool>,
250}
251
252/// Sent after a successful auth handshake, before any other traffic.
253#[derive(Debug, Clone, Serialize, Deserialize)]
254#[serde(rename_all = "camelCase")]
255pub struct AuthOkMsg {
256    pub identity: MessageIdentity,
257    pub scopes: Vec<String>,
258    /// Wire-protocol version the daemon speaks. Compare against
259    /// [`crate::PROTOCOL_VERSION`]. `None` means a pre-v1 daemon that didn't
260    /// send the field — treat as version 0.
261    #[serde(default, skip_serializing_if = "Option::is_none")]
262    pub protocol_version: Option<u32>,
263}
264
265#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
266#[serde(rename_all = "snake_case")]
267pub enum ErrorCode {
268    Unauthorized,
269    Forbidden,
270    NotFound,
271    InvalidRequest,
272    RateLimited,
273    Internal,
274}
275
276/// Per-session hit returned by `session.search`.
277#[derive(Debug, Clone, Serialize, Deserialize)]
278#[serde(rename_all = "camelCase")]
279pub struct SessionSearchHit {
280    pub session_id: String,
281    pub session_name: String,
282    pub workdir: String,
283    pub match_count: u32,
284    pub first_match_at: i64,
285    pub last_match_at: i64,
286    pub aggregate_score: f64,
287    pub snippets: Vec<SessionSearchSnippet>,
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize)]
291#[serde(rename_all = "camelCase")]
292pub struct SessionSearchSnippet {
293    pub episode_id: String,
294    pub kind: SearchSnippetKind,
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub tool_name: Option<String>,
297    pub summary: String,
298    pub excerpt: String,
299    pub created_at: i64,
300    pub score: f64,
301    pub file_paths: Vec<String>,
302}
303
304#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
305#[serde(rename_all = "snake_case")]
306pub enum SearchSnippetKind {
307    UserTurn,
308    AssistantTurn,
309    ToolCall,
310    Error,
311}