Skip to main content

agentmux_srv/backend/blockcontroller/shell/
controller.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `ShellController` struct + inner state definitions, constructor, and the
5//! small accessor / lock / meta helpers. The `Controller` trait impl and the
6//! spawn/IO orchestration live in [`super::lifecycle`].
7
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::sync::Arc;
10use std::sync::Mutex;
11use std::time::Instant;
12
13use tokio::sync::mpsc;
14
15use super::super::{
16    BlockControllerRuntimeStatus, BlockInputUnion, META_KEY_CMD, META_KEY_CMD_ARGS,
17    META_KEY_CMD_CLEAR_ON_START, META_KEY_CMD_CLOSE_ON_EXIT, META_KEY_CMD_CLOSE_ON_EXIT_DELAY,
18    META_KEY_CMD_CLOSE_ON_EXIT_FORCE, META_KEY_CMD_RUN_ONCE, META_KEY_CMD_RUN_ON_START,
19    META_KEY_CONNECTION, STATUS_INIT,
20};
21use crate::backend::eventbus::EventBus;
22use crate::backend::obj::{self, MetaMapType};
23use crate::backend::shellexec::ConnInterface;
24use crate::backend::storage::filestore::FileStore;
25use crate::backend::storage::store::Store;
26use crate::backend::mps;
27use crate::backend::wconfig::{ConfigState, SettingsType};
28
29/// Cap on the out-of-order input reorder buffer (`input_seq_buf`).
30///
31/// The input channel itself is now **unbounded** (`unbounded_channel`): a
32/// bounded `try_send` silently dropped input on burst (large pastes arriving
33/// faster than the PTY write loop drains — the original truncation bug), and
34/// the proper backpressure remedy (`send().await`) can't be applied here
35/// because `send_input` is a synchronous trait method holding a `std::Mutex`.
36/// An unbounded channel guarantees no input is ever dropped; terminal input is
37/// human-paced (and the frontend now chunks large pastes at 4 KB / 5 ms), and
38/// the PTY drain loop keeps up, so the queue does not grow in practice. This
39/// constant only bounds the reorder buffer (pathological out-of-order seqs).
40pub(super) const SHELL_INPUT_CH_SIZE: usize = 256;
41
42/// Inner state protected by mutex.
43/// Grace period (seconds) between SIGTERM and SIGKILL during stop().
44#[allow(dead_code)]
45pub(super) const KILL_GRACE_SECS: u64 = 5;
46
47pub(super) struct ShellControllerInner {
48    /// Current process status.
49    pub(super) proc_status: String,
50    /// Process exit code.
51    pub(super) proc_exit_code: i32,
52    /// Status version counter (incremented on each change).
53    pub(super) status_version: i32,
54    /// Connection name for the shell process.
55    pub(super) conn_name: String,
56    /// Input channel sender (sends to the PTY input loop). Unbounded so input
57    /// is never dropped on burst — see `SHELL_INPUT_CH_SIZE` doc.
58    pub(super) input_tx: Option<mpsc::UnboundedSender<BlockInputUnion>>,
59    /// Input channel receiver (consumed by the PTY input loop).
60    #[allow(dead_code)]
61    pub(super) input_rx: Option<mpsc::UnboundedReceiver<BlockInputUnion>>,
62    /// OS PID of the running child process, kept for signal delivery in stop().
63    pub(super) child_pid: Option<u32>,
64    /// Program name of that child (`pwsh`, `bash`, …), for the Shell drawer's
65    /// info line. Empty until the first spawn.
66    pub(super) shell_name: String,
67    /// Unix timestamp (ms) when the process was spawned; None until first spawn.
68    pub(super) spawn_ts_ms: Option<i64>,
69    /// Monotonic instant of the most recent PTY read; None until first output.
70    pub(super) last_pty_output: Option<Instant>,
71    /// True if this pane is running an agent CLI (e.g. claude).
72    pub(super) is_agent_pane: bool,
73    /// This pane's own jekt/muxbus identity, captured once at spawn time
74    /// from `resolve_agent_id_for_jekt` (see that fn's doc comment) — the
75    /// independent source of truth `Controller::agent_id()` exposes for
76    /// `inject_message_inner`'s recipient-identity check. `None` for a
77    /// plain (non-agent) terminal pane.
78    pub(super) agent_id: Option<String>,
79    /// Next expected input seq number (per-TermViewModel monotonic counter).
80    pub(super) input_seq_next: u64,
81    /// Out-of-order input packets waiting for their seq slot (capped at SHELL_INPUT_CH_SIZE).
82    pub(super) input_seq_buf: std::collections::BTreeMap<u64, BlockInputUnion>,
83}
84
85/// Factory function type for creating ConnInterface instances.
86/// This allows dependency injection for testing.
87pub type ConnFactory =
88    Box<dyn Fn(&str, &MetaMapType) -> Result<Box<dyn ConnInterface>, String> + Send + Sync>;
89
90/// ShellController manages one shell or command block.
91pub struct ShellController {
92    /// Controller type: "shell" or "cmd".
93    pub(super) controller_type: String,
94    pub(super) tab_id: String,
95    pub(super) block_id: String,
96    /// Prevents concurrent run() calls.
97    pub(super) run_lock: Arc<AtomicBool>,
98    /// Protected inner state.
99    pub(super) inner: Arc<Mutex<ShellControllerInner>>,
100    /// Optional factory for creating ConnInterface (for testing).
101    pub(super) conn_factory: Mutex<Option<ConnFactory>>,
102    /// MPS broker for publishing events (blockfile, controllerstatus).
103    pub(super) broker: Option<Arc<mps::Broker>>,
104    /// Event bus (unused for now, reserved for future event routing).
105    #[allow(dead_code)]
106    pub(super) event_bus: Option<Arc<EventBus>>,
107    /// AgentMux object store — used to seed cmd:cwd on shell spawn.
108    pub(super) mstore: Option<Arc<Store>>,
109    /// FileStore write-through target for PTY output persistence
110    /// (SPEC_TERMINAL_SCROLLBACK_PERSISTENCE_2026_07_23.md §2.1) — lets
111    /// `handle_append_block_file`'s "term" writes survive a reconnect,
112    /// mirroring `PersistentSubprocessController`/`SubprocessController`'s
113    /// existing filestore wiring.
114    pub(super) filestore: Option<Arc<FileStore>>,
115    /// This instance's API auth key, re-injected into the spawned PTY as
116    /// `AGENTMUX_AUTH_KEY` (see `lifecycle.rs`) so `muxsh`/`muxspect`/
117    /// `muxopen`/`muxlog`, run by a human from a plain Terminal-widget pane,
118    /// can authenticate the same way an agent pane's process already does
119    /// (`PersistentSpawnConfig`/`SubprocessSpawnConfig`'s own `env_vars`).
120    /// Can't be read back from the process env at spawn time the way
121    /// `AGENTMUX_LOCAL_URL` is — `config.rs` deliberately scrubs it from
122    /// this process's own environment right after startup (PR #801) — so it
123    /// has to be threaded in explicitly instead.
124    pub(super) auth_key: String,
125    /// The live, file-watched config (`bootstrap/background.rs`'s
126    /// `config_watcher`, kept current by `config_watcher_fs` and the
127    /// `setconfig` handler), read at each spawn for the global `cmd:env`
128    /// defaults. `None` in tests and mock contexts, which then get no global
129    /// defaults. Set via [`with_config`](Self::with_config).
130    pub(super) config: Option<Arc<ConfigState>>,
131}
132
133impl ShellController {
134    /// Create a new ShellController.
135    pub fn new(
136        controller_type: String,
137        tab_id: String,
138        block_id: String,
139        broker: Option<Arc<mps::Broker>>,
140        event_bus: Option<Arc<EventBus>>,
141        mstore: Option<Arc<Store>>,
142        filestore: Option<Arc<FileStore>>,
143        auth_key: String,
144    ) -> Self {
145        Self {
146            controller_type,
147            tab_id,
148            block_id,
149            run_lock: Arc::new(AtomicBool::new(false)),
150            inner: Arc::new(Mutex::new(ShellControllerInner {
151                proc_status: STATUS_INIT.to_string(),
152                proc_exit_code: 0,
153                status_version: 0,
154                conn_name: String::new(),
155                input_tx: None,
156                input_rx: None,
157                child_pid: None,
158                shell_name: String::new(),
159                spawn_ts_ms: None,
160                last_pty_output: None,
161                is_agent_pane: false,
162                agent_id: None,
163                input_seq_next: 0,
164                input_seq_buf: std::collections::BTreeMap::new(),
165            })),
166            conn_factory: Mutex::new(None),
167            broker,
168            event_bus,
169            mstore,
170            filestore,
171            auth_key,
172            config: None,
173        }
174    }
175
176    /// Attach the live config handle the spawn reads global `cmd:env`
177    /// defaults from. Builder-style, like `PersistentSubprocessController::
178    /// with_identity_stores`.
179    pub fn with_config(mut self, config: Option<Arc<ConfigState>>) -> Self {
180        self.config = config;
181        self
182    }
183
184    /// Current global settings. Read from the live config on every call, not
185    /// captured at construction, so a settings.json edit reaches the next
186    /// spawn of an existing pane too. Defaults when no config is attached.
187    pub(super) fn live_settings(&self) -> SettingsType {
188        self.config
189            .as_ref()
190            .map(|config| config.get_settings())
191            .unwrap_or_default()
192    }
193
194    /// Set a custom ConnInterface factory (for testing).
195    #[allow(dead_code)]
196    pub fn set_conn_factory(&self, factory: ConnFactory) {
197        *self.conn_factory.lock().unwrap() = Some(factory);
198    }
199
200    /// Try to acquire the run lock. Returns false if already running.
201    pub(super) fn try_lock_run(&self) -> bool {
202        self.run_lock
203            .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
204            .is_ok()
205    }
206
207    /// Release the run lock.
208    pub(super) fn unlock_run(&self) {
209        self.run_lock.store(false, Ordering::SeqCst);
210    }
211
212    /// Update process status and increment version (must hold inner lock).
213    pub(super) fn set_status(inner: &mut ShellControllerInner, status: &str) {
214        inner.proc_status = status.to_string();
215        inner.status_version += 1;
216    }
217
218    /// Get the runtime status (snapshot).
219    pub(super) fn get_status_snapshot(&self) -> BlockControllerRuntimeStatus {
220        let inner = self.inner.lock().unwrap();
221        BlockControllerRuntimeStatus {
222            durable: false,
223            blockid: self.block_id.clone(),
224            version: inner.status_version,
225            shellprocstatus: inner.proc_status.clone(),
226            shellprocconnname: inner.conn_name.clone(),
227            shellprocexitcode: inner.proc_exit_code,
228            spawn_ts_ms: inner.spawn_ts_ms,
229            shellprocpid: inner.child_pid,
230            shellprocname: inner.shell_name.clone(),
231            is_agent_pane: inner.is_agent_pane,
232            // The shell/PTY controller has no NDJSON-derived health monitor
233            // (no structured turn-end marker to key off, unlike
234            // persistent/stdout_reader.rs and acp.rs) — leave unset rather than
235            // guess. Mount reconciliation falls back to today's Idle default
236            // for these panes, same as before this field existed.
237            turn_active: false,
238        }
239    }
240
241    /// Seconds since last PTY output, or None if no output yet.
242    pub fn last_output_secs_ago(&self) -> Option<u64> {
243        self.inner.lock().unwrap().last_pty_output.map(|t| t.elapsed().as_secs())
244    }
245
246    /// True if this pane is running an agent CLI.
247    #[allow(dead_code)]
248    pub fn is_agent_pane(&self) -> bool {
249        self.inner.lock().unwrap().is_agent_pane
250    }
251
252    /// Check block meta for whether to run on start.
253    pub(super) fn should_run_on_start(meta: &MetaMapType) -> bool {
254        obj::meta_get_bool(meta, META_KEY_CMD_RUN_ON_START, true)
255    }
256
257    /// Check block meta for run-once mode (used in full lifecycle integration).
258    #[allow(dead_code)]
259    pub(super) fn should_run_once(meta: &MetaMapType) -> bool {
260        obj::meta_get_bool(meta, META_KEY_CMD_RUN_ONCE, false)
261    }
262
263    /// Check block meta for clear-on-start (used in full lifecycle integration).
264    #[allow(dead_code)]
265    pub(super) fn should_clear_on_start(meta: &MetaMapType) -> bool {
266        obj::meta_get_bool(meta, META_KEY_CMD_CLEAR_ON_START, false)
267    }
268
269    /// Check block meta for close-on-exit, as literally set — no default
270    /// inference. Use [`effective_close_on_exit`](Self::effective_close_on_exit)
271    /// at call sites that need the controller-type-aware default; this raw
272    /// getter exists so that default logic has a single, testable "was it
273    /// explicitly set" primitive to build on.
274    pub(super) fn should_close_on_exit(meta: &MetaMapType) -> bool {
275        obj::meta_get_bool(meta, META_KEY_CMD_CLOSE_ON_EXIT, false)
276    }
277
278    /// Whether this block should close its pane when the underlying process
279    /// exits — [`should_close_on_exit`](Self::should_close_on_exit) layered
280    /// with a controller-type-aware default for when `cmd:closeonexit`
281    /// isn't set at all. An explicit value in meta always wins either way;
282    /// this only decides the unset case.
283    ///
284    /// Defaults to `true` for `BLOCK_CONTROLLER_SHELL` (the interactive PTY
285    /// both the `Terminal` widget and an agent's composer-drawer shell
286    /// use) — matches the ordinary expectation that typing `exit` closes
287    /// the pane, the default behavior of most terminal apps. Defaults to
288    /// `false` for `BLOCK_CONTROLLER_CMD` (one-shot, non-interactive
289    /// command execution, e.g. a build command) — that pane exists
290    /// specifically to show its output/exit code after the command
291    /// finishes, and auto-closing it by default would hide exactly the
292    /// information it's for. See
293    /// docs/specs/SPEC_TERM_EXIT_RESPAWN_LOOP_2026_09_15.md §10.
294    pub(super) fn effective_close_on_exit(meta: &MetaMapType, controller_type: &str) -> bool {
295        if meta.contains_key(META_KEY_CMD_CLOSE_ON_EXIT) {
296            return Self::should_close_on_exit(meta);
297        }
298        controller_type == super::super::BLOCK_CONTROLLER_SHELL
299    }
300
301    /// Check block meta for force close-on-exit. **Not currently read by
302    /// any call site** — kept as a meta getter (parsed, tested, and
303    /// available for a future distinction) rather than deleted outright,
304    /// because two attempts at giving it a real meaning during this PR
305    /// both turned out unworkable, not because the getter itself is wrong:
306    ///
307    /// - Exit-code-based ("close even on a non-zero exit"): `wait()`'s
308    ///   exit-code capture is itself lossy — portable-pty's `ExitStatus`
309    ///   doesn't expose a raw code on all platforms, so success/failure is
310    ///   all that's reliably known (see the wait/cleanup task's own
311    ///   comment in `lifecycle.rs`) — not enough to build a principled
312    ///   "force" distinction on.
313    /// - Flusher-drain-timeout-based ("close even if a background
314    ///   descendant might still be producing output"): tried and reverted
315    ///   in this same PR after live-testing on Windows showed
316    ///   `FLUSHER_DRAIN_TIMEOUT` (10s) gets hit on essentially every
317    ///   ordinary `cmd.exe` exit — ConPTY's own console-host process
318    ///   routinely outlives the direct child by design — which made
319    ///   gating close-on-exit on it fire almost never, defeating the
320    ///   whole feature on the one platform this was tested on. See
321    ///   `docs/specs/SPEC_TERM_EXIT_RESPAWN_LOOP_2026_09_15.md` §10.
322    #[allow(dead_code)]
323    pub(super) fn should_close_on_exit_force(meta: &MetaMapType) -> bool {
324        obj::meta_get_bool(meta, META_KEY_CMD_CLOSE_ON_EXIT_FORCE, false)
325    }
326
327    /// Get the close-on-exit delay in ms — how long to leave the pane
328    /// showing its final output before actually closing it.
329    ///
330    /// **Defaults to 0 (close immediately).** The knob predates any caller
331    /// and its original 2000 was never exercised; when close-on-exit was
332    /// first wired up (§10) that 2s was kept as-is, and the reporter's
333    /// first working retest asked for it to go — a shell you just typed
334    /// `exit` into has already shown you whatever it was going to show, so
335    /// the pause reads as lag, not as a chance to read the output. Still
336    /// honored when set explicitly via `cmd:closeonexitdelay`, for a pane
337    /// that genuinely wants a beat before vanishing. See
338    /// docs/specs/SPEC_TERM_EXIT_RESPAWN_LOOP_2026_09_15.md §13.
339    pub(super) fn close_on_exit_delay_ms(meta: &MetaMapType) -> u64 {
340        match meta.get(META_KEY_CMD_CLOSE_ON_EXIT_DELAY) {
341            Some(serde_json::Value::Number(n)) => n.as_u64().unwrap_or(0),
342            _ => 0,
343        }
344    }
345
346    /// Get the connection name from block meta.
347    pub(super) fn get_conn_name(meta: &MetaMapType) -> String {
348        obj::meta_get_string(meta, META_KEY_CONNECTION, "local")
349    }
350
351    /// Get the command string from block meta.
352    pub(super) fn get_cmd_str(meta: &MetaMapType) -> String {
353        obj::meta_get_string(meta, META_KEY_CMD, "")
354    }
355
356    /// Get cmd:args array from block meta.
357    pub(super) fn get_cmd_args(meta: &MetaMapType) -> Vec<String> {
358        match meta.get(META_KEY_CMD_ARGS) {
359            Some(serde_json::Value::Array(arr)) => arr
360                .iter()
361                .filter_map(|v| v.as_str().map(|s| s.to_string()))
362                .collect(),
363            _ => vec![],
364        }
365    }
366
367    /// Check if cmd:interactive is set in block meta.
368    pub(super) fn is_interactive(meta: &MetaMapType) -> bool {
369        obj::meta_get_bool(meta, "cmd:interactive", false)
370    }
371
372    /// Publish current controller status via the MPS broker.
373    pub(super) fn publish_status(&self) {
374        if let Some(ref broker) = self.broker {
375            let status = self.get_status_snapshot();
376            super::super::publish_controller_status(broker, &status);
377        }
378    }
379}