Skip to main content

forge_workspace/
terminal.rs

1//! The per-seat terminals: the registry, the hand-off that parks on the
2//! person, and the idle clock.
3//!
4//! **The terminal belongs to the seat, not the occupant.** It is keyed by the
5//! slot and created lazily on first use; `/new`, `/resume` and a handoff
6//! leave the PTY, its shell and its scrollback exactly where they were. It
7//! lives as long as the seat: a release ends the seat and its terminal, and
8//! an explicit close, the shell exiting or forge exiting also ends it.
9//!
10//! **No approval gate.** Bash through the CLI already reaches the shell here,
11//! so the terminal adds no capability a session does not have. The one thing
12//! that is gated is the person's own input, through the hand-off or through
13//! their own take-control of the raised surface.
14//!
15//! **Lock order: `terminal_handoffs` before `terminals`.** Every path that
16//! needs both takes them in that order, so a claim, an answer, a typed byte
17//! and the idle sweep can never deadlock against each other. A path that
18//! needed the other order has to drop one guard before taking the other.
19
20use std::path::PathBuf;
21use std::sync::Arc;
22use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
23use std::time::{Duration, Instant};
24
25use forge_primitives::SessionSlot;
26use forge_primitives::browser::HandOffEnding;
27use forge_primitives::terminal::{
28    TerminalExit, TerminalHandOff, TerminalHandOffAnswer, TerminalInputExpected, TerminalInputKind,
29    TerminalSource, TerminalState, TerminalSummary,
30};
31use parking_lot::Mutex;
32use tokio::sync::oneshot;
33
34use crate::protocol::SessionUpdate;
35use crate::workspace::Workspace;
36
37/// One chunk of a terminal's output, re-exported so a server half can name
38/// the type its stream carries without depending on the crate directly.
39pub use forge_terminal::OutputChunk;
40
41/// The size a terminal opens at before a client's attach resizes it.
42pub(crate) const DEFAULT_COLS: u16 = 120;
43pub(crate) const DEFAULT_ROWS: u16 = 30;
44
45/// How often the idle clock looks at the registry.
46const CLOCK_TICK: Duration = Duration::from_secs(30);
47
48/// One seat's terminal, as the registry holds it.
49pub struct TerminalEntry {
50    /// The id the binary frames carry.
51    pub id: u16,
52    pub terminal: Arc<forge_terminal::Terminal>,
53    /// When an agent call or a viewer last touched it, for the idle clock.
54    pub last_used: Mutex<Instant>,
55    /// How many clients are attached.
56    pub attached: AtomicUsize,
57    /// The connection holding the person's control, or 0 for the agent.
58    /// **One at a time**, like the browser's controller: another client can
59    /// take it over, and the holder always hears it lost the role.
60    controller: AtomicU64,
61    /// The holder every attached viewer follows, so a bar can say who has it.
62    control: tokio::sync::watch::Sender<Option<u64>>,
63}
64
65impl TerminalEntry {
66    fn touch(&self) {
67        *self.last_used.lock() = Instant::now();
68    }
69
70    /// The person's connection holding the terminal, when one does.
71    pub fn controller(&self) -> Option<u64> {
72        match self.controller.load(Ordering::Relaxed) {
73            0 => None,
74            id => Some(id),
75        }
76    }
77
78    /// Give one connection the person's control, telling every attached
79    /// viewer who holds it. A take always lands: like the browser's role, a
80    /// second client taking over is how the first one loses it.
81    pub fn take_control(&self, viewer: u64) {
82        if viewer == 0 {
83            return;
84        }
85        self.controller.store(viewer, Ordering::Relaxed);
86        self.touch();
87        let _ = self.control.send(Some(viewer));
88    }
89
90    /// Give the person's control back to the agent whatever connection holds
91    /// it, which is what the end of a hand-off owes: the ask parked the
92    /// keyboard, and the ending takes it back.
93    pub fn clear_control(&self) {
94        if self.controller.swap(0, Ordering::Relaxed) != 0 {
95            self.touch();
96            let _ = self.control.send(None);
97        }
98    }
99
100    /// Give control back when its holder releases it; another connection's
101    /// release is not this one's to take.
102    pub fn release_control(&self, viewer: u64) -> bool {
103        if self.controller() != Some(viewer) {
104            return false;
105        }
106        self.controller.store(0, Ordering::Relaxed);
107        self.touch();
108        let _ = self.control.send(None);
109        true
110    }
111
112    /// Follow the controller: the latest holder, and every change.
113    pub fn control_stream(&self) -> tokio::sync::watch::Receiver<Option<u64>> {
114        self.control.subscribe()
115    }
116
117    fn idle_for(&self) -> Duration {
118        self.last_used.lock().elapsed()
119    }
120
121    /// The summary the strip and the session record draw.
122    pub fn summary(&self, slot: &SessionSlot, held: bool) -> TerminalSummary {
123        TerminalSummary {
124            id: self.id,
125            seat: slot.clone(),
126            shell: self.terminal.shell().to_owned(),
127            cwd: self.terminal.cwd().display().to_string(),
128            cols: self.terminal.cols(),
129            rows: self.terminal.rows(),
130            alive: self.terminal.is_alive(),
131            state: wire_state(self.terminal.state()),
132            title: self.terminal.title(),
133            held,
134        }
135    }
136}
137
138/// The wire state, from the crate's own.
139pub(crate) fn wire_state(state: forge_terminal::State) -> TerminalState {
140    TerminalState {
141        running: state.running,
142        input_expected: state.input_expected.map(|expected| TerminalInputExpected {
143            kind: match expected.kind {
144                forge_terminal::InputKind::Password => TerminalInputKind::Password,
145                forge_terminal::InputKind::Confirm => TerminalInputKind::Confirm,
146                forge_terminal::InputKind::Prompt => TerminalInputKind::Prompt,
147            },
148            evidence: expected.evidence,
149        }),
150        exit: state.exit.map(|exit| TerminalExit { code: exit.code, signal: exit.signal }),
151        source: match state.source {
152            forge_terminal::Source::Exact => TerminalSource::Exact,
153            forge_terminal::Source::Heuristic => TerminalSource::Heuristic,
154        },
155    }
156}
157
158impl Workspace {
159    /// The seat's terminal, spawned on first use.
160    ///
161    /// **One per seat, and the slot is the key.** A `/new` or `/resume`
162    /// swapping the occupant never reaches here, which is what makes the PTY
163    /// outlive it.
164    pub(crate) fn terminal_for(
165        self: &Arc<Self>,
166        slot: &SessionSlot,
167    ) -> Result<Arc<TerminalEntry>, String> {
168        self.spawn_terminal_at(slot, None)
169    }
170
171    /// The seat's terminal, spawned in `cwd` the first time; an existing
172    /// terminal is returned as it is.
173    ///
174    /// **A shell that exited without a close is replaced**, so the seat's
175    /// next open starts a fresh shell rather than handing back a dead PTY.
176    pub(crate) fn spawn_terminal_at(
177        self: &Arc<Self>,
178        slot: &SessionSlot,
179        cwd: Option<PathBuf>,
180    ) -> Result<Arc<TerminalEntry>, String> {
181        let mut terminals = self.terminals.lock();
182        if let Some(entry) = terminals.get(slot).cloned() {
183            if entry.terminal.is_alive() {
184                entry.touch();
185                return Ok(entry);
186            }
187            terminals.remove(slot);
188        }
189        let entry = self.spawn_terminal(slot, cwd)?;
190        terminals.insert(slot.clone(), Arc::clone(&entry));
191        drop(terminals);
192        self.ensure_terminal_clock();
193        self.watch_terminal_exit(slot, &entry);
194        self.watch_terminal_state(slot, &entry);
195        self.announce_terminal(slot, Some(entry.summary(slot, false)));
196        Ok(entry)
197    }
198
199    /// The seat's terminal, spawned on first use. **The person's door**: a
200    /// view can open one on a seat no agent has touched.
201    ///
202    /// **The door follows the seat read.** A declared seat answers a read
203    /// before anything starts in it (its directory resolves from the
204    /// declaration), so its door opens and the terminal it starts appears in
205    /// the record. A seat with no working tree - a worker never spawned, or a
206    /// slot naming no project - has its read refused, and the door refuses
207    /// with the same sentence rather than starting a shell nobody can draw.
208    pub fn open_terminal(
209        self: &Arc<Self>,
210        slot: &SessionSlot,
211    ) -> Result<Arc<TerminalEntry>, String> {
212        if self.cwd_for_session(slot).is_none() {
213            // **The seat read's own sentence**: a door that refused with
214            // different words would be a second answer to one fact.
215            return Err(format!("forge holds no session for {}", slot.display()));
216        }
217        self.terminal_for(slot)
218    }
219
220    /// A fresh terminal for `terminal__run`, not registered: the command's
221    /// own terminal, closed when the call returns.
222    pub(crate) fn spawn_terminal_for_run(
223        &self,
224        slot: &SessionSlot,
225        cwd: Option<PathBuf>,
226    ) -> Result<Arc<TerminalEntry>, String> {
227        self.spawn_terminal(slot, cwd)
228    }
229
230    /// The seat's terminal, when it has one.
231    pub fn terminal_if_any(&self, slot: &SessionSlot) -> Option<Arc<TerminalEntry>> {
232        self.terminals.lock().get(slot).cloned()
233    }
234
235    /// The seat's terminal, refused unless `id` names it.
236    pub fn terminal_by_id(
237        &self,
238        slot: &SessionSlot,
239        id: u16,
240    ) -> Result<Arc<TerminalEntry>, String> {
241        let Some(entry) = self.terminal_if_any(slot) else {
242            return Err(
243                "this seat has no terminal open; run terminal__open first (the id it returns is \
244                 what this call takes)"
245                    .to_owned(),
246            );
247        };
248        if entry.id != id {
249            return Err(format!("terminal {id} is not this seat's; its terminal is {}", entry.id));
250        }
251        entry.touch();
252        Ok(entry)
253    }
254
255    /// Whether a terminal hand-off is parked on the seat.
256    pub fn terminal_held(&self, slot: &SessionSlot) -> bool {
257        self.terminal_handoffs.lock().values().any(|(owner, _, _)| owner == slot)
258    }
259
260    /// Whether the person holds the seat's terminal through a client's
261    /// take-control, rather than through an agent's hand-off.
262    pub fn terminal_controlled(&self, slot: &SessionSlot) -> bool {
263        self.terminal_if_any(slot).is_some_and(|entry| entry.controller().is_some())
264    }
265
266    /// The person's own take: always explicit, and always landing.
267    ///
268    /// **A take is allowed while a hand-off parks**, because a park opens the
269    /// surface rather than handing out the keyboard: the person reads it,
270    /// takes control, and types. **The id is the terminal the caller is
271    /// viewing**: a replaced shell has a new id, and a take that landed on it
272    /// would hand a role no push could reach.
273    pub fn take_terminal_control(
274        &self,
275        slot: &SessionSlot,
276        id: u16,
277        viewer: u64,
278    ) -> Result<(), String> {
279        let entry = self.terminal_by_id(slot, id)?;
280        entry.take_control(viewer);
281        Ok(())
282    }
283
284    /// Close the seat's terminal, if it has one.
285    pub fn close_terminal(&self, slot: &SessionSlot) {
286        let removed = self.terminals.lock().remove(slot);
287        if let Some(entry) = removed {
288            entry.terminal.close();
289            self.announce_terminal(slot, None);
290        }
291        self.abandon_terminal_hand_offs(slot);
292    }
293
294    /// Close every terminal an idle clock may close: untouched by any agent
295    /// call, unattached, and unheld by a hand-off.
296    pub(crate) fn sweep_idle_terminals(&self) {
297        let idle_minutes = self.config_arc().terminal.idle_minutes;
298        if idle_minutes == 0 {
299            return;
300        }
301        let idle = Duration::from_secs(idle_minutes.saturating_mul(60));
302        // **The idle set is taken under `terminals` alone.** `terminal_held`
303        // takes `terminal_handoffs`, and the module's order is hand-offs
304        // before terminals; holding `terminals` across the held check would
305        // be the one path taking them the other way, which deadlocks against
306        // a take, an answer or a typed byte.
307        let idle_slots: Vec<SessionSlot> = self
308            .terminals
309            .lock()
310            .iter()
311            .filter(|(_, entry)| {
312                entry.attached.load(Ordering::Relaxed) == 0 && entry.idle_for() >= idle
313            })
314            .map(|(slot, _)| slot.clone())
315            .collect();
316        // **A held or controlled terminal is in use**, whatever the clock
317        // says: sweeping one would take the ask or the keyboard from the
318        // person it belongs to.
319        let victims: Vec<SessionSlot> = idle_slots
320            .into_iter()
321            .filter(|slot| !self.terminal_held(slot) && !self.terminal_controlled(slot))
322            .collect();
323        for slot in victims {
324            tracing::debug!(
325                event_name = "terminal_idle_closed",
326                slot = %slot.display(),
327                idle_minutes,
328                "an idle terminal was closed"
329            );
330            self.close_terminal(&slot);
331        }
332    }
333
334    /// Tell the views a terminal opened, changed or closed.
335    pub(crate) fn announce_terminal(&self, slot: &SessionSlot, terminal: Option<TerminalSummary>) {
336        let _ = self
337            .update_sender()
338            .send(SessionUpdate::TerminalChanged { key: slot.clone(), terminal });
339    }
340
341    /// Hold a terminal hand-off for the person at a client, and hand back its
342    /// id plus the receiver the caller awaits.
343    ///
344    /// **No timeout rides the wait.** The ask sits until an answer or the
345    /// caller's own drop. The one case answered up front is a stream nobody
346    /// can answer on, which fails closed rather than parking the session on a
347    /// prompt no view can draw.
348    pub(crate) fn register_terminal_hand_off(
349        &self,
350        caller: &SessionSlot,
351        terminal: u16,
352        reason: &str,
353    ) -> Result<(uuid::Uuid, oneshot::Receiver<HandOffEnding>), String> {
354        let entry = self.terminal_by_id(caller, terminal)?;
355        let state = wire_state(entry.terminal.state());
356        // The card describes the terminal when it asked; a program that turns
357        // ECHO back on later does not rewrite the reason the person was shown.
358        let secret = state
359            .input_expected
360            .as_ref()
361            .is_some_and(|expected| expected.kind == TerminalInputKind::Password);
362        let (sender, receiver) = oneshot::channel();
363        let id = uuid::Uuid::new_v4();
364        let handoff = TerminalHandOff { id, reason: reason.to_owned(), terminal, secret };
365        {
366            // **The park owns the keyboard, and takes it under its own lock**:
367            // a person's take that landed first loses it to the park, and the
368            // holder hears it through the control stream. The park then raises
369            // the surface as a viewer, and the person takes control to type.
370            let mut parked = self.terminal_handoffs.lock();
371            entry.clear_control();
372            parked.insert(id, (caller.clone(), handoff.clone(), sender));
373        }
374        // **The strip says the input is the person's** while the ask waits,
375        // so a row drawn beside the dock does not read as idle.
376        self.announce_terminal(caller, Some(entry.summary(caller, true)));
377        let answerable = self
378            .update_sender()
379            .send_answering(SessionUpdate::TerminalHandOffPending { key: caller.clone(), handoff });
380        if !answerable {
381            self.terminal_handoffs.lock().remove(&id);
382            // **The strip must not keep saying the input is the person's**
383            // for a park that was withdrawn before it ever showed.
384            self.announce_terminal(caller, Some(entry.summary(caller, false)));
385            let (_ignored_sender, dead_receiver) = oneshot::channel();
386            return Ok((id, dead_receiver));
387        }
388        self.note_ask(caller);
389        Ok((id, receiver))
390    }
391
392    /// Whether a hand-off with this id is still registered to `caller`, without
393    /// removing it.
394    pub(crate) fn terminal_handoff_waiting(&self, id: uuid::Uuid, caller: &SessionSlot) -> bool {
395        self.terminal_handoffs.lock().get(&id).is_some_and(|(owner, _, _)| owner == caller)
396    }
397
398    /// Remove a parked hand-off, answer its waiter, and tell every view it is
399    /// gone. `false` when the id is not this caller's to resolve.
400    pub(crate) fn resolve_terminal_hand_off(
401        &self,
402        id: uuid::Uuid,
403        caller: &SessionSlot,
404        answer: TerminalHandOffAnswer,
405    ) -> bool {
406        let mut parked = self.terminal_handoffs.lock();
407        if !parked.get(&id).is_some_and(|(owner, _, _)| owner == caller) {
408            return false;
409        }
410        let Some((owner, handoff, sender)) = parked.remove(&id) else { return false };
411        // **The ending takes the keyboard back while the park is still under
412        // the lock**, so a claim that saw the park as live cannot land after
413        // it and leave the agent blind behind a hand-off that is gone.
414        if let Some(entry) = self.terminal_if_any(&owner) {
415            entry.clear_control();
416        }
417        drop(parked);
418        tracing::debug!(
419            event_name = "terminal_hand_off_resolved",
420            id = %id,
421            ending = ?answer.ending,
422            source = ?answer.source,
423            client = answer.client,
424            seat = %owner.display(),
425            terminal = handoff.terminal,
426            "a parked terminal hand-off left the registry"
427        );
428        let _ = sender.send(answer.ending);
429        let _ = self.update_sender().send(SessionUpdate::TerminalHandOffResolved {
430            key: owner.clone(),
431            id,
432            answer,
433        });
434        // The strip stops saying the input waits.
435        if let Some(entry) = self.terminal_if_any(&owner) {
436            self.announce_terminal(&owner, Some(entry.summary(&owner, false)));
437        }
438        true
439    }
440
441    /// A seat ended - or `/new`/`/resume` replaced the occupant that was
442    /// waiting - with a hand-off still parked: resolve it as abandoned so no
443    /// dock outlives its asker.
444    pub(crate) fn abandon_terminal_hand_offs(&self, slot: &SessionSlot) {
445        let ids: Vec<uuid::Uuid> = self
446            .terminal_handoffs
447            .lock()
448            .iter()
449            .filter(|(_, (owner, _, _))| owner == slot)
450            .map(|(id, _)| *id)
451            .collect();
452        for id in ids {
453            let _ = self.resolve_terminal_hand_off(
454                id,
455                slot,
456                TerminalHandOffAnswer {
457                    ending: HandOffEnding::Abandoned,
458                    source: forge_primitives::browser::HandOffSource::Abandoned,
459                    client: None,
460                },
461            );
462        }
463    }
464
465    /// Spawn a terminal for `slot`, with its cwd and the environment its
466    /// `claude` child gets.
467    fn spawn_terminal(
468        &self,
469        slot: &SessionSlot,
470        cwd: Option<PathBuf>,
471    ) -> Result<Arc<TerminalEntry>, String> {
472        let Some(project) = self.project_for_slot(slot) else {
473            return Err(format!(
474                "{slot} names no configured project, so it has no cwd or environment to give a \
475                 terminal",
476                slot = slot.display()
477            ));
478        };
479        let account_env =
480            self.pool
481                .lock()
482                .get(slot)
483                .and_then(|pooled| self.accounts.env(&pooled.account))
484                .or_else(|| {
485                    project.accounts.first().and_then(|name| {
486                        self.accounts.env(&forge_gateway::AccountKey(name.clone()))
487                    })
488                })
489                .unwrap_or_default();
490        let mut env = crate::config::seat_env(&project, &account_env);
491        let cwd = cwd
492            .or_else(|| self.cwd_for_session(slot).map(PathBuf::from))
493            .unwrap_or_else(|| project.path.clone());
494        env.insert("PWD".to_owned(), cwd.display().to_string());
495        let config = self.config_arc();
496        let terminal = forge_terminal::Terminal::spawn(&forge_terminal::TerminalConfig {
497            shell: config.terminal.shell.clone(),
498            cwd,
499            env,
500            cols: DEFAULT_COLS,
501            rows: DEFAULT_ROWS,
502            scrollback_lines: config.terminal.scrollback_lines,
503        })
504        .map_err(|error| format!("the terminal could not start: {error}"))?;
505        let id = self.next_terminal_id.fetch_add(1, Ordering::Relaxed);
506        let (control, _) = tokio::sync::watch::channel(None);
507        Ok(Arc::new(TerminalEntry {
508            id,
509            terminal: Arc::new(terminal),
510            last_used: Mutex::new(Instant::now()),
511            attached: AtomicUsize::new(0),
512            controller: AtomicU64::new(0),
513            control,
514        }))
515    }
516
517    /// Tell the strip when the shell exits, so "the shell exited" is drawn
518    /// without waiting for a record read.
519    fn watch_terminal_exit(self: &Arc<Self>, slot: &SessionSlot, entry: &Arc<TerminalEntry>) {
520        let Ok(runtime) = tokio::runtime::Handle::try_current() else { return };
521        let weak = Arc::downgrade(self);
522        let slot = slot.clone();
523        let entry = Arc::clone(entry);
524        let mut exit = entry.terminal.exit_stream();
525        runtime.spawn(async move {
526            if exit.changed().await.is_err() {
527                return;
528            }
529            let Some(workspace) = weak.upgrade() else { return };
530            let held = workspace.terminal_held(&slot);
531            workspace.announce_terminal(&slot, Some(entry.summary(&slot, held)));
532        });
533    }
534
535    /// **A state change is a push, not a poll.** The strip's words - a
536    /// command running, a password wanted - come from the record, and a
537    /// record that only changed on open, close and exit would say "idle"
538    /// through a whole command.
539    fn watch_terminal_state(self: &Arc<Self>, slot: &SessionSlot, entry: &Arc<TerminalEntry>) {
540        let Ok(runtime) = tokio::runtime::Handle::try_current() else { return };
541        let weak = Arc::downgrade(self);
542        let slot = slot.clone();
543        let entry = Arc::clone(entry);
544        let mut states = entry.terminal.state_stream();
545        runtime.spawn(async move {
546            while states.changed().await.is_ok() {
547                let Some(workspace) = weak.upgrade() else { return };
548                // Only while this is still the seat's terminal: a replaced
549                // shell's watcher must not announce the old entry.
550                if workspace.terminal_if_any(&slot).map(|held| held.id) != Some(entry.id) {
551                    return;
552                }
553                let held = workspace.terminal_held(&slot);
554                workspace.announce_terminal(&slot, Some(entry.summary(&slot, held)));
555            }
556        });
557    }
558
559    /// Start the idle clock, once. The registry makes a second call a no-op.
560    fn ensure_terminal_clock(self: &Arc<Self>) {
561        // **No runtime means no clock this run**, the same early return the
562        // task had before the registry: a terminal created outside a runtime
563        // has no tick to sweep with.
564        if tokio::runtime::Handle::try_current().is_err() {
565            return;
566        }
567        let weak = Arc::downgrade(self);
568        self.supervisor.spawn(
569            "terminal_idle_clock",
570            forge_supervise::Policy::backoff(),
571            move || {
572                let weak = weak.clone();
573                async move {
574                    loop {
575                        tokio::time::sleep(CLOCK_TICK).await;
576                        let Some(workspace) = weak.upgrade() else { return };
577                        workspace.sweep_idle_terminals();
578                    }
579                }
580            },
581        );
582    }
583}
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588
589    /// ECHO off is the password signal, and it makes the hand-off's card say
590    /// the typing is hidden.
591    #[test]
592    fn a_password_terminal_hand_off_is_secret() {
593        let state = TerminalState {
594            running: false,
595            input_expected: Some(TerminalInputExpected {
596                kind: TerminalInputKind::Password,
597                evidence: "ECHO off".to_owned(),
598            }),
599            exit: None,
600            source: TerminalSource::Exact,
601        };
602        let secret = state
603            .input_expected
604            .as_ref()
605            .is_some_and(|expected| expected.kind == TerminalInputKind::Password);
606        assert!(secret);
607    }
608
609    /// **The terminal belongs to the slot, not the occupant.** `/new` and
610    /// `/resume` swap the session id and leave the slot alone, so a second
611    /// `terminal_for` on the same slot finds the same PTY - which is what
612    /// makes the shell and its scrollback outlive both.
613    #[tokio::test]
614    async fn the_terminal_belongs_to_the_slot_across_occupants() {
615        let (workspace, _rx) = Workspace::testing_stub();
616        workspace.seed_test_project("proj", "/tmp");
617        let slot = SessionSlot::lead("TestOrg", "proj");
618        let first = workspace.terminal_for(&slot).expect("a terminal");
619        // A `/new` or `/resume` is a new occupant on the same slot; the
620        // registry is keyed by the slot, so the next read is the same value.
621        let again = workspace.terminal_for(&slot).expect("the same terminal");
622        assert_eq!(again.id, first.id, "the terminal id is the slot's terminal");
623        assert!(
624            Arc::ptr_eq(&first.terminal, &again.terminal),
625            "the same PTY, shell and scrollback are what a new occupant finds",
626        );
627        workspace.close_terminal(&slot);
628    }
629
630    /// **A hand-off parks on the terminal and blinds the agent**: while one is
631    /// held, the write and read tools refuse before touching the screen.
632    #[tokio::test]
633    async fn a_parked_hand_off_blinds_the_agent() {
634        let (workspace, _rx) = Workspace::testing_stub();
635        workspace.seed_test_project("proj", "/tmp");
636        let slot = SessionSlot::lead("TestOrg", "proj");
637        let entry = workspace.terminal_for(&slot).expect("a terminal");
638        let (_id, _answer) = workspace
639            .register_terminal_hand_off(&slot, entry.id, "the sudo password")
640            .expect("a hand-off parks");
641        assert!(workspace.terminal_held(&slot), "the person owns the input");
642        let refused = crate::mcp::terminal::facade::ProdTerminalFacade::from_workspace(&workspace);
643        assert!(
644            refused.read(&slot, entry.id, None, 200, 32 * 1024).await.is_err(),
645            "a read while parked is refused",
646        );
647        assert!(
648            refused.write(&slot, entry.id, Some("x".to_owned()), Vec::new()).await.is_err(),
649            "a write while parked is refused",
650        );
651        workspace.close_terminal(&slot);
652    }
653
654    /// **The person's take is theirs, and an agent's call takes it back.**
655    /// Another client's take overrides the holder; a read is allowed while
656    /// the person holds the keyboard; a write takes it from them.
657    #[tokio::test]
658    async fn an_agents_write_takes_the_keyboard_from_the_person() {
659        let (workspace, _rx) = Workspace::testing_stub();
660        workspace.seed_test_project("proj", "/tmp");
661        let slot = SessionSlot::lead("TestOrg", "proj");
662        let entry = workspace.terminal_for(&slot).expect("a terminal");
663        entry.take_control(7);
664        assert!(workspace.terminal_controlled(&slot), "the person holds the terminal");
665        assert!(!workspace.terminal_held(&slot), "which is not a hand-off");
666        let facade = crate::mcp::terminal::facade::ProdTerminalFacade::from_workspace(&workspace);
667        assert!(
668            facade.read(&slot, entry.id, None, 10, 1024).await.is_ok(),
669            "a read while the person controls is allowed",
670        );
671        assert!(
672            facade.write(&slot, entry.id, Some("x".to_owned()), Vec::new()).await.is_ok(),
673            "a write lands and takes the keyboard from the person",
674        );
675        assert!(!workspace.terminal_controlled(&slot), "and the person no longer holds it");
676        entry.take_control(8);
677        assert_eq!(entry.controller(), Some(8), "another client's take overrides the holder");
678        assert!(!entry.release_control(7), "the old holder's release does not land");
679        assert!(entry.release_control(8), "the holder releases it");
680        assert!(!workspace.terminal_controlled(&slot), "the agent owns the input again");
681        workspace.close_terminal(&slot);
682    }
683
684    /// **A wait already in flight stops when a hand-off parks.** The entry
685    /// check is not enough: the person can take the screen while the agent
686    /// is polling it, and a wait that keeps reading would return what they
687    /// type.
688    #[tokio::test]
689    async fn a_wait_in_flight_stops_when_a_hand_off_parks() {
690        let (workspace, _rx) = Workspace::testing_stub();
691        workspace.seed_test_project("proj", "/tmp");
692        let slot = SessionSlot::lead("TestOrg", "proj");
693        let entry = workspace.terminal_for(&slot).expect("a terminal");
694        let facade = crate::mcp::terminal::facade::ProdTerminalFacade::from_workspace(&workspace);
695        let waiter = {
696            let facade = Arc::clone(&facade);
697            let slot = slot.clone();
698            let id = entry.id;
699            tokio::spawn(async move {
700                facade
701                    .wait(
702                        &slot,
703                        id,
704                        crate::mcp::terminal::facade::WaitUntil::Pattern("NEVER_SHOWN".to_owned()),
705                        5_000,
706                    )
707                    .await
708            })
709        };
710        // Let the wait reach its poll loop before the person takes the screen.
711        tokio::time::sleep(Duration::from_millis(150)).await;
712        let (_id, _answer) = workspace
713            .register_terminal_hand_off(&slot, entry.id, "the sudo password")
714            .expect("a hand-off parks");
715        let joined = tokio::time::timeout(Duration::from_secs(2), waiter)
716            .await
717            .expect("the wait returns once the person holds the screen")
718            .expect("the wait task joins");
719        assert!(joined.is_err(), "a wait in flight must refuse once a hand-off parks: {joined:?}");
720        workspace.close_terminal(&slot);
721    }
722
723    /// **`terminal__run` is a terminal call too.** A hand-off owns the seat's
724    /// terminal and its input; a run started behind it is the same call the
725    /// other tools refuse.
726    #[tokio::test]
727    async fn run_is_refused_while_a_hand_off_is_parked() {
728        let (workspace, _rx) = Workspace::testing_stub();
729        workspace.seed_test_project("proj", "/tmp");
730        let slot = SessionSlot::lead("TestOrg", "proj");
731        let entry = workspace.terminal_for(&slot).expect("a terminal");
732        let (_id, _answer) = workspace
733            .register_terminal_hand_off(&slot, entry.id, "the sudo password")
734            .expect("a hand-off parks");
735        let facade = crate::mcp::terminal::facade::ProdTerminalFacade::from_workspace(&workspace);
736        let result = facade.run(&slot, "echo x", None, 1_000).await;
737        assert!(result.is_err(), "run is refused while the person owns the screen");
738        workspace.close_terminal(&slot);
739    }
740
741    /// **An answer settles the park**: the waiter gets the ending, the seat
742    /// stops being held, and the views are told the ask resolved.
743    #[tokio::test]
744    async fn a_hand_off_answer_settles_the_park() {
745        let (workspace, mut rx) = Workspace::testing_stub();
746        workspace.seed_test_project("proj", "/tmp");
747        let slot = SessionSlot::lead("TestOrg", "proj");
748        let entry = workspace.terminal_for(&slot).expect("a terminal");
749        let (id, answer) = workspace
750            .register_terminal_hand_off(&slot, entry.id, "the sudo password")
751            .expect("a hand-off parks");
752        assert!(workspace.terminal_handoff_waiting(id, &slot));
753        let resolved = workspace.resolve_terminal_hand_off(
754            id,
755            &slot,
756            TerminalHandOffAnswer {
757                ending: HandOffEnding::Done,
758                source: forge_primitives::browser::HandOffSource::Unstated,
759                client: None,
760            },
761        );
762        assert!(resolved, "the registered hand-off resolves");
763        assert_eq!(answer.await.expect("an ending"), HandOffEnding::Done);
764        assert!(!workspace.terminal_held(&slot), "the agent owns the input again");
765        assert!(!workspace.terminal_handoff_waiting(id, &slot));
766        let mut told = false;
767        while let Ok(frame) = rx.try_recv() {
768            if matches!(
769                &*frame.update,
770                SessionUpdate::TerminalHandOffResolved { id: resolved, .. } if *resolved == id
771            ) {
772                told = true;
773            }
774        }
775        assert!(told, "the views were told the ask resolved");
776        workspace.close_terminal(&slot);
777    }
778
779    /// **A seat that ends with a hand-off parked abandons it**, so the waiter
780    /// stops and no dock outlives its asker.
781    #[tokio::test]
782    async fn ending_the_seat_abandons_a_parked_hand_off() {
783        let (workspace, _rx) = Workspace::testing_stub();
784        workspace.seed_test_project("proj", "/tmp");
785        let slot = SessionSlot::lead("TestOrg", "proj");
786        let entry = workspace.terminal_for(&slot).expect("a terminal");
787        let (id, answer) = workspace
788            .register_terminal_hand_off(&slot, entry.id, "the sudo password")
789            .expect("a hand-off parks");
790        workspace.release_session(&slot);
791        assert_eq!(
792            answer.await.expect("an ending"),
793            HandOffEnding::Abandoned,
794            "the seat ended, so the ask cannot be answered"
795        );
796        assert!(!workspace.terminal_handoff_waiting(id, &slot));
797    }
798
799    /// A hand-off id another seat names resolves nothing: the park is the
800    /// owner's alone.
801    #[tokio::test]
802    async fn another_seat_cannot_resolve_a_hand_off() {
803        let (workspace, _rx) = Workspace::testing_stub();
804        workspace.seed_test_project("proj", "/tmp");
805        let slot = SessionSlot::lead("TestOrg", "proj");
806        let other = SessionSlot::lead("OtherOrg", "proj");
807        let entry = workspace.terminal_for(&slot).expect("a terminal");
808        let (id, _answer) = workspace
809            .register_terminal_hand_off(&slot, entry.id, "the sudo password")
810            .expect("a hand-off parks");
811        let resolved = workspace.resolve_terminal_hand_off(
812            id,
813            &other,
814            TerminalHandOffAnswer {
815                ending: HandOffEnding::Done,
816                source: forge_primitives::browser::HandOffSource::Unstated,
817                client: None,
818            },
819        );
820        assert!(!resolved, "another seat's answer is refused");
821        assert!(workspace.terminal_held(&slot), "the ask still parks on its owner");
822        workspace.close_terminal(&slot);
823    }
824
825    /// **A command starting is pushed to the views**, so the strip says it is
826    /// running without waiting for a record re-read.
827    #[tokio::test]
828    async fn a_command_start_is_pushed_to_the_views() {
829        let (workspace, mut rx) = Workspace::testing_stub();
830        workspace.seed_test_project("proj", "/tmp");
831        let slot = SessionSlot::lead("TestOrg", "proj");
832        let entry = workspace.terminal_for(&slot).expect("a terminal");
833        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
834        while tokio::time::Instant::now() < deadline
835            && entry.terminal.screen().text.trim().is_empty()
836        {
837            tokio::time::sleep(Duration::from_millis(20)).await;
838        }
839        // The open's own announcements are not this test's.
840        while rx.try_recv().is_ok() {}
841        entry.terminal.write_text("sleep 1\r").expect("a write");
842        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
843        let mut running = false;
844        while tokio::time::Instant::now() < deadline && !running {
845            match rx.try_recv() {
846                Ok(frame) => {
847                    if let SessionUpdate::TerminalChanged { terminal: Some(summary), .. } =
848                        &*frame.update
849                    {
850                        running |= summary.state.running;
851                    }
852                }
853                Err(_) => tokio::time::sleep(Duration::from_millis(10)).await,
854            }
855        }
856        assert!(running, "a TerminalChanged said the command is running");
857        workspace.close_terminal(&slot);
858    }
859
860    /// **The seat ending ends its terminal.** The release path is the one
861    /// place teardown happens; a `/new` or `/resume` never reaches it.
862    #[tokio::test]
863    async fn ending_the_seat_closes_its_terminal() {
864        let (workspace, _rx) = Workspace::testing_stub();
865        workspace.seed_test_project("proj", "/tmp");
866        let slot = SessionSlot::lead("TestOrg", "proj");
867        let entry = workspace.terminal_for(&slot).expect("a terminal");
868        workspace.release_session(&slot);
869        assert!(
870            workspace.terminal_if_any(&slot).is_none(),
871            "the seat ended, so its terminal left the registry with it",
872        );
873        entry.terminal.close();
874    }
875
876    /// **The door follows the seat read.** A declared seat answers its read
877    /// before anything starts in it, so its door opens and the terminal it
878    /// starts appears in the record. A worker never spawned has no working
879    /// tree, its read is refused, and its door refuses with the same words.
880    #[tokio::test]
881    async fn opening_a_terminal_follows_the_seat_read() {
882        let (workspace, _rx) = Workspace::testing_stub();
883        workspace.seed_test_project("proj", "/tmp");
884
885        let declared = SessionSlot::lead("TestOrg", "proj");
886        let entry = workspace.open_terminal(&declared).expect("a declared seat answers its read");
887        drop(entry);
888        workspace.close_terminal(&declared);
889
890        let unstarted = SessionSlot::worker("TestOrg", "proj", "nobody");
891        assert!(
892            workspace.open_terminal(&unstarted).is_err(),
893            "a seat with no working tree opened a terminal",
894        );
895        assert!(workspace.terminal_if_any(&unstarted).is_none(), "and none was registered");
896    }
897
898    /// **The person takes the keyboard during a park, and the ending clears
899    /// it.** The ask raises the surface as a viewer; the take is the person's
900    /// explicit door, and the ending hands the keyboard back to the agent.
901    #[tokio::test]
902    async fn a_take_lands_during_a_park_and_the_ending_clears_it() {
903        let (workspace, _rx) = Workspace::testing_stub();
904        workspace.seed_test_project("proj", "/tmp");
905        let slot = SessionSlot::lead("TestOrg", "proj");
906        let entry = workspace.terminal_for(&slot).expect("a terminal");
907        let (parked, _rx) = workspace
908            .register_terminal_hand_off(&slot, entry.id, "the sudo password")
909            .expect("a hand-off parks");
910
911        assert!(
912            workspace.take_terminal_control(&slot, entry.id, 7).is_ok(),
913            "the person's take lands while the ask waits",
914        );
915        assert!(workspace.terminal_controlled(&slot), "and they hold the keyboard");
916
917        assert!(workspace.resolve_terminal_hand_off(
918            parked,
919            &slot,
920            TerminalHandOffAnswer {
921                ending: HandOffEnding::Done,
922                source: forge_primitives::browser::HandOffSource::Takeover,
923                client: Some(7),
924            },
925        ));
926        assert!(!workspace.terminal_controlled(&slot), "the ending gave the keyboard back");
927        entry.terminal.close();
928    }
929
930    /// **A take names the terminal it is viewing.** A replaced shell has a
931    /// new id; resolving the seat's current one would hand a role no push
932    /// could reach, and a seat with no terminal refuses rather than
933    /// pretending the press landed.
934    #[tokio::test]
935    async fn a_take_on_a_replaced_or_missing_terminal_is_refused() {
936        let (workspace, _rx) = Workspace::testing_stub();
937        workspace.seed_test_project("proj", "/tmp");
938        let slot = SessionSlot::lead("TestOrg", "proj");
939        let entry = workspace.terminal_for(&slot).expect("a terminal");
940        let stale = entry.id;
941        workspace.close_terminal(&slot);
942        let fresh = workspace.terminal_for(&slot).expect("a fresh terminal");
943        assert_ne!(fresh.id, stale, "the shell was replaced with a new id");
944        assert!(
945            workspace.take_terminal_control(&slot, stale, 7).is_err(),
946            "a take on a replaced terminal landed",
947        );
948        assert!(!workspace.terminal_controlled(&slot), "and no role was handed out");
949        assert!(
950            workspace.take_terminal_control(&slot, fresh.id, 7).is_ok(),
951            "the terminal this client is viewing takes the role",
952        );
953
954        let empty = SessionSlot::worker("TestOrg", "proj", "nobody");
955        assert!(
956            workspace.take_terminal_control(&empty, 1, 8).is_err(),
957            "a take with no terminal was answered as though it landed",
958        );
959        workspace.close_terminal(&slot);
960    }
961
962    /// **A park cannot land on a terminal the person holds**: the control
963    /// check shares the lock with the insert, so a take racing a hand-off
964    /// cannot leave one behind.
965    #[tokio::test]
966    async fn a_park_takes_the_keyboard_from_the_person() {
967        let (workspace, _rx) = Workspace::testing_stub();
968        workspace.seed_test_project("proj", "/tmp");
969        let slot = SessionSlot::lead("TestOrg", "proj");
970        let entry = workspace.terminal_for(&slot).expect("a terminal");
971        entry.take_control(7);
972        assert!(
973            workspace.register_terminal_hand_off(&slot, entry.id, "the sudo password").is_ok(),
974            "a hand-off parks over a person's take",
975        );
976        assert!(workspace.terminal_held(&slot), "and the ask is registered");
977        assert!(!workspace.terminal_controlled(&slot), "while the park owns the keyboard");
978        entry.terminal.close();
979    }
980
981    /// The crate's state crosses to the wire unchanged, so a model reading the
982    /// tool result sees the same evidence the classifier wrote.
983    #[test]
984    fn the_crates_state_crosses_verbatim() {
985        let state = forge_terminal::State {
986            running: true,
987            input_expected: Some(forge_terminal::InputExpected {
988                kind: forge_terminal::InputKind::Prompt,
989                evidence: "quiet at a prompt".to_owned(),
990            }),
991            exit: Some(forge_terminal::Exit { code: Some(3), signal: None }),
992            source: forge_terminal::Source::Heuristic,
993        };
994        let wire = wire_state(state);
995        assert!(wire.running);
996        assert_eq!(wire.exit, Some(TerminalExit { code: Some(3), signal: None }));
997        assert_eq!(wire.source, TerminalSource::Heuristic);
998        assert_eq!(wire.input_expected.expect("an input").kind, TerminalInputKind::Prompt);
999    }
1000}