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}