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        /// Which backend this catalog is for — lets the client drop a stale
60        /// result after a fast backend switch. Absent on older daemons.
61        #[serde(default)]
62        provider: Option<String>,
63    },
64
65    #[serde(rename = "session.message")]
66    SessionMessage(SessionMessage),
67
68    #[serde(rename = "session.message.delta")]
69    SessionMessageDelta(SessionMessageDelta),
70
71    #[serde(rename = "session.status_change", rename_all = "camelCase")]
72    SessionStatusChange {
73        session_id: String,
74        status: SessionStatus,
75        timestamp: String,
76    },
77
78    #[serde(rename = "session.info_update", rename_all = "camelCase")]
79    SessionInfoUpdate {
80        session: SessionInfo,
81        timestamp: String,
82    },
83
84    #[serde(rename = "scrollback.replay", rename_all = "camelCase")]
85    ScrollbackReplay {
86        session_id: String,
87        messages: Vec<SessionMessage>,
88        /// `scrollback.paging`: this snapshot is only the NEWEST window;
89        /// older history is fetched on demand via `scrollback.page`.
90        #[serde(default)]
91        tail: Option<bool>,
92        /// With `tail: true` — whether history older than the window exists.
93        #[serde(default)]
94        has_more: Option<bool>,
95    },
96
97    /// Answer to `scrollback.page` — history strictly OLDER than the anchor,
98    /// oldest→newest; the client PREPENDS (dedup by message id).
99    #[serde(rename = "scrollback.page.result", rename_all = "camelCase")]
100    ScrollbackPageResult {
101        request_id: String,
102        session_id: String,
103        messages: Vec<SessionMessage>,
104        has_more: bool,
105        /// "buffer" | "transcript" — diagnostics only; kept as a string so
106        /// new sources stay wire-additive.
107        source: String,
108    },
109
110    #[serde(rename = "session.search.result", rename_all = "camelCase")]
111    SessionSearchResult {
112        request_id: String,
113        query: String,
114        sessions: Vec<SessionSearchHit>,
115        workspace_id: String,
116        limit: u32,
117    },
118
119    #[serde(rename = "claude.config.result", rename_all = "camelCase")]
120    ClaudeConfigResult {
121        request_id: String,
122        workdir: String,
123        agents: Vec<ClaudeConfigAgent>,
124        skills: Vec<ClaudeConfigSkill>,
125        mcp_servers: Vec<ClaudeConfigMcpServer>,
126        hooks: Vec<ClaudeConfigHook>,
127    },
128
129    #[serde(rename = "session.export.result", rename_all = "camelCase")]
130    SessionExportResult {
131        request_id: String,
132        manifest: SessionExportManifest,
133        payload: SessionExportPayload,
134    },
135
136    #[serde(rename = "session.import.result", rename_all = "camelCase")]
137    SessionImportResult {
138        request_id: String,
139        new_session_id: String,
140        imported_messages: u32,
141        imported_episodes: u32,
142        imported_turns: u32,
143        pinned_files_written: u32,
144        warnings: Vec<String>,
145    },
146
147    /// Provider-initiated dialog (extension confirm gates, pick-one lists,
148    /// free text). Only sent to clients that declared the `ui.dialogs`
149    /// capability on their auth frame; answered with
150    /// [`ClientMessage::SessionUiResponse`](crate::client::ClientMessage::SessionUiResponse).
151    /// The daemon re-sends pending requests on attach and enforces
152    /// `timeout_ms` itself — clients only display the countdown.
153    #[serde(rename = "session.ui_request", rename_all = "camelCase")]
154    SessionUiRequest(SessionUiRequestMsg),
155
156    /// A dialog settled (answered here or elsewhere, timed out, or the turn
157    /// was interrupted). Dismiss the local copy; unknown reasons = dismiss.
158    #[serde(rename = "session.ui_resolved", rename_all = "camelCase")]
159    SessionUiResolved {
160        session_id: String,
161        request_id: String,
162        reason: UiResolvedReason,
163        timestamp: String,
164    },
165
166    /// Reply to `session.commands` — the backing provider's slash-command
167    /// catalog (extension commands, prompt templates, skills). Invoke by
168    /// sending `"/name args"` as plain `session.send` text.
169    #[serde(rename = "session.commands.result", rename_all = "camelCase")]
170    SessionCommandsResult {
171        request_id: String,
172        session_id: String,
173        provider_id: String,
174        commands: Vec<ProviderCommand>,
175    },
176
177    /// Forward-compat sink. Preserves raw JSON so the TUI can log it.
178    #[serde(other)]
179    Unknown,
180}
181
182/// Dialog flavor on a [`SessionUiRequestMsg`].
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
184#[serde(rename_all = "snake_case")]
185pub enum UiRequestMethod {
186    Select,
187    Confirm,
188    Input,
189    Editor,
190}
191
192/// Why a `session.ui_resolved` fired. `Other` sinks future reasons — every
193/// reason means "dismiss the local copy".
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
195#[serde(rename_all = "snake_case")]
196pub enum UiResolvedReason {
197    Answered,
198    Cancelled,
199    Timeout,
200    Interrupted,
201    #[serde(other)]
202    Other,
203}
204
205/// Payload of [`DaemonMessage::SessionUiRequest`].
206#[derive(Debug, Clone, Serialize, Deserialize)]
207#[serde(rename_all = "camelCase")]
208pub struct SessionUiRequestMsg {
209    pub session_id: String,
210    /// Echo back on `session.ui_response`.
211    pub request_id: String,
212    pub method: UiRequestMethod,
213    pub title: String,
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub message: Option<String>,
216    /// Choices for `method: select`.
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub options: Option<Vec<String>>,
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub placeholder: Option<String>,
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub prefill: Option<String>,
223    /// Auto-cancel deadline in ms from `timestamp` (daemon-enforced).
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub timeout_ms: Option<u64>,
226    pub timestamp: String,
227}
228
229/// One provider-defined slash command (see `SessionCommandsResult`).
230#[derive(Debug, Clone, Serialize, Deserialize)]
231#[serde(rename_all = "camelCase")]
232pub struct ProviderCommand {
233    /// Invokable name without the leading slash.
234    pub name: String,
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub description: Option<String>,
237    /// Provider-specific origin taxonomy (e.g. "extension" | "prompt" |
238    /// "skill"). Display verbatim, never switch on it.
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub source: Option<String>,
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub argument_hint: Option<String>,
243}
244
245/// Where the config entry was loaded from.
246#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
247#[serde(rename_all = "snake_case")]
248pub enum ClaudeConfigScope {
249    Global,
250    Workdir,
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize)]
254#[serde(rename_all = "camelCase")]
255pub struct ClaudeConfigAgent {
256    pub name: String,
257    pub description: Option<String>,
258    pub path: String,
259    pub scope: ClaudeConfigScope,
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub tools: Option<Vec<String>>,
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize)]
265#[serde(rename_all = "camelCase")]
266pub struct ClaudeConfigSkill {
267    pub name: String,
268    pub description: Option<String>,
269    pub path: String,
270    pub scope: ClaudeConfigScope,
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize)]
274#[serde(rename_all = "camelCase")]
275pub struct ClaudeConfigMcpServer {
276    pub name: String,
277    pub scope: ClaudeConfigScope,
278    pub path: String,
279    pub command: Option<String>,
280    pub args: Vec<String>,
281    pub env_keys: Vec<String>,
282    pub url: Option<String>,
283    #[serde(rename = "type")]
284    pub server_type: Option<String>,
285    /// HTTP-type MCP servers' header keys (values redacted at the daemon).
286    #[serde(default, skip_serializing_if = "Option::is_none")]
287    pub header_keys: Option<Vec<String>>,
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize)]
291#[serde(rename_all = "camelCase")]
292pub struct SessionExportManifest {
293    pub exported_at: String,
294    pub session: SessionExportMetaSlim,
295    pub workdir: SessionExportWorkdir,
296    pub counts: SessionExportCounts,
297}
298
299#[derive(Debug, Clone, Serialize, Deserialize)]
300#[serde(rename_all = "camelCase")]
301pub struct SessionExportMetaSlim {
302    pub id: String,
303    pub name: String,
304    pub created_at: String,
305    #[serde(default, skip_serializing_if = "Option::is_none")]
306    pub model: Option<String>,
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    pub mode: Option<String>,
309}
310
311#[derive(Debug, Clone, Serialize, Deserialize)]
312#[serde(rename_all = "camelCase")]
313pub struct SessionExportWorkdir {
314    pub alias: String,
315    pub alias_source: String,
316    pub original_absolute: String,
317}
318
319#[derive(Debug, Clone, Serialize, Deserialize)]
320#[serde(rename_all = "camelCase")]
321pub struct SessionExportCounts {
322    pub messages: u32,
323    pub episodes: u32,
324    pub turns: u32,
325    pub pinned_files: u32,
326}
327
328#[derive(Debug, Clone, Serialize, Deserialize)]
329#[serde(tag = "kind", rename_all = "camelCase")]
330pub enum SessionExportPayload {
331    Inline {
332        bundle: serde_json::Value,
333        size_bytes: u64,
334    },
335    File {
336        path: String,
337        size_bytes: u64,
338    },
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize)]
342#[serde(rename_all = "camelCase")]
343pub struct ClaudeConfigHook {
344    pub event: String,
345    pub scope: ClaudeConfigScope,
346    pub path: String,
347    pub matcher: Option<String>,
348    pub kind: String,
349    pub command: String,
350}
351
352/// One selectable model as reported by the Claude Code backend. Mirrors
353/// `ModelInfo` in `codeoid/src/protocol/types.ts`.
354#[derive(Debug, Clone, Serialize, Deserialize)]
355#[serde(rename_all = "camelCase")]
356pub struct ModelInfo {
357    /// Value passed to `/model` and forwarded to the SDK (e.g. `"opus[1m]"`).
358    pub value: String,
359    /// Human label (e.g. `"Opus"`).
360    pub display_name: String,
361    /// Optional one-line description from the backend.
362    #[serde(default, skip_serializing_if = "Option::is_none")]
363    pub description: Option<String>,
364    /// True for the backend's recommended default.
365    #[serde(default, skip_serializing_if = "Option::is_none")]
366    pub is_default: Option<bool>,
367}
368
369/// Sent after a successful auth handshake, before any other traffic.
370#[derive(Debug, Clone, Serialize, Deserialize)]
371#[serde(rename_all = "camelCase")]
372pub struct AuthOkMsg {
373    pub identity: MessageIdentity,
374    pub scopes: Vec<String>,
375    /// Wire-protocol version the daemon speaks. Compare against
376    /// [`crate::PROTOCOL_VERSION`]. `None` means a pre-v1 daemon that didn't
377    /// send the field — treat as version 0.
378    #[serde(default, skip_serializing_if = "Option::is_none")]
379    pub protocol_version: Option<u32>,
380    /// Capability identifiers the daemon supports (e.g. `commands.dynamic`,
381    /// `ui.dialogs`). Feature-detect on these instead of version-sniffing.
382    /// `None` on daemons that predate capability negotiation.
383    #[serde(default, skip_serializing_if = "Option::is_none")]
384    pub capabilities: Option<Vec<String>>,
385    /// Provider ids registered on this daemon, default first (feeds the
386    /// `/provider` command and the `/new --provider` flag). `None` on
387    /// daemons that predate multi-provider session creation.
388    #[serde(default, skip_serializing_if = "Option::is_none")]
389    pub providers: Option<Vec<String>>,
390}
391
392#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
393#[serde(rename_all = "snake_case")]
394pub enum ErrorCode {
395    Unauthorized,
396    Forbidden,
397    NotFound,
398    InvalidRequest,
399    RateLimited,
400    Internal,
401}
402
403/// Per-session hit returned by `session.search`.
404#[derive(Debug, Clone, Serialize, Deserialize)]
405#[serde(rename_all = "camelCase")]
406pub struct SessionSearchHit {
407    pub session_id: String,
408    pub session_name: String,
409    pub workdir: String,
410    pub match_count: u32,
411    pub first_match_at: i64,
412    pub last_match_at: i64,
413    pub aggregate_score: f64,
414    pub snippets: Vec<SessionSearchSnippet>,
415}
416
417#[derive(Debug, Clone, Serialize, Deserialize)]
418#[serde(rename_all = "camelCase")]
419pub struct SessionSearchSnippet {
420    pub episode_id: String,
421    pub kind: SearchSnippetKind,
422    #[serde(default, skip_serializing_if = "Option::is_none")]
423    pub tool_name: Option<String>,
424    pub summary: String,
425    pub excerpt: String,
426    pub created_at: i64,
427    pub score: f64,
428    pub file_paths: Vec<String>,
429}
430
431#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
432#[serde(rename_all = "snake_case")]
433pub enum SearchSnippetKind {
434    UserTurn,
435    AssistantTurn,
436    ToolCall,
437    Error,
438}