Skip to main content

oxi/rpc_mode/
protocol.rs

1//! JSONL framing, JSON-RPC 2.0 types, RPC command/response/event types,
2//! and serialization helpers.
3
4use anyhow::Result;
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use std::io::Write;
8use std::sync::Arc;
9
10// ============================================================================
11// JSONL Framing
12// ============================================================================
13
14/// Serialize a value as a strict JSONL record (JSON + LF).
15///
16/// Framing is LF-only. Payload strings may contain other Unicode separators
17/// such as U+2028 and U+2029. Clients must split records on `\n` only.
18pub fn serialize_json_line(value: &Value) -> String {
19    format!("{}\n", serde_json::to_string(value).unwrap_or_default())
20}
21
22/// Serialize a serializable value as a JSONL record.
23pub fn serialize_json_line_obj<T: Serialize>(value: &T) -> String {
24    match serde_json::to_string(value) {
25        Ok(s) => format!("{}\n", s),
26        Err(e) => {
27            tracing::error!("Failed to serialize JSONL: {}", e);
28            "{\"type\":\"response\",\"command\":\"internal\",\"success\":false,\"error\":\"Serialization error\"}\n".to_string()
29        }
30    }
31}
32
33/// Parse a JSONL line, trimming any trailing CR.
34pub fn parse_json_line(line: &str) -> Result<Value, serde_json::Error> {
35    let trimmed = line.trim_end_matches('\r');
36    serde_json::from_str(trimmed)
37}
38
39// ============================================================================
40// JSON-RPC 2.0 Types
41// ============================================================================
42
43/// JSON-RPC 2.0 request
44#[derive(Debug, Clone, Deserialize)]
45pub struct JsonRpcRequest {
46    /// JSON-RPC protocol version.
47    pub jsonrpc: String, // must be "2.0"
48    #[serde(default)]
49    /// Request correlation ID.
50    pub id: Option<Value>,
51    /// JSON-RPC method name.
52    pub method: String,
53    #[serde(default)]
54    /// Method parameters.
55    pub params: Option<Value>,
56}
57
58/// JSON-RPC 2.0 success response
59#[derive(Debug, Clone, Serialize)]
60pub struct JsonRpcSuccessResponse {
61    /// JSON-RPC protocol version.
62    pub jsonrpc: String,
63    /// Request correlation ID.
64    pub id: Value,
65    /// Result value.
66    pub result: Value,
67}
68
69/// JSON-RPC 2.0 error object
70#[derive(Debug, Clone, Serialize)]
71pub struct JsonRpcError {
72    /// Error code.
73    pub code: i64,
74    /// Error message.
75    pub message: String,
76    #[serde(skip_serializing_if = "Option::is_none")]
77    /// Response data.
78    pub data: Option<Value>,
79}
80
81/// JSON-RPC 2.0 error response
82#[derive(Debug, Clone, Serialize)]
83pub struct JsonRpcErrorResponse {
84    /// JSON-RPC protocol version.
85    pub jsonrpc: String,
86    /// Request correlation ID.
87    pub id: Value,
88    /// Error message.
89    pub error: JsonRpcError,
90}
91
92// Standard JSON-RPC error codes
93/// JSON-RPC parse error code.
94pub const JSONRPC_PARSE_ERROR: i64 = -32700;
95/// JSON-RPC invalid request error code.
96pub const JSONRPC_INVALID_REQUEST: i64 = -32600;
97/// JSON-RPC method-not-found error code.
98pub const JSONRPC_METHOD_NOT_FOUND: i64 = -32601;
99/// JSON-RPC invalid params error code.
100pub const JSONRPC_INVALID_PARAMS: i64 = -32602;
101/// JSON-RPC internal error code.
102pub const JSONRPC_INTERNAL_ERROR: i64 = -32603;
103
104// ============================================================================
105// RPC Types
106// ============================================================================
107
108/// RPC command from client
109#[derive(Debug, Clone, Deserialize)]
110#[serde(tag = "type", rename_all = "snake_case")]
111pub enum RpcCommand {
112    // ── Prompting ───────────────────────────────────────────────────
113    /// Send a prompt message to the agent.
114    Prompt {
115        /// Optional client-side request correlation ID.
116        id: Option<String>,
117        /// The user's prompt text.
118        message: String,
119        /// Optional inline images attached to the prompt.
120        images: Option<Vec<ImageData>>,
121        #[serde(default)]
122        /// Desired streaming behavior (e.g. "full", "partial").
123        streaming_behavior: Option<String>,
124    },
125    /// Send a steering message to interrupt the current stream.
126    Steer {
127        /// Optional client-side request correlation ID.
128        id: Option<String>,
129        /// The steering instruction text.
130        message: String,
131        /// Optional images attached to the steering message.
132        images: Option<Vec<ImageData>>,
133    },
134    /// Send a follow-up message to be processed after the current stream.
135    FollowUp {
136        /// Optional client-side request correlation ID.
137        id: Option<String>,
138        /// The follow-up message text.
139        message: String,
140        /// Optional images attached to the follow-up.
141        images: Option<Vec<ImageData>>,
142    },
143    /// Abort the current agent response.
144    Abort {
145        /// Optional client-side request correlation ID.
146        id: Option<String>,
147    },
148    /// Create a new session.
149    NewSession {
150        /// Optional client-side request correlation ID.
151        id: Option<String>,
152        /// Optional parent session ID for forked sessions.
153        parent_session: Option<String>,
154    },
155
156    // ── State ───────────────────────────────────────────────────────
157    /// Get the current agent state.
158    GetState {
159        /// Optional client-side request correlation ID.
160        id: Option<String>,
161    },
162
163    // ── Model ──────────────────────────────────────────────────────
164    /// Set the active model.
165    SetModel {
166        /// Optional client-side request correlation ID.
167        id: Option<String>,
168        /// Provider name (e.g. "anthropic").
169        provider: String,
170        /// Model identifier (e.g. "claude-sonnet-4-5").
171        model_id: String,
172    },
173    /// Cycle to the next available model.
174    CycleModel {
175        /// Optional client-side request correlation ID.
176        id: Option<String>,
177    },
178    /// List all available models.
179    GetAvailableModels {
180        /// Optional client-side request correlation ID.
181        id: Option<String>,
182    },
183
184    // ── Thinking ────────────────────────────────────────────────────
185    /// Set the thinking level.
186    SetThinkingLevel {
187        /// Optional client-side request correlation ID.
188        id: Option<String>,
189        /// Thinking level (e.g. "off", "low", "medium", "high", "xhigh").
190        level: String,
191    },
192    /// Cycle to the next thinking level.
193    CycleThinkingLevel {
194        /// Optional client-side request correlation ID.
195        id: Option<String>,
196    },
197
198    // ── Queue modes ─────────────────────────────────────────────────
199    /// Set the steering mode.
200    SetSteeringMode {
201        /// Optional client-side request correlation ID.
202        id: Option<String>,
203        /// Steering mode (e.g. "all", "system").
204        mode: String,
205    },
206    /// Set the follow-up mode.
207    SetFollowUpMode {
208        /// Optional client-side request correlation ID.
209        id: Option<String>,
210        /// Follow-up mode (e.g. "all", "system").
211        mode: String,
212    },
213
214    // ── Compaction ──────────────────────────────────────────────────
215    /// Manually trigger compaction.
216    Compact {
217        /// Optional client-side request correlation ID.
218        id: Option<String>,
219        /// Optional custom instructions for the compaction prompt.
220        custom_instructions: Option<String>,
221    },
222    /// Enable or disable auto-compaction.
223    SetAutoCompaction {
224        /// Optional client-side request correlation ID.
225        id: Option<String>,
226        /// Whether auto-compaction is enabled.
227        enabled: bool,
228    },
229
230    // ── Retry ───────────────────────────────────────────────────────
231    /// Enable or disable auto-retry.
232    SetAutoRetry {
233        /// Optional client-side request correlation ID.
234        id: Option<String>,
235        /// Whether auto-retry is enabled.
236        enabled: bool,
237    },
238    /// Abort the current retry attempt.
239    AbortRetry {
240        /// Optional client-side request correlation ID.
241        id: Option<String>,
242    },
243
244    // ── Bash ────────────────────────────────────────────────────────
245    /// Execute a bash command.
246    Bash {
247        /// Optional client-side request correlation ID.
248        id: Option<String>,
249        /// The shell command to execute.
250        command: String,
251    },
252    /// Abort the running bash command.
253    AbortBash {
254        /// Optional client-side request correlation ID.
255        id: Option<String>,
256    },
257
258    // ── Session ────────────────────────────────────────────────────
259    /// Get session statistics.
260    GetSessionStats {
261        /// Optional client-side request correlation ID.
262        id: Option<String>,
263    },
264    /// Export the session as HTML.
265    ExportHtml {
266        /// Optional client-side request correlation ID.
267        id: Option<String>,
268        /// Filesystem path for the exported HTML file.
269        output_path: Option<String>,
270    },
271    /// Switch to a different session.
272    SwitchSession {
273        /// Optional client-side request correlation ID.
274        id: Option<String>,
275        /// Path to the session file to switch to.
276        session_path: String,
277    },
278    /// Fork the session at a given entry.
279    Fork {
280        /// Optional client-side request correlation ID.
281        id: Option<String>,
282        /// ID of the entry at which to fork.
283        entry_id: String,
284    },
285    /// Clone the current session.
286    Clone {
287        /// Optional client-side request correlation ID.
288        id: Option<String>,
289    },
290    /// Get messages from the forked session.
291    GetForkMessages {
292        /// Optional client-side request correlation ID.
293        id: Option<String>,
294    },
295    /// Get the last assistant text response.
296    GetLastAssistantText {
297        /// Optional client-side request correlation ID.
298        id: Option<String>,
299    },
300    /// Set the session display name.
301    SetSessionName {
302        /// Optional client-side request correlation ID.
303        id: Option<String>,
304        /// New display name for the session.
305        name: String,
306    },
307
308    // ── Messages ───────────────────────────────────────────────────
309    /// Get all session messages.
310    GetMessages {
311        /// Optional client-side request correlation ID.
312        id: Option<String>,
313    },
314
315    // ── Commands ────────────────────────────────────────────────────
316    /// get commands.
317    GetCommands {
318        /// Optional client-side request correlation ID.
319        id: Option<String>,
320    },
321}
322
323/// Image data for RPC commands
324#[derive(Debug, Clone, Serialize, Deserialize)]
325pub struct ImageData {
326    /// Base64-encoded image data or URL source.
327    pub source: String,
328    #[serde(rename = "type")]
329    /// Image media type (e.g. "image/png").
330    pub media_type: String,
331}
332
333/// Base64-encoded image data
334#[derive(Debug, Clone)]
335pub struct RpcImageSource {
336    /// Decoded image bytes.
337    pub data: Vec<u8>,
338    /// MIME type (e.g. "image/png").
339    pub mime_type: String,
340}
341
342/// RPC response to client
343#[derive(Debug, Clone, Serialize)]
344#[serde(tag = "type", rename_all = "snake_case")]
345pub enum RpcResponse {
346    /// response.
347    Response {
348        /// Correlation ID matching the original request.
349        id: Option<String>,
350        /// Name of the command this responds to.
351        command: String,
352        /// Whether the command succeeded.
353        success: bool,
354        #[serde(skip_serializing_if = "Option::is_none")]
355        /// Response payload on success.
356        data: Option<Value>,
357        #[serde(skip_serializing_if = "Option::is_none")]
358        /// Error message on failure.
359        error: Option<String>,
360    },
361    /// extension ui request.
362    ExtensionUiRequest(RpcExtensionUiRequest),
363}
364
365/// Extension UI request
366#[derive(Debug, Clone, Serialize)]
367#[serde(tag = "method", rename_all = "snake_case")]
368pub enum RpcExtensionUiRequest {
369    /// Present a single-select menu to the user.
370    Select {
371        /// Unique request identifier.
372        id: String,
373        /// Dialog title.
374        title: String,
375        /// Available choices.
376        options: Vec<String>,
377        #[serde(skip_serializing_if = "Option::is_none")]
378        /// Timeout in seconds.
379        timeout: Option<u64>,
380    },
381    /// Ask the user for a yes/no confirmation.
382    Confirm {
383        /// Unique request identifier.
384        id: String,
385        /// Dialog title.
386        title: String,
387        /// Confirmation prompt text.
388        message: String,
389        #[serde(skip_serializing_if = "Option::is_none")]
390        /// Timeout in seconds.
391        timeout: Option<u64>,
392    },
393    /// Ask the user for a free-form text input.
394    Input {
395        /// Unique request identifier.
396        id: String,
397        /// Dialog title.
398        title: String,
399        #[serde(skip_serializing_if = "Option::is_none")]
400        /// Placeholder text for the input field.
401        placeholder: Option<String>,
402        #[serde(skip_serializing_if = "Option::is_none")]
403        /// Timeout in seconds.
404        timeout: Option<u64>,
405    },
406    /// Open a full-screen editor for multi-line text input.
407    Editor {
408        /// Unique request identifier.
409        id: String,
410        /// Dialog title.
411        title: String,
412        #[serde(skip_serializing_if = "Option::is_none")]
413        /// Pre-filled editor content.
414        prefill: Option<String>,
415    },
416    /// Display a non-blocking notification to the user.
417    Notify {
418        /// Unique request identifier.
419        id: String,
420        /// Notification body text.
421        message: String,
422        #[serde(skip_serializing_if = "Option::is_none")]
423        /// Notification type hint (e.g. "info", "warning", "error").
424        notify_type: Option<String>,
425    },
426    /// Set a status bar entry.
427    SetStatus {
428        /// Unique request identifier.
429        id: String,
430        /// Status bar key for deduplication.
431        status_key: String,
432        /// Status text to display; `None` clears the entry.
433        status_text: Option<String>,
434    },
435    /// Set or update a custom widget in the UI.
436    SetWidget {
437        /// Unique request identifier.
438        id: String,
439        /// Widget key for deduplication.
440        widget_key: String,
441        /// Lines of content to render.
442        widget_lines: Option<Vec<String>>,
443        #[serde(skip_serializing_if = "Option::is_none")]
444        /// Widget placement hint (e.g. "bottom", "sidebar").
445        widget_placement: Option<String>,
446    },
447    /// Set the window/terminal title.
448    SetTitle {
449        /// Unique request identifier.
450        id: String,
451        /// New title text.
452        title: String,
453    },
454    /// Replace the text in the user's input editor.
455    SetEditorText {
456        /// Unique request identifier.
457        id: String,
458        /// New editor text content.
459        text: String,
460    },
461}
462
463/// Extension UI response from client
464#[derive(Debug, Clone, Deserialize)]
465#[serde(tag = "type", rename_all = "snake_case")]
466pub enum RpcExtensionUiResponse {
467    /// Respond to an extension UI request.
468    ExtensionUiResponse {
469        /// The request ID being responded to.
470        id: String,
471        #[serde(default)]
472        /// The user's text input (for Input/Editor requests).
473        value: Option<String>,
474        #[serde(default)]
475        /// Whether the user confirmed (for Confirm requests).
476        confirmed: Option<bool>,
477        #[serde(default)]
478        /// Whether the user dismissed the request without answering.
479        cancelled: Option<bool>,
480    },
481}
482
483// ============================================================================
484// State / Info Types
485// ============================================================================
486
487/// Session state for get_state response
488#[derive(Debug, Clone, Serialize)]
489pub struct SessionState {
490    /// Current model info
491    #[serde(skip_serializing_if = "Option::is_none")]
492    pub model: Option<ModelInfo>,
493    /// thinking level.
494    pub thinking_level: String,
495    /// is streaming.
496    pub is_streaming: bool,
497    /// is compacting.
498    pub is_compacting: bool,
499    /// steering mode.
500    pub steering_mode: String,
501    /// follow up mode.
502    pub follow_up_mode: String,
503    /// Session file path
504    #[serde(skip_serializing_if = "Option::is_none")]
505    pub session_file: Option<String>,
506    /// Session identifier.
507    pub session_id: String,
508    #[serde(skip_serializing_if = "Option::is_none")]
509    /// session name.
510    pub session_name: Option<String>,
511    /// auto compaction enabled.
512    pub auto_compaction_enabled: bool,
513    /// message count.
514    pub message_count: usize,
515    /// pending message count.
516    pub pending_message_count: usize,
517}
518
519/// Model information for session state
520#[derive(Debug, Clone, Serialize)]
521pub struct ModelInfo {
522    /// Provider name.
523    pub provider: String,
524    /// Request correlation ID.
525    pub id: String,
526}
527
528/// Command info for get_commands response
529#[derive(Debug, Clone, Serialize)]
530pub struct CommandInfo {
531    /// Name value.
532    pub name: String,
533    #[serde(skip_serializing_if = "Option::is_none")]
534    /// description.
535    pub description: Option<String>,
536    /// source.
537    pub source: String,
538    #[serde(skip_serializing_if = "Option::is_none")]
539    /// source info.
540    pub source_info: Option<SourceInfo>,
541}
542
543/// Source info for command provenance
544#[derive(Debug, Clone, Serialize)]
545pub struct SourceInfo {
546    #[serde(skip_serializing_if = "Option::is_none")]
547    /// File path.
548    pub path: Option<String>,
549    #[serde(skip_serializing_if = "Option::is_none")]
550    /// origin.
551    pub origin: Option<String>,
552}
553
554/// Result of a session stat query
555#[derive(Debug, Clone, Serialize)]
556pub struct SessionStats {
557    /// message count.
558    pub message_count: usize,
559    /// token count.
560    pub token_count: Option<usize>,
561    /// last activity.
562    pub last_activity: Option<i64>,
563}
564
565/// Result of compaction
566#[derive(Debug, Clone, Serialize)]
567pub struct CompactionResult {
568    /// original count.
569    pub original_count: usize,
570    /// compacted count.
571    pub compacted_count: usize,
572    /// tokens saved.
573    pub tokens_saved: Option<usize>,
574}
575
576// ============================================================================
577// Event Types
578// ============================================================================
579
580/// Events emitted by the RPC server to the client during streaming.
581#[derive(Debug, Clone, Serialize)]
582#[serde(tag = "type", rename_all = "snake_case")]
583pub enum RpcEvent {
584    /// Agent started processing
585    AgentStart,
586    /// Streaming text chunk
587    TextChunk {
588        /// Streaming text content.
589        text: String,
590    },
591    /// Agent is thinking
592    Thinking,
593    /// A reasoning/thinking span ended — the model is about to produce the
594    /// answer or start another content block. Signal-only. Fires per span
595    /// for interleaved-reasoning models (Claude 4, o-series).
596    ThinkingEnd,
597    /// Partial tool-call arguments streamed while the LLM is still
598    /// constructing a tool call (before `tool_start`). Each `args_delta` is
599    /// a raw JSON fragment — accumulate per `tool_call_id`.
600    ToolCallDelta {
601        /// Tool call identifier (matches the id carried by `tool_start`).
602        tool_call_id: String,
603        /// Raw JSON argument fragment from the LLM stream.
604        args_delta: String,
605    },
606    /// Tool execution started
607    ToolStart {
608        /// Name of the tool being executed.
609        tool: String,
610    },
611    /// Tool execution finished
612    ToolEnd {
613        /// Name of the tool that finished.
614        tool: String,
615    },
616    /// Agent finished processing
617    AgentEnd,
618    /// Error occurred
619    Error {
620        /// Error message.
621        message: String,
622    },
623    /// Extension error
624    ExtensionError {
625        /// Filesystem path of the extension that errored.
626        extension_path: String,
627        /// Event name during which the error occurred.
628        event: String,
629        /// Error message.
630        error: String,
631    },
632}
633
634// ============================================================================
635// Pending Extension UI Request
636// ============================================================================
637
638/// Tracks pending extension UI requests awaiting client responses.
639pub struct PendingExtensionRequest {
640    /// Sender to deliver the client's response back to the awaiter.
641    pub resolve: tokio::sync::oneshot::Sender<RpcExtensionUiResponse>,
642}
643
644// ============================================================================
645// JSON-RPC 2.0 Method Mapping
646// ============================================================================
647
648/// Map a JSON-RPC 2.0 method + params to an internal RPC command value.
649pub fn jsonrpc_to_command(method: &str, params: Option<Value>, id: Option<Value>) -> Option<Value> {
650    let id_str = id.map(|v| match v {
651        Value::String(s) => s,
652        Value::Number(n) => n.to_string(),
653        _ => v.to_string(),
654    });
655
656    let cmd = match method {
657        "prompt" => serde_json::json!({
658            "type": "prompt",
659            "id": id_str,
660            "message": params.as_ref().and_then(|p| p.get("message")).and_then(|m| m.as_str()).unwrap_or(""),
661            "images": params.as_ref().and_then(|p| p.get("images")),
662            "streaming_behavior": params.as_ref().and_then(|p| p.get("streaming_behavior")),
663        }),
664        "steer" => serde_json::json!({
665            "type": "steer",
666            "id": id_str,
667            "message": params.as_ref().and_then(|p| p.get("message")).and_then(|m| m.as_str()).unwrap_or(""),
668        }),
669        "follow_up" => serde_json::json!({
670            "type": "follow_up",
671            "id": id_str,
672            "message": params.as_ref().and_then(|p| p.get("message")).and_then(|m| m.as_str()).unwrap_or(""),
673        }),
674        "abort" => serde_json::json!({ "type": "abort", "id": id_str }),
675        "new_session" => serde_json::json!({
676            "type": "new_session",
677            "id": id_str,
678            "parent_session": params.as_ref().and_then(|p| p.get("parent_session")).and_then(|v| v.as_str()),
679        }),
680        "get_state" => serde_json::json!({ "type": "get_state", "id": id_str }),
681        "set_model" => serde_json::json!({
682            "type": "set_model",
683            "id": id_str,
684            "provider": params.as_ref().and_then(|p| p.get("provider")).and_then(|v| v.as_str()).unwrap_or(""),
685            "model_id": params.as_ref().and_then(|p| p.get("modelId")).and_then(|v| v.as_str()).unwrap_or(""),
686        }),
687        "cycle_model" => serde_json::json!({ "type": "cycle_model", "id": id_str }),
688        "get_available_models" => {
689            serde_json::json!({ "type": "get_available_models", "id": id_str })
690        }
691        "set_thinking_level" => serde_json::json!({
692            "type": "set_thinking_level",
693            "id": id_str,
694            "level": params.as_ref().and_then(|p| p.get("level")).and_then(|v| v.as_str()).unwrap_or("default"),
695        }),
696        "cycle_thinking_level" => {
697            serde_json::json!({ "type": "cycle_thinking_level", "id": id_str })
698        }
699        "set_steering_mode" => serde_json::json!({
700            "type": "set_steering_mode",
701            "id": id_str,
702            "mode": params.as_ref().and_then(|p| p.get("mode")).and_then(|v| v.as_str()).unwrap_or("all"),
703        }),
704        "set_follow_up_mode" => serde_json::json!({
705            "type": "set_follow_up_mode",
706            "id": id_str,
707            "mode": params.as_ref().and_then(|p| p.get("mode")).and_then(|v| v.as_str()).unwrap_or("all"),
708        }),
709        "compact" => serde_json::json!({
710            "type": "compact",
711            "id": id_str,
712            "custom_instructions": params.as_ref().and_then(|p| p.get("customInstructions")).and_then(|v| v.as_str()),
713        }),
714        "set_auto_compaction" => serde_json::json!({
715            "type": "set_auto_compaction",
716            "id": id_str,
717            "enabled": params.as_ref().and_then(|p| p.get("enabled")).and_then(|v| v.as_bool()).unwrap_or(true),
718        }),
719        "set_auto_retry" => serde_json::json!({
720            "type": "set_auto_retry",
721            "id": id_str,
722            "enabled": params.as_ref().and_then(|p| p.get("enabled")).and_then(|v| v.as_bool()).unwrap_or(true),
723        }),
724        "abort_retry" => serde_json::json!({ "type": "abort_retry", "id": id_str }),
725        "bash" => serde_json::json!({
726            "type": "bash",
727            "id": id_str,
728            "command": params.as_ref().and_then(|p| p.get("command")).and_then(|v| v.as_str()).unwrap_or(""),
729        }),
730        "abort_bash" => serde_json::json!({ "type": "abort_bash", "id": id_str }),
731        "get_session_stats" => serde_json::json!({ "type": "get_session_stats", "id": id_str }),
732        "export_html" => serde_json::json!({
733            "type": "export_html",
734            "id": id_str,
735            "output_path": params.as_ref().and_then(|p| p.get("outputPath")).and_then(|v| v.as_str()),
736        }),
737        "switch_session" => serde_json::json!({
738            "type": "switch_session",
739            "id": id_str,
740            "session_path": params.as_ref().and_then(|p| p.get("sessionPath")).and_then(|v| v.as_str()).unwrap_or(""),
741        }),
742        "fork" => serde_json::json!({
743            "type": "fork",
744            "id": id_str,
745            "entry_id": params.as_ref().and_then(|p| p.get("entryId")).and_then(|v| v.as_str()).unwrap_or(""),
746        }),
747        "clone" => serde_json::json!({ "type": "clone", "id": id_str }),
748        "get_fork_messages" => serde_json::json!({ "type": "get_fork_messages", "id": id_str }),
749        "get_last_assistant_text" => {
750            serde_json::json!({ "type": "get_last_assistant_text", "id": id_str })
751        }
752        "set_session_name" => serde_json::json!({
753            "type": "set_session_name",
754            "id": id_str,
755            "name": params.as_ref().and_then(|p| p.get("name")).and_then(|v| v.as_str()).unwrap_or(""),
756        }),
757        "get_messages" => serde_json::json!({ "type": "get_messages", "id": id_str }),
758        "get_commands" => serde_json::json!({ "type": "get_commands", "id": id_str }),
759        _ => return None,
760    };
761
762    Some(cmd)
763}
764
765/// Convert an internal RpcResponse to a JSON-RPC 2.0 response.
766pub fn rpc_response_to_jsonrpc(response: &RpcResponse, rpc_id: Value) -> String {
767    match response {
768        RpcResponse::Response {
769            success: true,
770            data,
771            ..
772        } => {
773            let result = data.clone().unwrap_or(Value::Null);
774            let resp = JsonRpcSuccessResponse {
775                jsonrpc: "2.0".to_string(),
776                id: rpc_id,
777                result,
778            };
779            serde_json::to_string(&resp).unwrap_or_default()
780        }
781        RpcResponse::Response {
782            success: false,
783            error,
784            ..
785        } => {
786            let resp = JsonRpcErrorResponse {
787                jsonrpc: "2.0".to_string(),
788                id: rpc_id,
789                error: JsonRpcError {
790                    code: JSONRPC_INTERNAL_ERROR,
791                    message: error.clone().unwrap_or_default(),
792                    data: None,
793                },
794            };
795            serde_json::to_string(&resp).unwrap_or_default()
796        }
797        RpcResponse::ExtensionUiRequest(_) => {
798            // Extension UI requests don't map cleanly to JSON-RPC
799            // In JSON-RPC mode we skip these or send as notification
800            String::new()
801        }
802    }
803}
804
805// ============================================================================
806// JSONL Line Reader
807// ============================================================================
808
809/// A strict JSONL line reader that splits on LF only (not CR or other separators).
810/// This is important because JSON strings may contain U+2028/U+2029 which
811/// Node.js readline would incorrectly split on.
812pub struct JsonlLineReader {
813    buffer: String,
814}
815
816impl JsonlLineReader {
817    /// Create a new JSONL line reader.
818    pub fn new() -> Self {
819        Self {
820            buffer: String::new(),
821        }
822    }
823
824    /// Feed data into the reader, returning complete lines.
825    /// Lines are terminated by `\n` only. Trailing `\r` is stripped.
826    pub fn feed(&mut self, data: &str) -> Vec<String> {
827        self.buffer.push_str(data);
828        let mut lines = Vec::new();
829
830        while let Some(pos) = self.buffer.find('\n') {
831            let line = self.buffer[..pos].to_string();
832            self.buffer = self.buffer[pos + 1..].to_string();
833            // Strip trailing CR
834            let trimmed = line.trim_end_matches('\r').to_string();
835            if !trimmed.is_empty() {
836                lines.push(trimmed);
837            }
838        }
839
840        lines
841    }
842
843    /// Flush any remaining data as a final line (for EOF handling).
844    pub fn flush(&mut self) -> Option<String> {
845        if self.buffer.is_empty() {
846            return None;
847        }
848        let line = self.buffer.trim_end_matches('\r').to_string();
849        self.buffer.clear();
850        if line.is_empty() { None } else { Some(line) }
851    }
852
853    /// Check if there's buffered data.
854    pub fn has_buffered_data(&self) -> bool {
855        !self.buffer.is_empty()
856    }
857}
858
859impl Default for JsonlLineReader {
860    fn default() -> Self {
861        Self::new()
862    }
863}
864
865// ============================================================================
866// Output Writer
867// ============================================================================
868
869/// Thread-safe output writer that ensures atomic JSONL writes.
870pub struct RpcOutput {
871    inner: Arc<parking_lot::Mutex<std::io::Stdout>>,
872}
873
874impl RpcOutput {
875    /// Create a new output writer wrapping stdout.
876    pub fn new() -> Self {
877        Self {
878            inner: Arc::new(parking_lot::Mutex::new(std::io::stdout())),
879        }
880    }
881
882    /// Write a JSONL record atomically.
883    pub fn write_line(&self, value: &Value) {
884        let line = serialize_json_line(value);
885        let mut out = self.inner.lock();
886        let _ = out.write_all(line.as_bytes());
887        let _ = out.flush();
888    }
889
890    /// Write a serializable value as a JSONL record.
891    pub fn write_obj<T: Serialize>(&self, value: &T) {
892        let line = serialize_json_line_obj(value);
893        let mut out = self.inner.lock();
894        let _ = out.write_all(line.as_bytes());
895        let _ = out.flush();
896    }
897
898    /// Write a raw string (must already be a complete JSONL record with trailing LF).
899    pub fn write_raw(&self, raw: &str) {
900        let mut out = self.inner.lock();
901        let _ = out.write_all(raw.as_bytes());
902        let _ = out.flush();
903    }
904}
905
906impl Default for RpcOutput {
907    fn default() -> Self {
908        Self::new()
909    }
910}
911
912impl Clone for RpcOutput {
913    fn clone(&self) -> Self {
914        Self {
915            inner: Arc::clone(&self.inner),
916        }
917    }
918}
919
920// ============================================================================
921// Session Handoff Protocol
922// ============================================================================
923
924/// Information about a session for handoff between processes.
925#[derive(Debug, Clone, Serialize, Deserialize)]
926pub struct SessionHandoff {
927    /// Unique session identifier
928    pub session_id: String,
929    /// Session file path
930    pub session_file: Option<String>,
931    /// Session display name
932    pub session_name: Option<String>,
933    /// Parent session ID (for forked sessions)
934    pub parent_session_id: Option<String>,
935    /// Model being used
936    pub model_id: Option<String>,
937    /// Thinking level
938    pub thinking_level: Option<String>,
939    /// Message count at handoff time
940    pub message_count: usize,
941    /// Timestamp of handoff
942    pub timestamp: i64,
943}
944
945impl SessionHandoff {
946    /// Create a handoff from current session state.
947    pub fn from_state(state: &SessionState) -> Self {
948        Self {
949            session_id: state.session_id.clone(),
950            session_file: state.session_file.clone(),
951            session_name: state.session_name.clone(),
952            parent_session_id: None,
953            model_id: state
954                .model
955                .as_ref()
956                .map(|m| format!("{}/{}", m.provider, m.id)),
957            thinking_level: Some(state.thinking_level.clone()),
958            message_count: state.message_count,
959            timestamp: chrono::Utc::now().timestamp(),
960        }
961    }
962
963    /// Serialize to JSON for transmission.
964    pub fn to_json(&self) -> Result<String> {
965        Ok(serde_json::to_string(self)?)
966    }
967
968    /// Deserialize from JSON.
969    pub fn from_json(json: &str) -> Result<Self> {
970        Ok(serde_json::from_str(json)?)
971    }
972}