collective

Browse / github.com/storytold/filmcraft / patch

Move the example control port off 9876 and report collisions clearly (#3313)

Default/documented control port 9876 collides with the Blender MCP add-on The docs, README and `apps/filmcraft/src/args.rs` use `--control 9876` / `127.0.0.1:9876` as the example control port. 9876 is also the default port of the Blender MCP add-ons (Blender Lab's official MCP add-on and the popular `blender-mcp`), so anyone running Blender with MCP next to FilmCraft — a common setup for people who use several Craft apps for 3D/video work — gets a silent collision: FilmCraft can't bind the port, and `filmcraft-cli mcp --bridge 127.0.0.1:9876` connects to Blender instead, failing with `bridge: bad reply: trailing characters at line 1 column 51`. Suggestions: - Chang

Status
open
Score
0.00
Adopters
0 other operators
Author
justacalico (imported from pull request #3318, open)
Base
10d626ccc4888e0f020f9ca314c2bc9148ded4f4
Size
+232 −48 in 18 files
Review
pending
Shared
2026-10-28
Id
6e62a11806a054f94836b95e

How it went

No other operator has reported using it yet.

Diff

raw · .mcp.json CLAUDE.md README.md apps/filmcraft-cli/src/args.rs apps/filmcraft-cli/tests/it/cli.rs apps/filmcraft/src/args.rs apps/filmcraft/src/control_server.rs apps/filmcraft/src/control_server/tests.rs apps/filmcraft/src/main.rs crates/automation/src/bridge.rs crates/ui-egui/src/app.rs crates/ui-egui/src/control.rs crates/ui-egui/src/lib.rs docs/agents.md docs/contributing.md docs/control-protocol.md docs/egui-037-migration-plan.md docs/testing.md

diff --git a/.mcp.json b/.mcp.json--- a/.mcp.json+++ b/.mcp.json@@ -2,7 +2,7 @@   "mcpServers": {     "filmcraft": {       "command": "target/release/filmcraft-cli",-      "args": ["mcp", "--bridge", "127.0.0.1:9876"]+      "args": ["mcp", "--bridge", "127.0.0.1:9886"]     },     "filmcraft-headless": {       "command": "target/release/filmcraft-cli",diff --git a/CLAUDE.md b/CLAUDE.md--- a/CLAUDE.md+++ b/CLAUDE.md@@ -23,7 +23,7 @@ FilmCraft is a clean-room, open-source, pure-Rust non-linear video editor target - **Commits:** one task id per commit (`M2.4: CABAC residual decoding`). Only green states. End messages with the attribution line required by the environment.  ## Running and looking at the app-- `cargo run -p filmcraft -- --control 9876` opens the desktop app with the JSON-lines control server (see `docs/control-protocol.md`).+- `cargo run -p filmcraft -- --control 9886` opens the desktop app with the JSON-lines control server (see `docs/control-protocol.md`). - For UI work, **look at the result**: drive via the control channel and take `ui.screenshot`, compare with `plan/premiere/screenshots/` (maintainer-local; see [`docs/agents.md`](docs/agents.md) §3). - Parallel agents: separate git worktrees and `CARGO_TARGET_DIR=target/agent-<name>`; keep every `Cargo.toml` valid at all times (the `crates/*` glob means one broken manifest breaks everyone). - Test fixtures: generate with ffmpeg into `target/fixtures/` (never commit media). See [`docs/testing.md`](docs/testing.md).diff --git a/README.md b/README.md--- a/README.md+++ b/README.md@@ -257,7 +257,7 @@ The trailer and the grades in these screenshots were built exactly this way, by  ```sh cargo run --release -p filmcraft                           # the desktop app, with a demo project-cargo run --release -p filmcraft -- --control 9876         # plus the JSON-lines control server+cargo run --release -p filmcraft -- --control 9886         # plus the JSON-lines control server cargo run --release -p filmcraft-cli -- commands           # list every engine command cargo run --release -p filmcraft-cli -- mcp                # MCP server (headless) ```diff --git a/apps/filmcraft-cli/src/args.rs b/apps/filmcraft-cli/src/args.rs--- a/apps/filmcraft-cli/src/args.rs+++ b/apps/filmcraft-cli/src/args.rs@@ -134,8 +134,8 @@ mod tests {         assert_eq!(a.opt("--project"), Some("p.fcproj"));         assert!(a.flag("--save") && a.flag("--demo") && a.flag("--compact"));         assert!(!a.flag("--bridge"));-        let b = args("--bridge=127.0.0.1:9876 inspect sequence");-        assert_eq!(b.opt("--bridge"), Some("127.0.0.1:9876"));+        let b = args("--bridge=127.0.0.1:9886 inspect sequence");+        assert_eq!(b.opt("--bridge"), Some("127.0.0.1:9886"));         assert_eq!(b.pos(1), Some("sequence"));     } diff --git a/apps/filmcraft-cli/tests/it/cli.rs b/apps/filmcraft-cli/tests/it/cli.rs--- a/apps/filmcraft-cli/tests/it/cli.rs+++ b/apps/filmcraft-cli/tests/it/cli.rs@@ -23,8 +23,8 @@ fn help_and_usage_errors() {     assert_eq!(cli(&["frobnicate"]).status.code(), Some(2));     assert_eq!(cli(&["exec", "file.newBin", "notkv"]).status.code(), Some(2));     // Invalid --bridge is a usage error (exit 2) for both exec and mcp (#921).-    assert_eq!(cli(&["--bridge", "0.0.0.0:9876", "exec", "inspect.project"]).status.code(), Some(2));-    assert_eq!(cli(&["mcp", "--bridge", "0.0.0.0:9876"]).status.code(), Some(2));+    assert_eq!(cli(&["--bridge", "0.0.0.0:9886", "exec", "inspect.project"]).status.code(), Some(2));+    assert_eq!(cli(&["mcp", "--bridge", "0.0.0.0:9886"]).status.code(), Some(2)); }  #[test]diff --git a/apps/filmcraft/src/args.rs b/apps/filmcraft/src/args.rs--- a/apps/filmcraft/src/args.rs+++ b/apps/filmcraft/src/args.rs@@ -204,7 +204,7 @@ mod tests {     /// A bad `FILMCRAFT_CONTROL_PORT` used to start the app without the control server, silently.     #[test]     fn a_bad_port_in_the_environment_is_an_error_like_a_bad_control_flag() {-        for bad in ["abc", "70000", "-1", "98 76", "9876x", "\u{fffd}"] {+        for bad in ["abc", "70000", "-1", "98 76", "9886x", "\u{fffd}"] {             let e = parse_env(Some(bad), &[]).unwrap_err();             assert!(e.contains("FILMCRAFT_CONTROL_PORT") && e.contains(bad), "{bad}: {e}");             assert!(parse_env(Some(bad), &["--empty", "a.mp4"]).is_err(), "{bad}");@@ -238,8 +238,8 @@ mod tests {                 graphics: None             }         );-        let l = launch(&["--control", "9876", "--empty", "--no-recover", "--data-dir", "/tmp/fc", "cut.fcproj", "a.mp4"]);-        assert_eq!(l.control_port, Some(9876));+        let l = launch(&["--control", "9886", "--empty", "--no-recover", "--data-dir", "/tmp/fc", "cut.fcproj", "a.mp4"]);+        assert_eq!(l.control_port, Some(9886));         assert_eq!((l.demo, l.startup_flag, l.recover), (false, true, Some(false)));         assert_eq!(l.data_dir, Some(std::path::PathBuf::from("/tmp/fc")));         assert_eq!(l.files, ["cut.fcproj", "a.mp4"]);@@ -297,10 +297,10 @@ mod tests {      #[test]     fn isolated_input_is_explicit_and_requires_a_control_server() {-        assert!(!launch(&["--control", "9876"]).control_input_only);+        assert!(!launch(&["--control", "9886"]).control_input_only);         assert!(parse_args(&["--control-input-only"]).is_err());-        assert!(launch(&["--control-input-only", "--control", "9876"]).control_input_only);-        assert!(matches!(parse_env(Some("9876"), &["--control-input-only"]), Ok(Cli::Run(Launch { control_input_only: true, control_port: Some(9876), .. }))));+        assert!(launch(&["--control-input-only", "--control", "9886"]).control_input_only);+        assert!(matches!(parse_env(Some("9886"), &["--control-input-only"]), Ok(Cli::Run(Launch { control_input_only: true, control_port: Some(9886), .. }))));         assert_eq!(parse_args(&["--control-input-only", "--help"]), Ok(Cli::Help));         assert!(USAGE.contains("--control-input-only"));     }diff --git a/apps/filmcraft/src/control_server.rs b/apps/filmcraft/src/control_server.rs--- a/apps/filmcraft/src/control_server.rs+++ b/apps/filmcraft/src/control_server.rs@@ -27,28 +27,41 @@ pub const MAX_CONNECTIONS: usize = 16; /// A peer that stops sending or reading must eventually give back its connection slot. const CONNECTION_TIMEOUT: Duration = Duration::from_secs(120); -pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {+/// The request channel plus the startup failure, when the server is not listening. The channel+/// is returned either way so the app can wire it once; on a failure it never yields a request.+/// The reason comes back so the UI can show it (the status bar, `ui.inspect` → `control`):+/// stderr reaches nobody when the app is started from a desktop menu or by an agent (#3313).+pub fn start(port: u16, ctx: egui::Context) -> (Receiver<ControlRequest>, Option<String>) {     start_with_token(port, ctx, std::env::var("FILMCRAFT_CONTROL_TOKEN").ok()) } -fn start_with_token(port: u16, ctx: egui::Context, token: Option<String>) -> Receiver<ControlRequest> {+fn start_with_token(port: u16, ctx: egui::Context, token: Option<String>) -> (Receiver<ControlRequest>, Option<String>) {     let (tx, rx) = channel::<ControlRequest>();     // An inherited environment variable shares the credential with the owner's bridge process     // without exposing it in command-line arguments, logs, or a shared discovery file.     let Some(token) = token.filter(|t| t.len() == 64 && t.bytes().all(|b| b.is_ascii_hexdigit())) else {-        eprintln!("filmcraft: control server disabled: FILMCRAFT_CONTROL_TOKEN must contain 64 random hex characters (see docs/control-protocol.md)");-        return rx;+        return fail(rx, "control server disabled: FILMCRAFT_CONTROL_TOKEN must contain 64 random hex characters (see docs/control-protocol.md)".into());     };     let listener = match TcpListener::bind(("127.0.0.1", port)) {         Ok(l) => l,+        Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => {+            return fail(+                rx,+                format!("control server can't listen on 127.0.0.1:{port}: the port is already in use (another program is bound to it; pick a free port)"),+            );+        }         Err(e) => {-            eprintln!("filmcraft: control server failed to bind 127.0.0.1:{port}: {e}");-            return rx;+            return fail(rx, format!("control server failed to bind 127.0.0.1:{port}: {e}"));         }     };-    eprintln!("filmcraft: control server listening on 127.0.0.1:{port}");+    log::info!("control server listening on 127.0.0.1:{port}");     std::thread::spawn(move || accept_loop(listener, tx, ctx, token));-    rx+    (rx, None)+}++fn fail(rx: Receiver<ControlRequest>, reason: String) -> (Receiver<ControlRequest>, Option<String>) {+    log::error!("{reason}");+    (rx, Some(reason)) }  fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context, token: String) {@@ -70,7 +83,7 @@ fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Con             serve(stream, &tx, &ctx, &token);         });         if let Err(e) = spawned {-            eprintln!("filmcraft: control server could not start a connection thread: {e}");+            log::error!("control server could not start a connection thread: {e}");         }     } }@@ -86,7 +99,7 @@ impl Drop for Slot {  fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context, token: &str) {     if let Err(e) = serve_with_timeout(stream, tx, ctx, token, CONNECTION_TIMEOUT) {-        eprintln!("filmcraft: could not configure control connection: {e}");+        log::error!("could not configure control connection: {e}");     } } diff --git a/apps/filmcraft/src/control_server/tests.rs b/apps/filmcraft/src/control_server/tests.rs--- a/apps/filmcraft/src/control_server/tests.rs+++ b/apps/filmcraft/src/control_server/tests.rs@@ -30,7 +30,7 @@ fn requests_are_answered_in_order_with_ids() { fn http_request_closes_before_the_body_runs() {     // A browser's cross-origin `fetch` POST (issue #119): the request line is rejected and the     // connection closed, so the JSON body is never executed.-    let http = b"POST / HTTP/1.1\r\nHost: 127.0.0.1:9876\r\nOrigin: https://attacker.example\r\nContent-Type: text/plain\r\n\r\n\+    let http = b"POST / HTTP/1.1\r\nHost: 127.0.0.1:9886\r\nOrigin: https://attacker.example\r\nContent-Type: text/plain\r\n\r\n\             {\"id\":7,\"method\":\"engine.execute\",\"params\":{\"command\":\"file.openDemoProject\",\"params\":{}}}\n";     let (r, calls) = run(http);     assert!(calls.is_empty(), "nothing may run: {calls:?}");@@ -235,12 +235,29 @@ fn missing_or_invalid_token_leaves_the_listener_closed() {         let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();         let port = listener.local_addr().unwrap().port();         drop(listener);-        let rx = start_with_token(port, egui::Context::default(), token);+        let (rx, error) = start_with_token(port, egui::Context::default(), token);+        assert!(error.as_deref().is_some_and(|e| e.contains("FILMCRAFT_CONTROL_TOKEN")), "{error:?}");         assert!(matches!(rx.try_recv(), Err(std::sync::mpsc::TryRecvError::Disconnected)));         let _still_available = TcpListener::bind(("127.0.0.1", port)).unwrap();     } } +/// A port that is already bound used to be reported only on stderr: the app then ran without+/// the control server a script was waiting for (#3313).+#[test]+fn a_port_in_use_is_reported_as_a_startup_error() {+    let held = TcpListener::bind(("127.0.0.1", 0)).unwrap();+    let port = held.local_addr().unwrap().port();+    let (rx, error) = start_with_token(port, egui::Context::default(), Some(TOKEN.into()));+    let error = error.expect("a bound port must be reported");+    assert!(error.contains(&port.to_string()) && error.contains("in use"), "{error}");+    assert!(matches!(rx.try_recv(), Err(std::sync::mpsc::TryRecvError::Disconnected)));+    drop(held);+    let (rx, error) = start_with_token(port, egui::Context::default(), Some(TOKEN.into()));+    assert_eq!(error, None);+    drop(rx);+}+ #[test] fn unauthenticated_save_as_closes_before_any_command_runs() {     for token in [Value::Null, json!(false), json!(42), json!([]), json!({}), json!(""), json!("a".repeat(64)), json!(&TOKEN[..63]), json!(format!("{TOKEN}0"))]diff --git a/apps/filmcraft/src/main.rs b/apps/filmcraft/src/main.rs--- a/apps/filmcraft/src/main.rs+++ b/apps/filmcraft/src/main.rs@@ -347,7 +347,13 @@ fn app_creator(launch: Launch, graphics: graphics::GraphicsStart, created: Arc<A             app.ui.show_menu_bar = false;         }         if let Some(port) = control_port {-            let rx = control_server::start(port, cc.egui_ctx.clone());+            let (rx, control_error) = control_server::start(port, cc.egui_ctx.clone());+            // A listener that never came up is reported where a user or agent will see it:+            // the status bar and `ui.inspect` → `control` (#3313).+            if let Some(e) = control_error.clone() {+                app.status(e);+            }+            app.control_status = Some(filmcraft_ui_egui::ControlStatus { port, error: control_error });             app = if control_input_only { app.with_control_input_only(rx) } else { app.with_control(rx) };         }         Ok(Box::new(app))diff --git a/crates/automation/src/bridge.rs b/crates/automation/src/bridge.rs--- a/crates/automation/src/bridge.rs+++ b/crates/automation/src/bridge.rs@@ -30,7 +30,7 @@ pub struct BridgeClient { }  impl BridgeClient {-    /// `addr` such as `127.0.0.1:9876` (loopback only). Reads `FILMCRAFT_CONTROL_TOKEN`+    /// `addr` such as `127.0.0.1:9886` (loopback only). Reads `FILMCRAFT_CONTROL_TOKEN`     /// from the environment; the desktop app requires this credential on every request.     pub fn new(addr: impl Into<String>) -> Result<Self, AutomationError> {         let addr = addr.into();@@ -90,13 +90,44 @@ impl BridgeClient {             let id = self.next_id.fetch_add(1, Ordering::Relaxed);             let line = format!("{}\n", json!({"id": id, "token": self.token, "method": method, "params": params}));             let res: Result<Value, AutomationError> = async {-                conn.1.write_all(line.as_bytes()).await.map_err(|e| AutomationError::Bridge(e.to_string()))?;+                let gone = |e: &std::io::Error| {+                    matches!(e.kind(), std::io::ErrorKind::ConnectionReset | std::io::ErrorKind::BrokenPipe | std::io::ErrorKind::NotConnected)+                };+                conn.1.write_all(line.as_bytes()).await.map_err(|e| {+                    if gone(&e) {+                        AutomationError::Bridge(format!("{} closed the connection without a reply", self.addr))+                    } else {+                        AutomationError::Bridge(e.to_string())+                    }+                })?;                 let mut buf = String::new();-                tokio::time::timeout(Duration::from_secs(90), conn.0.read_line(&mut buf))+                let n = tokio::time::timeout(Duration::from_secs(90), conn.0.read_line(&mut buf))                     .await                     .map_err(|_| AutomationError::Bridge("timeout".into()))?-                    .map_err(|e| AutomationError::Bridge(e.to_string()))?;-                serde_json::from_str(&buf).map_err(|e| AutomationError::Bridge(format!("bad reply: {e}")))+                    .map_err(|e| {+                        if gone(&e) {+                            AutomationError::Bridge(format!("{} closed the connection without a reply", self.addr))+                        } else {+                            AutomationError::Bridge(e.to_string())+                        }+                    })?;+                if n == 0 {+                    return Err(AutomationError::Bridge(format!("{} closed the connection without a reply", self.addr)));+                }+                let v: Value = serde_json::from_str(&buf).map_err(|_| AutomationError::Bridge(Self::wrong_server(&self.addr)))?;+                // A reply the app never sends (another program on the port, e.g. a different+                // tool's server) used to surface as a bare parse or `ok` error (#3313). The app+                // echoes the request's `id` on success; its own `ok: false` rejects (bad token,+                // junk line) carry none.+                let known = match v.get("ok") {+                    Some(Value::Bool(true)) => v.get("id") == Some(&json!(id)),+                    Some(Value::Bool(false)) => true,+                    _ => false,+                };+                if !known {+                    return Err(AutomationError::Bridge(Self::wrong_server(&self.addr)));+                }+                Ok(v)             }             .await;             match res {@@ -119,6 +150,13 @@ impl BridgeClient {         Err(AutomationError::Bridge("unreachable".into()))     } +    /// The peer answered but not as the control server does (`{"ok": …}` per line): another+    /// program owns the port. The classic case is a different tool's server, which used to+    /// surface as a bare JSON parse error (#3313).+    fn wrong_server(addr: &str) -> String {+        format!("{addr} is not a FilmCraft control server: something else is listening on this port")+    }+     async fn connect(&self) -> Result<Conn, AutomationError> {         let s = tokio::time::timeout(Duration::from_secs(5), TcpStream::connect(&self.addr))             .await@@ -274,6 +312,79 @@ mod tests {         assert_eq!(b.execute("fast", json!({})).await.unwrap(), json!("fast"));     } +    /// A stand-in for another program's server (the Blender MCP add-on that also defaults to a+    /// nearby port, #3313): it answers every line, just never as the control server would.+    async fn foreign_app(reply: &str) -> String {+        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();+        let addr = listener.local_addr().unwrap().to_string();+        let reply = reply.to_string();+        tokio::spawn(async move {+            while let Ok((s, _)) = listener.accept().await {+                let reply = reply.clone();+                tokio::spawn(async move {+                    let (r, mut w) = s.into_split();+                    let mut lines = BufReader::new(r).lines();+                    while let Ok(Some(_)) = lines.next_line().await {+                        let _ = w.write_all(format!("{reply}\n").as_bytes()).await;+                    }+                });+            }+        });+        addr+    }++    /// A non-FilmCraft reply used to surface as `bad reply: …` (a JSON parse error) or a bare+    /// `app: error`; the port's real owner is named instead (#3313).+    #[tokio::test]+    async fn a_reply_from_another_program_is_named() {+        for reply in [+            "{\"status\": \"error\", \"message\": \"unknown command\"}",+            "{\"jsonrpc\": \"2.0\", \"id\": 1, \"result\": {}}",+            "not json at all",+            "{\"ok\": true}",+            "{\"ok\": true, \"id\": 9999}",+        ] {+            let addr = foreign_app(reply).await;+            let b = BridgeClient::new(addr.clone()).unwrap();+            let e = b.call("ui.inspect", json!({})).await.unwrap_err();+            let e = e.to_string();+            assert!(e.contains("not a FilmCraft control server") && e.contains(&addr), "{reply}: {e}");+        }+    }++    /// The app's own unsolicited rejects (`{"ok": false, "error": …}`) carry no `id` (bad token,+    /// junk line): they must reach the caller as the app's error, not as a foreign server.+    #[tokio::test]+    async fn an_error_reply_without_an_id_is_the_apps_own_error() {+        let addr = foreign_app("{\"ok\": false, \"error\": \"control authentication failed; closing the connection\"}").await;+        let b = BridgeClient::new(addr).unwrap();+        let e = b.call("ui.inspect", json!({})).await.unwrap_err();+        assert_eq!(e.to_string(), "app: control authentication failed; closing the connection");+    }++    /// A peer that hangs up silently is named too, not "EOF while parsing a value". Closing+    /// after reading the request sends a clean FIN; closing without reading sends a reset.+    #[tokio::test]+    async fn a_connection_closed_without_reply_is_named() {+        for read_first in [true, false] {+            let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();+            let addr = listener.local_addr().unwrap().to_string();+            tokio::spawn(async move {+                while let Ok((s, _)) = listener.accept().await {+                    tokio::spawn(async move {+                        if read_first {+                            let (r, _w) = s.into_split();+                            let _ = BufReader::new(r).lines().next_line().await;+                        }+                    });+                }+            });+            let b = BridgeClient::new(addr).unwrap();+            let e = b.call("ui.inspect", json!({})).await.unwrap_err();+            assert!(e.to_string().contains("closed the connection without a reply"), "read_first={read_first}: {e}");+        }+    }+     /// Everything else is forwarded unchanged.     #[tokio::test]     async fn other_commands_are_forwarded() {diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs--- a/crates/ui-egui/src/app.rs+++ b/crates/ui-egui/src/app.rs@@ -64,6 +64,7 @@ impl FilmcraftApp {             textures: HashMap::new(),             thumb_used: HashMap::new(),             control_rx: None,+            control_status: None,             pending_panel_raise: None,             control_input_only: false,             native_window_focused: None,diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs--- a/crates/ui-egui/src/control.rs+++ b/crates/ui-egui/src/control.rs@@ -65,6 +65,16 @@ impl ControlRequest {     } } +/// The control listener the app was asked to start (`--control`, `FILMCRAFT_CONTROL_PORT`):+/// `error` is why it is not listening (a bad `FILMCRAFT_CONTROL_TOKEN`, or a port another+/// program already bound). A bind failure is otherwise invisible to an agent (it sees only a+/// port that never answers), so `ui.inspect` reports it under `control` (#3313).+#[derive(Clone, Debug)]+pub struct ControlStatus {+    pub port: u16,+    pub error: Option<String>,+}+ pub enum Outcome {     Done(Value),     /// Try again on a later frame (e.g. the element is not on screen yet).@@ -595,6 +605,7 @@ pub fn inspect(app: &FilmcraftApp, ctx: &egui::Context) -> Value {         "keyboardFocus": keyboard_focus,         "input": {"mode": if app.control_input_only { "controlOnly" } else { "interactive" },             "nativeWindowFocused": app.native_window_focused, "effectiveWindowFocused": ctx.input(|input| input.focused)},+        "control": app.control_status.as_ref().map(|c| json!({"port": c.port, "listening": c.error.is_none(), "error": c.error})),         "ui": app.ui,         "sourcePlayback": {"playing": app.source_playback.clock.playing, "audioClock": app.source_playback.clock.audio_clock, "playhead": app.session.state.source_playhead.0},         "playback": {"playing": app.playback.playing, "speed": app.playback.speed, "loop": app.playback.looping, "audioClock": app.playback.audio_clock, "dropped": app.playback.meter.counts().1, "shown": app.playback.meter.counts().0, "preroll": app.playback.preroll.is_some()},@@ -760,6 +771,18 @@ mod input_bounds_tests {             .expect("hostile control parameters must not panic")     } +    /// `ui.inspect` reports the control listener's state, so an agent sees a bind failure the+    /// status bar showed only in passing (#3313).+    #[test]+    fn inspect_reports_the_control_listener() {+        let mut a = app();+        assert_eq!(inspect(&a, &egui::Context::default())["control"], Value::Null);+        a.control_status = Some(ControlStatus { port: 9886, error: None });+        assert_eq!(inspect(&a, &egui::Context::default())["control"], json!({"port": 9886, "listening": true, "error": null}));+        a.control_status = Some(ControlStatus { port: 9886, error: Some("port in use".into()) });+        assert_eq!(inspect(&a, &egui::Context::default())["control"], json!({"port": 9886, "listening": false, "error": "port in use"}));+    }+     /// Require an immediate error naming `field`, with all previously queued input unchanged.     fn assert_rejected(app: &mut FilmcraftApp, method: &str, params: Value, field: &str) {         let before = app.synthetic.clone();diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -76,7 +76,7 @@ use filmcraft_engine::{Services, Session}; use filmcraft_time::Tick; use serde_json::{Value, json}; -pub use control::ControlRequest;+pub use control::{ControlRequest, ControlStatus}; use dock::{LayoutExt as _, PanelKind}; use frames::{FrameKey, FrameServer, Target}; use native_dialogs::FileDialog;@@ -212,6 +212,9 @@ pub struct FilmcraftApp {     /// [`MAX_THUMB_TEXTURES`] are freed, least recently used first (#1682).     thumb_used: HashMap<String, u64>,     control_rx: Option<Receiver<ControlRequest>>,+    /// How the `--control` listener came up (set by the host): `ui.inspect` reports it under+    /// `control`, so a port that failed to bind is visible to agents, not just stderr (#3313).+    pub control_status: Option<ControlStatus>,     /// One explicit panel reveal; consumed by the custom renderer, never repeated per frame.     pub(crate) pending_panel_raise: Option<PanelKind>,     /// Explicit automation mode: native widget input and native menu commands are excluded.diff --git a/docs/agents.md b/docs/agents.md--- a/docs/agents.md+++ b/docs/agents.md@@ -12,7 +12,7 @@ FilmCraft from an agent. Part 2 covers developing FilmCraft as an agent. | Mode | Command | What it drives | |---|---|---| | Headless | `filmcraft-cli mcp --demo` or `--project p.fcproj` (neither = empty project) | an in-process engine session, no window |-| Bridge | `filmcraft-cli mcp --bridge 127.0.0.1:9876` | the running desktop app started with `filmcraft --control 9876` |+| Bridge | `filmcraft-cli mcp --bridge 127.0.0.1:9886` | the running desktop app started with `filmcraft --control 9886` |  Headless mode also takes `--data-dir DIR` (user presets). Any other option is rejected (exit 2): there are no folder-confinement flags such as `--automation-read-root`, so the server can read@@ -24,7 +24,7 @@ and write wherever the user can. ```sh cargo build --release -p filmcraft-cli -p filmcraft export FILMCRAFT_CONTROL_TOKEN="$(python3 -c 'import secrets; print(secrets.token_hex(32))')"-./target/release/filmcraft --control 9876 &      # only for the bridge server+./target/release/filmcraft --control 9886 &      # only for the bridge server claude                                           # approve the project MCP servers when asked ``` @@ -35,7 +35,7 @@ claude mcp add filmcraft-headless -- /abs/path/filmcraft/target/release/filmcraf ```  ```json-{ "mcpServers": { "filmcraft": { "command": "/abs/path/filmcraft-cli", "args": ["mcp", "--bridge", "127.0.0.1:9876"] } } }+{ "mcpServers": { "filmcraft": { "command": "/abs/path/filmcraft-cli", "args": ["mcp", "--bridge", "127.0.0.1:9886"] } } } ```  ### Effect parameter replies@@ -144,10 +144,12 @@ progress and stop it:  ## 2. Control channel -`filmcraft --control 9876` (or `FILMCRAFT_CONTROL_PORT=9876`) listens on `127.0.0.1` only. Send one+`filmcraft --control 9886` (or `FILMCRAFT_CONTROL_PORT=9886`) listens on `127.0.0.1` only. Send one JSON request per line and get one JSON reply per line. Set a fresh `FILMCRAFT_CONTROL_TOKEN` for the desktop session as above; the CLI / MCP bridge must inherit the same variable. Raw requests must-include it as `token` on every line (replace the placeholders below). Full setup and method table:+include it as `token` on every line (replace the placeholders below). If the listener could not+start (port already bound, missing token), `ui.inspect` reports it under `control`; a bridge that+reaches another program's server names that instead of a parse error. Full setup and method table: [control-protocol.md](control-protocol.md).  ```jsonc@@ -167,7 +169,7 @@ Minimal client:  ```python import json, os, socket-s = socket.create_connection(("127.0.0.1", 9876)); f = s.makefile("rw")+s = socket.create_connection(("127.0.0.1", 9886)); f = s.makefile("rw") def call(method, **params):     f.write(json.dumps({"id": 1, "token": os.environ["FILMCRAFT_CONTROL_TOKEN"], "method": method, "params": params}) + "\n"); f.flush()     return json.loads(f.readline())@@ -226,7 +228,7 @@ filmcraft-cli --project p.fcproj export out --preset "YouTube 1080p Full HD" --s filmcraft-cli export --list-presets prores       # built-in + user presets (--data-dir for another library) filmcraft-cli --project p.fcproj exec export.queue.add preset="Apple ProRes 422 HQ" path=renders/ start=true wait=true echo '{"id":"file.newBin","params":{"name":"Selects"}}' | filmcraft-cli --project p.fcproj --save run --filmcraft-cli --bridge 127.0.0.1:9876 exec window.workspace.color   # the running app+filmcraft-cli --bridge 127.0.0.1:9886 exec window.workspace.color   # the running app ```  `key=value` values are parsed as JSON when they can be (`3.5`, `true`, `[1,2]`), otherwise taken as@@ -263,7 +265,7 @@ failed write reports an error. The menu opens the normal save dialog; cancelling  For any UI change, look at the result: -1. `cargo run --release -p filmcraft -- --control 9876`+1. `cargo run --release -p filmcraft -- --control 9886` 2. Drive the feature through the control channel or the MCP bridge: commands, then clicks and drags    by automation id. 3. Assert on `ui.inspect`, `ui.elements` and `sequence.inspect`.diff --git a/docs/contributing.md b/docs/contributing.md--- a/docs/contributing.md+++ b/docs/contributing.md@@ -28,7 +28,7 @@ macOS. ```sh cargo run --release -p filmcraft                      # desktop app with the demo project cargo run --release -p filmcraft -- --empty           # start without the demo project-cargo run --release -p filmcraft -- --control 9876    # plus the JSON-lines control server+cargo run --release -p filmcraft -- --control 9886    # plus the JSON-lines control server cargo run --release -p filmcraft -- a.mp4 b.wav       # import media (or open a .fcproj) cargo run --release -p filmcraft-cli -- commands      # list engine commands cargo run --release -p filmcraft-cli -- render --demo --seconds 3 --out frame.pngdiff --git a/docs/control-protocol.md b/docs/control-protocol.md--- a/docs/control-protocol.md+++ b/docs/control-protocol.md@@ -1,15 +1,15 @@ # Control protocol & MCP  ## Desktop control channel-`filmcraft --control 9876` (or `FILMCRAFT_CONTROL_PORT`) listens on `127.0.0.1:<port>` (loopback only).+`filmcraft --control 9886` (or `FILMCRAFT_CONTROL_PORT`) listens on `127.0.0.1:<port>` (loopback only). The listener requires `FILMCRAFT_CONTROL_TOKEN`: 64 random hex characters shared with authorized clients through their inherited environment. Generate a fresh token for each desktop session, then start the desktop and bridge from that environment:  ```sh export FILMCRAFT_CONTROL_TOKEN="$(python3 -c 'import secrets; print(secrets.token_hex(32))')"-filmcraft --control 9876 &-filmcraft-cli mcp --bridge 127.0.0.1:9876+filmcraft --control 9886 &+filmcraft-cli mcp --bridge 127.0.0.1:9886 ```  The CLI / MCP bridge reads this variable automatically. Keep it private: don't put the value in@@ -18,6 +18,14 @@ the desktop leaves the listener closed. Every raw JSON request must include the `token` string; missing or incorrect credentials get an error and close the connection before any request is dispatched. Loopback alone does not restrict clients to the desktop owner's OS account. +Port 9886 is the documented example; the other Craft apps use their own (PhotoCraft 7878,+GridCraft 7979, WordCraft 7981, EffectCraft 9877). Pick a port nothing else listens on: Blender's+MCP add-ons default to 9876, a frequent neighbour of a video editor. If the port can't be bound+(already in use) or the token is missing, the app still starts but says so on stderr, in the log+file and in the status bar, and `ui.inspect` reports it under `control` (`{port, listening,+error}`). A bridge that reaches a server which is not FilmCraft reports+"not a FilmCraft control server: something else is listening on this port".+ Socket reads and writes time out after two minutes without progress, releasing the connection slot. Expiration closes the connection without an unsolicited reply; reconnect before the next request (the CLI / MCP bridge does this automatically). Waiting for a UI reply has its separate 60-second@@ -48,7 +56,7 @@ Methods (handlers in `crates/ui-egui/src/control.rs`): |---|---|---| | `engine.execute` / `ui.menu.invoke` | `{command, params}` | any engine or UI command id | | `engine.commands` / `ui.menu.list` | – | command registry / menu tree |-| `ui.inspect` | – | UI state (tool, workspace, dock, timeline view, playback, fps…) |+| `ui.inspect` | – | UI state (tool, workspace, dock, timeline view, playback, fps…); `control` reports the control listener `{port, listening, error}` (`null` when neither `--control` nor a non-empty `FILMCRAFT_CONTROL_PORT` was given) | | `ui.docking` | – | the panel layout (craft-ui `Layout`), the focused and maximized panel, and `hitRects`: each docking gesture source drawn last frame (`{hit, rect}`) | | `ui.elements` | `{prefix?}` | every on-screen interactive element: id, label, rect; `window` (the `n` of `panel.float.<n>`) marks an element drawn in a floating panel's own OS window, whose rect is in that window's coordinates | | `ui.set` | `{tool, workspace, mode, theme, focused, playbackRes, timeline:{pps,scroll,fit}, program:{…}, source:{…}, clipDialog:{param: value}, menuDialog:{param: value}, panels:{scopes, timecode, reference, events, progress}, export:{…}, keyframeSelection:[…]}` | `keyframeSelection` replaces the Effect Controls keyframe selection (`[{clip, effect, param, mask?, time}]`, `time` in media ticks; read back as `ui.inspect` → `ui.keyframe_selection`; a click on a lane keyframe selects just that one); `program` / `source` merge fields into the monitor view state (`res`, `paused_res`, `high_quality`, `display`, `zoom`, `pan`, `show_rulers`, `show_guides`, `lock_guides`, `snap`, `guides`, `compare_ref`, …; see [monitors.md](monitors.md)); `clipDialog` sets fields of the open Edit / Clip / File dialog (Paste Attributes, Make Subclip, Frame Hold Options, …); `menuDialog` those of the M3.11 dialogs (Find, Create Search Bin, Project Settings, Scene Edit Detection, Simplify Sequence, Automate to Sequence…; see `panels::menu_dialogs`); `panels` merges into the panel settings (`ui.inspect` → `ui.panels`; see `panels::panel_state`): Lumetri Scopes `{shown: ["vectorscopeYuv"|"vectorscopeHls"|"histogram"|"parade"|"waveform"], waveformType, paradeType, colorSpace, brightness, scale, clamp, targets}`, Timecode `{rows: [{source, mode, display}], showName}`, Reference Monitor `{ganged, time, display: "composite"|"scopes", scopes}`, Events `{level, selected}`, Progress `{showFinished}` `export` deep-merges into Export mode state (`preset` — applied first —, `settings` (ExportSettings JSON), `fileName`, `location`, `range`, `customStart`, `customEnd`, `openSections`, `manager` (Preset Manager: `query`, `favoritesOnly`, `selected`, `saveName`), `quickOpen`, `quickPath`, `quickPreset`); `program` / `source` merge fields into the monitor view state (`res`, `paused_res`, `high_quality`, `display`, `zoom`, `pan`, `show_rulers`, `show_guides`, `lock_guides`, `snap`, `guides`, `compare_ref`, …; see [monitors.md](monitors.md)); `clipDialog` sets fields of the open Edit / Clip / File dialog (Paste Attributes, Make Subclip, Frame Hold Options, …); `menuDialog` those of the M3.11 dialogs (Find, Create Search Bin, Project Settings, Scene Edit Detection, Simplify Sequence, Automate to Sequence…; see `panels::menu_dialogs`); `projectPanel` merges into the Project panel's view state (`bin` shown in place, `tabs` (bins in a tab or window: `{bin, floating, view, iconSize, nav}`), `activeTab`, `selectedBin`, `rename: {item|bin, text}`, `dialog` (`{"metadataDisplay": {columns, filter}}`, `{"savePresetAs": {name}}`, `{"managePresets": {selected, name}}`, `{"freeformOptions": {options}}`, `{"saveArrangement": {name, bin}}`)); `mediaBrowser` into the Media Browser's (`expanded`, `pathEdit`, `editColumns`, `treeWidth`). View settings, columns and presets are engine commands (`project.view.set`, `project.columns.set`, `project.viewPreset.*`, `mediaBrowser.settings`) |@@ -192,7 +200,7 @@ Ctrl), `Ctrl` (macOS ⌃), `Alt`, `Shift`. The dialog (Edit ▸ Keyboard Shortcu  ## MCP `filmcraft-cli mcp` serves MCP on stdio: headless (in-process session; `--demo` / `--project p.fcproj`)-or `--bridge 127.0.0.1:9876` to drive the running app. Tools: `command_list`, `command_run`,+or `--bridge 127.0.0.1:9886` to drive the running app. Tools: `command_list`, `command_run`, `command_batch`, `doc_inspect`, `render_preview`, `project_inspect`, `sequence_inspect`, `media_import`, `render_frame`, `ui_inspect`, `ui_elements`, `ui_click`, `ui_drag`, `ui_key`, `ui_type`, `ui_screenshot`, `ui_control`; resources `filmcraft://document` and `filmcraft://commands`. `.mcp.json` registers both.diff --git a/docs/egui-037-migration-plan.md b/docs/egui-037-migration-plan.md--- a/docs/egui-037-migration-plan.md+++ b/docs/egui-037-migration-plan.md@@ -1226,7 +1226,7 @@ python3 /home/codex/work/maintenance/task.py run \   --task filmcraft-egui037-migration \   --source /home/codex/work/CraftApps/egui037-migration-20261022/filmcraft-implementation \   --evidence /home/codex/work/ssd2/task-evidence/egui037-migration-20261022/filmcraft \-  -- cargo run --release -p filmcraft -- --control 9876+  -- cargo run --release -p filmcraft -- --control 9886 python3 /home/codex/work/maintenance/task.py status filmcraft-egui037-linux-ui-01 --tail-bytes 8192 # after the UI workflow, evidence capture and review: python3 /home/codex/work/maintenance/task.py finish filmcraft-egui037-linux-ui-01 \diff --git a/docs/testing.md b/docs/testing.md--- a/docs/testing.md+++ b/docs/testing.md@@ -253,7 +253,7 @@ project's sequences through a simulated 48 kHz device and counts dropouts, mixin For visual work, drive the real app:  ```sh-cargo run --release -p filmcraft -- --control 9876+cargo run --release -p filmcraft -- --control 9886 ```  Then script it over the control channel or MCP: run commands, click by automation id,