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}
295
296#[derive(Debug, Clone, Serialize, Deserialize)]
297#[serde(rename_all = "camelCase")]
298pub struct SettingState {
299    pub value: Value,
300    /// "default" | "config" | "env" | "unset".
301    pub source: String,
302}
303
304#[derive(Debug, Clone, Serialize, Deserialize)]
305#[serde(rename_all = "camelCase")]
306pub struct SecretStatus {
307    pub set: bool,
308    /// "env-file" | "external" | "unset".
309    pub source: String,
310}
311
312#[derive(Debug, Clone, Serialize, Deserialize)]
313#[serde(rename_all = "camelCase")]
314pub struct SettingError {
315    pub key: String,
316    pub message: String,
317}
318
319/// Dialog flavor on a [`SessionUiRequestMsg`].
320#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
321#[serde(rename_all = "snake_case")]
322pub enum UiRequestMethod {
323    Select,
324    Confirm,
325    Input,
326    Editor,
327}
328
329/// Why a `session.ui_resolved` fired. `Other` sinks future reasons — every
330/// reason means "dismiss the local copy".
331#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
332#[serde(rename_all = "snake_case")]
333pub enum UiResolvedReason {
334    Answered,
335    Cancelled,
336    Timeout,
337    Interrupted,
338    #[serde(other)]
339    Other,
340}
341
342/// Payload of [`DaemonMessage::SessionUiRequest`].
343#[derive(Debug, Clone, Serialize, Deserialize)]
344#[serde(rename_all = "camelCase")]
345pub struct SessionUiRequestMsg {
346    pub session_id: String,
347    /// Echo back on `session.ui_response`.
348    pub request_id: String,
349    pub method: UiRequestMethod,
350    pub title: String,
351    #[serde(default, skip_serializing_if = "Option::is_none")]
352    pub message: Option<String>,
353    /// Choices for `method: select`.
354    #[serde(default, skip_serializing_if = "Option::is_none")]
355    pub options: Option<Vec<String>>,
356    #[serde(default, skip_serializing_if = "Option::is_none")]
357    pub placeholder: Option<String>,
358    #[serde(default, skip_serializing_if = "Option::is_none")]
359    pub prefill: Option<String>,
360    /// Auto-cancel deadline in ms from `timestamp` (daemon-enforced).
361    #[serde(default, skip_serializing_if = "Option::is_none")]
362    pub timeout_ms: Option<u64>,
363    pub timestamp: String,
364}
365
366/// One provider-defined slash command (see `SessionCommandsResult`).
367#[derive(Debug, Clone, Serialize, Deserialize)]
368#[serde(rename_all = "camelCase")]
369pub struct ProviderCommand {
370    /// Invokable name without the leading slash.
371    pub name: String,
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub description: Option<String>,
374    /// Provider-specific origin taxonomy (e.g. "extension" | "prompt" |
375    /// "skill"). Display verbatim, never switch on it.
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub source: Option<String>,
378    #[serde(default, skip_serializing_if = "Option::is_none")]
379    pub argument_hint: Option<String>,
380}
381
382/// Where the config entry was loaded from.
383#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
384#[serde(rename_all = "snake_case")]
385pub enum ClaudeConfigScope {
386    Global,
387    Workdir,
388}
389
390#[derive(Debug, Clone, Serialize, Deserialize)]
391#[serde(rename_all = "camelCase")]
392pub struct ClaudeConfigAgent {
393    pub name: String,
394    pub description: Option<String>,
395    pub path: String,
396    pub scope: ClaudeConfigScope,
397    #[serde(default, skip_serializing_if = "Option::is_none")]
398    pub tools: Option<Vec<String>>,
399}
400
401#[derive(Debug, Clone, Serialize, Deserialize)]
402#[serde(rename_all = "camelCase")]
403pub struct ClaudeConfigSkill {
404    pub name: String,
405    pub description: Option<String>,
406    pub path: String,
407    pub scope: ClaudeConfigScope,
408}
409
410#[derive(Debug, Clone, Serialize, Deserialize)]
411#[serde(rename_all = "camelCase")]
412pub struct ClaudeConfigMcpServer {
413    pub name: String,
414    pub scope: ClaudeConfigScope,
415    pub path: String,
416    pub command: Option<String>,
417    pub args: Vec<String>,
418    pub env_keys: Vec<String>,
419    pub url: Option<String>,
420    #[serde(rename = "type")]
421    pub server_type: Option<String>,
422    /// HTTP-type MCP servers' header keys (values redacted at the daemon).
423    #[serde(default, skip_serializing_if = "Option::is_none")]
424    pub header_keys: Option<Vec<String>>,
425}
426
427#[derive(Debug, Clone, Serialize, Deserialize)]
428#[serde(rename_all = "camelCase")]
429pub struct SessionExportManifest {
430    pub exported_at: String,
431    pub session: SessionExportMetaSlim,
432    pub workdir: SessionExportWorkdir,
433    pub counts: SessionExportCounts,
434}
435
436#[derive(Debug, Clone, Serialize, Deserialize)]
437#[serde(rename_all = "camelCase")]
438pub struct SessionExportMetaSlim {
439    pub id: String,
440    pub name: String,
441    pub created_at: String,
442    #[serde(default, skip_serializing_if = "Option::is_none")]
443    pub model: Option<String>,
444    #[serde(default, skip_serializing_if = "Option::is_none")]
445    pub mode: Option<String>,
446}
447
448#[derive(Debug, Clone, Serialize, Deserialize)]
449#[serde(rename_all = "camelCase")]
450pub struct SessionExportWorkdir {
451    pub alias: String,
452    pub alias_source: String,
453    pub original_absolute: String,
454}
455
456#[derive(Debug, Clone, Serialize, Deserialize)]
457#[serde(rename_all = "camelCase")]
458pub struct SessionExportCounts {
459    pub messages: u32,
460    pub episodes: u32,
461    pub turns: u32,
462    pub pinned_files: u32,
463}
464
465#[derive(Debug, Clone, Serialize, Deserialize)]
466#[serde(tag = "kind", rename_all = "camelCase")]
467pub enum SessionExportPayload {
468    Inline {
469        bundle: serde_json::Value,
470        size_bytes: u64,
471    },
472    File {
473        path: String,
474        size_bytes: u64,
475    },
476}
477
478#[derive(Debug, Clone, Serialize, Deserialize)]
479#[serde(rename_all = "camelCase")]
480pub struct ClaudeConfigHook {
481    pub event: String,
482    pub scope: ClaudeConfigScope,
483    pub path: String,
484    pub matcher: Option<String>,
485    pub kind: String,
486    pub command: String,
487}
488
489/// One selectable model as reported by the Claude Code backend. Mirrors
490/// `ModelInfo` in `codeoid/src/protocol/types.ts`.
491#[derive(Debug, Clone, Serialize, Deserialize)]
492#[serde(rename_all = "camelCase")]
493pub struct ModelInfo {
494    /// Value passed to `/model` and forwarded to the SDK (e.g. `"opus[1m]"`).
495    pub value: String,
496    /// Human label (e.g. `"Opus"`).
497    pub display_name: String,
498    /// Optional one-line description from the backend.
499    #[serde(default, skip_serializing_if = "Option::is_none")]
500    pub description: Option<String>,
501    /// True for the backend's recommended default.
502    #[serde(default, skip_serializing_if = "Option::is_none")]
503    pub is_default: Option<bool>,
504}
505
506/// Sent after a successful auth handshake, before any other traffic.
507#[derive(Debug, Clone, Serialize, Deserialize)]
508#[serde(rename_all = "camelCase")]
509pub struct AuthOkMsg {
510    pub identity: MessageIdentity,
511    pub scopes: Vec<String>,
512    /// Wire-protocol version the daemon speaks. Compare against
513    /// [`crate::PROTOCOL_VERSION`]. `None` means a pre-v1 daemon that didn't
514    /// send the field — treat as version 0.
515    #[serde(default, skip_serializing_if = "Option::is_none")]
516    pub protocol_version: Option<u32>,
517    /// Capability identifiers the daemon supports (e.g. `commands.dynamic`,
518    /// `ui.dialogs`). Feature-detect on these instead of version-sniffing.
519    /// `None` on daemons that predate capability negotiation.
520    #[serde(default, skip_serializing_if = "Option::is_none")]
521    pub capabilities: Option<Vec<String>>,
522    /// Provider ids registered on this daemon, default first (feeds the
523    /// `/provider` command and the `/new --provider` flag). `None` on
524    /// daemons that predate multi-provider session creation.
525    #[serde(default, skip_serializing_if = "Option::is_none")]
526    pub providers: Option<Vec<String>>,
527}
528
529#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
530#[serde(rename_all = "snake_case")]
531pub enum ErrorCode {
532    Unauthorized,
533    Forbidden,
534    NotFound,
535    InvalidRequest,
536    RateLimited,
537    Internal,
538}
539
540/// Per-session hit returned by `session.search`.
541#[derive(Debug, Clone, Serialize, Deserialize)]
542#[serde(rename_all = "camelCase")]
543pub struct SessionSearchHit {
544    pub session_id: String,
545    pub session_name: String,
546    pub workdir: String,
547    pub match_count: u32,
548    pub first_match_at: i64,
549    pub last_match_at: i64,
550    pub aggregate_score: f64,
551    pub snippets: Vec<SessionSearchSnippet>,
552}
553
554#[derive(Debug, Clone, Serialize, Deserialize)]
555#[serde(rename_all = "camelCase")]
556pub struct SessionSearchSnippet {
557    pub episode_id: String,
558    pub kind: SearchSnippetKind,
559    #[serde(default, skip_serializing_if = "Option::is_none")]
560    pub tool_name: Option<String>,
561    pub summary: String,
562    pub excerpt: String,
563    pub created_at: i64,
564    pub score: f64,
565    pub file_paths: Vec<String>,
566}
567
568#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
569#[serde(rename_all = "snake_case")]
570pub enum SearchSnippetKind {
571    UserTurn,
572    AssistantTurn,
573    ToolCall,
574    Error,
575}