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