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
253/// Source of a `session.import` bundle.
254#[derive(Debug, Clone, Serialize, Deserialize)]
255#[serde(tag = "kind", rename_all = "camelCase")]
256pub enum SessionImportSource {
257    Inline { bundle: serde_json::Value },
258    File { path: String },
259}
260
261impl ClientMessage {
262    /// Correlation id used to match a [`DaemonMessage::ResponseOk`](crate::daemon::DaemonMessage::ResponseOk)
263    /// or [`DaemonMessage::ResponseError`](crate::daemon::DaemonMessage::ResponseError) back to this request.
264    #[must_use]
265    pub fn request_id(&self) -> &str {
266        match self {
267            Self::SessionCreate { id, .. }
268            | Self::SessionList { id }
269            | Self::ModelsList { id, .. }
270            | Self::SessionAttach { id, .. }
271            | Self::SessionDetach { id, .. }
272            | Self::SessionSend { id, .. }
273            | Self::SessionInterrupt { id, .. }
274            | Self::SessionApprove { id, .. }
275            | Self::SessionUiResponse { id, .. }
276            | Self::SessionPartAction { id, .. }
277            | Self::SessionCommands { id, .. }
278            | Self::SessionDestroy { id, .. }
279            | Self::SessionSetMode { id, .. }
280            | Self::SessionPin { id, .. }
281            | Self::SessionUnpin { id, .. }
282            | Self::SessionRotate { id, .. }
283            | Self::SessionSearch { id, .. }
284            | Self::SessionSetProvider { id, .. }
285            | Self::SessionFork { id, .. }
286            | Self::ScrollbackPage { id, .. }
287            | Self::SessionSetModel { id, .. }
288            | Self::SessionRename { id, .. }
289            | Self::ClaudeConfig { id, .. }
290            | Self::SessionExport { id, .. }
291            | Self::SessionImport { id, .. } => id,
292        }
293    }
294}
295
296/// One-shot attachment pushed with `session.send`.
297#[derive(Debug, Clone, Serialize, Deserialize)]
298#[serde(rename_all = "camelCase")]
299pub struct Attachment {
300    pub path: String,
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub content: Option<String>,
303    #[serde(default, skip_serializing_if = "Option::is_none")]
304    pub mime_type: Option<String>,
305    /// Base64-encoded bytes, mutually exclusive with `content`.
306    #[serde(default, skip_serializing_if = "Option::is_none")]
307    pub data: Option<String>,
308}
309
310/// Mid-turn priority hint.
311///
312/// * `Now` — interrupt the agent's current turn and observe immediately.
313/// * `Next` — let the current turn finish, then pick this up.
314/// * `Later` — queue as a standard follow-up (default).
315#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
316#[serde(rename_all = "snake_case")]
317pub enum SendPriority {
318    Now,
319    Next,
320    Later,
321}
322
323#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
324#[serde(rename_all = "snake_case")]
325pub enum SearchScope {
326    Workspace,
327    All,
328}