Skip to main content

agentmux_srv/server/
http_pty_shell.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! PTY shell HTTP handlers.
5//! Split out of server/mod.rs unchanged (SPEC_LARGE_FILE_MODULE_ANALYSIS_2026_09_30.md §4.1).
6
7use super::*;
8
9/// `POST /api/v1/ptyshell/create` — attach to (creating if needed) "this
10/// pane's one shell."
11///
12/// Reuses the same sub-block a human's composer-drawer shell already uses
13/// or will use (`META_KEY_SHELL_SUBBLOCK_ID`), in either direction: if the
14/// human already opened the drawer, the agent joins that exact PTY — same
15/// scrollback, same live output. If nothing exists yet, this creates it
16/// (the same `view: "term", controller: "shell"` headless-sub-block shape
17/// `createsubblock`'s WS handler builds for `AgentShellSubblock.tsx`) and
18/// persists the pointer, so a drawer opened *afterward* attaches to what
19/// the agent already started instead of spawning a second, independent
20/// shell. Either way, `resync_controller` runs before returning — unlike
21/// the WS path (which waits for a frontend to mount a `term` view and call
22/// `ControllerResyncCommand`), the PTY is live the moment this call
23/// returns; a UI viewer for it is optional, not a prerequisite.
24pub(super) async fn handle_pty_shell_create(
25    State(state): State<AppState>,
26    Json(req): Json<PtyShellCreateRequest>,
27) -> impl IntoResponse {
28    // Where the shell runs, checked the way a pane's connection is: a refused
29    // one fails the call with its reason rather than opening a local shell.
30    use super::app_api::connections::{self, AgentTarget};
31    let target = match connections::for_agent(&state.broker, req.connection.as_deref()).await {
32        Ok(t) => t,
33        Err(e) => {
34            return (StatusCode::BAD_REQUEST, Json(json!({ "error": e }))).into_response();
35        }
36    };
37    let connection = match &target {
38        AgentTarget::Local => "local".to_string(),
39        AgentTarget::Wsl(d) => crate::backend::remote::ConnTarget::Wsl(d.clone()).name(),
40        AgentTarget::Ssh(dest) => dest.name(),
41    };
42    // An SSH host runs with the user's identity: their consent first, and the
43    // askpass helper must exist, since its prompts may never reach the
44    // terminal the agent reads (`remote::askpass`).
45    let mut ssh_agent = String::new();
46    if let AgentTarget::Ssh(_) = &target {
47        if crate::backend::remote::askpass::program().is_none() {
48            let e = "this AgentMux has no askpass helper (agentmux-bashwrap) to run an agent's SSH shell with";
49            return (StatusCode::BAD_REQUEST, Json(json!({ "error": e }))).into_response();
50        }
51        ssh_agent = match connections::verified_agent(&state, &req.agent_block_id, req.auth.as_ref()) {
52            Ok(a) => a,
53            Err(e) => return (StatusCode::FORBIDDEN, Json(json!({ "error": e }))).into_response(),
54        };
55        // A running shell on another connection is refused before anything
56        // is asked: consent (and an "always") for a shell that then cannot
57        // start would be a grant the user gave for nothing.
58        if let Some(conflict) = running_shell_conflict(&state, &req.agent_block_id, &connection) {
59            return conflict;
60        }
61        if let Err(e) = connections::consent_for_ssh(&state, &req.agent_block_id, &ssh_agent, &connection, "an interactive shell (PtyShell)").await {
62            return (StatusCode::FORBIDDEN, Json(json!({ "error": e }))).into_response();
63        }
64    }
65    let remote = !matches!(target, AgentTarget::Local);
66    let existing_id = state
67        .mstore
68        .get::<crate::backend::obj::Block>(&req.agent_block_id)
69        .ok()
70        .flatten()
71        .and_then(|b| {
72            b.meta
73                .get(META_KEY_SHELL_SUBBLOCK_ID)
74                .and_then(|v| v.as_str())
75                .map(str::to_string)
76        });
77
78    // Reuse path: the pointer exists and still resolves to a real block.
79    // Deliberately ignores req.cwd/rows/cols here — an already-running
80    // shell's cwd can't change after spawn, and geometry is a separate,
81    // legitimate post-hoc operation via PtyShellResize, not a create-time
82    // parameter for a process that already has a size.
83    if let Some(id) = existing_id {
84        if let Some(resp) = try_attach_to_existing_shell(&state, &req.agent_block_id, &id, &connection).await {
85            return resp;
86        }
87        // `None` here means either a stale pointer (the block was deleted
88        // some other way — rare) or, as of this PR, the pane's shell
89        // having already exited (the common case this PR fixes — see
90        // `try_attach_to_existing_shell`'s own doc comment). Either way,
91        // fall through to creating a fresh one, same as if none existed.
92    }
93
94    let child_id = uuid::Uuid::new_v4().to_string();
95
96    let mut meta = crate::backend::obj::MetaMapType::new();
97    // Which srv run created this shell: a block with no controller is one this
98    // run is still starting (a concurrent create), not one left from before a
99    // restart (`try_attach_to_existing_shell`).
100    meta.insert(META_KEY_PTYSHELL_BOOT_ID.to_string(), json!(&*state.boot_id));
101    // The agent's plain-`gh` guard, as its Shell and its own CLI get it: the
102    // agent types into this shell, so `gh` here must not act as the user.
103    let agent_env: std::collections::HashMap<String, String> = state
104        .mstore
105        .get::<crate::backend::obj::Block>(&req.agent_block_id)
106        .ok()
107        .flatten()
108        .map(|b| crate::backend::blockcontroller::cmd_env_of(&b.meta))
109        .unwrap_or_default();
110    meta.insert(
111        crate::backend::gh_guard::META_KEY_PTYSHELL_GH_CONFIG_DIR.to_string(),
112        json!(crate::backend::gh_guard::guard_dir_for(&agent_env)),
113    );
114    // And the publish guard's git hooks, so a push typed here is scanned too.
115    // Local shells only: the hooks directory is a host path, which a WSL distro
116    // or an SSH host can't run (pushes from there aren't guarded).
117    let local = matches!(target, AgentTarget::Local);
118    if let Some(hooks) = local.then(|| crate::backend::publish_guard::hooks_dir_for(&agent_env, |k| std::env::var(k).ok())).flatten() {
119        meta.insert(
120            crate::backend::publish_guard::META_KEY_PTYSHELL_GIT_HOOKS_PATH.to_string(),
121            json!(hooks.to_string_lossy()),
122        );
123    }
124    meta.insert("view".to_string(), json!("term"));
125    meta.insert(
126        blockcontroller::META_KEY_CONTROLLER.to_string(),
127        json!(blockcontroller::BLOCK_CONTROLLER_SHELL),
128    );
129    // An agent's shell doesn't follow the user's default-shell setting
130    // (term:localshellpath and term:localshellopts are for their terminals):
131    // pin it to the automatic shell with no extra arguments. Only macOS and
132    // Linux reach that branch; Windows runs cmd.exe below.
133    meta.insert(
134        blockcontroller::META_KEY_TERM_LOCAL_SHELL_PATH.to_string(),
135        json!(crate::backend::shells::automatic().display().to_string()),
136    );
137    meta.insert(blockcontroller::META_KEY_TERM_LOCAL_SHELL_OPTS.to_string(), json!([]));
138    // Force cmd.exe directly on Windows rather than the "shell" controller's
139    // default pwsh/powershell auto-detection (`backend::shells::automatic`,
140    // which tries pwsh first). Discovered live, not assumed: PowerShell's
141    // PSReadLine issues a cursor-position query (`ESC[6n`, Device Status
142    // Report) on interactive startup and blocks forever waiting for a
143    // `ESC[row;colR` reply — which only a real terminal emulator answering
144    // on the other end provides (xterm.js does, for the `term` widget /
145    // AgentShellSubblock's human-facing sessions). This facility has no
146    // terminal emulator reading the byte stream, only a raw capture, so
147    // every PtyShell() would otherwise hang before printing even its first
148    // prompt. cmd.exe has no such startup handshake. Non-Windows shells
149    // (bash/zsh via $SHELL) aren't affected — left as the "shell"
150    // controller's own default. This is a known-narrow mitigation, not a
151    // general fix: any OTHER program that queries terminal capabilities via
152    // escape sequences and blocks for a reply would hit the same class of
153    // issue — see docs/specs/SPEC_AGENT_INTERACTIVE_PTY_SHELL_API_2026_09_10.md §5.
154    //
155    // A WSL shell is the distro's own login shell (`wsl.exe -d`), which has no
156    // such handshake, and `cmd.exe` would be looked up inside the distro.
157    #[cfg(windows)]
158    if !remote {
159        meta.insert(blockcontroller::META_KEY_CMD.to_string(), json!("cmd.exe"));
160        meta.insert("cmd:interactive".to_string(), json!(true));
161    }
162    if remote {
163        meta.insert(blockcontroller::META_KEY_CONNECTION.to_string(), json!(connection));
164    }
165    // If the caller didn't supply a cwd, fall back to the agent block's own
166    // cmd:cwd — the project directory the agent pane was launched with.
167    // Mirrors `handle_shell_create`'s identical fallback (Codex P1 on PR
168    // #3177): without this, the PTY spawns in agentmux-srv's own cwd
169    // (typically the portable runtime/ dir), not the agent's worktree.
170    //
171    // In WSL the cwd is a distro path, kept as given; no Windows fallback.
172    if let AgentTarget::Ssh(_) = &target {
173        let agent = ssh_agent.clone();
174        meta.insert(crate::backend::remote::askpass::META_KEY_AGENT_BLOCK.to_string(), json!(req.agent_block_id));
175        meta.insert(crate::backend::remote::askpass::META_KEY_AGENT.to_string(), json!(agent));
176    }
177    if remote {
178        if let Some(cwd) = req.cwd.as_deref().filter(|c| !c.trim().is_empty()) {
179            meta.insert(blockcontroller::META_KEY_CMD_CWD.to_string(), json!(cwd));
180        }
181    }
182    let effective_cwd = req.cwd.clone().or_else(|| {
183        state
184            .mstore
185            .get::<crate::backend::obj::Block>(&req.agent_block_id)
186            .ok()
187            .flatten()
188            .and_then(|b| {
189                let cwd = crate::backend::obj::meta_get_string(&b.meta, "cmd:cwd", "");
190                if cwd.is_empty() { None } else { Some(cwd) }
191            })
192    });
193    let effective_cwd = effective_cwd.filter(|_| !remote);
194    if let Some(cwd) = &effective_cwd {
195        // Same MSYS→native normalization createsubblock's WS handler applies —
196        // the PTY spawn path reads cmd:cwd raw with no conversion, so a
197        // Git-Bash-style path would otherwise fail with os error 267 on
198        // Windows.
199        match crate::backend::base::normalize_working_dir(cwd)
200            .filter(|p| std::path::Path::new(p).is_absolute())
201        {
202            Some(norm) => {
203                meta.insert(blockcontroller::META_KEY_CMD_CWD.to_string(), json!(norm));
204            }
205            None => {
206                tracing::warn!(
207                    raw_cwd = %cwd,
208                    "ptyshell.create: invalid or non-absolute cwd, dropping (no cwd)"
209                );
210            }
211        }
212    }
213
214    // Initial PTY geometry, if the caller specified one — otherwise
215    // `resync_controller`'s own fallback (25x200) applies. Both `rows`/`cols`
216    // being absent is the common case; either one alone still takes effect
217    // (see `pty_size_from_rt_opts`'s per-field guards).
218    let runtimeopts = if req.rows.is_none() && req.cols.is_none() {
219        None
220    } else {
221        Some(crate::backend::obj::RuntimeOpts {
222            termsize: crate::backend::obj::TermSize {
223                rows: req.rows.unwrap_or(25) as i64,
224                cols: req.cols.unwrap_or(200) as i64,
225            },
226            ..Default::default()
227        })
228    };
229    let rt_opts_json = runtimeopts.as_ref().map(|rt| {
230        json!({ "termsize": { "rows": rt.termsize.rows, "cols": rt.termsize.cols } })
231    });
232
233    let mut block = crate::backend::obj::Block {
234        oid: child_id.clone(),
235        parentoref: format!("block:{}", req.agent_block_id),
236        meta,
237        runtimeopts,
238        ..Default::default()
239    };
240
241    // Atomic claim-or-lose (Codex P2 on PR #3194): without this, two
242    // concurrent `PtyShell` calls that both observe an unset
243    // `term:shellsubblockid` (the check at the top of this function is
244    // NOT authoritative — it's a plain read, before this point) would each
245    // insert their own block and race to overwrite the other's pointer
246    // write, leaving one controller registered but orphaned from the
247    // pointer that's supposed to track it, and the two callers ending up
248    // attached to DIFFERENT shells despite both believing they're on "the
249    // pane's one shell." `with_tx` holds the store's single connection
250    // lock for its whole closure, so re-checking the pointer INSIDE it,
251    // atomically with the insert+link+set that follows, serializes any
252    // concurrent `PtyShell` call against this same agent_block_id.
253    //
254    // Does NOT close the equivalent race against the FRONTEND's own
255    // `createsubblock` path (`AgentShellSubblock.tsx`'s "no
256    // existingSubBlockId yet" branch): that's a separate, non-transactional
257    // multi-step RPC sequence (create, then a follow-up `SetMetaCommand`)
258    // this backend call has no visibility into or ability to serialize
259    // against. A human opening the drawer for the very first time on a
260    // pane in the same narrow window as an agent's first `PtyShell` call
261    // on that pane can still each end up with their own shell — a real,
262    // documented, accepted gap (see the spec's §10 for the full
263    // reasoning), not silently ignored, just out of scope for what a
264    // single backend transaction can enforce unilaterally.
265    let claim = state.mstore.with_tx(|tx| {
266        let mut parent = tx.must_get::<crate::backend::obj::Block>(&req.agent_block_id)?;
267        if let Some(winner_id) = parent
268            .meta
269            .get(META_KEY_SHELL_SUBBLOCK_ID)
270            .and_then(|v| v.as_str())
271        {
272            return Ok(Some(winner_id.to_string()));
273        }
274        tx.insert(&mut block)?;
275        parent.subblockids.get_or_insert_with(Vec::new).push(child_id.clone());
276        parent
277            .meta
278            .insert(META_KEY_SHELL_SUBBLOCK_ID.to_string(), json!(child_id));
279        tx.update(&mut parent)?;
280        Ok(None)
281    });
282
283    let (shell_id, block) = match claim {
284        // We lost the race — someone else's transaction committed the
285        // pointer first. Pivot to reusing THEIRS rather than proceeding
286        // with the block we prepared but never actually inserted.
287        Ok(Some(winner_id)) => {
288            if let Some(resp) = try_attach_to_existing_shell(&state, &req.agent_block_id, &winner_id, &connection).await {
289                return resp;
290            }
291            // Winner's block vanished between the transaction and now, OR
292            // (as of this PR) resolved to a shell that had already exited.
293            // Both stay exceedingly unlikely to land HERE specifically:
294            // `try_attach_to_existing_shell` now clears the parent's
295            // pointer itself the moment it finds an exited shell (see its
296            // own comment on the `RESYNC_ERR_ALREADY_EXITED` branch), so
297            // the common "human typed `exit`, then something calls
298            // PtyShellCreate again" case is intercepted one level up — the
299            // CLAIM transaction above reads the pointer as already-cleared
300            // and takes the plain `Ok(None)` fresh-insert path, never
301            // reaching this branch at all. Getting here for an exited
302            // shell specifically requires a genuine concurrent-call race
303            // (two callers both observe the stale pointer before either
304            // one's clear commits) — same rarity class as the pre-existing
305            // deleted-out-from-under-us case this comment originally
306            // described, not the common case. `block` above was only ever
307            // prepared in memory: the
308            // `with_tx` closure's `tx.insert` never ran for it (that only
309            // happens in the `Ok(None)` arm, when WE win the claim), so it
310            // has no backing row. Falling through with it as-is would send
311            // `resync_controller` a phantom block and return a `shell_id`
312            // with nothing in the store behind it — every later
313            // PtyShellInput/Read/Status/Stop call against it would then
314            // fail ownership/lookup checks (ReAgent P2 on PR #3194).
315            //
316            // Actually create it for real instead, mirroring what the
317            // `Ok(None)` arm does inside its transaction — just as a plain
318            // sequence, since we're no longer inside that transaction.
319            let insert_result = (|| -> Result<(), crate::backend::storage::error::StoreError> {
320                state.mstore.insert(&mut block)?;
321                let mut parent = state
322                    .mstore
323                    .must_get::<crate::backend::obj::Block>(&req.agent_block_id)?;
324                parent
325                    .subblockids
326                    .get_or_insert_with(Vec::new)
327                    .push(child_id.clone());
328                parent
329                    .meta
330                    .insert(META_KEY_SHELL_SUBBLOCK_ID.to_string(), json!(child_id));
331                state.mstore.update(&mut parent)?;
332                Ok(())
333            })();
334            if let Err(e) = insert_result {
335                return (
336                    StatusCode::INTERNAL_SERVER_ERROR,
337                    Json(json!({ "error": format!("ptyshell.create: fallback insert: {e}") })),
338                )
339                    .into_response();
340            }
341            if let Ok(Some(parent)) = state
342                .mstore
343                .get::<crate::backend::obj::Block>(&req.agent_block_id)
344            {
345                let update = crate::backend::obj::MuxObjUpdate {
346                    updatetype: "update".into(),
347                    otype: "block".into(),
348                    oid: req.agent_block_id.clone(),
349                    obj: Some(crate::backend::obj::mux_obj_to_value(&parent)),
350                };
351                state.event_bus.broadcast_event(&crate::backend::eventbus::EventBus::typed_ws_event::<crate::backend::mps_events::MuxObjUpdated>(&update, format!("block:{}", req.agent_block_id)));
352            }
353            (child_id.clone(), block)
354        }
355        Ok(None) => {
356            // We won the claim and the parent's meta changed (inside the
357            // transaction, via a raw `tx.update`, which — unlike
358            // `broadcast_meta_update` — does NOT itself notify any
359            // subscribed frontend). Broadcast now so a drawer already open
360            // (or opened moments later) actually sees the pointer reactively
361            // instead of only on its next unrelated refetch.
362            if let Ok(Some(parent)) = state.mstore.get::<crate::backend::obj::Block>(&req.agent_block_id) {
363                let update = crate::backend::obj::MuxObjUpdate {
364                    updatetype: "update".into(),
365                    otype: "block".into(),
366                    oid: req.agent_block_id.clone(),
367                    obj: Some(crate::backend::obj::mux_obj_to_value(&parent)),
368                };
369                state.event_bus.broadcast_event(&crate::backend::eventbus::EventBus::typed_ws_event::<crate::backend::mps_events::MuxObjUpdated>(&update, format!("block:{}", req.agent_block_id)));
370            }
371            (child_id.clone(), block)
372        }
373        Err(e) => {
374            return (
375                StatusCode::INTERNAL_SERVER_ERROR,
376                Json(json!({ "error": format!("ptyshell.create: claim: {e}") })),
377            )
378                .into_response();
379        }
380    };
381    let child_id = shell_id;
382
383    let registry = state.mstore.shared_agent_registry();
384    if let Err(e) = blockcontroller::resync_controller(
385        &block,
386        "ptyshell", // headless — no real tab; only used for tracing/scoping.
387        rt_opts_json,
388        false,
389        true, // respawn_if_done — irrelevant here, this block is always freshly inserted (STATUS_INIT), never STATUS_DONE
390        Some(Arc::clone(&state.broker)),
391        Some(Arc::clone(&state.event_bus)),
392        Some(Arc::clone(&state.mstore)),
393        Some(Arc::clone(&state.filestore)),
394        Some(Arc::clone(&state.id_store)),
395        Some(Arc::clone(&state.identity_store)),
396        registry,
397        state.boot_id.clone(),
398        &state.auth_key,
399        Some(Arc::clone(&state.config_watcher)),
400    ) {
401        // Roll back — the block was already inserted and linked into the
402        // parent's subblockids above (Codex P2 on PR #3177): without this,
403        // a failed spawn leaves a stale block + parent link + (possibly)
404        // registered controller behind that the caller has no way to clean
405        // up, since it never received a `shell_id` to call PtyShellStop with.
406        crate::sagas::agent_teardown::discard(&child_id);
407        let _ = state.mstore.delete::<crate::backend::obj::Block>(&child_id);
408        if let Ok(mut parent) = state
409            .mstore
410            .must_get::<crate::backend::obj::Block>(&req.agent_block_id)
411        {
412            let mut changed = false;
413            if let Some(ids) = parent.subblockids.as_mut() {
414                let before = ids.len();
415                ids.retain(|id| id != &child_id);
416                changed |= ids.len() != before;
417            }
418            // The pointer was already persisted before `resync_controller`
419            // ran — either inside the atomic claim's own transaction, or by
420            // the "winner vanished" fallback above — so unlinking
421            // `subblockids` alone isn't enough: without also clearing it
422            // here, `term:shellsubblockid` is left pointing at a block that
423            // was just deleted, and a human opening the composer drawer in
424            // this exact window (`agent-view.tsx`'s
425            // `existingSubBlockId={block()?.meta?.["term:shellsubblockid"]}`)
426            // attaches to nothing instead of getting a working shell
427            // (ReAgent P1, round 3 on PR #3194).
428            if parent.meta.get(META_KEY_SHELL_SUBBLOCK_ID).and_then(|v| v.as_str()) == Some(child_id.as_str())
429            {
430                parent.meta.remove(META_KEY_SHELL_SUBBLOCK_ID);
431                changed = true;
432            }
433            if changed {
434                let _ = state.mstore.update(&mut parent);
435                let update = crate::backend::obj::MuxObjUpdate {
436                    updatetype: "update".into(),
437                    otype: "block".into(),
438                    oid: req.agent_block_id.clone(),
439                    obj: Some(crate::backend::obj::mux_obj_to_value(&parent)),
440                };
441                state.event_bus.broadcast_event(&crate::backend::eventbus::EventBus::typed_ws_event::<crate::backend::mps_events::MuxObjUpdated>(&update, format!("block:{}", req.agent_block_id)));
442            }
443        }
444        return (
445            StatusCode::INTERNAL_SERVER_ERROR,
446            Json(json!({ "error": format!("ptyshell.create: spawn: {e}") })),
447        )
448            .into_response();
449    }
450
451    // baseline_len=0: a genuinely new block has no prior "term" file content
452    // to accidentally match against (see the reuse path's identical
453    // baseline logic above for why this matters at all).
454    answer_conpty_handshake_if_seen(&state, &child_id, 0).await;
455
456    // NOTE: the shellsubblockid pointer is already persisted — the atomic
457    // claim transaction above sets it on the parent in the same
458    // transaction as the insert, which is also strictly more correct than
459    // a separate post-hoc write here would be (no window where the block
460    // exists but isn't yet pointed to).
461    tracing::info!(block_id = %child_id, parent_id = %req.agent_block_id, "ptyshell.create: fresh");
462    (StatusCode::OK, Json(PtyShellCreateResponse { shell_id: child_id })).into_response()
463}
464
465/// Answer Windows ConPTY's own startup handshake, if seen, before the
466/// caller proceeds. Discovered live (not assumed): the moment a Windows
467/// ConPTY is created, it queries the cursor position (`ESC[6n`, Device
468/// Status Report) and blocks its own internal state — and therefore every
469/// later write — until it sees a `ESC[<row>;<col>R` reply. A human-facing
470/// `term` pane gets this for free because xterm.js, running in the
471/// browser, answers real cursor queries as part of being an actual
472/// terminal emulator; this headless facility has nothing playing that
473/// role, so without this the PTY would sit permanently stuck before
474/// printing even its first prompt — confirmed by a live round trip that
475/// hung indefinitely until this was added. `ESC[1;1R` (cursor at row 1,
476/// col 1) is a plausible-enough fixed answer: nothing here has rendered
477/// anything yet, so "top-left" is as true as any other coordinate, and
478/// ConPTY only wants to reconcile its internal buffer, not validate the
479/// value against anything a person would see.
480///
481/// `baseline_len` MUST be the "term" file's byte length taken immediately
482/// before `resync_controller` ran (0 for a genuinely new block) — see the
483/// call sites. Only bytes appended AFTER that offset are ever inspected.
484/// **Not** safe to scan the whole file regardless of reuse/fresh (an
485/// earlier version of this function did exactly that): the "term" file is
486/// append-only for the block's entire lifetime
487/// (`handle_append_block_file`'s `FILE_OP_APPEND`), so a long-running
488/// reused shell's history almost always still contains its own original,
489/// long-since-answered `ESC[6n` from whenever it first started — matching
490/// against that stale byte sequence would inject an unsolicited
491/// `ESC[1;1R` into whatever a human is doing RIGHT NOW on that shell
492/// (Codex P1 on PR #3194, confirmed live before this fix: `create` on an
493/// already-running reused shell always found the old query and misfired).
494/// The baseline makes every case correct uniformly: nothing new appears
495/// (the common reuse case) → correctly finds nothing; `resync_controller`
496/// had to respawn a dead process → correctly sees only ITS fresh query,
497/// never anything from the block's former life.
498#[cfg_attr(not(windows), allow(unused_variables))]
499pub(super) async fn answer_conpty_handshake_if_seen(state: &AppState, block_id: &str, baseline_len: usize) {
500    #[cfg(windows)]
501    {
502        let mut answered = false;
503        for _ in 0..20 {
504            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
505            let saw_dsr = state
506                .filestore
507                .read_file(block_id, "term")
508                .ok()
509                .flatten()
510                .map(|bytes| {
511                    let new_bytes = bytes.get(baseline_len..).unwrap_or(&[]);
512                    new_bytes.windows(4).any(|w| w == b"\x1b[6n")
513                })
514                .unwrap_or(false);
515            if saw_dsr {
516                let _ = blockcontroller::send_input(
517                    block_id,
518                    blockcontroller::BlockInputUnion::data(b"\x1b[1;1R".to_vec()),
519                    None,
520                );
521                answered = true;
522                break;
523            }
524        }
525        tracing::info!(block_id = %block_id, answered_dsr = answered, "ptyshell: ConPTY handshake check");
526    }
527}
528
529/// `POST /api/v1/ptyshell/input` — write raw text to the PTY, as if typed.
530/// Unlike `/api/v1/shell/input`, no newline is appended: a real terminal
531/// doesn't add one either, and PTY programs (readline, Sharprompt) expect
532/// the caller to send exactly what a keyboard would (e.g. `"y\n"` to answer
533/// a prompt and press Enter, or `"\x03"` for Ctrl+C — the OS PTY layer
534/// translates that raw byte into a real interrupt delivered to the
535/// foreground process, same as a human pressing Ctrl+C in a real terminal,
536/// without ending the shell).
537///
538/// There is deliberately no separate "send a signal" endpoint: an earlier
539/// version of this API had one (`PtyShellSignal`, `BlockInputUnion::signal`),
540/// removed after Codex correctly flagged (PR #3177) that
541/// `ShellController`'s input loop treats ANY signal as "close the PTY" —
542/// `if input.sig_name.is_some() { break; }` in
543/// `blockcontroller/shell/lifecycle.rs` — so it terminated the whole shell
544/// instead of interrupting just the foreground command, the opposite of
545/// what it advertised. Real signal delivery through a PTY is the raw
546/// control byte, handled here, not a separate primitive.
547pub(super) async fn handle_pty_shell_input(
548    State(state): State<AppState>,
549    Json(req): Json<PtyShellInputRequest>,
550) -> impl IntoResponse {
551    if !is_owned_by_agent(&state.mstore, &req.shell_id, &req.agent_block_id) {
552        return (
553            StatusCode::OK,
554            Json(PtyShellInputResponse {
555                written: false,
556                error: Some("not a shell belonging to this agent's own pane".to_string()),
557            }),
558        );
559    }
560    // Lock BEFORE writing, not after (ReAgent P1 on PR #3194): the
561    // previous ordering left `term:agentlockuntil` unset for the entire
562    // duration of `send_input` — the exact window `controllerinput`'s own
563    // lock check (added for Codex's earlier P1 on this same PR) needs the
564    // lock to already exist to catch anything. Locking first doesn't make
565    // the race provably impossible (a human keystroke processed in the
566    // sliver of time before this write actually commits could still slip
567    // through — true elimination would need a real mutex shared between
568    // this HTTP path and the WS `controllerinput` path, disproportionate
569    // machinery for a UX-level collision guard, not a hard security
570    // boundary), but it shrinks the window from "all of send_input's own
571    // duration plus the lock write's" down to just the lock write's own
572    // commit latency — the actual, meaningful fix here.
573    lock_shell_for_agent(&state, &req.shell_id);
574    let result = blockcontroller::send_input(
575        &req.shell_id,
576        blockcontroller::BlockInputUnion::data(req.text.into_bytes()),
577        None,
578    );
579    match result {
580        Ok(()) => (StatusCode::OK, Json(PtyShellInputResponse { written: true, error: None })),
581        Err(e) => (StatusCode::OK, Json(PtyShellInputResponse { written: false, error: Some(e) })),
582    }
583}
584
585/// Lock `shell_id` out from human keyboard input for `AGENT_LOCK_WINDOW_MS`
586/// from now — called BEFORE every agent write (input/resize), not after
587/// (see the call sites' own comment for why the ordering matters).
588/// Best-effort: a failure to write the lock meta just means the human's
589/// input isn't blocked this time, not that the agent's write itself failed.
590pub(super) fn lock_shell_for_agent(state: &AppState, shell_id: &str) {
591    let until_ms = agentmux_common::time::now_ms() + AGENT_LOCK_WINDOW_MS;
592
593    // In-memory first, and unconditionally: this is what `controllerinput`
594    // actually enforces against (see `blockcontroller::agent_lock`'s module
595    // doc for why the enforcement path can't afford a `Store` read). It
596    // cannot fail and costs a hash insert, so the lock is live before the
597    // persisted/broadcast half is even attempted — which also shrinks the
598    // pre-existing "human keystroke in the sliver before the write commits"
599    // window described above to the width of a mutex acquire.
600    blockcontroller::agent_lock::lock_until(shell_id, until_ms);
601
602    // Then the meta write, which drives the frontend's own badge + gate
603    // (`AgentShellSubblock.tsx`). Best-effort, exactly as before: a failure
604    // here costs the human the "Agent is using this shell" indicator, not
605    // the enforcement itself.
606    let mut meta = crate::backend::obj::MetaMapType::new();
607    meta.insert(META_KEY_AGENT_LOCK_UNTIL.to_string(), json!(until_ms));
608    if let Err(e) = broadcast_meta_update(state, shell_id, &meta) {
609        tracing::warn!(shell_id = %shell_id, error = %e, "ptyshell: failed to write agent lock");
610    }
611}
612
613/// `POST /api/v1/ptyshell/resize` — resize the PTY. Some TUIs render
614/// differently or wrap badly at the fallback geometry (25x200).
615pub(super) async fn handle_pty_shell_resize(
616    State(state): State<AppState>,
617    Json(req): Json<PtyShellResizeRequest>,
618) -> impl IntoResponse {
619    if !is_owned_by_agent(&state.mstore, &req.shell_id, &req.agent_block_id) {
620        return (
621            StatusCode::OK,
622            Json(PtyShellResizeResponse {
623                resized: false,
624                error: Some("not a shell belonging to this agent's own pane".to_string()),
625            }),
626        );
627    }
628    // Lock BEFORE writing — see the identical comment in
629    // `handle_pty_shell_input` for why the ordering matters.
630    lock_shell_for_agent(&state, &req.shell_id);
631    let result = blockcontroller::send_input(
632        &req.shell_id,
633        blockcontroller::BlockInputUnion::resize(crate::backend::obj::TermSize {
634            rows: req.rows as i64,
635            cols: req.cols as i64,
636        }),
637        None,
638    );
639    match result {
640        Ok(()) => (StatusCode::OK, Json(PtyShellResizeResponse { resized: true, error: None })),
641        Err(e) => (StatusCode::OK, Json(PtyShellResizeResponse { resized: false, error: Some(e) })),
642    }
643}
644
645/// `POST /api/v1/ptyshell/read` — read back the shell's raw output tail.
646///
647/// Reads the block's `term` file straight from `FileStore` — the same file
648/// `blockcontroller::shell::lifecycle`'s real PTY read loop write-throughs
649/// to (`handle_append_block_file(..., "term", ..., filestore, ...)`,
650/// `SPEC_TERMINAL_SCROLLBACK_PERSISTENCE_2026_07_23.md` §2.1). Deliberately
651/// NOT the optimized `blockfile:read_range` path (byte-offset index,
652/// cross-channel global-transcript fallback, and a different filename —
653/// "output" — used for agent CLI transcripts, a different content stream
654/// entirely), which solves problems this endpoint doesn't have: a PTY shell
655/// created through this API is always local and short-lived, so a plain
656/// whole-file read + in-memory tail is simpler and correct. This is a raw
657/// line log, not a rendered-screen snapshot — see the type's own doc
658/// comment in `api_types.rs`.
659pub(super) async fn handle_pty_shell_read(
660    State(state): State<AppState>,
661    Json(req): Json<PtyShellReadRequest>,
662) -> impl IntoResponse {
663    if !is_owned_by_agent(&state.mstore, &req.shell_id, &req.agent_block_id) {
664        return (
665            StatusCode::OK,
666            Json(PtyShellReadResponse { content: String::new(), truncated: false }),
667        )
668            .into_response();
669    }
670    let content = match state.filestore.read_file(&req.shell_id, "term") {
671        Ok(Some(bytes)) => String::from_utf8_lossy(&bytes).to_string(),
672        Ok(None) => String::new(),
673        Err(e) => {
674            return (
675                StatusCode::INTERNAL_SERVER_ERROR,
676                Json(json!({ "error": format!("ptyshell.read: {e}") })),
677            )
678                .into_response();
679        }
680    };
681    let tail_lines = req.tail_lines.unwrap_or(200).max(1) as usize;
682    let lines: Vec<&str> = content.lines().collect();
683    let start = lines.len().saturating_sub(tail_lines);
684    let truncated = start > 0;
685    let tail = lines[start..].join("\n");
686    (StatusCode::OK, Json(PtyShellReadResponse { content: tail, truncated })).into_response()
687}
688
689/// `POST /api/v1/ptyshell/status` — query whether the shell is still running.
690pub(super) async fn handle_pty_shell_status(
691    State(state): State<AppState>,
692    Json(req): Json<PtyShellStatusRequest>,
693) -> impl IntoResponse {
694    if !is_owned_by_agent(&state.mstore, &req.shell_id, &req.agent_block_id) {
695        return (StatusCode::OK, Json(PtyShellStatusResponse { running: false, exit_code: None }));
696    }
697    match blockcontroller::get_block_controller_status(&req.shell_id) {
698        Some(status) => {
699            let running = status.shellprocstatus == blockcontroller::STATUS_RUNNING;
700            let exit_code = (status.shellprocstatus == blockcontroller::STATUS_DONE)
701                .then_some(status.shellprocexitcode);
702            (StatusCode::OK, Json(PtyShellStatusResponse { running, exit_code }))
703        }
704        // No controller registered — unknown id or already torn down via
705        // ptyshell/stop. Same "plain not-running answer, not an error"
706        // contract ShellStatus already documents.
707        None => (StatusCode::OK, Json(PtyShellStatusResponse { running: false, exit_code: None })),
708    }
709}
710
711/// `POST /api/v1/ptyshell/stop` — release the agent's lock immediately,
712/// without waiting for `AGENT_LOCK_WINDOW_MS` to lapse on its own.
713///
714/// Does not touch the process or the block at all (see
715/// `PtyShellStopResponse`'s doc comment in `api_types.rs` for why: this
716/// shell is a shared, pane-scoped resource now, not a private one this API
717/// owns outright).
718pub(super) async fn handle_pty_shell_stop(
719    State(state): State<AppState>,
720    Json(req): Json<PtyShellStopRequest>,
721) -> impl IntoResponse {
722    if !is_owned_by_agent(&state.mstore, &req.shell_id, &req.agent_block_id) {
723        return (StatusCode::OK, Json(PtyShellStopResponse { released: false }));
724    }
725    // Released in memory first — that's the copy `controllerinput` enforces
726    // against, so the human's keyboard is live again the instant this
727    // returns, not once the meta write commits and the frontend hears about
728    // it. The return value is the authoritative "was a lease actually held",
729    // for the same reason.
730    let was_locked = blockcontroller::agent_lock::release(&req.shell_id);
731
732    let mut meta = crate::backend::obj::MetaMapType::new();
733    meta.insert(META_KEY_AGENT_LOCK_UNTIL.to_string(), serde_json::Value::Null);
734    let released = broadcast_meta_update(&state, &req.shell_id, &meta).is_ok() && was_locked;
735
736    tracing::info!(block_id = %req.shell_id, released, "ptyshell.stop: released lock");
737    (StatusCode::OK, Json(PtyShellStopResponse { released }))
738}
739
740/// The 409 for a pane whose agent shell is running (or starting, created by
741/// this run) on another connection than `connection`, checked without
742/// touching the shell; the same cases `try_attach_to_existing_shell` refuses.
743fn running_shell_conflict(state: &AppState, agent_block_id: &str, connection: &str) -> Option<axum::response::Response> {
744    let shell_id = state
745        .mstore
746        .get::<crate::backend::obj::Block>(agent_block_id)
747        .ok()
748        .flatten()
749        .and_then(|b| b.meta.get(META_KEY_SHELL_SUBBLOCK_ID).and_then(|v| v.as_str()).map(str::to_string))?;
750    let shell = state.mstore.get::<crate::backend::obj::Block>(&shell_id).ok().flatten()?;
751    // Running, or created by this srv run and still starting (a concurrent
752    // create won the claim): either way `try_attach_to_existing_shell` would
753    // refuse it, so it is refused here too, before anything is asked.
754    let live = match blockcontroller::get_block_controller_status(&shell_id) {
755        Some(s) => s.shellprocstatus == blockcontroller::STATUS_RUNNING,
756        None => {
757            crate::backend::obj::meta_get_string(&shell.meta, super::http_shell::META_KEY_PTYSHELL_BOOT_ID, "")
758                == *state.boot_id
759        }
760    };
761    if !live {
762        return None;
763    }
764    super::http_shell::connection_conflict(&shell, connection)
765}