Skip to main content

agentos_client/
shell.rs

1//! Network (fetch) and Shell / terminal methods + supporting types.
2//!
3//! Ported from `packages/core/src/agent-os.ts` (`fetch` + shell methods) and `runtime-compat.ts`
4//! (`ShellHandle`, `OpenShellOptions`, `ConnectTerminalOptions`).
5//!
6//! Id-vs-PID is load-bearing: `open_shell` returns a synthetic `shell-N` id; `connect_terminal`
7//! returns a PID and is NOT tracked in the shells map.
8//!
9//! The native wire protocol has no PTY/winsize request, so a shell is modeled as a guest process
10//! spawned via [`ExecuteRequest`]: its `process_id` is what `write_shell`/`close_shell` address on
11//! the wire, while the public boundary keeps the synthetic `shell-N` id.
12//!
13//! Stream routing mirrors the TS real-process spawn path exactly: the public `data` stream
14//! (`on_shell_data`) carries stdout ONLY, because TS wires only the kernel handle's `onData` (fed
15//! exclusively by `stdoutHandlers`) into the data handlers. stderr is delivered on a SEPARATE channel
16//! (`on_shell_stderr` + the [`OpenShellOptions::on_stderr`] callback), matching TS where stderr
17//! reaches the host only through `stderrHandlers` / the `onStderr` option. Fanning stderr into the
18//! data stream is only correct for the synthetic-prompt PTY path, which this native real-process path
19//! does not implement.
20
21use std::collections::BTreeMap;
22use std::sync::atomic::{AtomicUsize, Ordering};
23
24use anyhow::Result;
25use uuid::Uuid;
26
27use secure_exec_client::wire::{self, EventPayload, StreamChannel};
28
29use crate::agent_os::{AcpTerminalEntry, AgentOs, ShellEntry};
30use crate::error::ClientError;
31use crate::process::{install_output_callback, OutputCallback, ProcessStatus, StdinInput};
32use crate::stream::ByteStream;
33
34/// Channel capacity for a shell's data / stderr broadcasts.
35const SHELL_DATA_CHANNEL_CAPACITY: usize = 1024;
36
37/// Maximum active or spawning terminals created by `connect_terminal` per VM.
38const ACP_TERMINAL_LIMIT: usize = 1024;
39
40/// Default shell command used when [`OpenShellOptions::command`] is omitted (matches the kernel's
41/// PTY-backed `sh`).
42const DEFAULT_SHELL_COMMAND: &str = "sh";
43
44// ---------------------------------------------------------------------------
45// Supporting types
46// ---------------------------------------------------------------------------
47
48/// Options for `open_shell`.
49///
50/// `on_stderr` mirrors the TS `OpenShellOptions.onStderr` raw-byte callback: it is the dedicated
51/// path stderr reaches the caller (stderr is never fanned into the data stream). It is seeded into
52/// the stderr fan-out at open time, matching the TS `stderrHandlers.add(options.onStderr)` behavior.
53#[derive(Default)]
54pub struct OpenShellOptions {
55    pub command: Option<String>,
56    pub args: Vec<String>,
57    pub env: BTreeMap<String, String>,
58    pub cwd: Option<String>,
59    pub cols: Option<u16>,
60    pub rows: Option<u16>,
61    pub on_stderr: Option<OutputCallback>,
62}
63
64/// Options for `connect_terminal` (extends [`OpenShellOptions`]).
65///
66/// `on_data` mirrors the TS `ConnectTerminalOptions.onData` raw-byte callback. When omitted, TS pipes
67/// shell output to host stdout; the Rust port routes it through the shell's data subscription and
68/// requires the caller to provide the sink because there is no host-process stdio to bind to.
69#[derive(Default)]
70pub struct ConnectTerminalOptions {
71    pub base: OpenShellOptions,
72    pub on_data: Option<OutputCallback>,
73}
74
75/// The synthetic shell id returned by `open_shell` (`shell-N`, NOT a pid).
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct ShellHandle {
78    pub shell_id: String,
79}
80
81// ---------------------------------------------------------------------------
82// Helpers
83// ---------------------------------------------------------------------------
84
85/// Map a [`RejectedResponse`] into a [`ClientError::Kernel`] so the errno `code` survives.
86fn rejected_to_error(rejected: wire::RejectedResponse) -> ClientError {
87    ClientError::Kernel {
88        code: rejected.code,
89        message: rejected.message,
90    }
91}
92
93/// Encode a [`StdinInput`] into the wire `chunk` bytes. The wire `chunk` field is bare `data`
94/// (`Vec<u8>`), so raw Binary stdin is carried verbatim (no lossy UTF-8 conversion), matching the
95/// byte-exact TS `proc.writeStdin` contract.
96fn stdin_chunk(data: StdinInput) -> Vec<u8> {
97    match data {
98        StdinInput::Text(text) => text.into_bytes(),
99        StdinInput::Bytes(bytes) => bytes,
100    }
101}
102
103fn try_reserve_counter(counter: &AtomicUsize, limit: usize) -> bool {
104    counter
105        .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |count| {
106            (count < limit).then_some(count + 1)
107        })
108        .is_ok()
109}
110
111fn release_counter(counter: &AtomicUsize) {
112    let _ = counter.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |count| {
113        Some(count.saturating_sub(1))
114    });
115}
116
117struct AcpTerminalReservation<'a> {
118    agent: &'a AgentOs,
119    active: bool,
120}
121
122impl<'a> AcpTerminalReservation<'a> {
123    fn new(agent: &'a AgentOs) -> std::result::Result<Self, ClientError> {
124        if !try_reserve_counter(&agent.inner().acp_terminal_count, ACP_TERMINAL_LIMIT) {
125            return Err(ClientError::Sidecar(format!(
126                "acp terminal limit exceeded: at most {ACP_TERMINAL_LIMIT} terminals can be active per VM"
127            )));
128        }
129        Ok(Self {
130            agent,
131            active: true,
132        })
133    }
134
135    fn disarm(&mut self) {
136        self.active = false;
137    }
138}
139
140impl Drop for AcpTerminalReservation<'_> {
141    fn drop(&mut self) {
142        if self.active {
143            release_counter(&self.agent.inner().acp_terminal_count);
144        }
145    }
146}
147
148impl AgentOs {
149    /// The VM-scoped ownership scope used for every shell/fetch wire request.
150    fn vm_ownership(&self) -> wire::OwnershipScope {
151        wire::OwnershipScope::VmOwnership(wire::VmOwnership {
152            connection_id: self.connection_id().to_string(),
153            session_id: self.wire_session_id().to_string(),
154            vm_id: self.vm_id().to_string(),
155        })
156    }
157
158    pub(crate) fn finish_acp_terminal(&self, process_id: &str) {
159        if self.inner().acp_terminals.remove(process_id).is_some() {
160            release_counter(&self.inner().acp_terminal_count);
161        }
162    }
163
164    async fn start_acp_terminal(
165        &self,
166        execute: wire::ExecuteRequest,
167        ownership: wire::OwnershipScope,
168        pid_tx: tokio::sync::oneshot::Sender<std::result::Result<u32, ClientError>>,
169        process_id: &str,
170    ) -> Option<u32> {
171        {
172            let _terminal_lifecycle_guard = self.inner().acp_terminal_lifecycle_lock.lock().await;
173            if self.inner().disposed.load(Ordering::SeqCst) {
174                let error = ClientError::Sidecar(
175                    "cannot connect terminal after VM shutdown has started".to_string(),
176                );
177                let _ = pid_tx.send(Err(error));
178                self.finish_acp_terminal(process_id);
179                return None;
180            }
181        }
182
183        let result = match self
184            .transport()
185            .request_wire(ownership, wire::RequestPayload::ExecuteRequest(execute))
186            .await
187        {
188            Ok(wire::ResponsePayload::ProcessStartedResponse(wire::ProcessStartedResponse {
189                pid,
190                ..
191            })) => pid.ok_or_else(|| {
192                ClientError::Sidecar("connect_terminal: sidecar did not return a pid".to_string())
193            }),
194            Ok(wire::ResponsePayload::RejectedResponse(rejected)) => {
195                Err(rejected_to_error(rejected))
196            }
197            Ok(other) => Err(ClientError::Sidecar(format!(
198                "unexpected response to connect_terminal: {other:?}"
199            ))),
200            Err(error) => Err(error.into()),
201        };
202
203        match result {
204            Ok(pid) => {
205                let _ = pid_tx.send(Ok(pid));
206                Some(pid)
207            }
208            Err(error) => {
209                let _ = pid_tx.send(Err(error));
210                self.finish_acp_terminal(process_id);
211                None
212            }
213        }
214    }
215}
216
217// ---------------------------------------------------------------------------
218// Shell / terminal
219// ---------------------------------------------------------------------------
220//
221// Note: `fetch` (the Network half of this reference section) is scaffolded in `net.rs`, which owns
222// the `impl AgentOs { fn fetch }` block. It is intentionally NOT defined here to avoid a duplicate
223// definition; the helpers below (`rejected_to_error`, `vm_ownership`) are shared by both halves.
224
225impl AgentOs {
226    /// Open a PTY-backed shell. SYNC. Returns a synthetic `shell-N` id (NOT a pid).
227    ///
228    /// The shell id and its registry entry are allocated synchronously (matching the TS sync
229    /// contract); the actual guest-process spawn, output fan-out, and exit-task registration happen
230    /// on a background task because the wire spawn is async. The exit task is tracked in the
231    /// pending-shell-exit set so `dispose` can drain it (two-phase teardown).
232    ///
233    /// Stdout is fanned into the shell's `data` broadcast (`on_shell_data`); stderr is fanned into a
234    /// SEPARATE `stderr` broadcast (`on_shell_stderr` + the [`OpenShellOptions::on_stderr`] callback),
235    /// matching the TS real-process routing where stderr never reaches the data stream.
236    pub fn open_shell(&self, mut options: OpenShellOptions) -> Result<ShellHandle> {
237        let inner = self.inner();
238        let counter = inner.shell_counter.fetch_add(1, Ordering::SeqCst) + 1;
239        let shell_id = format!("shell-{counter}");
240        // The wire-side process id used by write_shell/close_shell and event routing.
241        let process_id = format!("shell-{}", Uuid::new_v4());
242
243        let (data_tx, _) = tokio::sync::broadcast::channel(SHELL_DATA_CHANNEL_CAPACITY);
244        let (stderr_tx, _) = tokio::sync::broadcast::channel(SHELL_DATA_CHANNEL_CAPACITY);
245        // Spawn-readiness gate: write/close await this before issuing their wire request.
246        let (spawned_tx, _) = tokio::sync::watch::channel(false);
247
248        // Seed any caller-provided initial stderr callback into the stderr fan-out, matching the TS
249        // initial-handler-set behavior (`stderrHandlers.add(options.onStderr)`).
250        if let Some(cb) = options.on_stderr.take() {
251            install_output_callback(stderr_tx.clone(), cb);
252        }
253
254        // Register the entry up front so write/resize/close can address it immediately, exactly like
255        // the TS map insert before the handle's async work settles.
256        let entry = ShellEntry {
257            pid: 0,
258            data_tx: data_tx.clone(),
259            stderr_tx: stderr_tx.clone(),
260            process_id: process_id.clone(),
261            spawned_tx: spawned_tx.clone(),
262        };
263        // `insert` fails only if the key already exists; the monotonic counter guarantees it cannot.
264        let _ = inner.shells.insert(shell_id.clone(), entry);
265
266        let command = options
267            .command
268            .clone()
269            .unwrap_or_else(|| DEFAULT_SHELL_COMMAND.to_string());
270        let execute = wire::ExecuteRequest {
271            process_id: process_id.clone(),
272            command: Some(command),
273            runtime: None,
274            entrypoint: None,
275            args: options.args.clone(),
276            env: options.env.clone().into_iter().collect(),
277            cwd: options.cwd.clone(),
278            wasm_permission_tier: None,
279        };
280
281        // Background: subscribe to events first (so no output is missed), issue the spawn, fan
282        // stdout into the data broadcast and stderr into the stderr broadcast, and complete when the
283        // process exits.
284        let agent = self.clone();
285        let ownership = self.vm_ownership();
286        let route_process_id = process_id.clone();
287        let exit_shell_id = shell_id.clone();
288        let exit_key = counter;
289        let handle = tokio::spawn(async move {
290            let mut events = agent.transport().subscribe_wire_events();
291
292            let response = match agent
293                .transport()
294                .request_wire(
295                    ownership.clone(),
296                    wire::RequestPayload::ExecuteRequest(execute),
297                )
298                .await
299            {
300                Ok(response) => response,
301                Err(error) => {
302                    tracing::warn!(?error, shell_id = %exit_shell_id, "open_shell spawn failed");
303                    // Drop the dead entry so later shell calls report ShellNotFound rather than hang.
304                    agent.inner().shells.remove(&exit_shell_id);
305                    agent.inner().pending_shell_exits.remove(&exit_key);
306                    return;
307                }
308            };
309
310            // Record the real kernel pid on the entry (TS `ShellHandle.pid`) and release the write
311            // gate so any queued `write_shell`/`close_shell` proceed against the live spawn.
312            if let wire::ResponsePayload::ProcessStartedResponse(wire::ProcessStartedResponse {
313                pid: Some(pid),
314                ..
315            }) = response
316            {
317                agent
318                    .inner()
319                    .shells
320                    .update(&exit_shell_id, |_, existing| existing.pid = pid);
321            }
322            let _ = spawned_tx.send(true);
323
324            loop {
325                let (_scope, payload) = match events.recv().await {
326                    Ok(value) => value,
327                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
328                    Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
329                };
330                match payload {
331                    EventPayload::ProcessOutputEvent(output) => {
332                        if output.process_id != route_process_id {
333                            continue;
334                        }
335                        // stdout -> data stream; stderr -> separate stderr stream (TS routing).
336                        match output.channel {
337                            StreamChannel::Stdout => {
338                                let _ = data_tx.send(output.chunk);
339                            }
340                            StreamChannel::Stderr => {
341                                let _ = stderr_tx.send(output.chunk);
342                            }
343                        }
344                    }
345                    EventPayload::ProcessExitedEvent(exited) => {
346                        if exited.process_id == route_process_id {
347                            break;
348                        }
349                    }
350                    EventPayload::VmLifecycleEvent(_)
351                    | EventPayload::StructuredEvent(_)
352                    | EventPayload::ExtEnvelope(_) => {}
353                }
354            }
355
356            // The `.finally` equivalent: remove from both the tracking set and the shells map (only
357            // if it is still our entry, matching the TS identity check).
358            agent.inner().pending_shell_exits.remove(&exit_key);
359            agent.inner().shells.remove_if(&exit_shell_id, |existing| {
360                existing.process_id == route_process_id
361            });
362            // remove_if takes `&mut V`; the comparison only reads, which is fine.
363        });
364
365        let _ = inner.pending_shell_exits.insert(counter, handle);
366
367        Ok(ShellHandle { shell_id })
368    }
369
370    /// Open a PTY-backed terminal for the ACP `terminal/create` host request. Like [`open_shell`] it
371    /// registers a `shell-N` entry (so `write_shell`/`resize_shell`/`close_shell` address it), but the
372    /// background fan-out also (a) appends every stdout/stderr chunk to the caller's output buffer via
373    /// `on_output`, and (b) records the process exit code into `exit_tx` so `terminal/output` and
374    /// `terminal/wait_for_exit` can observe it. Mirrors the TS `_handleAcpCreateTerminal`, which builds
375    /// the terminal on top of `openShell` and tracks `output` / `exitCode` / `waitPromise`.
376    pub(crate) fn acp_open_terminal(
377        &self,
378        options: OpenShellOptions,
379        exit_tx: tokio::sync::watch::Sender<Option<i32>>,
380        on_output: impl Fn(&[u8]) + Send + Sync + 'static,
381    ) -> Result<ShellHandle> {
382        let inner = self.inner();
383        let counter = inner.shell_counter.fetch_add(1, Ordering::SeqCst) + 1;
384        let shell_id = format!("shell-{counter}");
385        let process_id = format!("shell-{}", Uuid::new_v4());
386
387        let (data_tx, _) = tokio::sync::broadcast::channel(SHELL_DATA_CHANNEL_CAPACITY);
388        let (stderr_tx, _) = tokio::sync::broadcast::channel(SHELL_DATA_CHANNEL_CAPACITY);
389        let (spawned_tx, _) = tokio::sync::watch::channel(false);
390
391        let entry = ShellEntry {
392            pid: 0,
393            data_tx: data_tx.clone(),
394            stderr_tx: stderr_tx.clone(),
395            process_id: process_id.clone(),
396            spawned_tx: spawned_tx.clone(),
397        };
398        let _ = inner.shells.insert(shell_id.clone(), entry);
399
400        let command = options
401            .command
402            .clone()
403            .unwrap_or_else(|| DEFAULT_SHELL_COMMAND.to_string());
404        let execute = wire::ExecuteRequest {
405            process_id: process_id.clone(),
406            command: Some(command),
407            runtime: None,
408            entrypoint: None,
409            args: options.args.clone(),
410            env: options.env.clone().into_iter().collect(),
411            cwd: options.cwd.clone(),
412            wasm_permission_tier: None,
413        };
414
415        let agent = self.clone();
416        let ownership = self.vm_ownership();
417        let route_process_id = process_id.clone();
418        let exit_shell_id = shell_id.clone();
419        let exit_key = counter;
420        let on_output = std::sync::Arc::new(on_output);
421        let handle = tokio::spawn(async move {
422            let mut events = agent.transport().subscribe_wire_events();
423
424            let response = match agent
425                .transport()
426                .request_wire(
427                    ownership.clone(),
428                    wire::RequestPayload::ExecuteRequest(execute),
429                )
430                .await
431            {
432                Ok(response) => response,
433                Err(error) => {
434                    tracing::warn!(?error, shell_id = %exit_shell_id, "acp_open_terminal spawn failed");
435                    agent.inner().shells.remove(&exit_shell_id);
436                    agent.inner().pending_shell_exits.remove(&exit_key);
437                    let _ = exit_tx.send(Some(1));
438                    return;
439                }
440            };
441
442            if let wire::ResponsePayload::ProcessStartedResponse(wire::ProcessStartedResponse {
443                pid: Some(pid),
444                ..
445            }) = response
446            {
447                agent
448                    .inner()
449                    .shells
450                    .update(&exit_shell_id, |_, existing| existing.pid = pid);
451            }
452            let _ = spawned_tx.send(true);
453
454            let mut exit_code: i32 = 0;
455            loop {
456                let (_scope, payload) = match events.recv().await {
457                    Ok(value) => value,
458                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
459                    Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
460                };
461                match payload {
462                    EventPayload::ProcessOutputEvent(output) => {
463                        if output.process_id != route_process_id {
464                            continue;
465                        }
466                        // Both stdout and stderr are appended to the same terminal output buffer
467                        // (the agent reads a single combined stream), matching the TS handle.
468                        on_output(&output.chunk);
469                    }
470                    EventPayload::ProcessExitedEvent(exited) => {
471                        if exited.process_id == route_process_id {
472                            exit_code = exited.exit_code;
473                            break;
474                        }
475                    }
476                    EventPayload::VmLifecycleEvent(_)
477                    | EventPayload::StructuredEvent(_)
478                    | EventPayload::ExtEnvelope(_) => {}
479                }
480            }
481
482            agent.inner().pending_shell_exits.remove(&exit_key);
483            agent.inner().shells.remove_if(&exit_shell_id, |existing| {
484                existing.process_id == route_process_id
485            });
486            let _ = exit_tx.send(Some(exit_code));
487        });
488
489        // The fan-out/exit task is tracked in `pending_shell_exits` (drained by `dispose`), exactly
490        // like `open_shell`. It ends naturally when the process exits or is killed via
491        // `close_shell` / `acp_kill_terminal_shell`.
492        let _ = inner.pending_shell_exits.insert(counter, handle);
493        Ok(ShellHandle { shell_id })
494    }
495
496    /// Kill the backing process of an ACP terminal shell (SIGTERM), without removing the shell entry
497    /// or the host-terminal registry entry. Used by `terminal/kill`, which (unlike `close_shell` /
498    /// `terminal/release`) leaves the terminal addressable for output/exit queries afterward.
499    pub(crate) fn acp_kill_terminal_shell(
500        &self,
501        shell_id: &str,
502    ) -> std::result::Result<(), ClientError> {
503        let (process_id, spawned_rx) = self.shell_wire_handle(shell_id)?;
504        let agent = self.clone();
505        let ownership = self.vm_ownership();
506        tokio::spawn(async move {
507            wait_for_spawn(spawned_rx).await;
508            let payload = wire::RequestPayload::KillProcessRequest(wire::KillProcessRequest {
509                process_id,
510                signal: String::from("SIGTERM"),
511            });
512            if let Err(error) = agent.transport().request_wire(ownership, payload).await {
513                tracing::warn!(?error, "acp_kill_terminal_shell failed");
514            }
515        });
516        Ok(())
517    }
518
519    /// Connect a terminal bound to host stdio. Returns a PID. NOT tracked in the shells map; cannot
520    /// be addressed by other shell methods. Killed during dispose via the ACP-terminal registry.
521    ///
522    /// Mirrors the TS `connectTerminal`, which routes its `onData`/`onStderr` callbacks through
523    /// `openShell`. The Rust port opens a shell, wires the caller's `on_data` to the shell's data
524    /// stream and `on_stderr` to the shell's stderr stream, then returns the shell's pid. Host
525    /// stdin binding, terminal raw-mode, and SIGWINCH/resize forwarding are host-process concerns
526    /// that have no native wire op and are intentionally not bound here.
527    pub async fn connect_terminal(&self, options: ConnectTerminalOptions) -> Result<u32> {
528        let ConnectTerminalOptions { base, on_data } = options;
529
530        let process_id = format!("terminal-{}", Uuid::new_v4());
531        let command = base
532            .command
533            .clone()
534            .unwrap_or_else(|| DEFAULT_SHELL_COMMAND.to_string());
535        let (data_tx, _) = tokio::sync::broadcast::channel::<Vec<u8>>(SHELL_DATA_CHANNEL_CAPACITY);
536        let (stderr_tx, _) =
537            tokio::sync::broadcast::channel::<Vec<u8>>(SHELL_DATA_CHANNEL_CAPACITY);
538
539        // Wire the caller's onData/onStderr to the terminal's streams (TS routes both through the
540        // shell handle's onData/onStderr). onData defaults to host stdout in TS; the Rust port has no
541        // host process stdout to bind to, so it only fans out when a sink is supplied.
542        if let Some(cb) = on_data {
543            install_output_callback(data_tx.clone(), cb);
544        }
545        if let Some(cb) = base.on_stderr {
546            install_output_callback(stderr_tx.clone(), cb);
547        }
548
549        let execute = wire::ExecuteRequest {
550            process_id: process_id.clone(),
551            command: Some(command),
552            runtime: None,
553            entrypoint: None,
554            args: base.args.clone(),
555            env: base.env.clone().into_iter().collect(),
556            cwd: base.cwd.clone(),
557            wasm_permission_tier: None,
558        };
559
560        // Subscribe before issuing the spawn so no output is missed.
561        let events = self.transport().subscribe_wire_events();
562        let ownership = self.vm_ownership();
563        let (pid_tx, pid_rx) = tokio::sync::oneshot::channel();
564        let (start_tx, start_rx) = tokio::sync::oneshot::channel::<()>();
565        let agent = self.clone();
566        let route_process_id = process_id.clone();
567        let exit_task = tokio::spawn(async move {
568            if start_rx.await.is_err() {
569                return;
570            }
571            let terminal_pid = match agent
572                .start_acp_terminal(execute, ownership, pid_tx, &route_process_id)
573                .await
574            {
575                Some(pid) => pid,
576                None => return,
577            };
578            let mut events = events;
579            loop {
580                let (_scope, payload) = match events.recv().await {
581                    Ok(value) => value,
582                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
583                        if terminal_process_finished(&agent, terminal_pid).await {
584                            break;
585                        }
586                        continue;
587                    }
588                    Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
589                };
590                match payload {
591                    EventPayload::ProcessOutputEvent(output) => {
592                        if output.process_id != route_process_id {
593                            continue;
594                        }
595                        match output.channel {
596                            StreamChannel::Stdout => {
597                                let _ = data_tx.send(output.chunk);
598                            }
599                            StreamChannel::Stderr => {
600                                let _ = stderr_tx.send(output.chunk);
601                            }
602                        }
603                    }
604                    EventPayload::ProcessExitedEvent(exited) => {
605                        if exited.process_id == route_process_id {
606                            break;
607                        }
608                    }
609                    EventPayload::VmLifecycleEvent(_)
610                    | EventPayload::StructuredEvent(_)
611                    | EventPayload::ExtEnvelope(_) => {}
612                }
613            }
614            agent.finish_acp_terminal(&route_process_id);
615        });
616
617        {
618            let _terminal_lifecycle_guard = self.inner().acp_terminal_lifecycle_lock.lock().await;
619            if self.inner().disposed.load(Ordering::SeqCst) {
620                exit_task.abort();
621                return Err(ClientError::Sidecar(
622                    "cannot connect terminal after VM shutdown has started".to_string(),
623                )
624                .into());
625            }
626            let mut terminal_reservation = AcpTerminalReservation::new(self)?;
627            match self
628                .inner()
629                .acp_terminals
630                .insert(process_id.clone(), AcpTerminalEntry { exit_task })
631            {
632                Ok(()) => {}
633                Err((_, entry)) => {
634                    entry.exit_task.abort();
635                    return Err(ClientError::Sidecar(format!(
636                        "terminal process id collision while tracking ACP terminal: {process_id}"
637                    ))
638                    .into());
639                }
640            }
641            terminal_reservation.disarm();
642            if start_tx.send(()).is_err() {
643                self.finish_acp_terminal(&process_id);
644                return Err(ClientError::Sidecar(
645                    "terminal startup task ended before registration completed".to_string(),
646                )
647                .into());
648            }
649        }
650
651        pid_rx
652            .await
653            .map_err(|_| {
654                ClientError::Sidecar(
655                    "terminal startup task ended before returning a pid".to_string(),
656                )
657            })?
658            .map_err(Into::into)
659    }
660
661    /// Write to a shell. SYNC fire-and-forget. Errors with [`ClientError::ShellNotFound`].
662    pub fn write_shell(
663        &self,
664        shell_id: &str,
665        data: StdinInput,
666    ) -> std::result::Result<(), ClientError> {
667        let (process_id, spawned_rx) = self.shell_wire_handle(shell_id)?;
668        let chunk = stdin_chunk(data);
669
670        // Fire-and-forget: the TS handle.write returns void; surface only the synchronous
671        // ShellNotFound, and dispatch the wire write in the background after the spawn lands. TS
672        // openShell is fully synchronous so the spawn is always live by the time write runs; awaiting
673        // the readiness gate reproduces that ordering and avoids dropping early input.
674        let agent = self.clone();
675        let ownership = self.vm_ownership();
676        tokio::spawn(async move {
677            wait_for_spawn(spawned_rx).await;
678            let payload = wire::RequestPayload::WriteStdinRequest(wire::WriteStdinRequest {
679                process_id,
680                chunk,
681            });
682            if let Err(error) = agent.transport().request_wire(ownership, payload).await {
683                tracing::warn!(?error, "write_shell failed");
684            }
685        });
686
687        Ok(())
688    }
689
690    /// Subscribe to a shell's stdout data. SYNC register; multi-handler; dropping the returned stream
691    /// is the unsubscribe. Carries stdout ONLY (stderr is on `on_shell_stderr`). Errors with
692    /// [`ClientError::ShellNotFound`].
693    pub fn on_shell_data(&self, shell_id: &str) -> std::result::Result<ByteStream, ClientError> {
694        self.inner()
695            .shells
696            .read(shell_id, |_, entry| entry.data_tx.subscribe())
697            .map(ByteStream::new)
698            .ok_or_else(|| ClientError::ShellNotFound(shell_id.to_string()))
699    }
700
701    /// Subscribe to a shell's stderr. SYNC register; multi-handler; dropping the returned stream is
702    /// the unsubscribe. This is the dedicated stderr channel backing the TS `onStderr` option; stderr
703    /// is never fanned into `on_shell_data`. Errors with [`ClientError::ShellNotFound`].
704    pub fn on_shell_stderr(&self, shell_id: &str) -> std::result::Result<ByteStream, ClientError> {
705        self.inner()
706            .shells
707            .read(shell_id, |_, entry| entry.stderr_tx.subscribe())
708            .map(ByteStream::new)
709            .ok_or_else(|| ClientError::ShellNotFound(shell_id.to_string()))
710    }
711
712    /// Resize a shell's PTY winsize. SYNC. Errors with [`ClientError::ShellNotFound`].
713    ///
714    /// Validates shell existence (the load-bearing parity behavior). The native wire protocol has no
715    /// winsize request, so the resize itself is currently a best-effort no-op (the synthetic TS
716    /// kernel path is likewise a no-op).
717    pub fn resize_shell(
718        &self,
719        shell_id: &str,
720        cols: u16,
721        rows: u16,
722    ) -> std::result::Result<(), ClientError> {
723        // Existence check matches the TS `if (!entry) throw Shell not found`.
724        let _ = self.shell_wire_handle(shell_id)?;
725        tracing::warn!(
726            shell_id = %shell_id,
727            cols,
728            rows,
729            "resize_shell has no native winsize wire op; resize is a no-op"
730        );
731        Ok(())
732    }
733
734    /// Close a shell. SYNC. `kill()` + immediate map delete; the exit task is still drained by
735    /// `dispose`. Errors with [`ClientError::ShellNotFound`].
736    pub fn close_shell(&self, shell_id: &str) -> std::result::Result<(), ClientError> {
737        let (process_id, spawned_rx) = self.shell_wire_handle(shell_id)?;
738
739        // Immediate map delete, exactly like the TS `_shells.delete(shellId)`; the pending-exit task
740        // remains tracked so `dispose` still drains it (two-phase teardown).
741        self.inner().shells.remove(shell_id);
742
743        // Fire-and-forget kill (SIGTERM) after the spawn lands so the kill addresses a live process.
744        let agent = self.clone();
745        let ownership = self.vm_ownership();
746        tokio::spawn(async move {
747            wait_for_spawn(spawned_rx).await;
748            let payload = wire::RequestPayload::KillProcessRequest(wire::KillProcessRequest {
749                process_id,
750                signal: String::from("SIGTERM"),
751            });
752            if let Err(error) = agent.transport().request_wire(ownership, payload).await {
753                tracing::warn!(?error, "close_shell kill failed");
754            }
755        });
756
757        Ok(())
758    }
759
760    /// Look up the wire-side `process_id` and the spawn-readiness receiver for a shell id, or
761    /// [`ClientError::ShellNotFound`].
762    fn shell_wire_handle(
763        &self,
764        shell_id: &str,
765    ) -> std::result::Result<(String, tokio::sync::watch::Receiver<bool>), ClientError> {
766        self.inner()
767            .shells
768            .read(shell_id, |_, entry| {
769                (entry.process_id.clone(), entry.spawned_tx.subscribe())
770            })
771            .ok_or_else(|| ClientError::ShellNotFound(shell_id.to_string()))
772    }
773}
774
775/// Wait until the shell's background `Execute` request has been acked (the readiness gate flips to
776/// `true`). Returns immediately if it is already ready or the sender has dropped.
777async fn wait_for_spawn(mut spawned_rx: tokio::sync::watch::Receiver<bool>) {
778    if *spawned_rx.borrow() {
779        return;
780    }
781    while spawned_rx.changed().await.is_ok() {
782        if *spawned_rx.borrow() {
783            return;
784        }
785    }
786}
787
788async fn terminal_process_finished(agent: &AgentOs, pid: u32) -> bool {
789    match agent.all_processes().await {
790        Ok(processes) => match processes.into_iter().find(|process| process.pid == pid) {
791            Some(process) => process.status != ProcessStatus::Running,
792            None => true,
793        },
794        Err(error) => {
795            tracing::warn!(?error, pid, "terminal process snapshot failed");
796            false
797        }
798    }
799}
800
801#[cfg(test)]
802mod tests {
803    use super::*;
804
805    #[test]
806    fn reserve_counter_enforces_limit_and_release_reopens_slot() {
807        let counter = AtomicUsize::new(0);
808
809        assert!(try_reserve_counter(&counter, 2));
810        assert!(try_reserve_counter(&counter, 2));
811        assert!(!try_reserve_counter(&counter, 2));
812        release_counter(&counter);
813        assert!(try_reserve_counter(&counter, 2));
814    }
815}