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    cmp::Ordering,
7    io,
8    path::{Path, PathBuf},
9    time::UNIX_EPOCH,
10};
11
12// ^ [[Terminal Stream Protocol v3]] Wire-version history and compatibility boundaries.
13pub const PROTOCOL_VERSION: u32 = 11;
14pub const MAX_REQUEST_BYTES: usize = 1024 * 1024;
15pub const MAX_RESPONSE_BYTES: usize = 32 * 1024 * 1024;
16pub const WSX_PANE_ID_ENV: &str = "WSX_PANE_ID";
17pub const WSX_RUNTIME_GENERATION_ENV: &str = "WSX_RUNTIME_GENERATION";
18pub const WSX_VERSION: &str = env!("CARGO_PKG_VERSION");
19
20pub fn compare_wsx_versions(left: &str, right: &str) -> Option<Ordering> {
21    let left = parse_wsx_version(left)?;
22    let right = parse_wsx_version(right)?;
23    let core = left.core.cmp(&right.core);
24    if core != Ordering::Equal {
25        return Some(core);
26    }
27    match (left.prerelease, right.prerelease) {
28        (None, None) => Some(Ordering::Equal),
29        (None, Some(_)) => Some(Ordering::Greater),
30        (Some(_), None) => Some(Ordering::Less),
31        (Some(left), Some(right)) => compare_prerelease(left, right),
32    }
33}
34
35pub fn binary_identity_version(identity: &str) -> Option<&str> {
36    binary_identity_parts(identity).map(|(version, _)| version)
37}
38
39pub fn compare_binary_identities(left: &str, right: &str) -> Option<Ordering> {
40    let (left_version, left_modified) = binary_identity_parts(left)?;
41    let (right_version, right_modified) = binary_identity_parts(right)?;
42    let version = compare_wsx_versions(left_version, right_version)?;
43    if version != Ordering::Equal {
44        return Some(version);
45    }
46    Some(left_modified.cmp(&right_modified))
47}
48
49fn binary_identity_parts(identity: &str) -> Option<(&str, u128)> {
50    let fields = identity.split(':').collect::<Vec<_>>();
51    let expected_fields = if cfg!(unix) { 5 } else { 3 };
52    if fields.len() != expected_fields || parse_wsx_version(fields[0]).is_none() {
53        return None;
54    }
55    let mut values = fields[1..]
56        .iter()
57        .map(|value| u128::from_str_radix(value, 16));
58    let modified = values.next_back()?.ok()?;
59    values
60        .all(|value| value.is_ok())
61        .then_some((fields[0], modified))
62}
63
64struct ParsedVersion<'a> {
65    core: (u64, u64, u64),
66    prerelease: Option<&'a str>,
67}
68
69fn parse_wsx_version(version: &str) -> Option<ParsedVersion<'_>> {
70    let (precedence, build) = version
71        .split_once('+')
72        .map_or((version, None), |(precedence, build)| {
73            (precedence, Some(build))
74        });
75    if build.is_some_and(|build| !valid_identifiers(build, false)) {
76        return None;
77    }
78    let (core, prerelease) = precedence
79        .split_once('-')
80        .map_or((precedence, None), |(core, prerelease)| {
81            (core, Some(prerelease))
82        });
83    if prerelease.is_some_and(|value| !valid_identifiers(value, true)) {
84        return None;
85    }
86    let mut parts = core.split('.');
87    let core = (
88        parse_core_number(parts.next()?)?,
89        parse_core_number(parts.next()?)?,
90        parse_core_number(parts.next()?)?,
91    );
92    if parts.next().is_some() {
93        return None;
94    }
95    Some(ParsedVersion { core, prerelease })
96}
97
98fn parse_core_number(value: &str) -> Option<u64> {
99    if value.is_empty() || (value.len() > 1 && value.starts_with('0')) {
100        return None;
101    }
102    value.parse().ok()
103}
104
105fn valid_identifiers(value: &str, reject_numeric_leading_zero: bool) -> bool {
106    !value.is_empty()
107        && value.split('.').all(|identifier| {
108            !identifier.is_empty()
109                && identifier
110                    .bytes()
111                    .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
112                && !(reject_numeric_leading_zero
113                    && identifier.len() > 1
114                    && identifier.starts_with('0')
115                    && identifier.bytes().all(|byte| byte.is_ascii_digit()))
116        })
117}
118
119fn compare_prerelease(left: &str, right: &str) -> Option<Ordering> {
120    let mut left = left.split('.');
121    let mut right = right.split('.');
122    loop {
123        match (left.next(), right.next()) {
124            (Some(left), Some(right)) => {
125                let ordering = match (
126                    left.bytes().all(|byte| byte.is_ascii_digit()),
127                    right.bytes().all(|byte| byte.is_ascii_digit()),
128                ) {
129                    (true, true) => parse_core_number(left)?.cmp(&parse_core_number(right)?),
130                    (true, false) => Ordering::Less,
131                    (false, true) => Ordering::Greater,
132                    (false, false) => left.cmp(right),
133                };
134                if ordering != Ordering::Equal {
135                    return Some(ordering);
136                }
137            }
138            (Some(_), None) => return Some(Ordering::Greater),
139            (None, Some(_)) => return Some(Ordering::Less),
140            (None, None) => return Some(Ordering::Equal),
141        }
142    }
143}
144
145pub fn binary_identity(path: &Path) -> io::Result<String> {
146    let path = path.canonicalize()?;
147    let metadata = path.metadata()?;
148    let modified = metadata
149        .modified()?
150        .duration_since(UNIX_EPOCH)
151        .unwrap_or_default()
152        .as_nanos();
153    #[cfg(unix)]
154    return Ok(format!(
155        "{}:{:x}:{:x}:{:x}:{modified:x}",
156        env!("CARGO_PKG_VERSION"),
157        metadata.dev(),
158        metadata.ino(),
159        metadata.len()
160    ));
161    #[cfg(not(unix))]
162    Ok(format!(
163        "{}:{:x}:{modified:x}",
164        env!("CARGO_PKG_VERSION"),
165        metadata.len()
166    ))
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170#[serde(tag = "method", content = "params", rename_all = "snake_case")]
171pub enum Request {
172    Hello {
173        protocol: u32,
174    },
175    Snapshot,
176    Poll {
177        after_revision: u64,
178        timeout_ms: u64,
179        #[serde(default)]
180        tui: Option<TuiClientPresence>,
181    },
182    SynchronizeProjects {
183        projects: Vec<ProjectSpec>,
184    },
185    SessionCreate {
186        worktree_id: WorktreeId,
187        label: String,
188        command: Vec<String>,
189        #[serde(default)]
190        initial_input: Option<String>,
191        rows: u16,
192        cols: u16,
193    },
194    SessionRename {
195        session_id: SessionId,
196        label: String,
197        expected_revision: u64,
198    },
199    SessionReorder {
200        session_id: SessionId,
201        target_session_id: SessionId,
202        placement: SessionPlacement,
203        expected_revision: u64,
204    },
205    SessionClose {
206        session_id: SessionId,
207        expected_revision: u64,
208    },
209    PaneSplit {
210        session_id: SessionId,
211        target: PaneId,
212        axis: SplitAxis,
213        label: String,
214        command: Vec<String>,
215        #[serde(default)]
216        initial_input: Option<String>,
217        rows: u16,
218        cols: u16,
219        expected_revision: u64,
220    },
221    PaneFocus {
222        session_id: SessionId,
223        pane_id: PaneId,
224    },
225    PaneClose {
226        pane_id: PaneId,
227        expected_revision: u64,
228    },
229    TerminalAcquire {
230        pane_id: PaneId,
231        client_id: u64,
232        takeover: bool,
233    },
234    TerminalRelease {
235        pane_id: PaneId,
236        client_id: u64,
237    },
238    TerminalHeartbeat {
239        pane_id: PaneId,
240        client_id: u64,
241    },
242    TerminalSubscribe {
243        pane_id: PaneId,
244        client_id: u64,
245        takeover: bool,
246        rows: u16,
247        cols: u16,
248    },
249    TerminalInput {
250        pane_id: PaneId,
251        client_id: u64,
252        bytes: Vec<u8>,
253    },
254    TerminalKey {
255        pane_id: PaneId,
256        client_id: u64,
257        key: KeyEvent,
258    },
259    TerminalPaste {
260        pane_id: PaneId,
261        client_id: u64,
262        text: String,
263    },
264    TerminalMouse {
265        pane_id: PaneId,
266        client_id: u64,
267        mouse: MouseEvent,
268    },
269    TerminalResize {
270        pane_id: PaneId,
271        client_id: u64,
272        rows: u16,
273        cols: u16,
274    },
275    View {
276        pane_ids: Vec<PaneId>,
277    },
278    AgentReport {
279        pane_id: PaneId,
280        #[serde(default)]
281        runtime_generation: Option<String>,
282        provider: String,
283        state: AgentState,
284        #[serde(default)]
285        conversation_id: Option<String>,
286        #[serde(default)]
287        session_ref: Option<AgentSessionRef>,
288        capabilities: AgentCapabilities,
289    },
290    AgentClear {
291        pane_id: PaneId,
292        runtime_generation: String,
293        next_runtime_generation: String,
294    },
295    PluginList,
296    PluginReload,
297    LifecycleStatus,
298    PrepareReplacement {
299        target_binary_id: String,
300    },
301    Shutdown,
302}
303
304#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
305#[serde(tag = "type", content = "data", rename_all = "snake_case")]
306pub enum Event {
307    Changed {
308        revision: u64,
309        entity: String,
310        id: u64,
311    },
312    Exited {
313        revision: u64,
314        pane_id: PaneId,
315    },
316    ResyncRequired {
317        revision: u64,
318    },
319}
320
321#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
322pub struct ApiError {
323    pub code: String,
324    pub message: String,
325}
326
327impl ApiError {
328    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
329        Self {
330            code: code.into(),
331            message: message.into(),
332        }
333    }
334}
335
336#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
337#[serde(tag = "type", content = "data", rename_all = "snake_case")]
338pub enum Response {
339    Hello {
340        protocol: u32,
341        epoch: u64,
342        #[serde(default)]
343        capabilities: Capabilities,
344    },
345    Snapshot(Snapshot),
346    View {
347        snapshot: Snapshot,
348        frames: Vec<TerminalFrame>,
349    },
350    Events {
351        revision: u64,
352        events: Vec<Event>,
353    },
354    Plugins(Vec<PluginManifest>),
355    Lifecycle(DaemonLifecycle),
356    Replacement {
357        disposition: ReplacementDisposition,
358        live_runtimes: usize,
359        #[serde(default)]
360        daemon_version: String,
361        #[serde(default)]
362        target_version: String,
363        #[serde(default)]
364        blockers: Vec<ReplacementBlocker>,
365        #[serde(default)]
366        use_current_daemon: bool,
367    },
368    Created {
369        revision: u64,
370        id: u64,
371    },
372    Ack {
373        revision: u64,
374    },
375    Error(ApiError),
376}
377
378#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
379#[serde(tag = "type", content = "data", rename_all = "snake_case")]
380pub enum TerminalClientMessage {
381    Key(KeyEvent),
382    Paste(String),
383    Mouse(MouseEvent),
384    Input(Vec<u8>),
385    Resize { rows: u16, cols: u16 },
386    Heartbeat,
387    Resync,
388    Detach,
389}
390
391#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
392#[serde(tag = "type", content = "data", rename_all = "snake_case")]
393pub enum TerminalServerMessage {
394    Update(TerminalUpdate),
395    ClipboardWrite(Vec<u8>),
396    Error(ApiError),
397    Exited,
398}
399
400pub fn encode_line<T: Serialize>(value: &T) -> Result<Vec<u8>, serde_json::Error> {
401    let mut bytes = serde_json::to_vec(value)?;
402    bytes.push(b'\n');
403    Ok(bytes)
404}
405
406pub fn default_socket_path() -> PathBuf {
407    if let Some(path) = std::env::var_os("WSX_SOCKET") {
408        return PathBuf::from(path);
409    }
410    let root = std::env::var_os("XDG_STATE_HOME")
411        .map(PathBuf::from)
412        .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/state")))
413        .unwrap_or_else(std::env::temp_dir);
414    root.join("wsx/wsx.sock")
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    #[test]
422    fn protocol_is_tagged_and_line_delimited() {
423        let bytes = encode_line(&Request::Snapshot).unwrap();
424        assert_eq!(bytes.last(), Some(&b'\n'));
425        assert!(String::from_utf8(bytes).unwrap().contains("snapshot"));
426    }
427
428    #[test]
429    fn unknown_methods_including_recent_clear_are_rejected() {
430        for method in ["unknown_method", "project_recent_clear"] {
431            let json = format!(r#"{{"method":"{method}","params":{{}}}}"#);
432            assert!(serde_json::from_str::<Request>(&json).is_err(), "{method}");
433        }
434    }
435
436    #[test]
437    fn legacy_hello_defaults_capabilities_added_by_newer_protocols() {
438        let response = serde_json::from_str::<Response>(
439            r#"{"type":"hello","data":{"protocol":3,"epoch":1,"capabilities":{"pane_splits":true,"plugins":true,"agent_reports":true,"process_restore":false}}}"#,
440        )
441        .unwrap();
442
443        let Response::Hello { capabilities, .. } = response else {
444            panic!("expected hello response");
445        };
446        assert!(capabilities.pane_splits);
447        assert!(!capabilities.agent_session_restore);
448        assert!(!capabilities.resume_shell_fallback);
449        assert!(!capabilities.listening_ports);
450        assert!(!capabilities.foreground_jobs);
451        assert!(!capabilities.lifecycle_coordination);
452    }
453
454    #[test]
455    fn wsx_versions_and_builds_have_numeric_precedence() {
456        assert_eq!(
457            compare_wsx_versions("0.21.0", "0.20.9"),
458            Some(std::cmp::Ordering::Greater)
459        );
460        assert_eq!(
461            compare_binary_identities("0.21.0:1:2:3:20", "0.21.0:4:5:6:10"),
462            Some(std::cmp::Ordering::Greater)
463        );
464        assert_eq!(
465            compare_wsx_versions("0.21.0-beta.2", "0.21.0-beta.11"),
466            Some(std::cmp::Ordering::Less)
467        );
468        assert_eq!(
469            compare_wsx_versions("0.21.0-rc.1", "0.21.0"),
470            Some(std::cmp::Ordering::Less)
471        );
472        assert_eq!(
473            compare_wsx_versions("0.21.0+build.2", "0.21.0+build.1"),
474            Some(std::cmp::Ordering::Equal)
475        );
476        assert_eq!(
477            compare_wsx_versions("0.21.0-alpha.beta", "0.21.0-alpha.rc"),
478            Some(std::cmp::Ordering::Less)
479        );
480        assert_eq!(
481            compare_wsx_versions("0.21.0-alpha10", "0.21.0-alpha2"),
482            Some(std::cmp::Ordering::Less)
483        );
484        assert_eq!(compare_wsx_versions("0.21.0-01", "0.21.0"), None);
485        assert_eq!(compare_wsx_versions("", "0.21.0"), None);
486        assert_eq!(compare_wsx_versions("0.21.0", ""), None);
487        for malformed in [
488            "",
489            "malformed",
490            "0.21.0:1:2:3",
491            "0.21.0:1:2:3:10:5",
492            "0.21.0:1:2:3:not-hex",
493        ] {
494            assert_eq!(
495                compare_binary_identities(malformed, "0.21.0:1:2:3:10"),
496                None,
497                "{malformed:?} must not be a binary identity"
498            );
499        }
500    }
501
502    #[test]
503    fn legacy_lifecycle_requests_and_responses_default_version_fields() {
504        let poll = serde_json::from_str::<Request>(
505            r#"{"method":"poll","params":{"after_revision":7,"timeout_ms":1000}}"#,
506        )
507        .unwrap();
508        assert!(matches!(poll, Request::Poll { tui: None, .. }));
509
510        let replacement = serde_json::from_str::<Request>(
511            r#"{"method":"prepare_replacement","params":{"target_binary_id":"0.20.0:1:2:3:4"}}"#,
512        )
513        .unwrap();
514        assert!(matches!(replacement, Request::PrepareReplacement { .. }));
515
516        let lifecycle = serde_json::from_str::<Response>(
517            r#"{"type":"lifecycle","data":{"protocol":8,"epoch":7,"binary_id":"0.20.0:1:2:3:4","started_unix_ms":11,"phase":"replacement_pending","live_runtimes":2,"active_clients":1,"recovered_from_backup":false,"replacement_target":"0.21.0:1:2:3:4"}}"#,
518        )
519        .unwrap();
520        assert!(matches!(
521            lifecycle,
522            Response::Lifecycle(DaemonLifecycle {
523                binary_id,
524                version,
525                started_unix_ms: 11,
526                active_tuis: 0,
527                replacement_target,
528                replacement_target_version,
529                replacement_blockers,
530                ..
531            }) if binary_id == "0.20.0:1:2:3:4"
532                && version.is_empty()
533                && replacement_target.as_deref() == Some("0.21.0:1:2:3:4")
534                && replacement_target_version.is_empty()
535                && replacement_blockers.is_empty()
536        ));
537
538        let response = serde_json::from_str::<Response>(
539            r#"{"type":"replacement","data":{"disposition":"deferred","live_runtimes":2}}"#,
540        )
541        .unwrap();
542        assert!(matches!(
543            response,
544            Response::Replacement {
545                daemon_version,
546                target_version,
547                blockers,
548                use_current_daemon: false,
549                ..
550            } if daemon_version.is_empty() && target_version.is_empty() && blockers.is_empty()
551        ));
552    }
553
554    #[test]
555    fn lifecycle_control_is_additive_and_tagged() {
556        let request = Request::PrepareReplacement {
557            target_binary_id: "0.22.0:1:2:3:4".into(),
558        };
559        let encoded = serde_json::to_string(&request).unwrap();
560        assert_eq!(serde_json::from_str::<Request>(&encoded).unwrap(), request);
561
562        let response = Response::Lifecycle(DaemonLifecycle {
563            protocol: PROTOCOL_VERSION,
564            epoch: 7,
565            binary_id: "0.21.0:1:2:3:4".into(),
566            version: "0.21.0".into(),
567            started_unix_ms: 11,
568            phase: DaemonPhase::ReplacementPending,
569            live_runtimes: 2,
570            active_clients: 1,
571            active_tuis: 1,
572            recovered_from_backup: false,
573            replacement_target: Some("0.22.0:1:2:3:4".into()),
574            replacement_target_version: "0.22.0".into(),
575            replacement_blockers: vec![ReplacementBlocker::WorkingAgent],
576        });
577        let encoded = serde_json::to_string(&response).unwrap();
578        assert_eq!(
579            serde_json::from_str::<Response>(&encoded).unwrap(),
580            response
581        );
582    }
583
584    #[test]
585    fn legacy_snapshot_defaults_missing_foreground_job_metadata() {
586        let response = serde_json::from_str::<Response>(
587            r#"{"type":"snapshot","data":{"protocol":8,"epoch":1,"revision":1,"projects":[],"worktrees":[],"sessions":[],"panes":[],"capabilities":{}}}"#,
588        )
589        .unwrap();
590
591        let Response::Snapshot(snapshot) = response else {
592            panic!("expected snapshot response");
593        };
594        assert!(snapshot.pane_activity.is_empty());
595        assert!(!snapshot.capabilities.foreground_jobs);
596    }
597
598    #[test]
599    fn legacy_agent_report_defaults_missing_session_reference() {
600        let request = serde_json::from_str::<Request>(
601            r#"{"method":"agent_report","params":{"pane_id":1,"provider":"pi","state":"idle","conversation_id":"legacy","capabilities":{}}}"#,
602        )
603        .unwrap();
604
605        let Request::AgentReport {
606            session_ref,
607            runtime_generation,
608            ..
609        } = request
610        else {
611            panic!("expected agent report request");
612        };
613        assert_eq!(session_ref, None);
614        assert_eq!(runtime_generation, None);
615    }
616
617    #[test]
618    fn legacy_session_create_defaults_missing_initial_input() {
619        let request = serde_json::from_str::<Request>(
620            r#"{"method":"session_create","params":{"worktree_id":1,"label":"legacy","command":[],"rows":24,"cols":80}}"#,
621        )
622        .unwrap();
623
624        let Request::SessionCreate { initial_input, .. } = request else {
625            panic!("expected session create request");
626        };
627        assert_eq!(initial_input, None);
628    }
629
630    #[test]
631    fn clipboard_write_is_a_distinct_ephemeral_stream_message() {
632        let bytes =
633            encode_line(&TerminalServerMessage::ClipboardWrite(b"copied".to_vec())).unwrap();
634        assert_eq!(
635            String::from_utf8(bytes).unwrap(),
636            "{\"type\":\"clipboard_write\",\"data\":[99,111,112,105,101,100]}\n"
637        );
638    }
639
640    #[test]
641    fn legacy_terminal_wire_defaults_selection_and_pointer_bounds() {
642        let full = serde_json::from_str::<TerminalUpdate>(
643            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}}}"#,
644        )
645        .unwrap();
646        let TerminalUpdate::Full(full) = full else {
647            panic!("expected full terminal update");
648        };
649        assert!(full.selection.is_empty());
650
651        let patch = serde_json::from_str::<TerminalUpdate>(
652            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}}}"#,
653        )
654        .unwrap();
655        let TerminalUpdate::Patch { selection, .. } = patch else {
656            panic!("expected terminal patch");
657        };
658        assert!(selection.is_empty());
659
660        let mouse = serde_json::from_str::<MouseEvent>(
661            r#"{"action":"release","button":"left","x":0,"y":0,"shift":false,"control":false,"alt":false,"super_key":false}"#,
662        )
663        .unwrap();
664        assert!(mouse.in_bounds);
665    }
666
667    #[test]
668    fn full_terminal_baseline_stays_within_compact_size_budget() {
669        let cell = Cell {
670            symbol: " ".into(),
671            fg: Some([220, 220, 220]),
672            bg: Some([8, 9, 11]),
673            ..Cell::default()
674        };
675        let message = TerminalServerMessage::Update(TerminalUpdate::Full(TerminalFrame {
676            pane_id: PaneId(1),
677            terminal_id: TerminalId(2),
678            revision: 1,
679            cols: 120,
680            rows: 40,
681            cells: vec![cell; 120 * 40],
682            cursor: Cursor {
683                x: 0,
684                y: 0,
685                visible: true,
686                blinking: false,
687                shape: 0,
688            },
689            selection: Vec::new(),
690        }));
691        let bytes = encode_line(&message).unwrap();
692        assert!(
693            bytes.len() < 256 * 1024,
694            "baseline was {} bytes",
695            bytes.len()
696        );
697    }
698}