Skip to main content

wsx_core/runtime/
protocol.rs

1use super::domain::*;
2use serde::{Deserialize, Serialize};
3#[cfg(unix)]
4use std::os::unix::fs::MetadataExt;
5use std::{
6    io,
7    path::{Path, PathBuf},
8    time::UNIX_EPOCH,
9};
10
11// ^ [[Terminal Stream Protocol v3]] Wire-version history and compatibility boundaries.
12pub const PROTOCOL_VERSION: u32 = 11;
13pub const MAX_REQUEST_BYTES: usize = 1024 * 1024;
14pub const MAX_RESPONSE_BYTES: usize = 32 * 1024 * 1024;
15pub const WSX_RUNTIME_GENERATION_ENV: &str = "WSX_RUNTIME_GENERATION";
16
17pub fn binary_identity(path: &Path) -> io::Result<String> {
18    let path = path.canonicalize()?;
19    let metadata = path.metadata()?;
20    let modified = metadata
21        .modified()?
22        .duration_since(UNIX_EPOCH)
23        .unwrap_or_default()
24        .as_nanos();
25    #[cfg(unix)]
26    return Ok(format!(
27        "{}:{:x}:{:x}:{:x}:{modified:x}",
28        env!("CARGO_PKG_VERSION"),
29        metadata.dev(),
30        metadata.ino(),
31        metadata.len()
32    ));
33    #[cfg(not(unix))]
34    Ok(format!(
35        "{}:{:x}:{modified:x}",
36        env!("CARGO_PKG_VERSION"),
37        metadata.len()
38    ))
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(tag = "method", content = "params", rename_all = "snake_case")]
43pub enum Request {
44    Hello {
45        protocol: u32,
46    },
47    Snapshot,
48    Poll {
49        after_revision: u64,
50        timeout_ms: u64,
51    },
52    SynchronizeProjects {
53        projects: Vec<ProjectSpec>,
54    },
55    SessionCreate {
56        worktree_id: WorktreeId,
57        label: String,
58        command: Vec<String>,
59        #[serde(default)]
60        initial_input: Option<String>,
61        rows: u16,
62        cols: u16,
63    },
64    SessionRename {
65        session_id: SessionId,
66        label: String,
67        expected_revision: u64,
68    },
69    SessionReorder {
70        session_id: SessionId,
71        target_session_id: SessionId,
72        placement: SessionPlacement,
73        expected_revision: u64,
74    },
75    SessionClose {
76        session_id: SessionId,
77        expected_revision: u64,
78    },
79    PaneSplit {
80        session_id: SessionId,
81        target: PaneId,
82        axis: SplitAxis,
83        label: String,
84        command: Vec<String>,
85        #[serde(default)]
86        initial_input: Option<String>,
87        rows: u16,
88        cols: u16,
89        expected_revision: u64,
90    },
91    PaneFocus {
92        session_id: SessionId,
93        pane_id: PaneId,
94    },
95    PaneClose {
96        pane_id: PaneId,
97        expected_revision: u64,
98    },
99    TerminalAcquire {
100        pane_id: PaneId,
101        client_id: u64,
102        takeover: bool,
103    },
104    TerminalRelease {
105        pane_id: PaneId,
106        client_id: u64,
107    },
108    TerminalHeartbeat {
109        pane_id: PaneId,
110        client_id: u64,
111    },
112    TerminalSubscribe {
113        pane_id: PaneId,
114        client_id: u64,
115        takeover: bool,
116        rows: u16,
117        cols: u16,
118    },
119    TerminalInput {
120        pane_id: PaneId,
121        client_id: u64,
122        bytes: Vec<u8>,
123    },
124    TerminalKey {
125        pane_id: PaneId,
126        client_id: u64,
127        key: KeyEvent,
128    },
129    TerminalPaste {
130        pane_id: PaneId,
131        client_id: u64,
132        text: String,
133    },
134    TerminalMouse {
135        pane_id: PaneId,
136        client_id: u64,
137        mouse: MouseEvent,
138    },
139    TerminalResize {
140        pane_id: PaneId,
141        client_id: u64,
142        rows: u16,
143        cols: u16,
144    },
145    View {
146        pane_ids: Vec<PaneId>,
147    },
148    AgentReport {
149        pane_id: PaneId,
150        #[serde(default)]
151        runtime_generation: Option<String>,
152        provider: String,
153        state: AgentState,
154        #[serde(default)]
155        conversation_id: Option<String>,
156        #[serde(default)]
157        session_ref: Option<AgentSessionRef>,
158        capabilities: AgentCapabilities,
159    },
160    AgentClear {
161        pane_id: PaneId,
162        runtime_generation: String,
163        next_runtime_generation: String,
164    },
165    PluginList,
166    PluginReload,
167    LifecycleStatus,
168    PrepareReplacement {
169        target_binary_id: String,
170    },
171    Shutdown,
172}
173
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(tag = "type", content = "data", rename_all = "snake_case")]
176pub enum Event {
177    Changed {
178        revision: u64,
179        entity: String,
180        id: u64,
181    },
182    Exited {
183        revision: u64,
184        pane_id: PaneId,
185    },
186    ResyncRequired {
187        revision: u64,
188    },
189}
190
191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
192pub struct ApiError {
193    pub code: String,
194    pub message: String,
195}
196
197impl ApiError {
198    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
199        Self {
200            code: code.into(),
201            message: message.into(),
202        }
203    }
204}
205
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
207#[serde(tag = "type", content = "data", rename_all = "snake_case")]
208pub enum Response {
209    Hello {
210        protocol: u32,
211        epoch: u64,
212        #[serde(default)]
213        capabilities: Capabilities,
214    },
215    Snapshot(Snapshot),
216    View {
217        snapshot: Snapshot,
218        frames: Vec<TerminalFrame>,
219    },
220    Events {
221        revision: u64,
222        events: Vec<Event>,
223    },
224    Plugins(Vec<PluginManifest>),
225    Lifecycle(DaemonLifecycle),
226    Replacement {
227        disposition: ReplacementDisposition,
228        live_runtimes: usize,
229    },
230    Created {
231        revision: u64,
232        id: u64,
233    },
234    Ack {
235        revision: u64,
236    },
237    Error(ApiError),
238}
239
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
241#[serde(tag = "type", content = "data", rename_all = "snake_case")]
242pub enum TerminalClientMessage {
243    Key(KeyEvent),
244    Paste(String),
245    Mouse(MouseEvent),
246    Input(Vec<u8>),
247    Resize { rows: u16, cols: u16 },
248    Heartbeat,
249    Resync,
250    Detach,
251}
252
253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
254#[serde(tag = "type", content = "data", rename_all = "snake_case")]
255pub enum TerminalServerMessage {
256    Update(TerminalUpdate),
257    ClipboardWrite(Vec<u8>),
258    Error(ApiError),
259    Exited,
260}
261
262pub fn encode_line<T: Serialize>(value: &T) -> Result<Vec<u8>, serde_json::Error> {
263    let mut bytes = serde_json::to_vec(value)?;
264    bytes.push(b'\n');
265    Ok(bytes)
266}
267
268pub fn default_socket_path() -> PathBuf {
269    if let Some(path) = std::env::var_os("WSX_SOCKET") {
270        return PathBuf::from(path);
271    }
272    let root = std::env::var_os("XDG_STATE_HOME")
273        .map(PathBuf::from)
274        .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/state")))
275        .unwrap_or_else(std::env::temp_dir);
276    root.join("wsx/wsx.sock")
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn protocol_is_tagged_and_line_delimited() {
285        let bytes = encode_line(&Request::Snapshot).unwrap();
286        assert_eq!(bytes.last(), Some(&b'\n'));
287        assert!(String::from_utf8(bytes).unwrap().contains("snapshot"));
288    }
289
290    #[test]
291    fn unknown_methods_including_recent_clear_are_rejected() {
292        for method in ["unknown_method", "project_recent_clear"] {
293            let json = format!(r#"{{"method":"{method}","params":{{}}}}"#);
294            assert!(serde_json::from_str::<Request>(&json).is_err(), "{method}");
295        }
296    }
297
298    #[test]
299    fn legacy_hello_defaults_capabilities_added_by_newer_protocols() {
300        let response = serde_json::from_str::<Response>(
301            r#"{"type":"hello","data":{"protocol":3,"epoch":1,"capabilities":{"pane_splits":true,"plugins":true,"agent_reports":true,"process_restore":false}}}"#,
302        )
303        .unwrap();
304
305        let Response::Hello { capabilities, .. } = response else {
306            panic!("expected hello response");
307        };
308        assert!(capabilities.pane_splits);
309        assert!(!capabilities.agent_session_restore);
310        assert!(!capabilities.resume_shell_fallback);
311        assert!(!capabilities.listening_ports);
312        assert!(!capabilities.foreground_jobs);
313        assert!(!capabilities.lifecycle_coordination);
314    }
315
316    #[test]
317    fn lifecycle_control_is_additive_and_tagged() {
318        let request = Request::PrepareReplacement {
319            target_binary_id: "next-binary".into(),
320        };
321        let encoded = serde_json::to_string(&request).unwrap();
322        assert_eq!(serde_json::from_str::<Request>(&encoded).unwrap(), request);
323
324        let response = Response::Lifecycle(DaemonLifecycle {
325            protocol: PROTOCOL_VERSION,
326            epoch: 7,
327            binary_id: "current-binary".into(),
328            started_unix_ms: 11,
329            phase: DaemonPhase::ReplacementPending,
330            live_runtimes: 2,
331            active_clients: 1,
332            recovered_from_backup: false,
333            replacement_target: Some("next-binary".into()),
334        });
335        let encoded = serde_json::to_string(&response).unwrap();
336        assert_eq!(
337            serde_json::from_str::<Response>(&encoded).unwrap(),
338            response
339        );
340    }
341
342    #[test]
343    fn legacy_snapshot_defaults_missing_foreground_job_metadata() {
344        let response = serde_json::from_str::<Response>(
345            r#"{"type":"snapshot","data":{"protocol":8,"epoch":1,"revision":1,"projects":[],"worktrees":[],"sessions":[],"panes":[],"capabilities":{}}}"#,
346        )
347        .unwrap();
348
349        let Response::Snapshot(snapshot) = response else {
350            panic!("expected snapshot response");
351        };
352        assert!(snapshot.pane_activity.is_empty());
353        assert!(!snapshot.capabilities.foreground_jobs);
354    }
355
356    #[test]
357    fn legacy_agent_report_defaults_missing_session_reference() {
358        let request = serde_json::from_str::<Request>(
359            r#"{"method":"agent_report","params":{"pane_id":1,"provider":"pi","state":"idle","conversation_id":"legacy","capabilities":{}}}"#,
360        )
361        .unwrap();
362
363        let Request::AgentReport {
364            session_ref,
365            runtime_generation,
366            ..
367        } = request
368        else {
369            panic!("expected agent report request");
370        };
371        assert_eq!(session_ref, None);
372        assert_eq!(runtime_generation, None);
373    }
374
375    #[test]
376    fn legacy_session_create_defaults_missing_initial_input() {
377        let request = serde_json::from_str::<Request>(
378            r#"{"method":"session_create","params":{"worktree_id":1,"label":"legacy","command":[],"rows":24,"cols":80}}"#,
379        )
380        .unwrap();
381
382        let Request::SessionCreate { initial_input, .. } = request else {
383            panic!("expected session create request");
384        };
385        assert_eq!(initial_input, None);
386    }
387
388    #[test]
389    fn clipboard_write_is_a_distinct_ephemeral_stream_message() {
390        let bytes =
391            encode_line(&TerminalServerMessage::ClipboardWrite(b"copied".to_vec())).unwrap();
392        assert_eq!(
393            String::from_utf8(bytes).unwrap(),
394            "{\"type\":\"clipboard_write\",\"data\":[99,111,112,105,101,100]}\n"
395        );
396    }
397
398    #[test]
399    fn legacy_terminal_wire_defaults_selection_and_pointer_bounds() {
400        let full = serde_json::from_str::<TerminalUpdate>(
401            r#"{"kind":"full","data":{"pane_id":1,"terminal_id":2,"revision":3,"cols":1,"rows":1,"cells":[["x",null,null,0,0]],"cursor":{"x":0,"y":0,"visible":false,"blinking":false,"shape":0}}}"#,
402        )
403        .unwrap();
404        let TerminalUpdate::Full(full) = full else {
405            panic!("expected full terminal update");
406        };
407        assert!(full.selection.is_empty());
408
409        let patch = serde_json::from_str::<TerminalUpdate>(
410            r#"{"kind":"patch","data":{"pane_id":1,"terminal_id":2,"base_revision":3,"revision":4,"cols":1,"rows":1,"changed_rows":[],"cursor":{"x":0,"y":0,"visible":false,"blinking":false,"shape":0}}}"#,
411        )
412        .unwrap();
413        let TerminalUpdate::Patch { selection, .. } = patch else {
414            panic!("expected terminal patch");
415        };
416        assert!(selection.is_empty());
417
418        let mouse = serde_json::from_str::<MouseEvent>(
419            r#"{"action":"release","button":"left","x":0,"y":0,"shift":false,"control":false,"alt":false,"super_key":false}"#,
420        )
421        .unwrap();
422        assert!(mouse.in_bounds);
423    }
424
425    #[test]
426    fn full_terminal_baseline_stays_within_compact_size_budget() {
427        let cell = Cell {
428            symbol: " ".into(),
429            fg: Some([220, 220, 220]),
430            bg: Some([8, 9, 11]),
431            ..Cell::default()
432        };
433        let message = TerminalServerMessage::Update(TerminalUpdate::Full(TerminalFrame {
434            pane_id: PaneId(1),
435            terminal_id: TerminalId(2),
436            revision: 1,
437            cols: 120,
438            rows: 40,
439            cells: vec![cell; 120 * 40],
440            cursor: Cursor {
441                x: 0,
442                y: 0,
443                visible: true,
444                blinking: false,
445                shape: 0,
446            },
447            selection: Vec::new(),
448        }));
449        let bytes = encode_line(&message).unwrap();
450        assert!(
451            bytes.len() < 256 * 1024,
452            "baseline was {} bytes",
453            bytes.len()
454        );
455    }
456}