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 PTY path: the public `data` stream (`on_shell_data`) carries stdout
14//! and stderr in the order received from the sidecar. stderr is also delivered on an optional
15//! channel-specific diagnostic tap (`on_shell_stderr` + [`OpenShellOptions::on_stderr`]); terminal
16//! renderers consume only `data` so prompts and control sequences are neither reordered nor doubled.
17
18use std::collections::BTreeMap;
19use std::sync::atomic::{AtomicUsize, Ordering};
20
21use anyhow::Result;
22use uuid::Uuid;
23
24use agentos_sidecar_client::wire::{self, EventPayload, StreamChannel};
25
26use crate::agent_os::{AcpTerminalEntry, AgentOs, ShellEntry};
27use crate::error::ClientError;
28use crate::process::{install_output_callback, OutputCallback, ProcessStatus, StdinInput};
29
30/// Channel capacity for a shell's ordered terminal-data and diagnostic-stderr broadcasts.
31const SHELL_DATA_CHANNEL_CAPACITY: usize = 1024;
32
33/// Maximum active or spawning terminals created by `connect_terminal` per VM.
34const ACP_TERMINAL_LIMIT: usize = 1024;
35
36/// Default shell command used when [`OpenShellOptions::command`] is omitted (matches the kernel's
37/// PTY-backed `sh`).
38const DEFAULT_SHELL_COMMAND: &str = "sh";
39
40// ---------------------------------------------------------------------------
41// Supporting types
42// ---------------------------------------------------------------------------
43
44/// Callback-free options for portable `open_shell`.
45#[derive(Default)]
46pub struct OpenShellOptions {
47    pub command: Option<String>,
48    pub args: Vec<String>,
49    pub env: BTreeMap<String, String>,
50    pub cwd: Option<String>,
51    pub cols: Option<u16>,
52    pub rows: Option<u16>,
53}
54
55/// Options for `connect_terminal` (extends [`OpenShellOptions`]).
56///
57/// `on_data` mirrors the TS `ConnectTerminalOptions.onData` raw-byte callback. When omitted, TS pipes
58/// shell output to host stdout; the Rust port routes it through the shell's data subscription and
59/// requires the caller to provide the sink because there is no host-process stdio to bind to.
60#[derive(Default)]
61pub struct ConnectTerminalOptions {
62    pub base: OpenShellOptions,
63    pub on_data: Option<OutputCallback>,
64    pub on_stderr: Option<OutputCallback>,
65}
66
67/// The synthetic shell id returned by `open_shell` (`shell-N`, NOT a pid).
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct ShellHandle {
70    pub shell_id: String,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct ShellData {
75    pub shell_id: String,
76    pub data: Vec<u8>,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct ShellExit {
81    pub shell_id: String,
82    pub exit_code: i32,
83}
84
85// ---------------------------------------------------------------------------
86// Helpers
87// ---------------------------------------------------------------------------
88
89/// Map a [`RejectedResponse`] into a [`ClientError::Kernel`] so the errno `code` survives.
90fn rejected_to_error(rejected: wire::RejectedResponse) -> ClientError {
91    ClientError::Kernel {
92        code: rejected.code,
93        message: rejected.message,
94    }
95}
96
97/// Encode a [`StdinInput`] into the wire `chunk` bytes. The wire `chunk` field is bare `data`
98/// (`Vec<u8>`), so raw Binary stdin is carried verbatim (no lossy UTF-8 conversion), matching the
99/// byte-exact TS `proc.writeStdin` contract.
100fn stdin_chunk(data: StdinInput) -> Vec<u8> {
101    match data {
102        StdinInput::Text(text) => text.into_bytes(),
103        StdinInput::Bytes(bytes) => bytes,
104    }
105}
106
107fn try_reserve_counter(counter: &AtomicUsize, limit: usize) -> bool {
108    counter
109        .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |count| {
110            (count < limit).then_some(count + 1)
111        })
112        .is_ok()
113}
114
115fn release_counter(counter: &AtomicUsize) {
116    let _ = counter.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |count| {
117        Some(count.saturating_sub(1))
118    });
119}
120
121struct AcpTerminalReservation<'a> {
122    agent: &'a AgentOs,
123    active: bool,
124}
125
126impl<'a> AcpTerminalReservation<'a> {
127    fn new(agent: &'a AgentOs) -> std::result::Result<Self, ClientError> {
128        if !try_reserve_counter(&agent.inner().acp_terminal_count, ACP_TERMINAL_LIMIT) {
129            return Err(ClientError::Sidecar(format!(
130                "acp terminal limit exceeded: at most {ACP_TERMINAL_LIMIT} terminals can be active per VM"
131            )));
132        }
133        Ok(Self {
134            agent,
135            active: true,
136        })
137    }
138
139    fn disarm(&mut self) {
140        self.active = false;
141    }
142}
143
144impl Drop for AcpTerminalReservation<'_> {
145    fn drop(&mut self) {
146        if self.active {
147            release_counter(&self.agent.inner().acp_terminal_count);
148        }
149    }
150}
151
152impl AgentOs {
153    /// The VM-scoped ownership scope used for every shell/fetch wire request.
154    fn vm_ownership(&self) -> wire::OwnershipScope {
155        wire::OwnershipScope::VmOwnership(wire::VmOwnership {
156            connection_id: self.connection_id().to_string(),
157            session_id: self.wire_session_id().to_string(),
158            vm_id: self.vm_id().to_string(),
159        })
160    }
161
162    pub(crate) fn finish_acp_terminal(&self, process_id: &str) {
163        if self.inner().acp_terminals.remove(process_id).is_some() {
164            release_counter(&self.inner().acp_terminal_count);
165        }
166    }
167
168    async fn start_acp_terminal(
169        &self,
170        execute: wire::ExecuteRequest,
171        ownership: wire::OwnershipScope,
172        pid_tx: tokio::sync::oneshot::Sender<std::result::Result<u32, ClientError>>,
173        process_id: &str,
174    ) -> Option<u32> {
175        {
176            let _terminal_lifecycle_guard = self.inner().acp_terminal_lifecycle_lock.lock().await;
177            if self.inner().disposed.load(Ordering::SeqCst) {
178                let error = ClientError::Sidecar(
179                    "cannot connect terminal after VM shutdown has started".to_string(),
180                );
181                let _ = pid_tx.send(Err(error));
182                self.finish_acp_terminal(process_id);
183                return None;
184            }
185        }
186
187        let result = match self
188            .transport()
189            .request_wire(ownership, wire::RequestPayload::ExecuteRequest(execute))
190            .await
191        {
192            Ok(wire::ResponsePayload::ProcessStartedResponse(wire::ProcessStartedResponse {
193                pid,
194                ..
195            })) => pid.ok_or_else(|| {
196                ClientError::Sidecar("connect_terminal: sidecar did not return a pid".to_string())
197            }),
198            Ok(wire::ResponsePayload::RejectedResponse(rejected)) => {
199                Err(rejected_to_error(rejected))
200            }
201            Ok(other) => Err(ClientError::Sidecar(format!(
202                "unexpected response to connect_terminal: {other:?}"
203            ))),
204            Err(error) => Err(error.into()),
205        };
206
207        match result {
208            Ok(pid) => {
209                let _ = pid_tx.send(Ok(pid));
210                Some(pid)
211            }
212            Err(error) => {
213                let _ = pid_tx.send(Err(error));
214                self.finish_acp_terminal(process_id);
215                None
216            }
217        }
218    }
219}
220
221// ---------------------------------------------------------------------------
222// Shell / terminal
223// ---------------------------------------------------------------------------
224//
225// Note: `fetch` (the Network half of this reference section) is scaffolded in `net.rs`, which owns
226// the `impl AgentOs { fn fetch }` block. It is intentionally NOT defined here to avoid a duplicate
227// definition; the helpers below (`rejected_to_error`, `vm_ownership`) are shared by both halves.
228
229impl AgentOs {
230    /// Open a PTY-backed shell. SYNC. Returns a synthetic `shell-N` id (NOT a pid).
231    ///
232    /// The shell id and its registry entry are allocated synchronously (matching the TS sync
233    /// contract); the actual guest-process spawn, output fan-out, and exit-task registration happen
234    /// on a background task because the wire spawn is async. The exit task is tracked in the
235    /// pending-shell-exit set so `dispose` can drain it (two-phase teardown).
236    ///
237    /// Stdout and stderr are fanned into the shell's ordered `data` broadcast (`on_shell_data`).
238    /// Stderr is also fanned into a dedicated diagnostic broadcast (`on_shell_stderr` and the
239    /// [`OpenShellOptions::on_stderr`] callback); terminal renderers should consume only `data`.
240    pub fn open_shell(&self, mut options: OpenShellOptions) -> Result<ShellHandle> {
241        let inner = self.inner();
242        let counter = inner.shell_counter.fetch_add(1, Ordering::SeqCst) + 1;
243        let shell_id = format!("shell-{counter}");
244        // The wire-side process id used by write_shell/close_shell and event routing.
245        let process_id = format!("shell-{}", Uuid::new_v4());
246
247        let (data_tx, _) = tokio::sync::broadcast::channel(SHELL_DATA_CHANNEL_CAPACITY);
248        let (stderr_tx, _) = tokio::sync::broadcast::channel(SHELL_DATA_CHANNEL_CAPACITY);
249        // Spawn-readiness gate: write/close await this before issuing their wire request.
250        let (spawned_tx, _) = tokio::sync::watch::channel(false);
251        // Exit-code channel backing `wait_shell`.
252        let (exit_tx, _) = tokio::sync::watch::channel(None::<i32>);
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            exit_tx: exit_tx.clone(),
263        };
264        // `insert` fails only if the key already exists; the monotonic counter guarantees it cannot.
265        let _ = inner.shells.insert(shell_id.clone(), entry);
266
267        let command = options
268            .command
269            .clone()
270            .unwrap_or_else(|| DEFAULT_SHELL_COMMAND.to_string());
271        options
272            .env
273            .insert(String::from("AGENTOS_EXEC_TTY"), String::from("1"));
274        // Seed the PTY winsize env exactly like the TS openShell (COLUMNS/LINES).
275        if let Some(cols) = options.cols {
276            options
277                .env
278                .insert(String::from("COLUMNS"), cols.to_string());
279        }
280        if let Some(rows) = options.rows {
281            options.env.insert(String::from("LINES"), rows.to_string());
282        }
283        let execute = wire::ExecuteRequest {
284            process_id: process_id.clone(),
285            command: Some(command),
286            runtime: None,
287            entrypoint: None,
288            args: options.args.clone(),
289            env: options.env.clone().into_iter().collect(),
290            cwd: options.cwd.clone(),
291            wasm_permission_tier: None,
292        };
293
294        // Background: subscribe to events first (so no output is missed), issue the spawn, fan
295        // stdout into the data broadcast and stderr into the stderr broadcast, and complete when the
296        // process exits.
297        let agent = self.clone();
298        let ownership = self.vm_ownership();
299        let route_process_id = process_id.clone();
300        let exit_shell_id = shell_id.clone();
301        let exit_key = counter;
302        let handle = tokio::spawn(async move {
303            let mut events = agent.transport().subscribe_wire_events();
304
305            let response = match agent
306                .transport()
307                .request_wire(
308                    ownership.clone(),
309                    wire::RequestPayload::ExecuteRequest(execute),
310                )
311                .await
312            {
313                Ok(response) => response,
314                Err(error) => {
315                    tracing::warn!(?error, shell_id = %exit_shell_id, "open_shell spawn failed");
316                    // Drop the dead entry so later shell calls report ShellNotFound rather than hang.
317                    agent.inner().shells.remove(&exit_shell_id);
318                    agent.inner().pending_shell_exits.remove(&exit_key);
319                    return;
320                }
321            };
322
323            // Record the real kernel pid on the entry (TS `ShellHandle.pid`) and release the write
324            // gate so any queued `write_shell`/`close_shell` proceed against the live spawn.
325            if let wire::ResponsePayload::ProcessStartedResponse(wire::ProcessStartedResponse {
326                pid: Some(pid),
327                ..
328            }) = response
329            {
330                agent
331                    .inner()
332                    .shells
333                    .update(&exit_shell_id, |_, existing| existing.pid = pid);
334            }
335            // send_replace, not send: `watch::Sender::send` REFUSES to store the
336            // value while no receiver exists (and the initial receiver is dropped
337            // at channel creation), which left the spawn gate permanently false
338            // for any write/resize issued after this point — they hung forever in
339            // wait_for_spawn. send_replace stores unconditionally.
340            let _ = spawned_tx.send_replace(true);
341
342            loop {
343                let (_scope, payload) = match events.recv().await {
344                    Ok(value) => value,
345                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
346                    Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
347                };
348                match payload {
349                    EventPayload::ProcessOutputEvent(output) => {
350                        if output.process_id != route_process_id {
351                            continue;
352                        }
353                        // Publish every PTY chunk from this single wire-event consumer so terminal
354                        // control sequences retain their original stdout/stderr order.
355                        let _ = data_tx.send(output.chunk.clone());
356                        if output.channel == StreamChannel::Stderr {
357                            // Channel identity remains available as an optional diagnostic tap.
358                            let _ = stderr_tx.send(output.chunk);
359                        }
360                    }
361                    EventPayload::ProcessExitedEvent(exited) => {
362                        if exited.process_id == route_process_id {
363                            // Record the exit code for `wait_shell`: live waiters observe the watch
364                            // update; late waiters (after the entry is dropped below) find it in the
365                            // bounded retention map, mirroring the TS closed-shell retention.
366                            {
367                                let mut retained = agent.inner().closed_shell_exit_codes.lock();
368                                retained.push_back((exit_shell_id.clone(), exited.exit_code));
369                                while retained.len() > crate::CLOSED_SHELL_EXIT_CODE_RETENTION_LIMIT
370                                {
371                                    retained.pop_front();
372                                }
373                            }
374                            let _ = exit_tx.send(Some(exited.exit_code));
375                            break;
376                        }
377                    }
378                    EventPayload::VmLifecycleEvent(_)
379                    | EventPayload::ExecutionOutputEvent(_)
380                    | EventPayload::ExecutionCompletedEvent(_)
381                    | EventPayload::StructuredEvent(_)
382                    | EventPayload::ExtEnvelope(_) => {}
383                }
384            }
385
386            // The `.finally` equivalent: remove from both the tracking set and the shells map (only
387            // if it is still our entry, matching the TS identity check).
388            agent.inner().pending_shell_exits.remove(&exit_key);
389            agent.inner().shells.remove_if(&exit_shell_id, |existing| {
390                existing.process_id == route_process_id
391            });
392            // remove_if takes `&mut V`; the comparison only reads, which is fine.
393        });
394
395        let _ = inner.pending_shell_exits.insert(counter, handle);
396
397        Ok(ShellHandle { shell_id })
398    }
399
400    /// Open a PTY-backed terminal for the ACP `terminal/create` host request. Like [`open_shell`] it
401    /// registers a `shell-N` entry (so `write_shell`/`resize_shell`/`close_shell` address it), but the
402    /// background fan-out also (a) appends every stdout/stderr chunk to the caller's output buffer via
403    /// `on_output`, and (b) records the process exit code into `exit_tx` so `terminal/output` and
404    /// `terminal/wait_for_exit` can observe it. Mirrors the TS `_handleAcpCreateTerminal`, which builds
405    /// the terminal on top of `openShell` and tracks `output` / `exitCode` / `waitPromise`.
406    pub(crate) fn acp_open_terminal(
407        &self,
408        options: OpenShellOptions,
409        exit_tx: tokio::sync::watch::Sender<Option<i32>>,
410        on_output: impl Fn(&[u8]) + Send + Sync + 'static,
411    ) -> Result<ShellHandle> {
412        let inner = self.inner();
413        let counter = inner.shell_counter.fetch_add(1, Ordering::SeqCst) + 1;
414        let shell_id = format!("shell-{counter}");
415        let process_id = format!("shell-{}", Uuid::new_v4());
416
417        let (data_tx, _) = tokio::sync::broadcast::channel(SHELL_DATA_CHANNEL_CAPACITY);
418        let (stderr_tx, _) = tokio::sync::broadcast::channel(SHELL_DATA_CHANNEL_CAPACITY);
419        let (spawned_tx, _) = tokio::sync::watch::channel(false);
420
421        let entry = ShellEntry {
422            pid: 0,
423            data_tx: data_tx.clone(),
424            stderr_tx: stderr_tx.clone(),
425            process_id: process_id.clone(),
426            spawned_tx: spawned_tx.clone(),
427            // The caller-supplied exit channel doubles as the entry's `wait_shell` source.
428            exit_tx: exit_tx.clone(),
429        };
430        let _ = inner.shells.insert(shell_id.clone(), entry);
431
432        let command = options
433            .command
434            .clone()
435            .unwrap_or_else(|| DEFAULT_SHELL_COMMAND.to_string());
436        let execute = wire::ExecuteRequest {
437            process_id: process_id.clone(),
438            command: Some(command),
439            runtime: None,
440            entrypoint: None,
441            args: options.args.clone(),
442            env: options.env.clone().into_iter().collect(),
443            cwd: options.cwd.clone(),
444            wasm_permission_tier: None,
445        };
446
447        let agent = self.clone();
448        let ownership = self.vm_ownership();
449        let route_process_id = process_id.clone();
450        let exit_shell_id = shell_id.clone();
451        let exit_key = counter;
452        let on_output = std::sync::Arc::new(on_output);
453        let handle = tokio::spawn(async move {
454            let mut events = agent.transport().subscribe_wire_events();
455
456            let response = match agent
457                .transport()
458                .request_wire(
459                    ownership.clone(),
460                    wire::RequestPayload::ExecuteRequest(execute),
461                )
462                .await
463            {
464                Ok(response) => response,
465                Err(error) => {
466                    tracing::warn!(?error, shell_id = %exit_shell_id, "acp_open_terminal spawn failed");
467                    agent.inner().shells.remove(&exit_shell_id);
468                    agent.inner().pending_shell_exits.remove(&exit_key);
469                    let _ = exit_tx.send(Some(1));
470                    return;
471                }
472            };
473
474            if let wire::ResponsePayload::ProcessStartedResponse(wire::ProcessStartedResponse {
475                pid: Some(pid),
476                ..
477            }) = response
478            {
479                agent
480                    .inner()
481                    .shells
482                    .update(&exit_shell_id, |_, existing| existing.pid = pid);
483            }
484            // send_replace, not send: `watch::Sender::send` REFUSES to store the
485            // value while no receiver exists (and the initial receiver is dropped
486            // at channel creation), which left the spawn gate permanently false
487            // for any write/resize issued after this point — they hung forever in
488            // wait_for_spawn. send_replace stores unconditionally.
489            let _ = spawned_tx.send_replace(true);
490
491            let mut exit_code: i32 = 0;
492            loop {
493                let (_scope, payload) = match events.recv().await {
494                    Ok(value) => value,
495                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
496                    Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
497                };
498                match payload {
499                    EventPayload::ProcessOutputEvent(output) => {
500                        if output.process_id != route_process_id {
501                            continue;
502                        }
503                        let _ = data_tx.send(output.chunk.clone());
504                        if output.channel == StreamChannel::Stderr {
505                            let _ = stderr_tx.send(output.chunk.clone());
506                        }
507                        // Both channels are appended exactly once to the terminal output buffer.
508                        on_output(&output.chunk);
509                    }
510                    EventPayload::ProcessExitedEvent(exited) => {
511                        if exited.process_id == route_process_id {
512                            exit_code = exited.exit_code;
513                            break;
514                        }
515                    }
516                    EventPayload::VmLifecycleEvent(_)
517                    | EventPayload::ExecutionOutputEvent(_)
518                    | EventPayload::ExecutionCompletedEvent(_)
519                    | EventPayload::StructuredEvent(_)
520                    | EventPayload::ExtEnvelope(_) => {}
521                }
522            }
523
524            agent.inner().pending_shell_exits.remove(&exit_key);
525            agent.inner().shells.remove_if(&exit_shell_id, |existing| {
526                existing.process_id == route_process_id
527            });
528            let _ = exit_tx.send(Some(exit_code));
529        });
530
531        // The fan-out/exit task is tracked in `pending_shell_exits` (drained by `dispose`), exactly
532        // like `open_shell`. It ends naturally when the process exits or is killed via
533        // `close_shell` / `acp_kill_terminal_shell`.
534        let _ = inner.pending_shell_exits.insert(counter, handle);
535        Ok(ShellHandle { shell_id })
536    }
537
538    /// Kill the backing process of an ACP terminal shell (SIGTERM), without removing the shell entry
539    /// or the host-terminal registry entry. Used by `terminal/kill`, which (unlike `close_shell` /
540    /// `terminal/release`) leaves the terminal addressable for output/exit queries afterward.
541    pub(crate) fn acp_kill_terminal_shell(
542        &self,
543        shell_id: &str,
544    ) -> std::result::Result<(), ClientError> {
545        let (process_id, spawned_rx) = self.shell_wire_handle(shell_id)?;
546        let agent = self.clone();
547        let ownership = self.vm_ownership();
548        tokio::spawn(async move {
549            wait_for_spawn(spawned_rx).await;
550            let payload = wire::RequestPayload::KillProcessRequest(wire::KillProcessRequest {
551                process_id,
552                signal: String::from("SIGTERM"),
553            });
554            if let Err(error) = agent.transport().request_wire(ownership, payload).await {
555                tracing::warn!(?error, "acp_kill_terminal_shell failed");
556            }
557        });
558        Ok(())
559    }
560
561    /// Connect a terminal bound to host stdio. Returns a PID. NOT tracked in the shells map; cannot
562    /// be addressed by other shell methods. Killed during dispose via the ACP-terminal registry.
563    ///
564    /// Mirrors the TS `connectTerminal`, which routes its `onData`/`onStderr` callbacks through
565    /// `openShell`. The Rust port opens a shell, wires the caller's `on_data` to ordered terminal data
566    /// and `on_stderr` to the optional diagnostic tap, then returns the shell's pid. Host
567    /// stdin binding, terminal raw-mode, and SIGWINCH/resize forwarding are host-process concerns
568    /// that have no native wire op and are intentionally not bound here.
569    pub async fn connect_terminal(&self, options: ConnectTerminalOptions) -> Result<u32> {
570        let ConnectTerminalOptions {
571            base,
572            on_data,
573            on_stderr,
574        } = options;
575
576        let process_id = format!("terminal-{}", Uuid::new_v4());
577        let command = base
578            .command
579            .clone()
580            .unwrap_or_else(|| DEFAULT_SHELL_COMMAND.to_string());
581        let (data_tx, _) = tokio::sync::broadcast::channel::<Vec<u8>>(SHELL_DATA_CHANNEL_CAPACITY);
582        let (stderr_tx, _) =
583            tokio::sync::broadcast::channel::<Vec<u8>>(SHELL_DATA_CHANNEL_CAPACITY);
584
585        // onData defaults to host stdout in TS; the Rust port has no host process stdout to bind to,
586        // so it only fans out when a sink is supplied. onStderr is diagnostic and independent.
587        if let Some(cb) = on_data {
588            install_output_callback(data_tx.clone(), cb);
589        }
590        if let Some(cb) = on_stderr {
591            install_output_callback(stderr_tx.clone(), cb);
592        }
593
594        let execute = wire::ExecuteRequest {
595            process_id: process_id.clone(),
596            command: Some(command),
597            runtime: None,
598            entrypoint: None,
599            args: base.args.clone(),
600            env: base.env.clone().into_iter().collect(),
601            cwd: base.cwd.clone(),
602            wasm_permission_tier: None,
603        };
604
605        // Subscribe before issuing the spawn so no output is missed.
606        let events = self.transport().subscribe_wire_events();
607        let ownership = self.vm_ownership();
608        let (pid_tx, pid_rx) = tokio::sync::oneshot::channel();
609        let (start_tx, start_rx) = tokio::sync::oneshot::channel::<()>();
610        let agent = self.clone();
611        let route_process_id = process_id.clone();
612        let exit_task = tokio::spawn(async move {
613            if start_rx.await.is_err() {
614                return;
615            }
616            let terminal_pid = match agent
617                .start_acp_terminal(execute, ownership, pid_tx, &route_process_id)
618                .await
619            {
620                Some(pid) => pid,
621                None => return,
622            };
623            let mut events = events;
624            loop {
625                let (_scope, payload) = match events.recv().await {
626                    Ok(value) => value,
627                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
628                        if terminal_process_finished(&agent, terminal_pid).await {
629                            break;
630                        }
631                        continue;
632                    }
633                    Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
634                };
635                match payload {
636                    EventPayload::ProcessOutputEvent(output) => {
637                        if output.process_id != route_process_id {
638                            continue;
639                        }
640                        let _ = data_tx.send(output.chunk.clone());
641                        if output.channel == StreamChannel::Stderr {
642                            let _ = stderr_tx.send(output.chunk);
643                        }
644                    }
645                    EventPayload::ProcessExitedEvent(exited) => {
646                        if exited.process_id == route_process_id {
647                            break;
648                        }
649                    }
650                    EventPayload::VmLifecycleEvent(_)
651                    | EventPayload::ExecutionOutputEvent(_)
652                    | EventPayload::ExecutionCompletedEvent(_)
653                    | EventPayload::StructuredEvent(_)
654                    | EventPayload::ExtEnvelope(_) => {}
655                }
656            }
657            agent.finish_acp_terminal(&route_process_id);
658        });
659
660        {
661            let _terminal_lifecycle_guard = self.inner().acp_terminal_lifecycle_lock.lock().await;
662            if self.inner().disposed.load(Ordering::SeqCst) {
663                exit_task.abort();
664                return Err(ClientError::Sidecar(
665                    "cannot connect terminal after VM shutdown has started".to_string(),
666                )
667                .into());
668            }
669            let mut terminal_reservation = AcpTerminalReservation::new(self)?;
670            match self
671                .inner()
672                .acp_terminals
673                .insert(process_id.clone(), AcpTerminalEntry { exit_task })
674            {
675                Ok(()) => {}
676                Err((_, entry)) => {
677                    entry.exit_task.abort();
678                    return Err(ClientError::Sidecar(format!(
679                        "terminal process id collision while tracking ACP terminal: {process_id}"
680                    ))
681                    .into());
682                }
683            }
684            terminal_reservation.disarm();
685            if start_tx.send(()).is_err() {
686                self.finish_acp_terminal(&process_id);
687                return Err(ClientError::Sidecar(
688                    "terminal startup task ended before registration completed".to_string(),
689                )
690                .into());
691            }
692        }
693
694        pid_rx
695            .await
696            .map_err(|_| {
697                ClientError::Sidecar(
698                    "terminal startup task ended before returning a pid".to_string(),
699                )
700            })?
701            .map_err(Into::into)
702    }
703
704    /// Write to a shell. SYNC fire-and-forget. Errors with [`ClientError::ShellNotFound`].
705    pub fn write_shell(
706        &self,
707        shell_id: &str,
708        data: StdinInput,
709    ) -> std::result::Result<(), ClientError> {
710        let (process_id, spawned_rx) = self.shell_wire_handle(shell_id)?;
711        let chunk = stdin_chunk(data);
712
713        // Fire-and-forget: the TS handle.write returns void; surface only the synchronous
714        // ShellNotFound, and dispatch the wire write in the background after the spawn lands. TS
715        // openShell is fully synchronous so the spawn is always live by the time write runs; awaiting
716        // the readiness gate reproduces that ordering and avoids dropping early input.
717        let agent = self.clone();
718        let ownership = self.vm_ownership();
719        tokio::spawn(async move {
720            wait_for_spawn(spawned_rx).await;
721            let payload = wire::RequestPayload::WriteStdinRequest(wire::WriteStdinRequest {
722                process_id,
723                chunk,
724            });
725            if let Err(error) = agent.transport().request_wire(ownership, payload).await {
726                tracing::warn!(?error, "write_shell failed");
727            }
728        });
729
730        Ok(())
731    }
732
733    /// Write to a shell and AWAIT the wire write. Same routing as [`Self::write_shell`], but the
734    /// caller observes wire failures instead of a fire-and-forget warn — used by the actor plugin's
735    /// `writeShell` action so a failed write rejects the action.
736    pub async fn write_shell_awaited(
737        &self,
738        shell_id: &str,
739        data: StdinInput,
740    ) -> std::result::Result<(), ClientError> {
741        let (process_id, spawned_rx) = self.shell_wire_handle(shell_id)?;
742        let chunk = stdin_chunk(data);
743        tracing::debug!(shell_id, "write_shell_awaited: waiting for spawn gate");
744        wait_for_spawn(spawned_rx).await;
745        tracing::debug!(shell_id, "write_shell_awaited: issuing wire write");
746        let payload =
747            wire::RequestPayload::WriteStdinRequest(wire::WriteStdinRequest { process_id, chunk });
748        let response = self
749            .transport()
750            .request_wire(self.vm_ownership(), payload)
751            .await?;
752        tracing::debug!(shell_id, "write_shell_awaited: wire write acked");
753        match response {
754            wire::ResponsePayload::RejectedResponse(rejected) => Err(rejected_to_error(rejected)),
755            _ => Ok(()),
756        }
757    }
758
759    /// Subscribe to a shell's ordered terminal data. SYNC register; multi-handler; dropping the
760    /// returned stream is the unsubscribe. Carries stdout and stderr exactly once in wire order.
761    /// Use [`Self::on_shell_stderr`] only as a channel-specific diagnostic tap, not as a second
762    /// terminal-rendering stream. Errors with [`ClientError::ShellNotFound`].
763    pub fn on_shell_data(
764        &self,
765        shell_id: &str,
766        mut handler: impl FnMut(ShellData) + Send + 'static,
767    ) -> std::result::Result<crate::stream::Subscription, ClientError> {
768        let mut rx = self
769            .inner()
770            .shells
771            .read(shell_id, |_, entry| entry.data_tx.subscribe())
772            .ok_or_else(|| ClientError::ShellNotFound(shell_id.to_string()))?;
773        let shell_id = shell_id.to_string();
774        let task = tokio::spawn(async move {
775            loop {
776                match rx.recv().await {
777                    Ok(data) => handler(ShellData {
778                        shell_id: shell_id.clone(),
779                        data,
780                    }),
781                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
782                    Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
783                }
784            }
785        });
786        Ok(crate::stream::Subscription::new(move || task.abort()))
787    }
788
789    /// Subscribe to a shell's stderr. SYNC register; multi-handler; dropping the returned stream is
790    /// the unsubscribe. This is the optional diagnostic channel backing the TS `onStderr` option;
791    /// stderr is also present once in ordered `on_shell_data`. Errors with
792    /// [`ClientError::ShellNotFound`].
793    pub fn on_shell_stderr(
794        &self,
795        shell_id: &str,
796        mut handler: impl FnMut(ShellData) + Send + 'static,
797    ) -> std::result::Result<crate::stream::Subscription, ClientError> {
798        let mut rx = self
799            .inner()
800            .shells
801            .read(shell_id, |_, entry| entry.stderr_tx.subscribe())
802            .ok_or_else(|| ClientError::ShellNotFound(shell_id.to_string()))?;
803        let shell_id = shell_id.to_string();
804        let task = tokio::spawn(async move {
805            loop {
806                match rx.recv().await {
807                    Ok(data) => handler(ShellData {
808                        shell_id: shell_id.clone(),
809                        data,
810                    }),
811                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
812                    Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
813                }
814            }
815        });
816        Ok(crate::stream::Subscription::new(move || task.abort()))
817    }
818
819    pub fn on_shell_exit(
820        &self,
821        shell_id: &str,
822        handler: impl FnOnce(ShellExit) + Send + 'static,
823    ) -> std::result::Result<crate::stream::Subscription, ClientError> {
824        let mut rx = self
825            .inner()
826            .shells
827            .read(shell_id, |_, entry| entry.exit_tx.subscribe())
828            .ok_or_else(|| ClientError::ShellNotFound(shell_id.to_string()))?;
829        if let Some(exit_code) = *rx.borrow() {
830            handler(ShellExit {
831                shell_id: shell_id.to_string(),
832                exit_code,
833            });
834            return Ok(crate::stream::Subscription::noop());
835        }
836        let shell_id = shell_id.to_string();
837        let task = tokio::spawn(async move {
838            while rx.changed().await.is_ok() {
839                if let Some(exit_code) = *rx.borrow() {
840                    handler(ShellExit {
841                        shell_id,
842                        exit_code,
843                    });
844                    return;
845                }
846            }
847        });
848        Ok(crate::stream::Subscription::new(move || task.abort()))
849    }
850
851    /// Resize a shell's PTY winsize. SYNC fire-and-forget, mirroring the TS `ShellHandle.resize`
852    /// (which dispatches `resizePty` in the background after the spawn lands). Errors with
853    /// [`ClientError::ShellNotFound`].
854    pub fn resize_shell(
855        &self,
856        shell_id: &str,
857        cols: u16,
858        rows: u16,
859    ) -> std::result::Result<(), ClientError> {
860        // Existence check matches the TS `if (!entry) throw Shell not found`.
861        let (process_id, spawned_rx) = self.shell_wire_handle(shell_id)?;
862
863        let agent = self.clone();
864        let ownership = self.vm_ownership();
865        tokio::spawn(async move {
866            wait_for_spawn(spawned_rx).await;
867            let payload = wire::RequestPayload::ResizePtyRequest(wire::ResizePtyRequest {
868                process_id,
869                cols,
870                rows,
871            });
872            if let Err(error) = agent.transport().request_wire(ownership, payload).await {
873                tracing::warn!(?error, "resize_shell failed");
874            }
875        });
876
877        Ok(())
878    }
879
880    /// Wait for a shell to exit and return its process exit code (TS `waitShell`). Resolves
881    /// immediately for a shell that already exited within the bounded retention window. Errors with
882    /// [`ClientError::ShellNotFound`] for an unknown id.
883    pub async fn wait_shell(&self, shell_id: &str) -> std::result::Result<i32, ClientError> {
884        let exit_rx = self
885            .inner()
886            .shells
887            .read(shell_id, |_, entry| entry.exit_tx.subscribe());
888        let Some(mut exit_rx) = exit_rx else {
889            // Entry already dropped: fall back to the recorded exit code (TS retention behavior).
890            let retained = self.inner().closed_shell_exit_codes.lock();
891            return retained
892                .iter()
893                .rev()
894                .find(|(id, _)| id == shell_id)
895                .map(|(_, code)| *code)
896                .ok_or_else(|| ClientError::ShellNotFound(shell_id.to_string()));
897        };
898        loop {
899            if let Some(code) = *exit_rx.borrow_and_update() {
900                return Ok(code);
901            }
902            if exit_rx.changed().await.is_err() {
903                // Sender dropped without publishing a code (spawn failure / teardown): check the
904                // retention map once more before reporting the shell unknown.
905                let retained = self.inner().closed_shell_exit_codes.lock();
906                return retained
907                    .iter()
908                    .rev()
909                    .find(|(id, _)| id == shell_id)
910                    .map(|(_, code)| *code)
911                    .ok_or_else(|| ClientError::ShellNotFound(shell_id.to_string()));
912            }
913        }
914    }
915
916    /// Close a shell. SYNC. `kill()` + immediate map delete; the exit task is still drained by
917    /// `dispose`. Errors with [`ClientError::ShellNotFound`].
918    pub fn close_shell(&self, shell_id: &str) -> std::result::Result<(), ClientError> {
919        let (process_id, spawned_rx) = self.shell_wire_handle(shell_id)?;
920
921        // Immediate map delete, exactly like the TS `_shells.delete(shellId)`; the pending-exit task
922        // remains tracked so `dispose` still drains it (two-phase teardown).
923        self.inner().shells.remove(shell_id);
924
925        // Fire-and-forget kill (SIGTERM) after the spawn lands so the kill addresses a live process.
926        let agent = self.clone();
927        let ownership = self.vm_ownership();
928        tokio::spawn(async move {
929            wait_for_spawn(spawned_rx).await;
930            let payload = wire::RequestPayload::KillProcessRequest(wire::KillProcessRequest {
931                process_id,
932                signal: String::from("SIGTERM"),
933            });
934            if let Err(error) = agent.transport().request_wire(ownership, payload).await {
935                tracing::warn!(?error, "close_shell kill failed");
936            }
937        });
938
939        Ok(())
940    }
941
942    /// Look up the wire-side `process_id` and the spawn-readiness receiver for a shell id, or
943    /// [`ClientError::ShellNotFound`].
944    fn shell_wire_handle(
945        &self,
946        shell_id: &str,
947    ) -> std::result::Result<(String, tokio::sync::watch::Receiver<bool>), ClientError> {
948        self.inner()
949            .shells
950            .read(shell_id, |_, entry| {
951                (entry.process_id.clone(), entry.spawned_tx.subscribe())
952            })
953            .ok_or_else(|| ClientError::ShellNotFound(shell_id.to_string()))
954    }
955}
956
957/// Wait until the shell's background `Execute` request has been acked (the readiness gate flips to
958/// `true`). Returns immediately if it is already ready or the sender has dropped.
959async fn wait_for_spawn(mut spawned_rx: tokio::sync::watch::Receiver<bool>) {
960    if *spawned_rx.borrow() {
961        return;
962    }
963    while spawned_rx.changed().await.is_ok() {
964        if *spawned_rx.borrow() {
965            return;
966        }
967    }
968}
969
970async fn terminal_process_finished(agent: &AgentOs, pid: u32) -> bool {
971    match agent.all_processes().await {
972        Ok(processes) => match processes.into_iter().find(|process| process.pid == pid) {
973            Some(process) => process.status != ProcessStatus::Running,
974            None => true,
975        },
976        Err(error) => {
977            tracing::warn!(?error, pid, "terminal process snapshot failed");
978            false
979        }
980    }
981}
982
983#[cfg(test)]
984mod tests {
985    use super::*;
986
987    #[test]
988    fn reserve_counter_enforces_limit_and_release_reopens_slot() {
989        let counter = AtomicUsize::new(0);
990
991        assert!(try_reserve_counter(&counter, 2));
992        assert!(try_reserve_counter(&counter, 2));
993        assert!(!try_reserve_counter(&counter, 2));
994        release_counter(&counter);
995        assert!(try_reserve_counter(&counter, 2));
996    }
997}