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 std::collections::HashMap;
21
22use serde::{Deserialize, Serialize};
23use serde_json::Value;
24
25use crate::message::{MessageIdentity, SessionMessage, SessionMessageDelta};
26use crate::session::{SessionInfo, SessionStatus};
27
28/// Tagged union of every message the daemon can push to a client.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30#[serde(tag = "type")]
31pub enum DaemonMessage {
32    #[serde(rename = "auth.ok")]
33    AuthOk(AuthOkMsg),
34
35    #[serde(rename = "response.ok", rename_all = "camelCase")]
36    ResponseOk {
37        request_id: String,
38        #[serde(default, skip_serializing_if = "Option::is_none")]
39        data: Option<Value>,
40    },
41
42    #[serde(rename = "response.error", rename_all = "camelCase")]
43    ResponseError {
44        request_id: String,
45        error: String,
46        code: ErrorCode,
47    },
48
49    #[serde(rename = "session.list.result", rename_all = "camelCase")]
50    SessionListResult {
51        request_id: String,
52        sessions: Vec<SessionInfo>,
53    },
54
55    #[serde(rename = "models.list.result", rename_all = "camelCase")]
56    ModelsListResult {
57        request_id: String,
58        models: Vec<ModelInfo>,
59        /// True when these came from the live backend; false = built-in fallback.
60        live: bool,
61        /// Which backend this catalog is for — lets the client drop a stale
62        /// result after a fast backend switch. Absent on older daemons.
63        #[serde(default)]
64        provider: Option<String>,
65    },
66
67    #[serde(rename = "session.message")]
68    SessionMessage(SessionMessage),
69
70    #[serde(rename = "session.message.delta")]
71    SessionMessageDelta(SessionMessageDelta),
72
73    #[serde(rename = "session.status_change", rename_all = "camelCase")]
74    SessionStatusChange {
75        session_id: String,
76        status: SessionStatus,
77        timestamp: String,
78    },
79
80    #[serde(rename = "session.info_update", rename_all = "camelCase")]
81    SessionInfoUpdate {
82        session: SessionInfo,
83        timestamp: String,
84    },
85
86    #[serde(rename = "scrollback.replay", rename_all = "camelCase")]
87    ScrollbackReplay {
88        session_id: String,
89        messages: Vec<SessionMessage>,
90        /// `scrollback.paging`: this snapshot is only the NEWEST window;
91        /// older history is fetched on demand via `scrollback.page`.
92        #[serde(default)]
93        tail: Option<bool>,
94        /// With `tail: true` — whether history older than the window exists.
95        #[serde(default)]
96        has_more: Option<bool>,
97    },
98
99    /// Answer to `scrollback.page` — history strictly OLDER than the anchor,
100    /// oldest→newest; the client PREPENDS (dedup by message id).
101    #[serde(rename = "scrollback.page.result", rename_all = "camelCase")]
102    ScrollbackPageResult {
103        request_id: String,
104        session_id: String,
105        messages: Vec<SessionMessage>,
106        has_more: bool,
107        /// "buffer" | "transcript" — diagnostics only; kept as a string so
108        /// new sources stay wire-additive.
109        source: String,
110    },
111
112    #[serde(rename = "session.search.result", rename_all = "camelCase")]
113    SessionSearchResult {
114        request_id: String,
115        query: String,
116        sessions: Vec<SessionSearchHit>,
117        workspace_id: String,
118        limit: u32,
119    },
120
121    #[serde(rename = "claude.config.result", rename_all = "camelCase")]
122    ClaudeConfigResult {
123        request_id: String,
124        workdir: String,
125        agents: Vec<ClaudeConfigAgent>,
126        skills: Vec<ClaudeConfigSkill>,
127        mcp_servers: Vec<ClaudeConfigMcpServer>,
128        hooks: Vec<ClaudeConfigHook>,
129    },
130
131    #[serde(rename = "session.export.result", rename_all = "camelCase")]
132    SessionExportResult {
133        request_id: String,
134        manifest: SessionExportManifest,
135        payload: SessionExportPayload,
136    },
137
138    #[serde(rename = "session.import.result", rename_all = "camelCase")]
139    SessionImportResult {
140        request_id: String,
141        new_session_id: String,
142        imported_messages: u32,
143        imported_episodes: u32,
144        imported_turns: u32,
145        pinned_files_written: u32,
146        warnings: Vec<String>,
147    },
148
149    /// Provider-initiated dialog (extension confirm gates, pick-one lists,
150    /// free text). Only sent to clients that declared the `ui.dialogs`
151    /// capability on their auth frame; answered with
152    /// [`ClientMessage::SessionUiResponse`](crate::client::ClientMessage::SessionUiResponse).
153    /// The daemon re-sends pending requests on attach and enforces
154    /// `timeout_ms` itself — clients only display the countdown.
155    #[serde(rename = "session.ui_request", rename_all = "camelCase")]
156    SessionUiRequest(SessionUiRequestMsg),
157
158    /// A dialog settled (answered here or elsewhere, timed out, or the turn
159    /// was interrupted). Dismiss the local copy; unknown reasons = dismiss.
160    #[serde(rename = "session.ui_resolved", rename_all = "camelCase")]
161    SessionUiResolved {
162        session_id: String,
163        request_id: String,
164        reason: UiResolvedReason,
165        timestamp: String,
166    },
167
168    /// Reply to `session.commands` — the backing provider's slash-command
169    /// catalog (extension commands, prompt templates, skills). Invoke by
170    /// sending `"/name args"` as plain `session.send` text.
171    #[serde(rename = "session.commands.result", rename_all = "camelCase")]
172    SessionCommandsResult {
173        request_id: String,
174        session_id: String,
175        provider_id: String,
176        commands: Vec<ProviderCommand>,
177    },
178
179    /// Reply to `settings.schema` — the declarative settings manifest.
180    #[serde(rename = "settings.schema.result", rename_all = "camelCase")]
181    SettingsSchemaResult {
182        request_id: String,
183        manifest: SettingsManifest,
184    },
185
186    /// Reply to `settings.get` — current effective values + secret presence.
187    #[serde(rename = "settings.get.result", rename_all = "camelCase")]
188    SettingsGetResult {
189        request_id: String,
190        snapshot: SettingsSnapshot,
191    },
192
193    /// Reply to `settings.set` — outcome + the post-write snapshot.
194    #[serde(rename = "settings.set.result", rename_all = "camelCase")]
195    SettingsSetResult {
196        request_id: String,
197        ok: bool,
198        snapshot: SettingsSnapshot,
199        errors: Vec<SettingError>,
200        restart_required: bool,
201    },
202
203    /// Forward-compat sink. Preserves raw JSON so the TUI can log it.
204    #[serde(other)]
205    Unknown,
206}
207
208// ── Settings manifest + snapshot (mirrors codeoid/packages/protocol settings.ts) ──
209
210/// The declarative settings manifest served over `settings.schema`. Rendered
211/// generically — `kind` / `backing` / `source` are kept as strings so a new
212/// value the daemon introduces never breaks deserialization.
213#[derive(Debug, Clone, Serialize, Deserialize)]
214#[serde(rename_all = "camelCase")]
215pub struct SettingsManifest {
216    pub version: u32,
217    pub tabs: Vec<SettingsTab>,
218}
219
220#[derive(Debug, Clone, Serialize, Deserialize)]
221#[serde(rename_all = "camelCase")]
222pub struct SettingsTab {
223    pub id: String,
224    pub title: String,
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub icon: Option<String>,
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub description: Option<String>,
229    pub groups: Vec<SettingsGroup>,
230}
231
232#[derive(Debug, Clone, Serialize, Deserialize)]
233#[serde(rename_all = "camelCase")]
234pub struct SettingsGroup {
235    pub id: String,
236    pub title: String,
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub description: Option<String>,
239    pub fields: Vec<SettingField>,
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize)]
243#[serde(rename_all = "camelCase")]
244pub struct SettingField {
245    pub key: String,
246    pub label: String,
247    #[serde(default)]
248    pub help: String,
249    /// "string" | "boolean" | "int" | "float" | "enum" | "string[]" | "secret".
250    pub kind: String,
251    /// "config" | "env".
252    pub backing: String,
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub path: Option<String>,
255    #[serde(default, skip_serializing_if = "Option::is_none")]
256    pub env_var: Option<String>,
257    #[serde(default, skip_serializing_if = "Option::is_none")]
258    pub default: Option<Value>,
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub options: Option<Vec<SettingOption>>,
261    #[serde(default, skip_serializing_if = "Option::is_none")]
262    pub min: Option<f64>,
263    #[serde(default, skip_serializing_if = "Option::is_none")]
264    pub max: Option<f64>,
265    #[serde(default, skip_serializing_if = "Option::is_none")]
266    pub placeholder: Option<String>,
267    #[serde(default)]
268    pub advanced: bool,
269    #[serde(default)]
270    pub secret: bool,
271    /// "live" | "next-session" | "restart".
272    #[serde(default)]
273    pub applies: String,
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize)]
277#[serde(rename_all = "camelCase")]
278pub struct SettingOption {
279    pub value: String,
280    pub label: String,
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    pub description: Option<String>,
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize)]
286#[serde(rename_all = "camelCase")]
287pub struct SettingsSnapshot {
288    /// key → current non-secret value + provenance.
289    pub values: HashMap<String, SettingState>,
290    /// key → secret presence + source (never the value).
291    pub secrets: HashMap<String, SecretStatus>,
292    pub config_path: String,
293    pub env_path: String,
294    /// Read-only registry MCP servers + live health (cross-backend mounter).
295    /// Absent from older daemons — defaults to empty so deserialization is
296    /// forward-compatible.
297    #[serde(default)]
298    pub mcp_servers: Vec<McpServerStatus>,
299}
300
301/// Read-only status of one registry MCP server, mirrored from the TS protocol
302/// (`McpServerStatus`). Config comes from the daemon's registry; `health`/`tools`
303/// reflect what the daemon-owned client has observed so far (no live probe).
304#[derive(Debug, Clone, Serialize, Deserialize)]
305#[serde(rename_all = "camelCase")]
306pub struct McpServerStatus {
307    pub name: String,
308    /// "stdio" | "http" | "in-process".
309    pub transport: String,
310    /// "readonly" | "prompt".
311    pub trust: String,
312    /// "global" | "workspace" | "session".
313    pub scope: String,
314    /// Backends this server mounts on; `None` = all.
315    #[serde(default)]
316    pub backends: Option<Vec<String>>,
317    pub enabled: bool,
318    /// `codeoid_memory` — always present, not user-declared.
319    pub builtin: bool,
320    /// "connected" | "error" | "idle" | "disabled".
321    pub health: String,
322    pub tool_count: u32,
323    pub tools: Vec<String>,
324    #[serde(default, skip_serializing_if = "Option::is_none")]
325    pub error: Option<String>,
326}
327
328#[derive(Debug, Clone, Serialize, Deserialize)]
329#[serde(rename_all = "camelCase")]
330pub struct SettingState {
331    pub value: Value,
332    /// "default" | "config" | "env" | "unset".
333    pub source: String,
334}
335
336#[derive(Debug, Clone, Serialize, Deserialize)]
337#[serde(rename_all = "camelCase")]
338pub struct SecretStatus {
339    pub set: bool,
340    /// "env-file" | "external" | "unset".
341    pub source: String,
342}
343
344#[derive(Debug, Clone, Serialize, Deserialize)]
345#[serde(rename_all = "camelCase")]
346pub struct SettingError {
347    pub key: String,
348    pub message: String,
349}
350
351/// Dialog flavor on a [`SessionUiRequestMsg`].
352#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
353#[serde(rename_all = "snake_case")]
354pub enum UiRequestMethod {
355    Select,
356    Confirm,
357    Input,
358    Editor,
359}
360
361/// Why a `session.ui_resolved` fired. `Other` sinks future reasons — every
362/// reason means "dismiss the local copy".
363#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
364#[serde(rename_all = "snake_case")]
365pub enum UiResolvedReason {
366    Answered,
367    Cancelled,
368    Timeout,
369    Interrupted,
370    #[serde(other)]
371    Other,
372}
373
374/// Payload of [`DaemonMessage::SessionUiRequest`].
375#[derive(Debug, Clone, Serialize, Deserialize)]
376#[serde(rename_all = "camelCase")]
377pub struct SessionUiRequestMsg {
378    pub session_id: String,
379    /// Echo back on `session.ui_response`.
380    pub request_id: String,
381    pub method: UiRequestMethod,
382    pub title: String,
383    #[serde(default, skip_serializing_if = "Option::is_none")]
384    pub message: Option<String>,
385    /// Choices for `method: select`.
386    #[serde(default, skip_serializing_if = "Option::is_none")]
387    pub options: Option<Vec<String>>,
388    #[serde(default, skip_serializing_if = "Option::is_none")]
389    pub placeholder: Option<String>,
390    #[serde(default, skip_serializing_if = "Option::is_none")]
391    pub prefill: Option<String>,
392    /// Auto-cancel deadline in ms from `timestamp` (daemon-enforced).
393    #[serde(default, skip_serializing_if = "Option::is_none")]
394    pub timeout_ms: Option<u64>,
395    pub timestamp: String,
396}
397
398/// One provider-defined slash command (see `SessionCommandsResult`).
399#[derive(Debug, Clone, Serialize, Deserialize)]
400#[serde(rename_all = "camelCase")]
401pub struct ProviderCommand {
402    /// Invokable name without the leading slash.
403    pub name: String,
404    #[serde(default, skip_serializing_if = "Option::is_none")]
405    pub description: Option<String>,
406    /// Provider-specific origin taxonomy (e.g. "extension" | "prompt" |
407    /// "skill"). Display verbatim, never switch on it.
408    #[serde(default, skip_serializing_if = "Option::is_none")]
409    pub source: Option<String>,
410    #[serde(default, skip_serializing_if = "Option::is_none")]
411    pub argument_hint: Option<String>,
412}
413
414/// Where the config entry was loaded from.
415#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
416#[serde(rename_all = "snake_case")]
417pub enum ClaudeConfigScope {
418    Global,
419    Workdir,
420}
421
422#[derive(Debug, Clone, Serialize, Deserialize)]
423#[serde(rename_all = "camelCase")]
424pub struct ClaudeConfigAgent {
425    pub name: String,
426    pub description: Option<String>,
427    pub path: String,
428    pub scope: ClaudeConfigScope,
429    #[serde(default, skip_serializing_if = "Option::is_none")]
430    pub tools: Option<Vec<String>>,
431}
432
433#[derive(Debug, Clone, Serialize, Deserialize)]
434#[serde(rename_all = "camelCase")]
435pub struct ClaudeConfigSkill {
436    pub name: String,
437    pub description: Option<String>,
438    pub path: String,
439    pub scope: ClaudeConfigScope,
440}
441
442#[derive(Debug, Clone, Serialize, Deserialize)]
443#[serde(rename_all = "camelCase")]
444pub struct ClaudeConfigMcpServer {
445    pub name: String,
446    pub scope: ClaudeConfigScope,
447    pub path: String,
448    pub command: Option<String>,
449    pub args: Vec<String>,
450    pub env_keys: Vec<String>,
451    pub url: Option<String>,
452    #[serde(rename = "type")]
453    pub server_type: Option<String>,
454    /// HTTP-type MCP servers' header keys (values redacted at the daemon).
455    #[serde(default, skip_serializing_if = "Option::is_none")]
456    pub header_keys: Option<Vec<String>>,
457}
458
459#[derive(Debug, Clone, Serialize, Deserialize)]
460#[serde(rename_all = "camelCase")]
461pub struct SessionExportManifest {
462    pub exported_at: String,
463    pub session: SessionExportMetaSlim,
464    pub workdir: SessionExportWorkdir,
465    pub counts: SessionExportCounts,
466}
467
468#[derive(Debug, Clone, Serialize, Deserialize)]
469#[serde(rename_all = "camelCase")]
470pub struct SessionExportMetaSlim {
471    pub id: String,
472    pub name: String,
473    pub created_at: String,
474    #[serde(default, skip_serializing_if = "Option::is_none")]
475    pub model: Option<String>,
476    #[serde(default, skip_serializing_if = "Option::is_none")]
477    pub mode: Option<String>,
478}
479
480#[derive(Debug, Clone, Serialize, Deserialize)]
481#[serde(rename_all = "camelCase")]
482pub struct SessionExportWorkdir {
483    pub alias: String,
484    pub alias_source: String,
485    pub original_absolute: String,
486}
487
488#[derive(Debug, Clone, Serialize, Deserialize)]
489#[serde(rename_all = "camelCase")]
490pub struct SessionExportCounts {
491    pub messages: u32,
492    pub episodes: u32,
493    pub turns: u32,
494    pub pinned_files: u32,
495}
496
497#[derive(Debug, Clone, Serialize, Deserialize)]
498#[serde(tag = "kind", rename_all = "camelCase")]
499pub enum SessionExportPayload {
500    Inline {
501        bundle: serde_json::Value,
502        size_bytes: u64,
503    },
504    File {
505        path: String,
506        size_bytes: u64,
507    },
508}
509
510#[derive(Debug, Clone, Serialize, Deserialize)]
511#[serde(rename_all = "camelCase")]
512pub struct ClaudeConfigHook {
513    pub event: String,
514    pub scope: ClaudeConfigScope,
515    pub path: String,
516    pub matcher: Option<String>,
517    pub kind: String,
518    pub command: String,
519}
520
521/// One selectable model as reported by the Claude Code backend. Mirrors
522/// `ModelInfo` in `codeoid/src/protocol/types.ts`.
523#[derive(Debug, Clone, Serialize, Deserialize)]
524#[serde(rename_all = "camelCase")]
525pub struct ModelInfo {
526    /// Value passed to `/model` and forwarded to the SDK (e.g. `"opus[1m]"`).
527    pub value: String,
528    /// Human label (e.g. `"Opus"`).
529    pub display_name: String,
530    /// Optional one-line description from the backend.
531    #[serde(default, skip_serializing_if = "Option::is_none")]
532    pub description: Option<String>,
533    /// True for the backend's recommended default.
534    #[serde(default, skip_serializing_if = "Option::is_none")]
535    pub is_default: Option<bool>,
536}
537
538/// Sent after a successful auth handshake, before any other traffic.
539#[derive(Debug, Clone, Serialize, Deserialize)]
540#[serde(rename_all = "camelCase")]
541pub struct AuthOkMsg {
542    pub identity: MessageIdentity,
543    pub scopes: Vec<String>,
544    /// Wire-protocol version the daemon speaks. Compare against
545    /// [`crate::PROTOCOL_VERSION`]. `None` means a pre-v1 daemon that didn't
546    /// send the field — treat as version 0.
547    #[serde(default, skip_serializing_if = "Option::is_none")]
548    pub protocol_version: Option<u32>,
549    /// Capability identifiers the daemon supports (e.g. `commands.dynamic`,
550    /// `ui.dialogs`). Feature-detect on these instead of version-sniffing.
551    /// `None` on daemons that predate capability negotiation.
552    #[serde(default, skip_serializing_if = "Option::is_none")]
553    pub capabilities: Option<Vec<String>>,
554    /// Provider ids registered on this daemon, default first (feeds the
555    /// `/provider` command and the `/new --provider` flag). `None` on
556    /// daemons that predate multi-provider session creation.
557    #[serde(default, skip_serializing_if = "Option::is_none")]
558    pub providers: Option<Vec<String>>,
559}
560
561#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
562#[serde(rename_all = "snake_case")]
563pub enum ErrorCode {
564    Unauthorized,
565    Forbidden,
566    NotFound,
567    InvalidRequest,
568    RateLimited,
569    Internal,
570}
571
572/// Per-session hit returned by `session.search`.
573#[derive(Debug, Clone, Serialize, Deserialize)]
574#[serde(rename_all = "camelCase")]
575pub struct SessionSearchHit {
576    pub session_id: String,
577    pub session_name: String,
578    pub workdir: String,
579    pub match_count: u32,
580    pub first_match_at: i64,
581    pub last_match_at: i64,
582    pub aggregate_score: f64,
583    pub snippets: Vec<SessionSearchSnippet>,
584}
585
586#[derive(Debug, Clone, Serialize, Deserialize)]
587#[serde(rename_all = "camelCase")]
588pub struct SessionSearchSnippet {
589    pub episode_id: String,
590    pub kind: SearchSnippetKind,
591    #[serde(default, skip_serializing_if = "Option::is_none")]
592    pub tool_name: Option<String>,
593    pub summary: String,
594    pub excerpt: String,
595    pub created_at: i64,
596    pub score: f64,
597    pub file_paths: Vec<String>,
598}
599
600#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
601#[serde(rename_all = "snake_case")]
602pub enum SearchSnippetKind {
603    UserTurn,
604    AssistantTurn,
605    ToolCall,
606    Error,
607}