Skip to main content

agentmux_srv/server/app_api/
agent_io.rs

1use super::*;
2
3pub fn register(engine: &Arc<WshRpcEngine>, state: &AppState) {
4    register_agent_send(engine, state);
5    register_agent_stop(engine, state);
6    register_agent_status(engine, state);
7    register_agent_list(engine, state);
8    register_agent_output(engine, state);
9    register_agent_process_list(engine, state);
10    register_agent_tracked_blocks(engine, state);
11    register_swarm_other_instances(engine, state);
12    register_ambient_outcomes(engine);
13    register_agent_open_panes(engine, state);
14    register_agent_kill_process(engine, state);
15    register_agent_kill_tree(engine, state);
16}
17
18fn register_agent_process_list(engine: &Arc<WshRpcEngine>, state: &AppState) {
19    let process_tracker = state.process_tracker.clone();
20    engine.register_typed(COMMAND_AGENT_PROCESS_LIST, move |cmd: AgentProcessListCommand, _ctx| {
21        let process_tracker = process_tracker.clone();
22        async move {
23                let members = process_tracker.list_block(&cmd.block_id);
24                let confidence = process_tracker.confidence_of(&cmd.block_id);
25                let processes: Vec<AgentProcessInfo> = members
26                    .into_iter()
27                    .map(|m| AgentProcessInfo {
28                        pid: m.pid,
29                        command: m.command,
30                        rss_bytes: m.rss_bytes,
31                        started_at_ms: m.started_at_ms,
32                    })
33                    .collect();
34                Ok(AgentProcessListResult {
35                    block_id: cmd.block_id,
36                    confidence,
37                    processes,
38                })
39        }
40    });
41}
42
43fn register_agent_tracked_blocks(engine: &Arc<WshRpcEngine>, state: &AppState) {
44    let process_broker = state.process_broker.clone();
45    // No argument: `Value` takes whatever the caller sends.
46    engine.register_typed(COMMAND_AGENT_TRACKED_BLOCKS, move |_data: serde_json::Value, _ctx| {
47        let process_broker = process_broker.clone();
48        async move {
49                // Was: `process_tracker.list_all_blocks()` unioned with
50                // `reactive::get_global_handler().list_active_blocks()` — two
51                // structurally different, independently-populated registries
52                // concatenated with no reconciliation (a provider-chain
53                // anti-pattern — see
54                // docs/specs/REPORT_PROCESS_ARCHITECTURE_STATE_AND_RETHINK_2026_07_22.md
55                // §1/§3.0.2). `ProcessBroker::list_agent_panes()` sources
56                // discovery from `blockcontroller::get_all_controllers()`
57                // instead (authoritative for every controller type, closing
58                // the coverage gap the old `process_tracker`-only half had
59                // for `shell`/`acp` blocks) filtered down to agent panes —
60                // `list()` alone would also include plain terminals, which
61                // this RPC's contract has never included (reagent/codex P1).
62                let block_ids: Vec<String> = process_broker
63                    .list_agent_panes()
64                    .into_iter()
65                    .map(|status| status.block_id)
66                    .collect();
67                Ok(AgentTrackedBlocksResult { block_ids })
68        }
69    });
70}
71
72/// `swarm.other-instances`: this machine's other channels (the host-global shared
73/// registry) and the LAN hosts (the LAN discovery peer list), with their agents'
74/// names (Phases 1 and 2 of SPEC_SWARM_OTHER_HOSTS_AND_CHANNELS_2026_10_02.md)
75/// and, where their instance reports it, their state (Phase 4). No content, no
76/// credentials and no addresses.
77fn register_swarm_other_instances(engine: &Arc<WshRpcEngine>, state: &AppState) {
78    let hostname = state.hostname.clone();
79    let own_url = state.local_web_url.clone();
80    let lan_discovery = state.lan_discovery.clone();
81    let http = state.http_client.clone();
82    // The response stays `Value`: the frontend models the snapshot with its own
83    // types (swarm-remote.ts), which the stub keeps (generated-manifest.json `returns`).
84    engine.register_typed(COMMAND_SWARM_OTHER_INSTANCES, move |_data: serde_json::Value, _ctx| {
85        let hostname = hostname.clone();
86        let own_url = own_url.clone();
87        let lan_peers = lan_discovery.get_instances();
88        let http = http.clone();
89        async move {
90                // A directory read; off the async runtime's worker threads.
91                let entries = tokio::task::spawn_blocking(|| {
92                    crate::registry::resolve_shared_reactive_dir()
93                        .map(|dir| crate::backend::reactive::registry::list_all_shared(&dir))
94                        .unwrap_or_default()
95                })
96                .await
97                .unwrap_or_default();
98                let own_addrs = crate::backend::lan_listeners::cached_local_addresses();
99                let own_channel = crate::backend::reactive::registry::local_channel_id();
100                let now_ms = agentmux_common::time::now_ms_u64();
101                let targets = crate::backend::swarm_remote::channel_status_targets(&entries, &own_channel, &own_url, now_ms);
102                let statuses = crate::backend::swarm_remote::channel_statuses(&http, &targets).await;
103                let snapshot = crate::backend::swarm_remote::snapshot(
104                    &entries,
105                    &statuses,
106                    &lan_peers,
107                    &own_addrs,
108                    &hostname,
109                    &own_channel,
110                    &own_url,
111                    now_ms,
112                );
113                serde_json::to_value(&snapshot).map_err(|e| format!("swarm.other-instances: {e}"))
114        }
115    });
116}
117
118/// `ambient.outcomes`: how ambient calls have ended since srv started, so "the
119/// swarm has been blank for an hour" is visible without a database copy
120/// (SPEC_AMBIENT_SWARM_SUMMARY_HARDENING_2026_10_02.md section 5.8).
121fn register_ambient_outcomes(engine: &Arc<WshRpcEngine>) {
122    // `Value` response, as for swarm.other-instances: the frontend keeps its own type.
123    engine.register_typed(COMMAND_AMBIENT_OUTCOMES, |_data: serde_json::Value, _ctx| async move {
124        serde_json::to_value(crate::ambient::outcome::snapshot()).map_err(|e| format!("ambient.outcomes: {e}"))
125    });
126}
127
128/// `agent.open-panes`: every agent pane in this instance, wherever it is.
129/// A renderer only knows the panes in its own window, so "is this agent
130/// open?" asked of a renderer misses one in a floating or other window —
131/// My agents then offered a reattach for an agent that was live, and the
132/// second pane could never run (SPEC_AGENT_SAME_PROCESS_DUPLICATE_PANE_
133/// RECOVERY_2026_10_01.md §2 G0). Same source as `agent.tracked-blocks`.
134fn register_agent_open_panes(engine: &Arc<WshRpcEngine>, state: &AppState) {
135    let process_broker = state.process_broker.clone();
136    let mstore = state.mstore.clone();
137    engine.register_typed(COMMAND_AGENT_OPEN_PANES, move |_data: serde_json::Value, _ctx| {
138        let process_broker = process_broker.clone();
139        let mstore = mstore.clone();
140        async move {
141                let block_ids: Vec<String> = process_broker
142                    .list_agent_panes()
143                    .into_iter()
144                    .map(|status| status.block_id)
145                    .collect();
146                let panes = tokio::task::spawn_blocking(move || open_agent_panes(&mstore, &block_ids))
147                    .await
148                    .map_err(|e| format!("agent.open-panes: {e}"))?;
149                Ok(AgentOpenPanesResult { panes })
150        }
151    });
152}
153
154/// Where each of `block_ids` is open: its agent, tab, and the windows
155/// showing its workspace. The agent is resolved like the rest of srv
156/// resolves a block's agent (`instance_get_active_for_block`: `agentId`,
157/// the legacy `agent:id`, a template-launched row, the latest launch on the
158/// block) so a pane from an older build is not reported closed (Codex P1 on
159/// #4127); the raw meta id is the fallback when that finds no row. Blocks
160/// with no resolvable agent or no parent tab are skipped. Workspaces and
161/// windows are read once for the whole list.
162pub(crate) fn open_agent_panes(store: &Store, block_ids: &[String]) -> Vec<AgentOpenPane> {
163    let workspaces = store.get_all::<Workspace>().unwrap_or_default();
164    let windows = store.get_all::<obj::Window>().unwrap_or_default();
165    let mut panes: Vec<AgentOpenPane> = block_ids
166        .iter()
167        .filter_map(|block_id| {
168            let block = store.get::<Block>(block_id).ok().flatten()?;
169            let agent_id = match store.instance_get_active_for_block(block_id) {
170                Ok(Some(row)) if !row.id.is_empty() => row.id,
171                _ => {
172                    let meta_id = obj::meta_get_string(&block.meta, "agentId", "");
173                    if meta_id.is_empty() {
174                        obj::meta_get_string(&block.meta, "agent:id", "")
175                    } else {
176                        meta_id
177                    }
178                }
179            };
180            if agent_id.is_empty() {
181                return None;
182            }
183            let tab_id = block.parentoref.strip_prefix("tab:")?.to_string();
184            let tab = store.get::<Tab>(&tab_id).ok().flatten()?;
185            let mut window_ids: Vec<String> = workspaces
186                .iter()
187                .find(|w| w.tabids.contains(&tab_id) || w.pinnedtabids.contains(&tab_id))
188                .map(|ws| {
189                    windows
190                        .iter()
191                        .filter(|w| w.workspaceid == ws.oid)
192                        .map(|w| w.oid.clone())
193                        .collect()
194                })
195                .unwrap_or_default();
196            window_ids.sort();
197            Some(AgentOpenPane { block_id: block_id.clone(), agent_id, tab_id, tab_name: tab.name, window_ids })
198        })
199        .collect();
200    panes.sort_by(|a, b| a.block_id.cmp(&b.block_id));
201    panes
202}
203
204fn register_agent_kill_process(engine: &Arc<WshRpcEngine>, state: &AppState) {
205    let process_tracker = state.process_tracker.clone();
206    engine.register_typed(COMMAND_AGENT_KILL_PROCESS, move |cmd: AgentKillProcessCommand, _ctx| {
207        let process_tracker = process_tracker.clone();
208        async move {
209                tracing::info!(
210                    block_id = %cmd.block_id,
211                    pid = cmd.pid,
212                    "agent.kill-process"
213                );
214                let ok = process_tracker.kill_pid(&cmd.block_id, cmd.pid);
215                Ok(AgentKillResult { ok })
216        }
217    });
218}
219
220fn register_agent_kill_tree(engine: &Arc<WshRpcEngine>, state: &AppState) {
221    let process_tracker = state.process_tracker.clone();
222    engine.register_typed(COMMAND_AGENT_KILL_TREE, move |cmd: AgentKillTreeCommand, _ctx| {
223        let process_tracker = process_tracker.clone();
224        async move {
225                tracing::info!(block_id = %cmd.block_id, "agent.kill-tree");
226                let ok = process_tracker.kill_tree(&cmd.block_id);
227                Ok(AgentKillResult { ok })
228        }
229    });
230}
231
232/// The environment an `agent.send` spawn is given: `cmd:env` through
233/// `build_persistent_spawn_env`, exactly as the `agentinput` path builds it
234/// — identity gate, MuxBus token, wrapper auth, agent identity (UID + token
235/// for a row-backed block, `AGENTMUX_AGENT_ID` from `agentName` when
236/// `cmd:env` lacks it), git identity, tools PATH. Identity M4b-1 (spec
237/// §6.5.8); factored out so the carriage is testable.
238pub(crate) async fn agent_send_spawn_env(
239    mstore: Arc<crate::backend::storage::store::Store>,
240    id_store: Arc<crate::backend::storage::store::Store>,
241    identity_store: Arc<crate::backend::storage::store::Store>,
242    broker: Option<Arc<crate::backend::mps::Broker>>,
243    block: &Block,
244    block_id: &str,
245    auth_key: &str,
246) -> Result<std::collections::HashMap<String, String>, crate::identity::resolver::SpawnGateError> {
247    let env_vars = crate::backend::blockcontroller::cmd_env_of(&block.meta);
248    crate::server::agent_handlers::input::build_persistent_spawn_env(
249        mstore,
250        id_store,
251        identity_store,
252        broker,
253        &block.meta,
254        block_id,
255        auth_key,
256        env_vars,
257    )
258    .await
259}
260
261fn register_agent_send(engine: &Arc<WshRpcEngine>, state: &AppState) {
262    let mstore = state.mstore.clone();
263    let id_store = state.id_store.clone();
264    let identity_store = state.identity_store.clone();
265    let broker = state.broker.clone();
266    let container_manager = state.container_manager.clone();
267    let filestore = state.filestore.clone();
268    // codex P1, PR #2802: needed to persist_last_failure/publish
269    // EVENT_AGENT_FAILURE for a pre-spawn identity gate refusal — see the
270    // same fix in agent_handlers/input.rs's agentinput handler.
271    let event_bus = state.event_bus.clone();
272    // Identity M4b-1: `build_persistent_spawn_env` re-injects the wrapper's
273    // auth key, as `agentinput` does.
274    let auth_key = state.auth_key.clone();
275
276    engine.register_typed(COMMAND_AGENT_SEND, move |cmd: CommandAgentSendData, _ctx| {
277        let mstore = mstore.clone();
278        let id_store = id_store.clone();
279        let identity_store = identity_store.clone();
280        let broker = broker.clone();
281        let container_manager = container_manager.clone();
282        let filestore = filestore.clone();
283        let event_bus = event_bus.clone();
284        let auth_key = auth_key.clone();
285        async move {
286                tracing::info!(block_id = %cmd.block_id, "agent.send");
287
288                let ctrl = blockcontroller::get_controller(&cmd.block_id)
289                    .ok_or_else(|| format!("NOT_RUNNING: no controller for block {}", cmd.block_id))?;
290
291                // Re-read spawn config from block metadata (same pattern as agentinput)
292                let block: Block = mstore
293                    .get(&cmd.block_id)
294                    .map_err(|e| format!("agent.send: {e}"))?
295                    .ok_or_else(|| format!("BLOCK_NOT_FOUND: {}", cmd.block_id))?;
296
297                let cli_command = obj::meta_get_string(&block.meta, "cmd", "claude");
298                let cli_args: Vec<String> = match block.meta.get("cmd:args") {
299                    Some(serde_json::Value::Array(arr)) => arr
300                        .iter()
301                        .filter_map(|v| v.as_str().map(|s| s.to_string()))
302                        .collect(),
303                    _ => vec![],
304                };
305                // Same backstop as `agentinput`: this path has no frontend rebuild.
306                let cli_args = crate::backend::agent_runtime::with_runtime_flags(&block.meta, cli_args);
307                let working_dir = obj::meta_get_string(&block.meta, "cmd:cwd", "");
308                // Identity M4b-1 (spec §6.5.8): the same env builder as
309                // `agentinput`, so a row-backed block's process carries its
310                // UID + token — and the wrapper auth, MuxBus, identity and
311                // PATH vars `agentinput` gives — instead of a hand copy that
312                // had only the identity gate. Same gate error, same arm below.
313                let mut env_vars = match agent_send_spawn_env(
314                    mstore.clone(),
315                    id_store.clone(),
316                    identity_store.clone(),
317                    Some(broker.clone()),
318                    &block,
319                    &cmd.block_id,
320                    &auth_key,
321                )
322                .await
323                {
324                    Ok(env) => env,
325                    Err(gate) => {
326                        let error_frame = serde_json::json!({
327                            "type": "result",
328                            "is_error": true,
329                            "subtype": "error_during_execution",
330                            "error": {"message": format!("[AgentMux] {gate}")}
331                        })
332                        .to_string();
333                        // Some(filestore): the frame must be PERSISTED to the
334                        // block file, not just live-broadcast — otherwise the
335                        // error vanishes on pane reload/reconnect (reagent P1,
336                        // PR #2164 round 2). And to the agent's global zone,
337                        // which is what a pane with an agentId reloads from
338                        // (Phase 5a-3c).
339                        let global_zone = crate::backend::blockcontroller::shell::resolve_global_output_zone(
340                            &Some(mstore.clone()),
341                            &cmd.block_id,
342                        );
343                        crate::backend::blockcontroller::shell::handle_append_block_file(
344                            &broker,
345                            &cmd.block_id,
346                            crate::backend::blockcontroller::subprocess::SUBPROCESS_OUTPUT_SUBJECT,
347                            format!("{error_frame}\n").as_bytes(),
348                            Some(&filestore),
349                            global_zone.as_deref(),
350                        );
351                        // codex P1, PR #2802: same fix as agent_handlers/
352                        // input.rs's agentinput handler — the frame above
353                        // only lands in the block's raw output log, which
354                        // the recovery banner does NOT read from. Classify
355                        // + persist + publish so the structured card
356                        // (agent:last_failure meta + EVENT_AGENT_FAILURE)
357                        // populates for this pre-spawn refusal too.
358                        let gate_failure = crate::agents::failure::classify(
359                            None,
360                            None,
361                            &gate.to_string(),
362                            None,
363                        );
364                        crate::backend::blockcontroller::core::persist_last_failure(
365                            &cmd.block_id,
366                            Some(&gate_failure),
367                            &Some(mstore.clone()),
368                            &Some(event_bus.clone()),
369                        );
370                        broker.publish_event::<crate::backend::mps_events::AgentFailed>(&gate_failure, vec![format!("block:{}", cmd.block_id)], 1);
371                        return Err(format!("identity spawn gate: {gate}"));
372                    }
373                };
374                let session_id_field = obj::meta_get_string(
375                    &block.meta, "agent:session_id_field", "session_id",
376                );
377
378                // Dispatch to persistent or subprocess controller
379                let mut session_id = None;
380                // App Server owns a persistent thread/turn process and has its own
381                // typed protocol path — mirrors the websocket agentinput dispatch in
382                // agent_handlers/input.rs, which was already updated for this
383                // controller type. Without this branch, an app-server-backed agent
384                // opened via agent.open could never receive a turn via agent.send
385                // (ReAgent P1, PR #3215).
386                if let Some(app_server_ctrl) = ctrl
387                    .as_any()
388                    .downcast_ref::<blockcontroller::app_server_controller::AppServerController>()
389                {
390                    app_server_ctrl.send_message(cmd.message)?;
391                } else if let Some(persistent_ctrl) = ctrl
392                    .as_any()
393                    .downcast_ref::<blockcontroller::persistent::PersistentSubprocessController>()
394                {
395                    // Container agents use per-turn docker exec — incompatible with a
396                    // long-lived persistent subprocess. Fail loudly instead of silently
397                    // spawning the CLI on the host.
398                    let agent_mode = obj::meta_get_string(&block.meta, "agentMode", "host");
399                    if agent_mode == "container" {
400                        return Err("container agents require a subprocess controller; this provider uses a persistent controller".to_string());
401                    }
402                    // Resume parity with the subprocess path: pass the resume
403                    // flag + captured session id so a respawn (e.g. after a
404                    // /model change) continues the same conversation.
405                    let resume_flag = obj::meta_get_string(
406                        &block.meta, "agent:resume_flag", "--resume",
407                    );
408                    let persisted_session_id = obj::meta_get_string(
409                        &block.meta, "agent:sessionid", "",
410                    );
411                    let config = blockcontroller::persistent::PersistentSpawnConfig {
412                        cli_command,
413                        cli_args,
414                        working_dir,
415                        env_vars,
416                        session_id_field,
417                        resume_flag,
418                        session_id: persisted_session_id,
419                        message_id: None,
420                    };
421                    persistent_ctrl.send_message(cmd.message, config)?;
422                    session_id = persistent_ctrl.session_id();
423                } else if let Some(subprocess_ctrl) = ctrl
424                    .as_any()
425                    .downcast_ref::<blockcontroller::subprocess::SubprocessController>()
426                {
427                    let resume_flag = obj::meta_get_string(
428                        &block.meta, "agent:resume_flag", "--resume",
429                    );
430                    let resume_strategy = obj::meta_get_string(
431                        &block.meta,
432                        "agent:resume_strategy",
433                        if session_id_field == "thread_id" {
434                            "codex-exec"
435                        } else if resume_flag.is_empty() {
436                            "none"
437                        } else {
438                            "flag"
439                        },
440                    );
441                    let prompt_arg_flag =
442                        obj::meta_get_string(&block.meta, "agent:prompt_arg_flag", "");
443                    // Picker reattach (parallel of the websocket-path
444                    // logic): hydrate the persisted session id from
445                    // block meta so spawn_turn appends --resume <sid>
446                    // on the FIRST turn after reattach.
447                    let persisted_session_id = obj::meta_get_string(
448                        &block.meta, "agent:sessionid", "",
449                    );
450
451                    // Container agent branch: use Docker socket API exec (P1a: no
452                    // secrets in argv). Host agent branch: regular CLI subprocess.
453                    let agent_mode = obj::meta_get_string(&block.meta, "agentMode", "host");
454                    // Cross-process session-lease key (registry::LeaseStore) —
455                    // read once for both branches below. Only the host
456                    // branch's spawn_turn enforces it in this PR; the
457                    // container branch's field is unused for now.
458                    let instance_id = obj::meta_get_string(&block.meta, "agentId", "");
459                    if agent_mode == "container" {
460                        let cm = container_manager.get().await
461                            .ok_or_else(|| "Docker not available on this host; cannot start container agent".to_string())?;
462                        let container_image = obj::meta_get_string(&block.meta, "agent:container_image", "");
463                        // Use agentId (UUID) — always valid as a Docker name; display names can have spaces.
464                        let agent_id = obj::meta_get_string(&block.meta, "agentId", "");
465                        let container_name = crate::backend::container::container_name_for_slug(&agent_id);
466                        let volumes_json = obj::meta_get_string(&block.meta, "agent:container_volumes", "[]");
467                        let volumes: Vec<String> = serde_json::from_str(&volumes_json).unwrap_or_default();
468
469                        // Same credential/workspace mounts the `agentinput` path
470                        // builds — see its comment. `CLAUDE_CONFIG_DIR` is a host
471                        // path stripped by CONTAINER_ENV_DENYLIST, so mounting it
472                        // is the only way the in-container CLI sees credentials.
473                        let mount_spec = crate::backend::container::ContainerMountSpec {
474                            claude_config_host_dir: env_vars
475                            .get("CLAUDE_CONFIG_DIR")
476                            .and_then(|d| crate::backend::container::credentials_dir_if_file_backed(d)),
477                            workspace_host_dir: Some(working_dir.clone()).filter(|d| !d.is_empty()),
478                        };
479
480                        // Same AGENTMUX_LOCAL_URL rewrite the `agentinput` path
481                        // does — see its comment (input.rs). Without this a
482                        // container agent opened via MuxAPI path gets no
483                        // sidecar URL at all, same as the RPC path before this fix.
484                        if let Ok(local_url) = std::env::var("AGENTMUX_LOCAL_URL") {
485                            env_vars.insert(
486                                "AGENTMUX_LOCAL_URL".to_string(),
487                                crate::backend::container::rewrite_local_url_for_container(&local_url),
488                            );
489                        }
490
491                        let container_command = obj::meta_get_string(
492                            &block.meta, "agent:container_command", "claude",
493                        );
494                        let agent_provider = obj::meta_get_string(&block.meta, "agentProvider", "claude");
495
496                        // Start the container and make sure the provider CLI is
497                        // installed in it; a failure is written to the pane first.
498                        crate::server::agent_handlers::container_turn::prepare(
499                            crate::server::agent_handlers::container_turn::ContainerTurnPrep {
500                                cm: &cm,
501                                block_id: &cmd.block_id,
502                                container_name: &container_name,
503                                image: &container_image,
504                                volumes: &volumes,
505                                mount_spec: &mount_spec,
506                                provider_id: &agent_provider,
507                                container_command: &container_command,
508                                broker: &broker,
509                                filestore: &filestore,
510                                mstore: &mstore,
511                            },
512                        )
513                        .await?;
514
515
516                        tracing::info!(
517                            container = %container_name,
518                            image = %crate::backend::container_image::resolve_container_image(&container_image),
519                            "container agent turn: bollard exec (env via Docker socket, not argv)",
520                        );
521
522                        // Env is passed via CreateExecOptions.env (Docker socket API),
523                        // NOT as -e KEY=VALUE argv args — this prevents CWE-214 exposure.
524                        // spawn_container_turn filters config.env_vars (denylist) per
525                        // turn, so cmd:cwd (host path) and host-path vars never reach
526                        // the container, and each queued turn uses its own env.
527
528                        // Base cmd: [container_command, ...cli_args]. The command
529                        // is the provider CLI resolved INSIDE the image (on PATH,
530                        // e.g. `claude`) — NOT `cli_command`/`cmd`, which is the
531                        // host-resolved absolute npm path and does not exist in the
532                        // container (docker exec would fail "no such file or
533                        // directory"). cli_args are format flags + provider flags —
534                        // no host paths, safe as-is. spawn_container_turn appends
535                        // --resume <sid> internally.
536                        let mut base_cmd = vec![container_command];
537                        base_cmd.extend(cli_args);
538
539                        let config = blockcontroller::subprocess::SubprocessSpawnConfig {
540                            cli_command: String::new(), // unused by spawn_container_turn
541                            cli_args: vec![],           // unused by spawn_container_turn
542                            working_dir: String::new(), // unused — container has own cwd
543                            env_vars,
544                            message: cmd.message,
545                            resume_flag,
546                            resume_strategy,
547                            prompt_arg_flag,
548                            session_id_field,
549                            message_id: None,
550                            session_id: if persisted_session_id.is_empty() {
551                                None
552                            } else {
553                                Some(persisted_session_id)
554                            },
555                            instance_id: instance_id.clone(),
556                        };
557                        subprocess_ctrl.spawn_container_turn(cm.clone(), container_name, base_cmd, config)?;
558                    } else {
559                        // Host agent: regular CLI subprocess (env set on child process, not in argv).
560                        let config = blockcontroller::subprocess::SubprocessSpawnConfig {
561                            cli_command,
562                            cli_args,
563                            working_dir,
564                            env_vars,
565                            message: cmd.message,
566                            resume_flag,
567                            resume_strategy,
568                            prompt_arg_flag,
569                            session_id_field,
570                            message_id: None,
571                            session_id: if persisted_session_id.is_empty() {
572                                None
573                            } else {
574                                Some(persisted_session_id)
575                            },
576                            instance_id,
577                        };
578                        subprocess_ctrl.spawn_turn(config)?;
579                    }
580                } else if let Some(acp_ctrl) = ctrl
581                    .as_any()
582                    .downcast_ref::<blockcontroller::acp::AcpController>()
583                {
584                    // If the agent exited, start it again; the message waits
585                    // for the new session.
586                    acp_ctrl.ensure_started(block.meta.clone())?;
587                    acp_ctrl.send_message(cmd.message, None)?;
588                } else {
589                    return Err("NOT_RUNNING: controller type not supported".to_string());
590                }
591
592                Ok(AgentSendResult {
593                    block_id: cmd.block_id,
594                    status: "running".to_string(),
595                    session_id,
596                })
597        }
598    });
599}
600
601/// Core `agent.stop` logic, factored out so `fleet.bulk-stop`
602/// (`server/app_api/fleet.rs`) can loop it per target instead of
603/// duplicating the controller-lookup/stop/status-read sequence.
604pub(crate) async fn stop_one_agent_block(
605    state: &AppState,
606    block_id: &str,
607    signal: Option<&str>,
608    stop_background: Option<bool>,
609) -> Result<AgentStopResult, String> {
610    tracing::info!(block_id = %block_id, signal = ?signal, ?stop_background, "agent.stop");
611
612    let force = matches!(signal, Some("SIGKILL") | Some("SIGTERM"));
613    crate::sagas::agent_teardown::stop(state, block_id, !force, stop_background).await?;
614
615    let exit_code = blockcontroller::get_block_controller_status(block_id)
616        .map(|s| s.shellprocexitcode);
617
618    Ok(AgentStopResult {
619        block_id: block_id.to_string(),
620        status: "done".to_string(),
621        exit_code,
622    })
623}
624
625fn register_agent_stop(engine: &Arc<WshRpcEngine>, state: &AppState) {
626    let state = state.clone();
627    engine.register_typed(COMMAND_AGENT_STOP_API, move |cmd: CommandAgentStopApiData, _ctx| {
628        let state = state.clone();
629        async move {
630                stop_one_agent_block(&state, &cmd.block_id, cmd.signal.as_deref(), cmd.stop_background).await
631        }
632    });
633}
634
635fn register_agent_status(engine: &Arc<WshRpcEngine>, state: &AppState) {
636    let mstore = state.mstore.clone();
637
638    engine.register_typed(COMMAND_AGENT_STATUS, move |cmd: CommandAgentStatusData, _ctx| {
639        let mstore = mstore.clone();
640        async move {
641                let block: Block = mstore
642                    .get(&cmd.block_id)
643                    .map_err(|e| format!("agent.status: {e}"))?
644                    .ok_or_else(|| format!("BLOCK_NOT_FOUND: {}", cmd.block_id))?;
645
646                let agent_id = obj::meta_get_string(&block.meta, "agentId", "");
647                let provider = obj::meta_get_string(&block.meta, "agentProvider", "");
648                let controller_type = obj::meta_get_string(&block.meta, "controller", "");
649
650                let runtime = blockcontroller::get_block_controller_status(&cmd.block_id);
651                let status = runtime.as_ref()
652                    .map(|s| s.shellprocstatus.clone())
653                    .unwrap_or_else(|| "init".to_string());
654                let exit_code = runtime.as_ref()
655                    .and_then(|s| if s.shellprocstatus == "done" { Some(s.shellprocexitcode) } else { None });
656                let pid = None; // PID not currently exposed in status struct
657
658                // Get session ID from block meta
659                let session_id = block.meta.get("agent:sessionid")
660                    .and_then(|v| v.as_str())
661                    .map(|s| s.to_string());
662
663                Ok(AgentStatusResult {
664                    block_id: cmd.block_id,
665                    agent_id,
666                    provider,
667                    controller_type,
668                    status,
669                    session_id,
670                    pid,
671                    exit_code,
672                })
673        }
674    });
675}
676
677fn register_agent_list(engine: &Arc<WshRpcEngine>, state: &AppState) {
678    let mstore = state.mstore.clone();
679
680    // No argument: `Value` takes whatever the caller sends.
681    engine.register_typed(COMMAND_AGENT_LIST, move |_data: serde_json::Value, _ctx| {
682        let mstore = mstore.clone();
683        async move {
684                let tabs: Vec<Tab> = mstore.get_all::<Tab>()
685                    .map_err(|e| format!("agent.list: {e}"))?;
686
687                let mut agents = Vec::new();
688                for tab in &tabs {
689                    for block_id in &tab.blockids {
690                        if let Ok(Some(block)) = mstore.get::<Block>(block_id) {
691                            let agent_id = obj::meta_get_string(&block.meta, "agentId", "");
692                            if agent_id.is_empty() {
693                                continue;
694                            }
695                            let provider = obj::meta_get_string(&block.meta, "agentProvider", "");
696                            let status = blockcontroller::get_block_controller_status(block_id)
697                                .map(|s| s.shellprocstatus)
698                                .unwrap_or_else(|| "init".to_string());
699                            let session_id = block.meta.get("agent:sessionid")
700                                .and_then(|v| v.as_str())
701                                .map(|s| s.to_string());
702
703                            agents.push(AgentListEntry {
704                                block_id: block_id.clone(),
705                                tab_id: tab.oid.clone(),
706                                agent_id,
707                                provider,
708                                status,
709                                session_id,
710                            });
711                        }
712                    }
713                }
714
715                Ok(AgentListResult { agents })
716        }
717    });
718}
719
720fn register_agent_output(engine: &Arc<WshRpcEngine>, state: &AppState) {
721    let broker = state.broker.clone();
722
723    engine.register_typed(COMMAND_AGENT_OUTPUT, move |cmd: CommandAgentOutputData, _ctx| {
724        let broker = broker.clone();
725        async move {
726                let scope = format!("block:{}", cmd.block_id);
727                let max = cmd.max_lines.unwrap_or(1000);
728                let after = cmd.after_line.unwrap_or(0);
729
730                // Read persisted blockfile events from broker history
731                let mut all_lines: Vec<String> = Vec::new();
732                {
733                    let events = broker.read_event_history(
734                        crate::backend::mps::EVENT_BLOCK_FILE,
735                        &scope,
736                        max + after, // read enough to cover offset
737                    );
738                    for event in events {
739                        if let Some(ref data) = event.data {
740                            if let Some(data64) = data.get("data64").and_then(|v| v.as_str()) {
741                                if let Ok(bytes) = base64::engine::general_purpose::STANDARD
742                                    .decode(data64)
743                                {
744                                    let text = String::from_utf8_lossy(&bytes);
745                                    for line in text.lines() {
746                                        if !line.trim().is_empty() {
747                                            all_lines.push(line.to_string());
748                                        }
749                                    }
750                                }
751                            }
752                        }
753                    }
754                }
755
756                let total = all_lines.len();
757                let lines: Vec<String> = all_lines.into_iter()
758                    .skip(after)
759                    .take(max)
760                    .collect();
761                let has_more = after + lines.len() < total;
762
763                Ok(AgentOutputResult {
764                    block_id: cmd.block_id,
765                    lines,
766                    total_lines: total,
767                    has_more,
768                })
769        }
770    });
771}
772
773#[cfg(test)]
774mod tests {
775    use super::*;
776    use crate::backend::storage::agents::test_agent_def;
777    use crate::backend::storage::store::Store;
778
779    fn block_with(store: &Store, block_id: &str, agent_id: &str) -> Block {
780        let mut block = Block {
781            oid: block_id.to_string(),
782            parentoref: String::new(),
783            version: 0,
784            runtimeopts: None,
785            stickers: None,
786            meta: {
787                let mut m = obj::MetaMapType::new();
788                m.insert("view".to_string(), serde_json::json!("agent"));
789                m.insert("agentId".to_string(), serde_json::json!(agent_id));
790                m.insert("agentName".to_string(), serde_json::json!("AgentY"));
791                m.insert("cmd:env".to_string(), serde_json::json!({"KEEP_ME": "1"}));
792                m
793            },
794            subblockids: None,
795        };
796        store.insert(&mut block).unwrap();
797        block
798    }
799
800    async fn env_for(
801        store: &Arc<Store>,
802        block: &Block,
803    ) -> std::collections::HashMap<String, String> {
804        agent_send_spawn_env(
805            store.clone(),
806            store.clone(),
807            store.clone(),
808            None,
809            block,
810            &block.oid,
811            "test-key",
812        )
813        .await
814        .expect("no spawn gate for a provider without credentials")
815    }
816
817    /// Identity M4b-1 (spec §6.5.8): an `agent.send` spawn on a row-backed
818    /// block carries that row's UID and its own token — the env
819    /// `agentinput` builds — plus the block's `cmd:env` and the wrapper
820    /// auth key. Before M4b-1 it got `cmd:env` + identity injection only.
821    #[tokio::test]
822    async fn an_agent_send_spawn_carries_the_rows_uid_and_token() {
823        let store = Arc::new(Store::open_in_memory().unwrap());
824        store.apply_identity_schema_for_tests().unwrap();
825        // A provider with no credential class, so the spawn gate (which
826        // refuses an OAuth-class agent with no bound account) stays out of
827        // a test about identity carriage.
828        let mut def = test_agent_def(
829            "uid-send-y",
830            "AgentY",
831            "test-no-credentials",
832            "agent",
833            1,
834            "",
835        );
836        def.slug = "agenty".to_string();
837        store.agent_def_insert(&mut def).unwrap();
838        let block = block_with(&store, "block-send-y", "uid-send-y");
839
840        let env = env_for(&store, &block).await;
841        assert_eq!(
842            env.get("AGENTMUX_AGENT_UID").map(String::as_str),
843            Some("uid-send-y")
844        );
845        let token = store
846            .agent_token_load("uid-send-y")
847            .unwrap()
848            .expect("minted");
849        assert_eq!(env.get("AGENTMUX_AGENT_TOKEN"), Some(&token));
850        assert_eq!(
851            env.get("KEEP_ME").map(String::as_str),
852            Some("1"),
853            "cmd:env kept"
854        );
855        assert_eq!(
856            env.get("AGENTMUX_AUTH_KEY").map(String::as_str),
857            Some("test-key")
858        );
859    }
860
861    /// Plain-`gh` guard: a spawn built through `build_persistent_spawn_env` —
862    /// the path `agentinput`, eager resume and `agent.send` all share — points
863    /// `GH_CONFIG_DIR` at the agent's login-free dir, overriding a value the
864    /// block's `cmd:env` carries (e.g. a human's real gh config).
865    #[tokio::test]
866    async fn an_agent_send_spawn_overrides_gh_config_dir_with_the_guard_dir() {
867        let store = Arc::new(Store::open_in_memory().unwrap());
868        store.apply_identity_schema_for_tests().unwrap();
869        let mut def = test_agent_def("uid-gh-y", "AgentY", "test-no-credentials", "agent", 1, "");
870        def.slug = "agenty".to_string();
871        store.agent_def_insert(&mut def).unwrap();
872        let mut block = block_with(&store, "block-gh-y", "uid-gh-y");
873        block.meta.insert(
874            "cmd:env".to_string(),
875            serde_json::json!({"GH_CONFIG_DIR": "/home/human/.config/gh"}),
876        );
877        store.update(&mut block).unwrap();
878
879        let env = env_for(&store, &block).await;
880        let dir = std::path::PathBuf::from(env.get("GH_CONFIG_DIR").expect("always set"));
881        assert_eq!(dir.parent(), Some(crate::backend::gh_guard::guard_config_home().as_path()));
882        assert!(
883            dir.file_name().unwrap().to_string_lossy().starts_with("gh-"),
884            "{}",
885            dir.display()
886        );
887        assert_ne!(dir, std::path::PathBuf::from("/home/human/.config/gh"));
888    }
889
890    /// A block with no row (a provider-key pane) gets neither.
891    #[tokio::test]
892    async fn an_agent_send_spawn_without_a_row_carries_no_uid_or_token() {
893        let store = Arc::new(Store::open_in_memory().unwrap());
894        store.apply_identity_schema_for_tests().unwrap();
895        let block = block_with(&store, "block-send-none", "claude");
896        let env = env_for(&store, &block).await;
897        assert!(!env.contains_key("AGENTMUX_AGENT_UID"));
898        assert!(!env.contains_key("AGENTMUX_AGENT_TOKEN"));
899    }
900}
901
902#[cfg(test)]
903mod open_panes_tests {
904    use super::*;
905    use crate::backend::storage::store::Store;
906
907    fn insert_tab(store: &Store, name: &str) -> Tab {
908        let mut tab = Tab { oid: uuid::Uuid::new_v4().to_string(), name: name.to_string(), ..Default::default() };
909        store.insert(&mut tab).unwrap();
910        tab
911    }
912
913    fn insert_workspace(store: &Store, tab_ids: &[&str]) -> Workspace {
914        let mut ws = Workspace {
915            oid: uuid::Uuid::new_v4().to_string(),
916            tabids: tab_ids.iter().map(|t| t.to_string()).collect(),
917            ..Default::default()
918        };
919        store.insert(&mut ws).unwrap();
920        ws
921    }
922
923    fn insert_window(store: &Store, workspace_id: &str) -> obj::Window {
924        let mut w = obj::Window { oid: uuid::Uuid::new_v4().to_string(), workspaceid: workspace_id.to_string(), ..Default::default() };
925        store.insert(&mut w).unwrap();
926        w
927    }
928
929    fn insert_block(store: &Store, tab_id: &str, agent_id: Option<&str>) -> String {
930        let mut meta = obj::MetaMapType::new();
931        if let Some(a) = agent_id {
932            meta.insert("agentId".to_string(), serde_json::json!(a));
933        }
934        let mut block = Block {
935            oid: uuid::Uuid::new_v4().to_string(),
936            parentoref: format!("tab:{tab_id}"),
937            meta,
938            ..Default::default()
939        };
940        store.insert(&mut block).unwrap();
941        block.oid
942    }
943
944    /// The 2026-10-01 case: Korp torn off into a floating window (its own
945    /// workspace and `Window`), a second agent in the main window. Both are
946    /// reported, each with the window that shows it.
947    #[test]
948    fn lists_agent_panes_in_every_window_including_a_floating_one() {
949        let store = Store::open_in_memory().unwrap();
950        let main_tab = insert_tab(&store, "Tab 2");
951        let main_ws = insert_workspace(&store, &[&main_tab.oid]);
952        let main_win = insert_window(&store, &main_ws.oid);
953        let float_tab = insert_tab(&store, "");
954        let float_ws = insert_workspace(&store, &[&float_tab.oid]);
955        let float_win = insert_window(&store, &float_ws.oid);
956
957        let agent3 = insert_block(&store, &main_tab.oid, Some("agent3-def"));
958        let korp = insert_block(&store, &float_tab.oid, Some("korp-def"));
959
960        let panes = open_agent_panes(&store, &[agent3.clone(), korp.clone()]);
961        let korp_pane = panes.iter().find(|p| p.block_id == korp).expect("floating pane listed");
962        assert_eq!(korp_pane.agent_id, "korp-def");
963        assert_eq!(korp_pane.tab_id, float_tab.oid);
964        assert_eq!(korp_pane.window_ids, vec![float_win.oid]);
965        let a3 = panes.iter().find(|p| p.block_id == agent3).expect("main pane listed");
966        assert_eq!(a3.tab_name, "Tab 2");
967        assert_eq!(a3.window_ids, vec![main_win.oid]);
968    }
969
970    /// A pane from an older build carries only the legacy `agent:id` key.
971    #[test]
972    fn resolves_a_legacy_agent_id_pane_to_its_agent_row() {
973        let store = Store::open_in_memory().unwrap();
974        let mut def = crate::backend::storage::agents::test_agent_def(
975            "uid-legacy",
976            "Korp",
977            "test-no-credentials",
978            "agent",
979            1,
980            "",
981        );
982        store.agent_def_insert(&mut def).unwrap();
983        let tab = insert_tab(&store, "t");
984        insert_workspace(&store, &[&tab.oid]);
985        let mut meta = obj::MetaMapType::new();
986        meta.insert("agent:id".to_string(), serde_json::json!("uid-legacy"));
987        let mut block = Block {
988            oid: uuid::Uuid::new_v4().to_string(),
989            parentoref: format!("tab:{}", tab.oid),
990            meta,
991            ..Default::default()
992        };
993        store.insert(&mut block).unwrap();
994
995        let panes = open_agent_panes(&store, &[block.oid.clone()]);
996        assert_eq!(panes.len(), 1);
997        assert_eq!(panes[0].agent_id, "uid-legacy");
998    }
999
1000    #[test]
1001    fn skips_non_agent_and_orphan_blocks_and_reports_a_windowless_workspace() {
1002        let store = Store::open_in_memory().unwrap();
1003        let tab = insert_tab(&store, "t");
1004        insert_workspace(&store, &[&tab.oid]); // open in no window
1005        let terminal = insert_block(&store, &tab.oid, None);
1006        let agent = insert_block(&store, &tab.oid, Some("def"));
1007        let missing = "no-such-block".to_string();
1008
1009        let panes = open_agent_panes(&store, &[terminal, agent.clone(), missing]);
1010        assert_eq!(panes.len(), 1);
1011        assert_eq!(panes[0].block_id, agent);
1012        assert!(panes[0].window_ids.is_empty());
1013    }
1014}