@alwith/module-computer 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,27 +1,15 @@
1
- //! Session-bound MCP transport and FIFO desktop ownership. No conversation persistence.
2
- use crate::Connection;
1
+ use crate::{Connection, preview::CursorPosition};
2
+ use alwith_native_client::Handle;
3
3
  use serde::Serialize;
4
- use serde_json::{Value, json};
5
- use std::{
6
- collections::{HashMap, HashSet, VecDeque},
7
- sync::{Arc, Mutex},
8
- time::{Duration, Instant},
9
- };
10
- use tokio::{
11
- io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader},
12
- net::{TcpListener, TcpStream},
13
- process::Command,
14
- sync::{Notify, watch},
15
- };
16
- use uuid::Uuid;
17
-
18
- #[derive(Clone, Debug, PartialEq, Eq, Serialize, specta::Type)]
4
+ use serde_json::json;
5
+ use std::{collections::HashSet, sync::Arc};
6
+ #[derive(Clone, Debug, PartialEq, Eq, Serialize, specta::Type, serde::Deserialize)]
19
7
  #[serde(rename_all = "camelCase")]
20
8
  pub struct Target {
21
9
  pub pid: u32,
22
10
  pub window_id: u32,
23
11
  }
24
- #[derive(Clone, Debug, Serialize, specta::Type)]
12
+ #[derive(Clone, Debug, Serialize, specta::Type, serde::Deserialize)]
25
13
  #[serde(rename_all = "camelCase")]
