Skip to main content

codeoid_protocol/
client.rs

1//! Client → Daemon messages.
2//!
3//! Every request carries an `id` so the daemon's `response.ok` /
4//! `response.error` / `session.list.result` can be correlated back to the
5//! caller.
6//!
7//! # Wire-format notes
8//!
9//! The TS daemon expects `camelCase` fields (e.g. `sessionId`, `approvalId`).
10//! Every struct-like variant below gets a per-variant `rename_all = "camelCase"`
11//! so `session_id` serializes as `sessionId`, etc. `#[serde(rename_all)]` on
12//! the enum itself only affects variant names, not variant fields — hence
13//! the repetition.
14
15use serde::{Deserialize, Serialize};
16use serde_json;
17
18use crate::session::SessionMode;
19
20/// Tagged union of every message a client can send the daemon.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22#[serde(tag = "type")]
23pub enum ClientMessage {
24    #[serde(rename = "session.create", rename_all = "camelCase")]
25    SessionCreate {
26        id: String,
27        name: String,
28        workdir: String,
29        /// Backend for the session (one of `AuthOkMsg.providers`). The
30        /// daemon fail-closes on unknown ids. Absent = daemon default.
31        #[serde(default, skip_serializing_if = "Option::is_none")]
32        provider_id: Option<String>,
33    },
34
35    #[serde(rename = "session.list", rename_all = "camelCase")]
36    SessionList { id: String },
37
38    /// Request the backend's selectable model catalog. Daemon answers with
39    /// [`DaemonMessage::ModelsListResult`](crate::daemon::DaemonMessage::ModelsListResult).
40    #[serde(rename = "models.list", rename_all = "camelCase")]
41    ModelsList {
42        id: String,
43        /// Which backend's catalog to fetch. Absent = the daemon's default
44        /// backend (older clients). Additive on the wire.
45        #[serde(default, skip_serializing_if = "Option::is_none")]
46        provider: Option<String>,
47    },
48
49    #[serde(rename = "session.attach", rename_all = "camelCase")]
50    SessionAttach { id: String, session_id: String },
51
52    #[serde(rename = "session.detach", rename_all = "camelCase")]
53    SessionDetach { id: String, session_id: String },
54
55    #[serde(rename = "session.send", rename_all = "camelCase")]
56    SessionSend {
57        id: String,
58        session_id: String,
59        text: String,
60        #[serde(default, skip_serializing_if = "Option::is_none")]
61        attachments: Option<Vec<Attachment>>,
62        #[serde(default, skip_serializing_if = "Option::is_none")]
63        priority: Option<SendPriority>,
64    },
65
66    #[serde(rename = "session.interrupt", rename_all = "camelCase")]
67    SessionInterrupt { id: String, session_id: String },
68
69    #[serde(rename = "session.approve", rename_all = "camelCase")]
70    SessionApprove {
71        id: String,
72        session_id: String,
73        approval_id: String,
74        approved: bool,
75        /// Optional patch shallow-merged into the original tool input
76        /// before the SDK runs the tool. Required for AskUserQuestion
77        /// where it carries `{ "answers": { "<question>": "..." } }`.
78        /// Omitted for binary approvals (Bash, Edit, ExitPlanMode, …).
79        #[serde(default, skip_serializing_if = "Option::is_none")]
80        updated_input: Option<serde_json::Value>,
81    },
82
83    /// Answer a provider-initiated dialog
84    /// ([`DaemonMessage::SessionUiRequest`](crate::daemon::DaemonMessage::SessionUiRequest)).
85    /// Exactly one payload field applies per method: `value` for
86    /// select/input/editor, `confirmed` for confirm, `cancelled: true` to
87    /// dismiss any method. First answer wins; a late answer gets `not_found`
88    /// (the `session.ui_resolved` broadcast already dismissed it).
89    #[serde(rename = "session.ui_response", rename_all = "camelCase")]
90    SessionUiResponse {
91        id: String,
92        session_id: String,
93        request_id: String,
94        #[serde(default, skip_serializing_if = "Option::is_none")]
95        value: Option<String>,
96        #[serde(default, skip_serializing_if = "Option::is_none")]
97        confirmed: Option<bool>,
98        #[serde(default, skip_serializing_if = "Option::is_none")]
99        cancelled: Option<bool>,
100    },
101
102    /// Activate a `ContentPart::Button` from a message's `parts[]`. The
103    /// daemon validates the button exists on the real message before
104    /// forwarding to the provider.
105    #[serde(rename = "session.part_action", rename_all = "camelCase")]
106    SessionPartAction {
107        id: String,
108        session_id: String,
109        message_id: String,
110        action: String,
111        #[serde(default, skip_serializing_if = "Option::is_none")]
112        data: Option<serde_json::Value>,
113    },
114
115    /// Fetch the session's provider-defined command catalog. Daemon answers
116    /// with [`DaemonMessage::SessionCommandsResult`](crate::daemon::DaemonMessage::SessionCommandsResult).
117    /// Gated on the daemon capability `commands.dynamic`.
118    #[serde(rename = "session.commands", rename_all = "camelCase")]
119    SessionCommands { id: String, session_id: String },
120
121    #[serde(rename = "session.destroy", rename_all = "camelCase")]
122    SessionDestroy { id: String, session_id: String },
123
124    #[serde(rename = "session.set_mode", rename_all = "camelCase")]
125    SessionSetMode {
126        id: String,
127        session_id: String,
128        mode: SessionMode,
129        #[serde(default, skip_serializing_if = "Option::is_none")]
130        max_turns: Option<u32>,
131    },
132
133    #[serde(rename = "session.pin", rename_all = "camelCase")]
134    SessionPin {
135        id: String,
136        session_id: String,
137        path: String,
138    },
139
140    #[serde(rename = "session.unpin", rename_all = "camelCase")]
141    SessionUnpin {
142        id: String,
143        session_id: String,
144        path: String,
145    },
146
147    #[serde(rename = "session.rotate", rename_all = "camelCase")]
148    SessionRotate { id: String, session_id: String },
149
150    #[serde(rename = "session.search", rename_all = "camelCase")]
151    SessionSearch {
152        id: String,
153        query: String,
154        #[serde(default, skip_serializing_if = "Option::is_none")]
155        scope: Option<SearchScope>,
156        #[serde(default, skip_serializing_if = "Option::is_none")]
157        workdir: Option<String>,
158        #[serde(default, skip_serializing_if = "Option::is_none")]
159        limit: Option<u32>,
160    },
161
162    /// Switch a live session's BACKEND (claude ⇄ pi …). The session id,
163    /// scrollback, and transcript stay; the daemon carries the history to
164    /// the new backend as a transcript and resets the model to its default.
165    /// Rejected mid-turn and on unknown ids.
166    #[serde(rename = "session.set_provider", rename_all = "camelCase")]
167    SessionSetProvider {
168        id: String,
169        session_id: String,
170        provider_id: String,
171    },
172
173    /// Branch a session into an independent one, optionally onto a different
174    /// backend. The fork is seeded with a deep copy of the parent's canonical
175    /// history + restamped scrollback; the parent is untouched. `name` absent
176    /// ⇒ parent name + " (fork)"; `provider_id` absent ⇒ parent's backend.
177    /// Fail-closed: foreign/unknown session ⇒ not_found, unknown provider ⇒
178    /// invalid_request.
179    #[serde(rename = "session.fork", rename_all = "camelCase")]
180    SessionFork {
181        id: String,
182        session_id: String,
183        #[serde(default, skip_serializing_if = "Option::is_none")]
184        name: Option<String>,
185        #[serde(default, skip_serializing_if = "Option::is_none")]
186        provider_id: Option<String>,
187    },
188
189    /// Fetch history OLDER than a message the client already holds
190    /// (`scrollback.paging`). Anchored by message id — ids survive daemon
191    /// restarts, seq cursors don't. Answered with `scrollback.page.result`.
192    #[serde(rename = "scrollback.page", rename_all = "camelCase")]
193    ScrollbackPage {
194        id: String,
195        session_id: String,
196        /// The OLDEST message id the client currently holds.
197        before_message_id: String,
198        #[serde(default, skip_serializing_if = "Option::is_none")]
199        max_bytes: Option<u64>,
200    },
201
202    #[serde(rename = "session.set_model", rename_all = "camelCase")]
203    SessionSetModel {
204        id: String,
205        session_id: String,
206        model: String,
207        #[serde(default, skip_serializing_if = "Option::is_none")]
208        fallback_model: Option<Option<String>>,
209    },
210
211    #[serde(rename = "session.rename", rename_all = "camelCase")]
212    SessionRename {
213        id: String,
214        session_id: String,
215        name: String,
216    },
217
218    /// Snapshot of Claude Code config (`~/.claude/` + workdir `.claude/`)
219    /// for the focused session — agents, skills, MCP servers, hooks.
220    /// Daemon answers with `ClaudeConfigResult`.
221    #[serde(rename = "claude.config", rename_all = "camelCase")]
222    ClaudeConfig { id: String, session_id: String },
223
224    /// Export a session as a portable `ShareBundle`.
225    #[serde(rename = "session.export", rename_all = "camelCase")]
226    SessionExport {
227        id: String,
228        session_id: String,
229        #[serde(default, skip_serializing_if = "Option::is_none")]
230        include_memory: Option<bool>,
231        #[serde(default, skip_serializing_if = "Option::is_none")]
232        include_pinned_files: Option<bool>,
233        #[serde(default, skip_serializing_if = "Option::is_none")]
234        alias_override: Option<String>,
235        #[serde(default, skip_serializing_if = "Option::is_none")]
236        to_file: Option<bool>,
237    },
238
239    /// Import a session bundle. The TUI typically uses the `file`
240    /// variant (operator pastes a path); the web pushes inline JSON.
241    #[serde(rename = "session.import", rename_all = "camelCase")]
242    SessionImport {
243        id: String,
244        source: SessionImportSource,
245        target_workdir: String,
246        #[serde(default, skip_serializing_if = "Option::is_none")]
247        name_override: Option<String>,
248        #[serde(default, skip_serializing_if = "Option::is_none")]
249        write_pinned_files: Option<bool>,
250    },
251
252    /// Fetch the declarative settings manifest (tabs → groups → fields).
253    /// Daemon answers with `settings.schema.result`.
254    #[serde(rename = "settings.schema", rename_all = "camelCase")]
255    SettingsSchema { id: String },
256
257    /// Fetch the current effective settings — values + secret presence.
258    /// Daemon answers with `settings.get.result`.
259    #[serde(rename = "settings.get", rename_all = "camelCase")]
260    SettingsGet { id: String },
261
262    /// Apply a batch of settings changes. Daemon answers with
263    /// `settings.set.result` (config patches are validated as a whole — a
264    /// single bad patch rejects the batch).
265    #[serde(rename = "settings.set", rename_all = "camelCase")]
266    SettingsSet {
267        id: String,
268        patches: Vec<SettingPatch>,
269    },
270}
271
272/// One change requested by `settings.set`, addressed by field `key`. The
273/// value is a raw JSON scalar / array / null (`null` clears the field).
274#[derive(Debug, Clone, Serialize, Deserialize)]
275#[serde(rename_all = "camelCase")]
276pub struct SettingPatch {
277    pub key: String,
278    pub value: serde_json::Value,
279}
280
281/// Source of a `session.import` bundle.
282#[derive(Debug, Clone, Serialize, Deserialize)]
283#[serde(tag = "kind", rename_all = "camelCase")]
284pub enum SessionImportSource {
285    Inline { bundle: serde_json::Value },
286    File { path: String },
287}
288
289impl ClientMessage {
290    /// Correlation id used to match a [`DaemonMessage::ResponseOk`](crate::daemon::DaemonMessage::ResponseOk)
291    /// or [`DaemonMessage::ResponseError`](crate::daemon::DaemonMessage::ResponseError) back to this request.
292    #[must_use]
293    pub fn request_id(&self) -> &str {
294        match self {
295            Self::SessionCreate { id, .. }
296            | Self::SessionList { id }
297            | Self::ModelsList { id, .. }
298            | Self::SessionAttach { id, .. }
299            | Self::SessionDetach { id, .. }
300            | Self::SessionSend { id, .. }
301            | Self::SessionInterrupt { id, .. }
302            | Self::SessionApprove { id, .. }
303            | Self::SessionUiResponse { id, .. }
304            | Self::SessionPartAction { id, .. }
305            | Self::SessionCommands { id, .. }
306            | Self::SessionDestroy { id, .. }
307            | Self::SessionSetMode { id, .. }
308            | Self::SessionPin { id, .. }
309            | Self::SessionUnpin { id, .. }
310            | Self::SessionRotate { id, .. }
311            | Self::SessionSearch { id, .. }
312            | Self::SessionSetProvider { id, .. }
313            | Self::SessionFork { id, .. }
314            | Self::ScrollbackPage { id, .. }
315            | Self::SessionSetModel { id, .. }
316            | Self::SessionRename { id, .. }
317            | Self::ClaudeConfig { id, .. }
318            | Self::SessionExport { id, .. }
319            | Self::SessionImport { id, .. }
320            | Self::SettingsSchema { id }
321            | Self::SettingsGet { id }
322            | Self::SettingsSet { id, .. } => id,
323        }
324    }
325}
326
327/// One-shot attachment pushed with `session.send`.
328#[derive(Debug, Clone, Serialize, Deserialize)]
329#[serde(rename_all = "camelCase")]
330pub struct Attachment {
331    pub path: String,
332    #[serde(default, skip_serializing_if = "Option::is_none")]
333    pub content: Option<String>,
334    #[serde(default, skip_serializing_if = "Option::is_none")]
335    pub mime_type: Option<String>,
336    /// Base64-encoded bytes, mutually exclusive with `content`.
337    #[serde(default, skip_serializing_if = "Option::is_none")]
338    pub data: Option<String>,
339}
340
341/// Mid-turn priority hint.
342///
343/// * `Now` — interrupt the agent's current turn and observe immediately.
344/// * `Next` — let the current turn finish, then pick this up.
345/// * `Later` — queue as a standard follow-up (default).
346#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
347#[serde(rename_all = "snake_case")]
348pub enum SendPriority {
349    Now,
350    Next,
351    Later,
352}
353
354#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
355#[serde(rename_all = "snake_case")]
356pub enum SearchScope {
357    Workspace,
358    All,
359}