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
11pub 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}