26
14
  pub struct Activity {
27
15
  pub session_id: String,
@@ -32,743 +20,51 @@ pub struct Activity {
32
20
  pub target: Option<Target>,
33
21
  pub revision: u64,
34
22
  }
35
- struct PreviewCommand {
36
- revision: u64,
37
- focus: bool,
38
- reply: tokio::sync::oneshot::Sender<Result<Value, String>>,
39
- }
40
- struct Entry {
41
- session: Option<String>,
42
- driver: Connection,
43
- created: Instant,
44
- cancel: watch::Sender<bool>,
45
- connected: bool,
46
- initialized: bool,
47
- listed: bool,
48
- tools: HashSet<String>,
49
- labeled_tools: HashSet<String>,
50
- preview: Option<tokio::sync::mpsc::UnboundedSender<PreviewCommand>>,
51
- }
52
- #[derive(Default)]
53
- struct State {
54
- entries: HashMap<String, Entry>,
55
- owner: Option<String>,
56
- queue: VecDeque<String>,
57
- running: HashSet<String>,
58
- blocked: HashSet<String>,
59
- action: Option<String>,
60
- target: Option<Target>,
61
- revision: u64,
62
- in_flight: usize,
63
- release_pending: bool,
64
- faulted: bool,
65
- }
66
- pub struct Gateway {
67
- state: Mutex<State>,
68
- changed: Notify,
69
- address: Mutex<Option<String>>,
70
- }
23
+
24
+ pub struct Gateway(Handle);
71
25
  impl Default for Gateway {
72
26
  fn default() -> Self {
73
- Self { state: Mutex::new(State::default()), changed: Notify::new(), address: Mutex::new(None) }
27
+ Self(
28
+ Handle::open_with_close("computer", "gateway_close", json!({"kind":"gateway"}))
29
+ .expect("Native computer gateway is required"),
30
+ )
74
31
  }
75
32
  }
76
33
  impl Gateway {
77
34
  pub async fn listen(self: &Arc<Self>) -> Result<(), String> {
78
- let listener = TcpListener::bind("127.0.0.1:0").await.map_err(|e| e.to_string())?;
79
- *self.address.lock().unwrap() = Some(listener.local_addr().map_err(|e| e.to_string())?.to_string());
80
- let weak = Arc::downgrade(self);
81
- tokio::spawn(async move {
82
- loop {
83
- let Ok((stream, _)) = listener.accept().await else { break };
84
- let Some(gateway) = weak.upgrade() else { break };
85
- tokio::spawn(async move {
86
- let _ = gateway.serve(stream).await;
87
- });
88
- }
89
- });
90
- Ok(())
35
+ self.0.call_async("gateway_listen", json!({})).await
91
36
  }
92
37
  pub fn register(&self, driver: Connection) -> Result<(String, String), String> {
93
- let address = self.address.lock().unwrap().clone().ok_or("Computer gateway is not ready")?;
94
- let token = Uuid::new_v4().to_string();
95
- let mut state = self.state.lock().unwrap();
96
- state.entries.retain(|_, e| e.connected || e.created.elapsed() < Duration::from_secs(300));
97
- state.entries.insert(
98
- token.clone(),
99
- Entry {
100
- session: None,
101
- driver,
102
- created: Instant::now(),
103
- cancel: watch::channel(false).0,
104
- connected: false,
105
- initialized: false,
106
- listed: false,
107
- tools: HashSet::new(),
108
- labeled_tools: HashSet::new(),
109
- preview: None,
110
- },
111
- );
112
- Ok((token, address))
38
+ self.0.call("gateway_register", json!({"driver":driver}))
113
39
  }
114
40
  pub fn bind(&self, token: &str, session: &str) -> Result<(), String> {
115
- if session.is_empty() {
116
- return Err("Missing conversation identity".into());
117
- }
118
- let mut s = self.state.lock().unwrap();
119
- if !s.entries.contains_key(token) {
120
- return Err("Computer connection expired".into());
121
- }
122
- let previous: Vec<_> = s
123
- .entries
124
- .iter()
125
- .filter(|(key, e)| key.as_str() != token && e.session.as_deref() == Some(session))
126
- .map(|(key, _)| key.clone())
127
- .collect();
128
- if previous.iter().any(|key| s.owner.as_ref() == Some(key)) {
129
- return Err("Cannot replace an active computer connection".into());
130
- }
131
- if s.entries[token].session.as_deref().is_some_and(|id| id != session) {
132
- return Err("Computer connection is already bound".into());
133
- }
134
- for key in previous {
135
- if let Some(e) = s.entries.remove(&key) {
136
- e.cancel.send_replace(true);
137
- }
138
- s.queue.retain(|queued| queued != &key);
139
- }
140
- let e = s.entries.get_mut(token).ok_or("Computer connection expired")?;
141
- if e.session.as_deref().is_some_and(|id| id != session) {
142
- return Err("Computer connection is already bound".into());
143
- }
144
- e.session = Some(session.into());
145
- drop(s);
146
- self.changed.notify_waiters();
147
- Ok(())
41
+ self.0.call("gateway_bind", json!({"token":token,"session":session}))
148
42
  }
149
- /// Readiness belongs to the actual Codex MCP connection, not a separate probe.
150
43
  pub async fn ensure_ready(&self, session: &str) -> Result<(), String> {
151
- tokio::time::timeout(Duration::from_secs(20), async {
152
- loop {
153
- {
154
- let s = self.state.lock().unwrap();
155
- if s.faulted {
156
- return Err("Computer transport needs to restart".into());
157
- }
158
- let e = s
159
- .entries
160
- .values()
161
- .find(|e| e.session.as_deref() == Some(session))
162
- .ok_or("Computer connection closed; retry to reconnect")?;
163
- if *e.cancel.borrow() {
164
- return Err("Computer connection revoked".into());
165
- }
166
- if e.initialized && e.listed {
167
- if ["list_windows", "get_window_state", "click", "run_steps"]
168
- .iter()
169
- .all(|name| e.tools.contains(*name))
170
- {
171
- return Ok(());
172
- }
173
- return Err("Computer server is missing required tools".into());
174
- }
175
- }
176
- tokio::time::sleep(Duration::from_millis(50)).await;
177
- }
178
- })
179
- .await
180
- .map_err(|_| "Computer MCP initialization timed out; retry to reconnect".to_string())?
181
- }
182
- fn observe_discovery(&self, token: &str, request: &Value, response: &Value) {
183
- let mut s = self.state.lock().unwrap();
184
- let Some(e) = s.entries.get_mut(token) else { return };
185
- match request["method"].as_str() {
186
- Some("initialize") => e.initialized = response["result"]["protocolVersion"].is_string(),
187
- Some("tools/list") => {
188
- e.listed = false;
189
- if let Some(tools) = response["result"]["tools"].as_array() {
190
- if request["params"]["cursor"].is_null() {
191
- e.tools.clear();
192
- e.labeled_tools.clear();
193
- }
194
- e.labeled_tools.extend(
195
- tools
196
- .iter()
197
- .filter(|t| t["inputSchema"]["properties"].get("session").is_some())
198
- .filter_map(|t| t["name"].as_str().map(String::from)),
199
- );
200
- e.tools.extend(tools.iter().filter_map(|t| t["name"].as_str().map(String::from)));
201
- e.listed = response["result"]["nextCursor"].is_null();
202
- }
203
- }
204
- _ => {}
205
- }
44
+ self.0.call_async("gateway_ensure_ready", json!({"session":session})).await
206
45
  }
207
- /// Runtime is authoritative. Waiting for user input retains the lease.
208
46
  pub fn sync_running(&self, running: HashSet<String>) {
209
- let mut s = self.state.lock().unwrap();
210
- s.blocked.retain(|id| running.contains(id));
211
- s.running = running;
212
- let entries = &s.entries;
213
- let active = &s.running;
214
- let keep: VecDeque<_> = s
215
- .queue
216
- .iter()
217
- .filter(|key| entries.get(*key).and_then(|e| e.session.as_ref()).is_some_and(|id| active.contains(id)))
218
- .cloned()
219
- .collect();
220
- s.queue = keep;
221
- let finished = s
222
- .owner
223
- .as_ref()
224
- .and_then(|k| s.entries.get(k))
225
- .and_then(|e| e.session.as_ref())
226
- .is_some_and(|id| !s.running.contains(id));
227
- if finished {
228
- if s.in_flight == 0 { release(&mut s) } else { s.release_pending = true }
229
- }
230
- drop(s);
231
- self.changed.notify_waiters();
47
+ self.0.call::<()>("gateway_sync_running", json!({"running":running})).expect("Native computer sync failed");
232
48
  }
233
49
  pub fn stop_session(&self, session: &str) {
234
- let mut s = self.state.lock().unwrap();
235
- if s.running.contains(session) {
236
- s.blocked.insert(session.into());
237
- }
238
- let keys: Vec<_> =
239
- s.entries.iter().filter(|(_, e)| e.session.as_deref() == Some(session)).map(|(k, _)| k.clone()).collect();
240
- for key in keys {
241
- s.queue.retain(|k| k != &key);
242
- }
243
- // An already dispatched native operation must drain before a new owner can enter.
244
- if s.owner.as_ref().and_then(|k| s.entries.get(k)).and_then(|e| e.session.as_deref()) == Some(session) {
245
- if s.in_flight == 0 { release(&mut s) } else { s.release_pending = true }
246
- }
247
- drop(s);
248
- self.changed.notify_waiters();
50
+ self.0.call::<()>("gateway_stop_session", json!({"session":session})).expect("Native computer stop failed");
249
51
  }
250
52
  pub fn stop_all(&self) {
251
- let sessions: Vec<_> = self.state.lock().unwrap().entries.values().filter_map(|e| e.session.clone()).collect();
252
- for id in sessions {
253
- self.stop_session(&id)
254
- }
255
- let mut s = self.state.lock().unwrap();
256
- for e in s.entries.values() {
257
- e.cancel.send_replace(true);
258
- }
259
- *s = State::default();
260
- drop(s);
261
- self.changed.notify_waiters();
53
+ self.0.call::<()>("gateway_stop_all", json!({})).expect("Native computer stop failed");
262
54
  }
263
55
  pub fn faulted(&self) -> bool {
264
- self.state.lock().unwrap().faulted
56
+ self.0.call("gateway_faulted", json!({})).expect("Native computer state failed")
265
57
  }
266
58
  pub fn activity(&self, session: &str) -> Activity {
267
- let s = self.state.lock().unwrap();
268
- let owner = s.owner.as_ref().and_then(|k| s.entries.get(k)).and_then(|e| e.session.clone());
269
- let position = s
270
- .queue
271
- .iter()
272
- .position(|k| s.entries.get(k).and_then(|e| e.session.as_deref()) == Some(session))
273
- .map_or(0, |p| p + 1);
274
- let active = owner.as_deref() == Some(session);
275
- Activity {
276
- session_id: session.into(),
277
- phase: if active {
278
- "active"
279
- } else if position > 0 {
280
- "queued"
281
- } else {
282
- "idle"
283
- }
284
- .into(),
285
- queue_position: position,
286
- owner_session_id: owner,
287
- action: active.then(|| s.action.clone()).flatten(),
288
- target: active.then(|| s.target.clone()).flatten(),
289
- revision: s.revision,
290
- }
59
+ self.0.call("gateway_activity", json!({"session":session})).expect("Native computer activity failed")
291
60
  }
292
- async fn preview_command(&self, session: &str, revision: u64, focus: bool) -> Result<Value, String> {
293
- let (reply, response) = tokio::sync::oneshot::channel();
294
- {
295
- let s = self.state.lock().unwrap();
296
- let e = s.owner.as_ref().and_then(|token| s.entries.get(token)).ok_or("No active computer session")?;
297
- if e.session.as_deref() != Some(session) || *e.cancel.borrow() || s.revision != revision {
298
- return Err("Computer preview changed; try again".into());
299
- }
300
- e.preview
301
- .as_ref()
302
- .ok_or("Computer connection unavailable")?
303
- .send(PreviewCommand { revision, focus, reply })
304
- .map_err(|_| "Computer connection closed")?;
305
- }
306
- tokio::time::timeout(Duration::from_secs(if focus { 5 } else { 1 }), response)
61
+ pub async fn cursor_position(&self, session: &str) -> Option<CursorPosition> {
62
+ self.0
63
+ .call_async("gateway_cursor_position", json!({"session":session}))
307
64
  .await
308
- .map_err(|_| "Computer preview request timed out")?
309
- .map_err(|_| "Computer connection closed")?
310
- }
311
- /// Internal reads share the MCP transport that owns the cursor session.
312
- pub async fn cursor_position(&self, session: &str) -> Option<crate::preview::CursorPosition> {
313
- let revision = self.activity(session).revision;
314
- let response = self.preview_command(session, revision, false).await.ok()?;
315
- let data = &response["structuredContent"];
316
- if data["enabled"] != true {
317
- return None;
318
- }
319
- let x = data["position"]["x"].as_f64()?;
320
- let y = data["position"]["y"].as_f64()?;
321
- (x.is_finite() && y.is_finite()).then_some(crate::preview::CursorPosition { x, y })
65
+ .expect("Native computer cursor failed")
322
66
  }
323
67
  pub async fn focus_preview(&self, session: &str, revision: u64) -> Result<(), String> {
324
- let response = self.preview_command(session, revision, true).await?;
325
- if response["isError"] == true || response["structuredContent"]["activated"] != true {
326
- return Err("The controlled window could not be focused".into());
327
- }
328
- Ok(())
329
- }
330
-
331
- async fn acquire(&self, token: &str, args: &Value, tool: &str) -> Result<(), String> {
332
- loop {
333
- let notified = self.changed.notified();
334
- {
335
- let mut s = self.state.lock().unwrap();
336
- if s.faulted {
337
- return Err("Computer driver transport failed; restarting is required".into());
338
- }
339
- let e = s.entries.get(token).ok_or("Computer connection closed")?;
340
- if *e.cancel.borrow() {
341
- return Err("Computer operation stopped".into());
342
- }
343
- if !e.initialized || !e.listed || !e.tools.contains(tool) {
344
- return Err("Computer tool is not ready or not registered".into());
345
- }
346
- if e.session.is_none() {
347
- return Err("Computer connection is not bound to a conversation".into());
348
- }
349
-
350
- if let Some(id) = e.session.clone() {
351
- if s.blocked.contains(&id) || !s.running.contains(&id) {
352
- return Err("Computer turn is not running".into());
353
- }
354
- if s.owner.as_deref() == Some(token)
355
- || (s.owner.is_none() && s.queue.front().is_none_or(|k| k == token))
356
- {
357
- s.queue.retain(|k| k != token);
358
- s.owner = Some(token.into());
359
- s.in_flight += 1;
360
- s.action = Some(tool.into());
361
- let next_target = target(args);
362
- let changed_app = args
363
- .get("pid")
364
- .and_then(Value::as_u64)
365
- .is_some_and(|pid| s.target.as_ref().is_some_and(|t| u64::from(t.pid) != pid));
366
- let clears_target = matches!(tool, "launch_app" | "get_desktop_state") || changed_app;
367
- if (next_target.is_some() || clears_target) && s.target != next_target {
368
- s.target = next_target;
369
- s.revision += 1;
370
- }
371
- return Ok(());
372
- }
373
- if !s.queue.iter().any(|k| k == token) {
374
- s.queue.push_back(token.into());
375
- }
376
- }
377
- }
378
- notified.await;
379
- }
380
- }
381
- fn complete(&self, token: &str, response: &Value) {
382
- let mut s = self.state.lock().unwrap();
383
- if s.owner.as_deref() != Some(token) {
384
- return;
385
- }
386
- if let Some(t) = target(&response["result"]["structuredContent"])
387
- && s.target.as_ref() != Some(&t)
388
- {
389
- s.target = Some(t);
390
- s.revision += 1;
391
- }
392
- s.in_flight = s.in_flight.saturating_sub(1);
393
- let cancelled = s.entries.get(token).is_none_or(|e| *e.cancel.borrow());
394
- if s.in_flight == 0 && (s.release_pending || cancelled) {
395
- release(&mut s)
396
- }
397
- drop(s);
398
- self.changed.notify_waiters();
399
- }
400
- fn disconnect(&self, token: &str) {
401
- let mut s = self.state.lock().unwrap();
402
- s.queue.retain(|k| k != token);
403
- // EOF while a call is outstanding is not proof that the native action stopped.
404
- if s.owner.as_deref() == Some(token) && s.in_flight == 0 {
405
- release(&mut s)
406
- }
407
- if s.owner.as_deref() != Some(token) {
408
- s.entries.remove(token);
409
- }
410
- drop(s);
411
- self.changed.notify_waiters();
412
- }
413
- async fn serve(self: Arc<Self>, stream: TcpStream) -> Result<(), String> {
414
- let (reader, mut writer) = stream.into_split();
415
- let mut reader = BufReader::new(reader);
416
- let token = tokio::time::timeout(Duration::from_secs(5), read_line(&mut reader, 128))
417
- .await
418
- .map_err(|_| "Gateway authentication timed out")??;
419
- let (driver, mut cancel) = {
420
- let mut s = self.state.lock().unwrap();
421
- let e = s.entries.get_mut(token.trim()).ok_or("Unknown computer connection")?;
422
- if e.connected || *e.cancel.borrow() {
423
- return Err("Computer connection cannot reconnect".into());
424
- }
425
- e.connected = true;
426
- (clone_connection(&e.driver), e.cancel.subscribe())
427
- };
428
- let token = token.trim().to_owned();
429
- let result = self.clone().relay(&token, driver, &mut cancel, &mut reader, &mut writer).await;
430
- {
431
- let mut state = self.state.lock().unwrap();
432
- if state.owner.as_deref() == Some(&token) && state.in_flight > 0 {
433
- state.faulted = true;
434
- }
435
- }
436
- self.disconnect(&token);
437
- result
438
- }
439
- async fn relay(
440
- self: Arc<Self>, token: &str, driver: Connection, cancel: &mut watch::Receiver<bool>,
441
- reader: &mut BufReader<tokio::net::tcp::OwnedReadHalf>, writer: &mut tokio::net::tcp::OwnedWriteHalf,
442
- ) -> Result<(), String> {
443
- use std::process::Stdio;
444
- let mut command = Command::new(&driver.command);
445
- command.env_clear();
446
- for (key, value) in std::env::vars_os() {
447
- if crate::inherited_environment(&key.to_string_lossy()) {
448
- command.env(key, value);
449
- }
450
- }
451
- command
452
- .args(&driver.args)
453
- .envs(driver.env.iter().map(|e| (&e.name, &e.value)))
454
- .stdin(Stdio::piped())
455
- .stdout(Stdio::piped())
456
- .stderr(Stdio::null())
457
- .kill_on_drop(true);
458
- let mut child = command.spawn().map_err(|e| e.to_string())?;
459
- let mut input = child.stdin.take().ok_or("Missing MCP stdin")?;
460
- let mut output = BufReader::new(child.stdout.take().ok_or("Missing MCP stdout")?);
461
- let (tx, mut rx) = tokio::sync::mpsc::channel::<String>(16);
462
- // Read ahead while a request waits in the queue, so EOF cancels it immediately.
463
- let incoming = async {
464
- loop {
465
- match read_line(reader, 1024 * 1024).await {
466
- Ok(line) => {
467
- if tx.send(line).await.is_err() {
468
- break;
469
- }
470
- }
471
- Err(_) => {
472
- let session = self.state.lock().unwrap().entries.get(token).and_then(|e| e.session.clone());
473
- if let Some(session) = session {
474
- self.stop_session(&session);
475
- }
476
- break;
477
- }
478
- }
479
- }
480
- drop(tx);
481
- };
482
- let (preview_tx, mut preview_rx) = tokio::sync::mpsc::unbounded_channel::<PreviewCommand>();
483
- self.state.lock().unwrap().entries.get_mut(token).ok_or("Computer connection closed")?.preview =
484
- Some(preview_tx);
485
- let forwarding = async {
486
- loop {
487
- let line = tokio::select! {
488
- line = rx.recv() => match line { Some(line) => line, None => break },
489
- Some(command) = preview_rx.recv() => {
490
- if command.reply.is_closed() { continue; }
491
- let args = {
492
- let mut s = self.state.lock().unwrap();
493
- if s.owner.as_deref() != Some(token) || s.revision != command.revision || s.release_pending {
494
- let _ = command.reply.send(Err("Computer preview changed; try again".into()));
495
- continue;
496
- }
497
- let mut args = json!({"session":format!("U {}", &token[..8])});
498
- if command.focus {
499
- let Some(target) = s.target.as_ref() else {
500
- let _ = command.reply.send(Err("No controlled window".into()));
501
- continue;
502
- };
503
- args["pid"] = json!(target.pid);
504
- args["window_id"] = json!(target.window_id);
505
- }
506
- s.in_flight += 1;
507
- args
508
- };
509
- let id = format!("preview-{}", Uuid::new_v4());
510
- let name = if command.focus { "bring_to_front" } else { "get_agent_cursor_state" };
511
- let request = json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":name,"arguments":args}});
512
- write_line(&mut input, &request.to_string()).await?;
513
- loop {
514
- let line = read_line(&mut output, 16 * 1024 * 1024).await?;
515
- let response: Value = serde_json::from_str(&line).map_err(|e| e.to_string())?;
516
- if response["id"] == id {
517
- self.complete(token, &json!({}));
518
- let result = response.get("result").cloned().ok_or_else(|| "Computer preview request failed".to_owned());
519
- let _ = command.reply.send(result);
520
- break;
521
- }
522
- write_line(writer, &line).await?;
523
- }
524
- continue;
525
- }
526
- };
527
- let mut request: Value = serde_json::from_str(&line).map_err(|e| e.to_string())?;
528
- let call = request["method"] == "tools/call";
529
- if call {
530
- let name = request["params"]["name"].as_str().ok_or("Missing tool name")?.to_owned();
531
- if let Err(error) = self.acquire(token, &request["params"]["arguments"], &name).await {
532
- let reply = json!({"jsonrpc":"2.0","id":request["id"],"result":{"isError":true,"content":[{"type":"text","text":error}]}});
533
- write_line(writer, &reply.to_string()).await?;
534
- continue;
535
- }
536
- }
537
- if call {
538
- let s = self.state.lock().unwrap();
539
- if s.entries
540
- .get(token)
541
- .is_some_and(|e| e.labeled_tools.contains(request["params"]["name"].as_str().unwrap_or("")))
542
- {
543
- let arguments = &mut request["params"]["arguments"];
544
- if arguments.is_null() {
545
- *arguments = json!({});
546
- }
547
- arguments
548
- .as_object_mut()
549
- .ok_or("Tool arguments must be an object")?
550
- .insert("session".into(), json!(format!("U {}", &token[..8])));
551
- }
552
- }
553
- write_line(&mut input, &request.to_string()).await?;
554
- if request.get("id").is_none() {
555
- continue;
556
- }
557
- loop {
558
- let response = read_line(&mut output, 16 * 1024 * 1024).await?;
559
- let value: Value = serde_json::from_str(&response).map_err(|e| e.to_string())?;
560
- let matched = value.get("id") == request.get("id");
561
- if matched {
562
- self.observe_discovery(token, &request, &value);
563
- }
564
- if call && matched {
565
- self.complete(token, &value)
566
- }
567
- write_line(writer, &response).await?;
568
- if matched {
569
- break;
570
- }
571
- }
572
- }
573
- Ok::<(), String>(())
574
- };
575
- // EOF stops queued work but lets an outstanding native action return before handoff.
576
- let drain = async {
577
- tokio::pin!(incoming, forwarding);
578
- tokio::select! {
579
- result = &mut forwarding => result,
580
- _ = &mut incoming => tokio::time::timeout(Duration::from_secs(120), &mut forwarding).await.map_err(|_| "Native operation did not drain")?,
581
- }
582
- };
583
- tokio::select! {result=drain=>result,_=cancel.changed()=>Err("Computer connection stopped".into())}
584
- }
585
- }
586
- fn clone_connection(c: &Connection) -> Connection {
587
- Connection {
588
- generation: c.generation.clone(),
589
- command: c.command.clone(),
590
- args: c.args.clone(),
591
- env: c.env.iter().map(|e| crate::Environment { name: e.name.clone(), value: e.value.clone() }).collect(),
592
- }
593
- }
594
- fn release(s: &mut State) {
595
- s.owner = None;
596
- s.target = None;
597
- s.action = None;
598
- s.revision += 1;
599
- s.release_pending = false;
600
- }
601
- fn target(v: &Value) -> Option<Target> {
602
- Some(Target {
603
- pid: u32::try_from(v.get("pid")?.as_u64()?).ok()?,
604
- window_id: u32::try_from(v.get("window_id")?.as_u64()?).ok()?,
605
- })
606
- }
607
- async fn read_line<R: tokio::io::AsyncBufRead + Unpin>(r: &mut R, limit: u64) -> Result<String, String> {
608
- let mut line = String::new();
609
- r.take(limit + 1).read_line(&mut line).await.map_err(|e| e.to_string())?;
610
- if line.len() as u64 > limit || !line.ends_with('\n') {
611
- return Err("Invalid MCP framing or closed connection".into());
612
- }
613
- Ok(line)
614
- }
615
- async fn write_line<W: tokio::io::AsyncWrite + Unpin>(w: &mut W, line: &str) -> Result<(), String> {
616
- w.write_all(line.trim_end_matches('\n').as_bytes()).await.map_err(|e| e.to_string())?;
617
- w.write_all(b"\n").await.map_err(|e| e.to_string())
618
- }
619
-
620
- #[cfg(test)]
621
- mod tests {
622
- use super::*;
623
- fn gateway() -> Gateway {
624
- let g = Gateway::default();
625
- *g.address.lock().unwrap() = Some("127.0.0.1:1".into());
626
- g
627
- }
628
- #[tokio::test]
629
- async fn preview_commands_use_the_owner_transport_and_reject_stale_or_other_sessions() {
630
- let g = gateway();
631
- let token = connection(&g, "a");
632
- g.sync_running(["a".into()].into());
633
- g.acquire(&token, &json!({"pid":1,"window_id":2}), "click").await.unwrap();
634
- g.complete(&token, &json!({}));
635
- let revision = g.activity("a").revision;
636
- let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
637
- g.state.lock().unwrap().entries.get_mut(&token).unwrap().preview = Some(tx);
638
- assert!(g.focus_preview("b", revision).await.is_err());
639
- assert!(g.focus_preview("a", revision + 1).await.is_err());
640
- assert!(rx.try_recv().is_err());
641
- let (cursor, ()) = tokio::join!(g.cursor_position("a"), async {
642
- let command = rx.recv().await.unwrap();
643
- assert!(!command.focus);
644
- command.reply.send(Ok(json!({"structuredContent":{"enabled":true,"position":{"x":40,"y":50}}}))).unwrap();
645
- });
646
- assert_eq!(cursor.unwrap().x, 40.0);
647
- let (focused, ()) = tokio::join!(g.focus_preview("a", revision), async {
648
- let command = rx.recv().await.unwrap();
649
- assert!(command.focus);
650
- command.reply.send(Ok(json!({"structuredContent":{"activated":true}}))).unwrap();
651
- });
652
- focused.unwrap();
653
- }
654
- #[tokio::test]
655
- async fn readiness_and_replacement_are_session_scoped() {
656
- let g = gateway();
657
- *g.address.lock().unwrap() = Some("127.0.0.1:1".into());
658
- let a = connection(&g, "a");
659
- let b = connection(&g, "b");
660
- assert!(g.ensure_ready("a").await.is_ok());
661
- g.observe_discovery(&a, &json!({"method":"tools/list"}), &json!({"result":{"tools":[]}}));
662
- assert!(g.ensure_ready("a").await.unwrap_err().contains("missing required"));
663
- assert!(g.ensure_ready("b").await.is_ok());
664
- let replacement = connection(&g, "a");
665
- assert!(!g.state.lock().unwrap().entries.contains_key(&a));
666
- assert!(g.state.lock().unwrap().entries.contains_key(&b));
667
- assert!(g.state.lock().unwrap().entries.contains_key(&replacement));
668
- assert!(g.ensure_ready("a").await.is_ok());
669
- }
670
-
671
- fn connection(g: &Gateway, id: &str) -> String {
672
- let (token, _) = g
673
- .register(Connection { generation: "g".into(), command: "unused".into(), args: vec![], env: vec![] })
674
- .unwrap();
675
- g.bind(&token, id).unwrap();
676
- g.observe_discovery(
677
- &token,
678
- &json!({"method":"initialize"}),
679
- &json!({"result":{"protocolVersion":"2024-11-05"}}),
680
- );
681
- g.observe_discovery(&token, &json!({"method":"tools/list"}), &json!({"result":{"tools":[{"name":"click"},{"name":"list_windows"},{"name":"get_window_state"},{"name":"run_steps"},{"name":"get_app_state"}]}}));
682
- token
683
- }
684
- #[tokio::test]
685
- async fn fifo_ownership_survives_tool_completion_until_runtime_finishes_turn() {
686
- let g = gateway();
687
- let a = connection(&g, "a");
688
- let b = connection(&g, "b");
689
- g.sync_running(["a".into(), "b".into()].into());
690
- g.acquire(&a, &json!({"pid":1,"window_id":2}), "click").await.unwrap();
691
- g.complete(&a, &json!({}));
692
- assert!(tokio::time::timeout(Duration::from_millis(10), g.acquire(&b, &json!({}), "click")).await.is_err());
693
- assert_eq!(g.activity("b").queue_position, 1);
694
- assert!(g.activity("b").target.is_none());
695
- g.sync_running(["b".into()].into());
696
- g.acquire(&b, &json!({"pid":3,"window_id":4}), "click").await.unwrap();
697
- assert_eq!(g.activity("b").target, Some(Target { pid: 3, window_id: 4 }));
698
- }
699
- #[tokio::test]
700
- async fn cancelled_owner_drains_before_handoff_and_is_blocked_for_the_turn() {
701
- let g = gateway();
702
- let a = connection(&g, "a");
703
- let b = connection(&g, "b");
704
- g.sync_running(["a".into(), "b".into()].into());
705
- g.acquire(&a, &json!({}), "run_steps").await.unwrap();
706
- g.stop_session("a");
707
- assert!(tokio::time::timeout(Duration::from_millis(10), g.acquire(&b, &json!({}), "click")).await.is_err());
708
- assert!(g.acquire(&a, &json!({}), "click").await.is_err());
709
- g.complete(&a, &json!({}));
710
- g.acquire(&b, &json!({}), "click").await.unwrap();
711
- }
712
- #[tokio::test]
713
- async fn cancelling_queued_conversation_never_stops_owner() {
714
- let g = gateway();
715
- let a = connection(&g, "a");
716
- let b = connection(&g, "b");
717
- g.sync_running(["a".into(), "b".into()].into());
718
- g.acquire(&a, &json!({}), "click").await.unwrap();
719
- let _ = tokio::time::timeout(Duration::from_millis(10), g.acquire(&b, &json!({}), "click")).await;
720
- g.stop_session("b");
721
- assert_eq!(g.activity("a").phase, "active");
722
- assert_eq!(g.activity("b").queue_position, 0);
723
- }
724
- #[tokio::test]
725
- async fn closing_an_idle_session_does_not_block_its_next_turn() {
726
- let g = gateway();
727
- let a = connection(&g, "a");
728
- g.stop_session("a");
729
- g.sync_running(["a".into()].into());
730
- g.acquire(&a, &json!({}), "click").await.unwrap();
731
- }
732
- #[tokio::test]
733
- async fn switching_apps_clears_the_previous_window_preview() {
734
- let g = gateway();
735
- let a = connection(&g, "a");
736
- g.sync_running(["a".into()].into());
737
- g.acquire(&a, &json!({"pid":1,"window_id":2}), "get_window_state").await.unwrap();
738
- g.complete(&a, &json!({}));
739
- let revision = g.activity("a").revision;
740
- g.acquire(&a, &json!({"pid":3}), "get_app_state").await.unwrap();
741
- assert!(g.activity("a").target.is_none());
742
- assert!(g.activity("a").revision > revision);
743
- }
744
- #[test]
745
- fn binding_is_immutable_and_global_shutdown_invalidates_tokens() {
746
- let g = gateway();
747
- let a = connection(&g, "a");
748
- assert!(g.bind(&a, "b").is_err());
749
- g.stop_all();
750
- assert!(g.bind(&a, "a").is_err());
751
- }
752
- #[tokio::test]
753
- async fn runtime_completion_does_not_release_an_in_flight_action() {
754
- let g = gateway();
755
- let a = connection(&g, "a");
756
- g.sync_running(["a".into()].into());
757
- g.acquire(&a, &json!({}), "click").await.unwrap();
758
- g.sync_running(HashSet::new());
759
- assert_eq!(g.activity("a").phase, "active");
760
- g.complete(&a, &json!({}));
761
- assert_eq!(g.activity("a").phase, "idle");
762
- }
763
- #[tokio::test]
764
- async fn stale_target_revisions_change_on_handoff() {
765
- let g = gateway();
766
- let a = connection(&g, "a");
767
- g.sync_running(["a".into()].into());
768
- g.acquire(&a, &json!({"pid":1,"window_id":2}), "click").await.unwrap();
769
- let old = g.activity("a").revision;
770
- g.complete(&a, &json!({}));
771
- g.sync_running(HashSet::new());
772
- assert_ne!(old, g.activity("a").revision);
68
+ self.0.call_async("gateway_focus_preview", json!({"session":session,"revision":revision})).await
773
69
  }
774
70
  }