Skip to main content

forge_workspace/mcp/terminal/
facade.rs

1//! The narrow surface the terminal tools call: the seat's terminal, driven
2//! by bytes and keys and read as text.
3//!
4//! Mirrors [`crate::mcp::browser::facade`]: the `Tool` impls hold an
5//! `Arc<dyn TerminalFacade>` so their tests drive a `MockTerminalFacade`
6//! instead of a live PTY.
7
8use std::path::PathBuf;
9use std::sync::Arc;
10use std::time::Duration;
11
12use async_trait::async_trait;
13use forge_primitives::SessionSlot;
14use forge_primitives::browser::{HandOffEnding, HandOffSource};
15use forge_primitives::terminal::{TerminalExit, TerminalState};
16use forge_terminal::Key;
17use tokio::time::{Instant, timeout};
18
19use crate::terminal::{TerminalEntry, wire_state};
20use crate::workspace::Workspace;
21
22/// What `terminal__open` asks for.
23#[derive(Clone, Debug, Default)]
24pub struct OpenRequest {
25    /// A command typed after the shell is ready.
26    pub command: Option<String>,
27    /// The cwd the terminal is first spawned in.
28    pub cwd: Option<String>,
29    pub cols: Option<u16>,
30    pub rows: Option<u16>,
31}
32
33/// What `terminal__open` answers.
34#[derive(Clone, Debug, PartialEq, Eq)]
35pub struct OpenAnswer {
36    pub id: u16,
37    pub screen: String,
38    pub cols: u16,
39    pub rows: u16,
40    pub shell: String,
41    pub cwd: String,
42}
43
44/// What `terminal__read` answers.
45#[derive(Clone, Debug, PartialEq, Eq)]
46pub struct ReadAnswer {
47    pub text: String,
48    pub cursor: u64,
49    pub cols: u16,
50    pub rows: u16,
51    pub state: TerminalState,
52    pub truncated: bool,
53}
54
55/// What `terminal__wait` waits for.
56#[derive(Clone, Debug, PartialEq, Eq)]
57pub enum WaitUntil {
58    /// The running command finished. Exact through OSC 133; the heuristic
59    /// falls back to a quiet prompt and says so in `state.source`.
60    Exit,
61    /// The screen has been quiet for the quiet window.
62    Quiet,
63    /// The screen contains this text.
64    Pattern(String),
65}
66
67/// What `terminal__wait` answers.
68#[derive(Clone, Debug, PartialEq, Eq)]
69pub struct WaitAnswer {
70    pub matched: bool,
71    pub text: String,
72    pub cursor: u64,
73    pub state: TerminalState,
74}
75
76/// What `terminal__run` answers.
77#[derive(Clone, Debug, PartialEq, Eq)]
78pub struct RunAnswer {
79    pub exit: Option<TerminalExit>,
80    pub text: String,
81    pub truncated: bool,
82}
83
84/// The agent's terminal, as one tool call sees it.
85#[async_trait]
86pub trait TerminalFacade: Send + Sync {
87    async fn open(&self, seat: &SessionSlot, request: OpenRequest) -> Result<OpenAnswer, String>;
88
89    async fn write(
90        &self,
91        seat: &SessionSlot,
92        id: u16,
93        text: Option<String>,
94        keys: Vec<Key>,
95    ) -> Result<TerminalState, String>;
96
97    async fn read(
98        &self,
99        seat: &SessionSlot,
100        id: u16,
101        since: Option<u64>,
102        max_lines: usize,
103        max_bytes: usize,
104    ) -> Result<ReadAnswer, String>;
105
106    async fn wait(
107        &self,
108        seat: &SessionSlot,
109        id: u16,
110        until: WaitUntil,
111        timeout_ms: u64,
112    ) -> Result<WaitAnswer, String>;
113
114    async fn resize(&self, seat: &SessionSlot, id: u16, cols: u16, rows: u16)
115    -> Result<(), String>;
116
117    async fn close(&self, seat: &SessionSlot, id: u16) -> Result<Option<TerminalExit>, String>;
118
119    async fn hand_off(
120        &self,
121        seat: &SessionSlot,
122        id: u16,
123        reason: &str,
124    ) -> Result<HandOffEnding, String>;
125
126    async fn run(
127        &self,
128        seat: &SessionSlot,
129        command: &str,
130        cwd: Option<String>,
131        timeout_ms: u64,
132    ) -> Result<RunAnswer, String>;
133}
134
135/// Production impl: the workspace's registry.
136pub struct ProdTerminalFacade {
137    workspace: std::sync::Weak<Workspace>,
138}
139
140impl ProdTerminalFacade {
141    pub fn from_workspace(workspace: &Arc<Workspace>) -> Arc<dyn TerminalFacade> {
142        Arc::new(Self { workspace: Arc::downgrade(workspace) })
143    }
144
145    fn workspace(&self) -> Result<Arc<Workspace>, String> {
146        self.workspace
147            .upgrade()
148            .ok_or_else(|| "the workspace went away before the call was staged".to_owned())
149    }
150}
151
152#[async_trait]
153impl TerminalFacade for ProdTerminalFacade {
154    async fn open(&self, seat: &SessionSlot, request: OpenRequest) -> Result<OpenAnswer, String> {
155        let workspace = self.workspace()?;
156        // **The screen and the PTY are the person's while a hand-off is
157        // parked.** A parallel call or a subagent must not read their typing
158        // or type into their prompt.
159        refuse_when_held(&workspace, seat)?;
160        let entry = match request.cwd.as_deref() {
161            Some(cwd) if !cwd.trim().is_empty() => {
162                // A cwd names where the terminal is spawned, and a terminal is
163                // per seat: a different cwd on an open terminal would need a
164                // second one, and a seat has one.
165                if workspace.terminal_if_any(seat).is_some() {
166                    workspace.terminal_for(seat)?
167                } else {
168                    workspace.spawn_terminal_at(seat, Some(PathBuf::from(cwd)))?
169                }
170            }
171            _ => workspace.terminal_for(seat)?,
172        };
173        if let (Some(cols), Some(rows)) = (request.cols, request.rows) {
174            entry.terminal.resize(cols, rows).map_err(|error| error.to_string())?;
175        }
176        if let Some(command) = request.command.filter(|command| !command.trim().is_empty()) {
177            wait_ready(&entry).await;
178            entry.terminal.write_text(&command).map_err(|error| error.to_string())?;
179            entry.terminal.write_keys(&[Key::Enter]).map_err(|error| error.to_string())?;
180        }
181        let screen = entry.terminal.screen();
182        Ok(OpenAnswer {
183            id: entry.id,
184            screen: screen.text,
185            cols: screen.cols,
186            rows: screen.rows,
187            shell: entry.terminal.shell().to_owned(),
188            cwd: entry.terminal.cwd().display().to_string(),
189        })
190    }
191
192    async fn write(
193        &self,
194        seat: &SessionSlot,
195        id: u16,
196        text: Option<String>,
197        keys: Vec<Key>,
198    ) -> Result<TerminalState, String> {
199        let workspace = self.workspace()?;
200        refuse_when_held(&workspace, seat)?;
201        let entry = workspace.terminal_by_id(seat, id)?;
202        // **An agent's write takes the keyboard from the person.** The holder
203        // hears it through the control stream, and the write lands rather than
204        // being refused: a tool call that cannot act is a dead end for the
205        // session.
206        entry.clear_control();
207        if let Some(text) = text.filter(|text| !text.is_empty()) {
208            entry.terminal.write_text(&text).map_err(|error| error.to_string())?;
209        }
210        if !keys.is_empty() {
211            entry.terminal.write_keys(&keys).map_err(|error| error.to_string())?;
212        }
213        Ok(wire_state(entry.terminal.state()))
214    }
215
216    async fn read(
217        &self,
218        seat: &SessionSlot,
219        id: u16,
220        since: Option<u64>,
221        max_lines: usize,
222        max_bytes: usize,
223    ) -> Result<ReadAnswer, String> {
224        let workspace = self.workspace()?;
225        refuse_when_held(&workspace, seat)?;
226        let entry = workspace.terminal_by_id(seat, id)?;
227        let read = entry.terminal.read(since, max_lines, max_bytes);
228        Ok(ReadAnswer {
229            text: read.text,
230            cursor: read.cursor,
231            cols: read.cols,
232            rows: read.rows,
233            state: wire_state(entry.terminal.state()),
234            truncated: read.truncated,
235        })
236    }
237
238    async fn wait(
239        &self,
240        seat: &SessionSlot,
241        id: u16,
242        until: WaitUntil,
243        timeout_ms: u64,
244    ) -> Result<WaitAnswer, String> {
245        let workspace = self.workspace()?;
246        refuse_when_held(&workspace, seat)?;
247        let entry = workspace.terminal_by_id(seat, id)?;
248        let deadline = Instant::now() + Duration::from_millis(timeout_ms);
249        // **The entry check is not enough**: the person can take the screen
250        // while this polls it, and a wait that kept reading would return
251        // what they type.
252        let blind = Some((workspace.as_ref(), seat));
253        let matched = match until {
254            WaitUntil::Exit => wait_exit(&entry, deadline, blind).await?,
255            WaitUntil::Quiet => wait_quiet(&entry, deadline, blind).await?,
256            WaitUntil::Pattern(pattern) => wait_pattern(&entry, &pattern, deadline, blind).await?,
257        };
258        // **The last gap is a gap too**: a park that lands after the loop's
259        // final check must not get this read.
260        refuse_when_held(&workspace, seat)?;
261        let read = entry.terminal.read(None, 200, 32 * 1024);
262        Ok(WaitAnswer {
263            matched,
264            text: read.text,
265            cursor: read.cursor,
266            state: wire_state(entry.terminal.state()),
267        })
268    }
269
270    async fn resize(
271        &self,
272        seat: &SessionSlot,
273        id: u16,
274        cols: u16,
275        rows: u16,
276    ) -> Result<(), String> {
277        let workspace = self.workspace()?;
278        refuse_when_held(&workspace, seat)?;
279        let entry = workspace.terminal_by_id(seat, id)?;
280        entry.terminal.resize(cols, rows).map_err(|error| error.to_string())
281    }
282
283    async fn close(&self, seat: &SessionSlot, id: u16) -> Result<Option<TerminalExit>, String> {
284        let workspace = self.workspace()?;
285        refuse_when_held(&workspace, seat)?;
286        let entry = workspace.terminal_by_id(seat, id)?;
287        entry.terminal.close();
288        let deadline = Instant::now() + Duration::from_secs(2);
289        while Instant::now() < deadline && entry.terminal.is_alive() {
290            tokio::time::sleep(Duration::from_millis(10)).await;
291        }
292        if entry.terminal.is_alive() {
293            // **A close that did not take is said, not reported as ok.** The
294            // entry stays so the next call can try again or read the screen.
295            return Err("the terminal's shell did not exit within 2 s; it is still open".to_owned());
296        }
297        let exit = entry
298            .terminal
299            .shell_exit()
300            .map(|exit| TerminalExit { code: exit.code, signal: exit.signal });
301        workspace.close_terminal(seat);
302        Ok(exit)
303    }
304
305    async fn hand_off(
306        &self,
307        seat: &SessionSlot,
308        id: u16,
309        reason: &str,
310    ) -> Result<HandOffEnding, String> {
311        let workspace = self.workspace()?;
312        if workspace.terminal_held(seat) {
313            return Err("a hand-off is already parked on this terminal".to_owned());
314        }
315        // **The park takes the keyboard** (under its own lock) from a person
316        // who holds it; the ending hands the agent the keyboard back.
317        let (parked, answer) = workspace.register_terminal_hand_off(seat, id, reason)?;
318        let guard =
319            ResolveOnDrop { workspace: Arc::clone(&workspace), id: parked, caller: seat.clone() };
320        let ending = answer
321            .await
322            .map_err(|_| "no attached client can show the terminal hand-off".to_owned())?;
323        drop(guard);
324        Ok(ending)
325    }
326
327    async fn run(
328        &self,
329        seat: &SessionSlot,
330        command: &str,
331        cwd: Option<String>,
332        timeout_ms: u64,
333    ) -> Result<RunAnswer, String> {
334        let workspace = self.workspace()?;
335        refuse_when_held(&workspace, seat)?;
336        let entry = workspace.spawn_terminal_for_run(
337            seat,
338            cwd.filter(|cwd| !cwd.trim().is_empty()).map(PathBuf::from),
339        )?;
340        // **Guaranteed close on every path**: the guard ends the process group
341        // even when a write or a read fails, so no terminal leaks.
342        let guard = CloseOnDrop { terminal: Arc::clone(&entry.terminal) };
343        wait_ready(&entry).await;
344        let deadline = Instant::now() + Duration::from_millis(timeout_ms);
345        entry.terminal.write_text(command).map_err(|error| error.to_string())?;
346        entry.terminal.write_keys(&[Key::Enter]).map_err(|error| error.to_string())?;
347        let matched = wait_exit(&entry, deadline, None).await?;
348        let read = entry.terminal.read(None, 500, 64 * 1024);
349        let exit = if matched {
350            entry
351                .terminal
352                .state()
353                .exit
354                .map(|exit| TerminalExit { code: exit.code, signal: exit.signal })
355        } else {
356            // The timeout killed the process group; the signal is the record.
357            entry.terminal.close();
358            Some(TerminalExit { code: None, signal: Some(9) })
359        };
360        drop(guard);
361        Ok(RunAnswer { exit, text: read.text, truncated: read.truncated })
362    }
363}
364
365/// **The screen and the PTY belong to the person while a hand-off is
366/// parked.** Every tool that touches either is refused, so a parallel call or
367/// a subagent cannot read their typing or write into their prompt.
368///
369/// **A person's own take-control does not refuse anything**: it is theirs to
370/// hold, and a tool that acts takes it back (see `write` and `hand_off`).
371fn refuse_when_held(workspace: &Workspace, seat: &SessionSlot) -> Result<(), String> {
372    if workspace.terminal_held(seat) {
373        return Err(held_error());
374    }
375    Ok(())
376}
377
378fn held_error() -> String {
379    "a hand-off is parked on this terminal: the person owns its screen and input until they answer"
380        .to_owned()
381}
382
383/// The seat a wait is polling, while it may be taken by the person - through
384/// a parked hand-off or through a client's take-control. A wait that sees it
385/// owned stops rather than reading what they type.
386type Blind<'a> = Option<(&'a Workspace, &'a SessionSlot)>;
387
388fn refusal_now(blind: Blind<'_>) -> Option<String> {
389    let (workspace, seat) = blind?;
390    if workspace.terminal_held(seat) {
391        return Some(held_error());
392    }
393    None
394}
395
396/// Resolves a parked hand-off when the awaiting handler goes - a session that
397/// died mid-wait, never a decision.
398struct ResolveOnDrop {
399    workspace: Arc<Workspace>,
400    id: uuid::Uuid,
401    caller: SessionSlot,
402}
403
404impl Drop for ResolveOnDrop {
405    fn drop(&mut self) {
406        self.workspace.resolve_terminal_hand_off(
407            self.id,
408            &self.caller,
409            forge_primitives::terminal::TerminalHandOffAnswer {
410                ending: HandOffEnding::Abandoned,
411                source: HandOffSource::Abandoned,
412                client: None,
413            },
414        );
415    }
416}
417
418/// Closes a terminal when the run's work is over, whatever path it took.
419struct CloseOnDrop {
420    terminal: Arc<forge_terminal::Terminal>,
421}
422
423impl Drop for CloseOnDrop {
424    fn drop(&mut self) {
425        self.terminal.close();
426    }
427}
428
429/// Wait until the shell has drawn something, so a typed command reaches a
430/// ready line editor rather than being buffered before its prompt exists.
431async fn wait_ready(entry: &TerminalEntry) {
432    let mut output = entry.terminal.subscribe();
433    let deadline = Instant::now() + Duration::from_millis(1200);
434    loop {
435        if !entry.terminal.screen().text.trim().is_empty() {
436            return;
437        }
438        let left = deadline.saturating_duration_since(Instant::now());
439        if left.is_zero() {
440            return;
441        }
442        let _ = timeout(left.min(Duration::from_millis(50)), output.recv()).await;
443    }
444}
445
446/// Wait for a command to finish: exact through the marks, the heuristic's
447/// quiet prompt otherwise, and the shell's own exit for either.
448async fn wait_exit(
449    entry: &TerminalEntry,
450    deadline: Instant,
451    blind: Blind<'_>,
452) -> Result<bool, String> {
453    let mut states = entry.terminal.state_stream();
454    loop {
455        if let Some(why) = refusal_now(blind) {
456            return Err(why);
457        }
458        if !entry.terminal.is_alive() {
459            return Ok(true);
460        }
461        let state = entry.terminal.state();
462        let finished = match state.source {
463            forge_terminal::Source::Exact => !state.running && state.exit.is_some(),
464            forge_terminal::Source::Heuristic => {
465                !state.running
466                    && state
467                        .input_expected
468                        .as_ref()
469                        .is_some_and(|expected| expected.kind == forge_terminal::InputKind::Prompt)
470            }
471        };
472        if finished {
473            return Ok(true);
474        }
475        let left = deadline.saturating_duration_since(Instant::now());
476        if left.is_zero() {
477            return Ok(false);
478        }
479        let _ = timeout(left.min(Duration::from_millis(50)), states.changed()).await;
480    }
481}
482
483/// Wait until no output has arrived for the quiet window.
484async fn wait_quiet(
485    entry: &TerminalEntry,
486    deadline: Instant,
487    blind: Blind<'_>,
488) -> Result<bool, String> {
489    let mut output = entry.terminal.subscribe();
490    loop {
491        if let Some(why) = refusal_now(blind) {
492            return Err(why);
493        }
494        let left = deadline.saturating_duration_since(Instant::now());
495        if left.is_zero() {
496            return Ok(false);
497        }
498        let window = Duration::from_millis(300);
499        match timeout(window.min(left), output.recv()).await {
500            Ok(Ok(_)) => {}
501            Ok(Err(_)) => return Ok(true),
502            Err(_) if left >= window => return Ok(true),
503            Err(_) => return Ok(false),
504        }
505    }
506}
507
508/// Wait until the screen contains `pattern`.
509async fn wait_pattern(
510    entry: &TerminalEntry,
511    pattern: &str,
512    deadline: Instant,
513    blind: Blind<'_>,
514) -> Result<bool, String> {
515    let mut output = entry.terminal.subscribe();
516    loop {
517        if let Some(why) = refusal_now(blind) {
518            return Err(why);
519        }
520        if entry.terminal.screen().text.contains(pattern) {
521            return Ok(true);
522        }
523        let left = deadline.saturating_duration_since(Instant::now());
524        if left.is_zero() {
525            return Ok(false);
526        }
527        let _ = timeout(left.min(Duration::from_millis(100)), output.recv()).await;
528    }
529}
530
531/// One captured write: the terminal, the text and the keys.
532#[cfg(test)]
533type WriteCall = (u16, Option<String>, Vec<Key>);
534
535/// One captured read: the terminal, the cursor and the two bounds.
536#[cfg(test)]
537type ReadCall = (u16, Option<u64>, usize, usize);
538
539/// Mock for the tools' unit tests: captures what a call carried and answers
540/// with a preloaded outcome.
541#[cfg(test)]
542pub struct MockTerminalFacade {
543    pub opens: parking_lot::Mutex<Vec<OpenRequest>>,
544    pub writes: parking_lot::Mutex<Vec<WriteCall>>,
545    pub reads: parking_lot::Mutex<Vec<ReadCall>>,
546    pub waits: parking_lot::Mutex<Vec<(u16, WaitUntil, u64)>>,
547    pub resizes: parking_lot::Mutex<Vec<(u16, u16, u16)>>,
548    pub closes: parking_lot::Mutex<Vec<u16>>,
549    pub hand_offs: parking_lot::Mutex<Vec<(u16, String)>>,
550    pub runs: parking_lot::Mutex<Vec<(String, Option<String>, u64)>>,
551    pub seats: parking_lot::Mutex<Vec<SessionSlot>>,
552    pub answer: parking_lot::Mutex<Result<OpenAnswer, String>>,
553    pub state: parking_lot::Mutex<Result<TerminalState, String>>,
554    pub read_answer: parking_lot::Mutex<Result<ReadAnswer, String>>,
555    pub wait_answer: parking_lot::Mutex<Result<WaitAnswer, String>>,
556    pub close_answer: parking_lot::Mutex<Result<Option<TerminalExit>, String>>,
557    pub hand_off_answer: parking_lot::Mutex<Result<HandOffEnding, String>>,
558    pub run_answer: parking_lot::Mutex<Result<RunAnswer, String>>,
559}
560
561#[cfg(test)]
562impl MockTerminalFacade {
563    pub fn new() -> Self {
564        Self {
565            opens: parking_lot::Mutex::new(Vec::new()),
566            writes: parking_lot::Mutex::new(Vec::new()),
567            reads: parking_lot::Mutex::new(Vec::new()),
568            waits: parking_lot::Mutex::new(Vec::new()),
569            resizes: parking_lot::Mutex::new(Vec::new()),
570            closes: parking_lot::Mutex::new(Vec::new()),
571            hand_offs: parking_lot::Mutex::new(Vec::new()),
572            runs: parking_lot::Mutex::new(Vec::new()),
573            seats: parking_lot::Mutex::new(Vec::new()),
574            answer: parking_lot::Mutex::new(Ok(OpenAnswer {
575                id: 1,
576                screen: "$ ".to_owned(),
577                cols: 120,
578                rows: 30,
579                shell: "/bin/zsh".to_owned(),
580                cwd: "/tmp".to_owned(),
581            })),
582            state: parking_lot::Mutex::new(Ok(empty_state())),
583            read_answer: parking_lot::Mutex::new(Ok(ReadAnswer {
584                text: "hello".to_owned(),
585                cursor: 1,
586                cols: 120,
587                rows: 30,
588                state: empty_state(),
589                truncated: false,
590            })),
591            wait_answer: parking_lot::Mutex::new(Ok(WaitAnswer {
592                matched: true,
593                text: "done".to_owned(),
594                cursor: 2,
595                state: empty_state(),
596            })),
597            close_answer: parking_lot::Mutex::new(Ok(Some(TerminalExit {
598                code: Some(0),
599                signal: None,
600            }))),
601            hand_off_answer: parking_lot::Mutex::new(Ok(HandOffEnding::Done)),
602            run_answer: parking_lot::Mutex::new(Ok(RunAnswer {
603                exit: Some(TerminalExit { code: Some(0), signal: None }),
604                text: "ran".to_owned(),
605                truncated: false,
606            })),
607        }
608    }
609
610    pub fn into_arc(self) -> Arc<dyn TerminalFacade> {
611        Arc::new(self)
612    }
613}
614
615#[cfg(test)]
616pub(crate) fn empty_state() -> TerminalState {
617    TerminalState {
618        running: false,
619        input_expected: None,
620        exit: None,
621        source: forge_primitives::terminal::TerminalSource::Heuristic,
622    }
623}
624
625#[cfg(test)]
626#[async_trait]
627impl TerminalFacade for MockTerminalFacade {
628    async fn open(&self, seat: &SessionSlot, request: OpenRequest) -> Result<OpenAnswer, String> {
629        self.seats.lock().push(seat.clone());
630        self.opens.lock().push(request);
631        self.answer.lock().clone()
632    }
633
634    async fn write(
635        &self,
636        seat: &SessionSlot,
637        id: u16,
638        text: Option<String>,
639        keys: Vec<Key>,
640    ) -> Result<TerminalState, String> {
641        self.seats.lock().push(seat.clone());
642        self.writes.lock().push((id, text, keys));
643        self.state.lock().clone()
644    }
645
646    async fn read(
647        &self,
648        seat: &SessionSlot,
649        id: u16,
650        since: Option<u64>,
651        max_lines: usize,
652        max_bytes: usize,
653    ) -> Result<ReadAnswer, String> {
654        self.seats.lock().push(seat.clone());
655        self.reads.lock().push((id, since, max_lines, max_bytes));
656        self.read_answer.lock().clone()
657    }
658
659    async fn wait(
660        &self,
661        seat: &SessionSlot,
662        id: u16,
663        until: WaitUntil,
664        timeout_ms: u64,
665    ) -> Result<WaitAnswer, String> {
666        self.seats.lock().push(seat.clone());
667        self.waits.lock().push((id, until, timeout_ms));
668        self.wait_answer.lock().clone()
669    }
670
671    async fn resize(
672        &self,
673        seat: &SessionSlot,
674        id: u16,
675        cols: u16,
676        rows: u16,
677    ) -> Result<(), String> {
678        self.seats.lock().push(seat.clone());
679        self.resizes.lock().push((id, cols, rows));
680        Ok(())
681    }
682
683    async fn close(&self, seat: &SessionSlot, id: u16) -> Result<Option<TerminalExit>, String> {
684        self.seats.lock().push(seat.clone());
685        self.closes.lock().push(id);
686        self.close_answer.lock().clone()
687    }
688
689    async fn hand_off(
690        &self,
691        seat: &SessionSlot,
692        id: u16,
693        reason: &str,
694    ) -> Result<HandOffEnding, String> {
695        self.seats.lock().push(seat.clone());
696        self.hand_offs.lock().push((id, reason.to_owned()));
697        self.hand_off_answer.lock().clone()
698    }
699
700    async fn run(
701        &self,
702        seat: &SessionSlot,
703        command: &str,
704        cwd: Option<String>,
705        timeout_ms: u64,
706    ) -> Result<RunAnswer, String> {
707        self.seats.lock().push(seat.clone());
708        self.runs.lock().push((command.to_owned(), cwd, timeout_ms));
709        self.run_answer.lock().clone()
710    }
711}