diff --git a/crates/cli/tests/agent_target.rs b/crates/cli/tests/agent_target.rs index c59ae02c0..5a9cc7b43 100644 --- a/crates/cli/tests/agent_target.rs +++ b/crates/cli/tests/agent_target.rs @@ -687,3 +687,221 @@ fn agent_target_result_is_honored_only_from_the_lease_holder() { assert_eq!(verdict["sessionId"], serde_json::json!("aabbccdd"), "{verdict}"); let _ = &mut b; } + +/// The generate verb as the agent runs it, against this helper's dir. +fn spawn_cli(s: &Server, args: &[&str]) -> std::process::Child { + std::process::Command::new(env!("CARGO_BIN_EXE_impeccable")) + .args(args) + .current_dir(&s.dir) + .env("IMPECCABLE_LIVE_COPY_AGENT", "off") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("spawn cli") +} + +fn cli_json(child: std::process::Child) -> (i32, serde_json::Value, String) { + let out = child.wait_with_output().expect("cli output"); + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); + // live-generate prints one pretty object; live-poll prints one line. + let v: serde_json::Value = serde_json::from_str(stdout.trim()) + .or_else(|_| serde_json::from_str(stdout.trim().lines().last().unwrap_or(""))) + .unwrap_or_else(|e| panic!("{e}\nstdout: {stdout}\nstderr: {stderr}")); + (out.status.code().unwrap_or(-1), v, stderr) +} + +#[test] +fn live_generate_collects_its_own_generate_event_and_reply_then_poll_returns_the_next_one() { + let s = Server::start("collect"); + let mut a = Overlay::connect(s.port, &s.token, "tab-a"); + a.next(|m| m["type"] == "connected"); + let child = spawn_cli(&s, &["live-generate", "--selector", "h1", "--action", "bolder", "--count", "3"]); + let target_id = a.next(|m| m["type"] == "agent_target")["targetId"].as_str().unwrap().to_string(); + assert_eq!(s.claim(&target_id, "tab-a", true)["granted"], serde_json::json!(true)); + let (status, ack) = post_json(s.port, "/events", generate_event_for(&s, &target_id, "c0ffee11", "tab-a")); + assert_eq!(status, 200, "{ack}"); + let (code, verdict, stderr) = cli_json(child); + assert_eq!(code, 0, "{verdict}\n{stderr}"); + assert_eq!(verdict["ok"], serde_json::json!(true), "{verdict}"); + assert_eq!(verdict["sessionId"], serde_json::json!("c0ffee11")); + // B: the session's generate event rides along, leased, with the fast path. + assert_eq!(verdict["event"]["type"], serde_json::json!("generate"), "{verdict}"); + assert_eq!(verdict["event"]["id"], serde_json::json!("c0ffee11")); + assert_eq!(verdict["event"]["origin"], serde_json::json!("agent")); + assert!(verdict["event"]["_instructions"].as_str().unwrap().contains("Fast path"), "{verdict}"); + assert!(verdict["_instructions"].as_str().unwrap().contains("--reply c0ffee11 done --file --then-poll"), "{verdict}"); + // Leased: a plain poll finds nothing else to hand out. + let (_, polled) = http(s.port, "GET", &format!("/poll?token={}&timeout=300", s.token), None); + assert!(polled.contains("\"timeout\""), "{polled}"); + // A: reply done and wait for the next event in one call; a steer lands + // while it waits. + let child = spawn_cli(&s, &["live-poll", "--reply", "c0ffee11", "done", "--file", "index.html", "--then-poll", "--timeout=8000"]); + std::thread::sleep(Duration::from_millis(900)); + let (status, body) = post_json(s.port, "/events", serde_json::json!({ "token": s.token, "type": "steer", "id": "c0ffee11", "message": "warmer" })); + assert_eq!(status, 200, "{body}"); + let (code, event, stderr) = cli_json(child); + assert_eq!(code, 0, "{event}\n{stderr}"); + assert_eq!(event["type"], serde_json::json!("steer"), "{event}"); + assert_eq!(event["_replyAck"]["ok"], serde_json::json!(true), "{event}"); + assert_eq!(event["_replyAck"]["status"], serde_json::json!("done")); + assert_eq!(event["_replyAck"]["file"], serde_json::json!("index.html")); + assert!(event["_replyAck"].get("_instructions").is_none(), "{event}"); + assert!(event["_instructions"].as_str().unwrap().contains("steer_done"), "{event}"); +} + +#[test] +fn live_generate_boot_and_open_run_the_lane_from_a_cold_project() { + // No helper running: --boot starts one (the lane's flags), --open hands the + // dev URL to the configured browser, and the overlay that page brings up + // serves the target. + let dir = std::env::temp_dir().join(format!("impeccable-agent-target-cold-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join(".impeccable/live")).unwrap(); + std::fs::write(dir.join("index.html"), "

t

").unwrap(); + std::fs::write(dir.join(".impeccable/live/config.json"), "{\"files\":[\"index.html\"],\"insertBefore\":\"\",\"commentSyntax\":\"html\"}").unwrap(); + // The "browser": a script that records the URL it was asked to open. + let opener = dir.join("opener.sh"); + std::fs::write(&opener, format!("#!/bin/sh\necho \"$1\" > {}\n", dir.join("opened.txt").display())).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&opener, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + std::fs::write(dir.join(".impeccable/config.local.json"), format!("{{\"browser\":\"{}\"}}", opener.display())).unwrap(); + // A stand-in dev server serving the injected page, so --dev-url finds it. + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let dev_port = listener.local_addr().unwrap().port(); + let page_dir = dir.clone(); + std::thread::spawn(move || { + for stream in listener.incoming().flatten() { + let mut stream = stream; + let mut buf = [0u8; 2048]; + let _ = std::io::Read::read(&mut stream, &mut buf); + let body = std::fs::read_to_string(page_dir.join("index.html")).unwrap_or_default(); + let res = format!("HTTP/1.0 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body); + let _ = std::io::Write::write_all(&mut stream, res.as_bytes()); + } + }); + let out = std::process::Command::new(env!("CARGO_BIN_EXE_impeccable")) + .args(["live-generate", "--selector", "h1", "--action", "bolder", "--boot", "--open", "--wait-for-browser", "1500"]) + .current_dir(&dir) + .env("IMPECCABLE_LIVE_COPY_AGENT", "off") + .env("IMPECCABLE_DEV_URL_CANDIDATES", format!("http://127.0.0.1:{}/", dev_port)) + .env("IMPECCABLE_AGENT_TARGET_TIMEOUT_MS", "400") + .output() + .expect("cli"); + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| panic!("{e}: {stdout}")); + // The boot ran (helper started, page injected, bar hidden, dev URL found). + assert_eq!(v["boot"]["liveBarHidden"], serde_json::json!(true), "{v}"); + assert_eq!(v["boot"]["devUrl"], serde_json::json!(format!("http://127.0.0.1:{}/", dev_port)), "{v}"); + assert_eq!(v["boot"]["contextMissing"], serde_json::json!(["PRODUCT.md", "DESIGN.md"]), "{v}"); + assert!(v["boot"].get("serverToken").is_none() && v["boot"].get("_instructions").is_none(), "{v}"); + // The page was handed to the configured browser, which never connects an + // overlay here, so the wait ends in no_browser_connected naming the open. + let opened = std::fs::read_to_string(dir.join("opened.txt")).unwrap_or_default(); + assert_eq!(opened.trim(), format!("http://127.0.0.1:{}/", dev_port), "{v}"); + assert_eq!(v["error"], serde_json::json!("no_browser_connected"), "{v}"); + assert_eq!(v["opened"]["url"], serde_json::json!(format!("http://127.0.0.1:{}/", dev_port))); + assert!(v["_instructions"].as_str().unwrap().contains("opened in the browser"), "{v}"); + // Cleanup: stop the helper the boot started. + let info: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(dir.join(".impeccable/live/server.json")).unwrap()).unwrap(); + let _ = http(info["port"].as_u64().unwrap() as u16, "GET", &format!("/stop?token={}", info["token"].as_str().unwrap()), None); + std::thread::sleep(Duration::from_millis(500)); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn live_generate_asks_the_harness_to_open_the_page_instead_of_a_second_browser() { + // Helper up, no page connected, nothing asked to open, nothing to wait + // for: the verdict hands the dev URL back with the harness's own way of + // opening it. The dev server here answers without our tag, so the + // caller's hint is reported unverified. + let s = Server::start("browser-needed"); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let dev_port = listener.local_addr().unwrap().port(); + std::thread::spawn(move || { + for stream in listener.incoming().flatten() { + let mut stream = stream; + let mut buf = [0u8; 2048]; + let _ = std::io::Read::read(&mut stream, &mut buf); + let body = "

t

"; + let res = format!("HTTP/1.0 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body); + let _ = std::io::Write::write_all(&mut stream, res.as_bytes()); + } + }); + let hint = format!("http://127.0.0.1:{}/", dev_port); + let run = |provider: &str, extra: &[&str]| -> serde_json::Value { + let mut args = vec!["live-generate", "--selector", "h1", "--action", "bolder"]; + args.extend_from_slice(extra); + let out = std::process::Command::new(env!("CARGO_BIN_EXE_impeccable")) + .args(&args) + .current_dir(&s.dir) + .env("IMPECCABLE_LIVE_COPY_AGENT", "off") + .env("IMPECCABLE_PROVIDER_ID", provider) + .env("IMPECCABLE_DEV_URL_CANDIDATES", "http://127.0.0.1:1/") + .output() + .expect("cli"); + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + serde_json::from_str(stdout.trim()).unwrap_or_else(|e| panic!("{e}: {stdout}")) + }; + let cursor = run("cursor", &["--dev-url", &hint]); + assert_eq!(cursor["error"], serde_json::json!("browser_needed"), "{cursor}"); + assert_eq!(cursor["devUrl"], serde_json::json!(hint)); + assert_eq!(cursor["devUrlVerified"], serde_json::json!(false)); + assert_eq!(cursor["harness"], serde_json::json!("cursor")); + let text = cursor["_instructions"].as_str().unwrap(); + assert!(text.contains("browser_navigate") && text.contains(&hint) && !text.contains("--open"), "{text}"); + let claude = run("claude-code", &["--dev-url", &hint]); + assert!(claude["_instructions"].as_str().unwrap().contains("Browser pane"), "{claude}"); + let codex = run("codex", &["--dev-url", &hint]); + assert!(codex["_instructions"].as_str().unwrap().contains("--open"), "{codex}"); + // No hint and nothing on the usual ports: no_dev_server, with the + // harness's way to start one. + let none = run("claude-code", &[]); + assert_eq!(none["error"], serde_json::json!("no_dev_server"), "{none}"); + assert!(none["_instructions"].as_str().unwrap().contains("preview_start"), "{none}"); + // `--open` on a harness with its own browser launches nothing: BROWSER + // names a script that would record the launch, and it never runs. + let opener = s.dir.join("opener-guard.sh"); + std::fs::write(&opener, format!("#!/bin/sh\necho \"$1\" > {}\n", s.dir.join("guard-opened.txt").display())).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&opener, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + let out = std::process::Command::new(env!("CARGO_BIN_EXE_impeccable")) + .args(["live-generate", "--selector", "h1", "--action", "bolder", "--dev-url", &hint, "--open"]) + .current_dir(&s.dir) + .env("IMPECCABLE_LIVE_COPY_AGENT", "off") + .env("IMPECCABLE_PROVIDER_ID", "cursor") + .env("IMPECCABLE_DEV_URL_CANDIDATES", "http://127.0.0.1:1/") + .env("BROWSER", opener.to_string_lossy().to_string()) + .output() + .expect("cli"); + let guarded: serde_json::Value = serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()).unwrap(); + assert_eq!(guarded["error"], serde_json::json!("browser_needed"), "{guarded}"); + assert_eq!(guarded["openIgnored"], serde_json::json!("harness browser"), "{guarded}"); + assert!(guarded["_instructions"].as_str().unwrap().starts_with("--open was ignored"), "{guarded}"); + std::thread::sleep(Duration::from_millis(300)); + assert!(!s.dir.join("guard-opened.txt").exists(), "the harness browser guard must not launch the opener"); + // The user's explicit choice still wins on that harness. + let out = std::process::Command::new(env!("CARGO_BIN_EXE_impeccable")) + .args(["live-generate", "--selector", "h1", "--action", "bolder", "--dev-url", &hint, "--open", "--wait-for-browser", "500"]) + .current_dir(&s.dir) + .env("IMPECCABLE_LIVE_COPY_AGENT", "off") + .env("IMPECCABLE_PROVIDER_ID", "cursor") + .env("IMPECCABLE_DEV_URL_CANDIDATES", "http://127.0.0.1:1/") + .env("IMPECCABLE_BROWSER", opener.to_string_lossy().to_string()) + .output() + .expect("cli"); + let explicit: serde_json::Value = serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()).unwrap(); + assert_eq!(explicit["opened"]["url"], serde_json::json!(hint), "{explicit}"); + assert_eq!(std::fs::read_to_string(s.dir.join("guard-opened.txt")).unwrap().trim(), hint); + // A wait budget means the caller is opening the page in parallel: the + // verb waits instead of handing the URL back. + let waited = run("cursor", &["--dev-url", &hint, "--wait-for-browser", "700"]); + assert_eq!(waited["error"], serde_json::json!("no_browser_connected"), "{waited}"); + assert!(waited["_instructions"].as_str().unwrap().contains("browser_navigate"), "{waited}"); +} diff --git a/crates/live/src/bake.rs b/crates/live/src/bake.rs new file mode 100644 index 000000000..345b5ed98 --- /dev/null +++ b/crates/live/src/bake.rs @@ -0,0 +1,484 @@ +//! Mechanical bake of an accepted generate-lane variant. The lane's variants +//! carry no knobs, so what accept leaves behind (the chosen variant inside +//! its `data-impeccable-variant` div, and every variant's CSS in one +//! `").unwrap()); + +/// Plan the bake, or say why it is not mechanical. `css_lines` is the whole +/// preview stylesheet (JSX template wrap already stripped), `restored` the +/// accepted variant at the wrapper's indentation, `source_after_unwrap` the +/// source file with the variant unwrapped (to find its own `\n{v1}{v2}{v3} {{/* impeccable-variants-end {s} */}}\n \n \n \n );\n}}\n", + s = SESSION, + v1 = variant("1", false), + v2 = variant("2", true), + v3 = variant("3", true) + ) + } + + fn project(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("impeccable-accept-bake-{}-{}", tag, std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("src")).unwrap(); + std::fs::create_dir_all(dir.join(".impeccable/live")).unwrap(); + std::fs::write(dir.join("src/App.jsx"), app_jsx()).unwrap(); + std::fs::write(dir.join("src/styles.css"), ".pricing-grid { display: grid; gap: 20px; }\n.pricing-card { padding: 20px; }\n").unwrap(); + std::fs::write(dir.join("index.html"), "
").unwrap(); + std::fs::write(dir.join("package.json"), "{\"name\":\"t\"}").unwrap(); + dir + } + + fn accept(dir: &PathBuf, args: &[&str]) -> Value { + let env: Env = std::env::vars().collect(); + let (mut io, captured) = Io::captured("", dir.clone(), env); + let argv: Vec = args.iter().map(|a| a.to_string()).collect(); + let code = run(&argv, &mut io); + drop(io); + let out = String::from_utf8_lossy(&captured.stdout.borrow()).into_owned(); + let err = String::from_utf8_lossy(&captured.stderr.borrow()).into_owned(); + assert_eq!(code, 0, "stdout: {out}\nstderr: {err}"); + serde_json::from_str(out.trim()).unwrap_or_else(|e| panic!("{e}: {out}")) + } + + #[test] + fn a_bake_makes_the_variant_permanent_and_appends_its_rules() { + let dir = project("flag"); + let result = accept(&dir, &["--id", SESSION, "--variant", "2", "--bake"]); + assert_eq!(result["handled"], json!(true), "{result}"); + assert_eq!(result["baked"], json!(true), "{result}"); + assert_eq!(result["carbonize"], json!(false)); + assert_eq!(result["variant"], json!("2")); + assert_eq!(result["css"]["file"], json!("src/styles.css"), "{result}"); + assert_eq!(result["css"]["rules"], json!(2)); + assert_eq!(result["css"]["anchor"], json!("div.pricing-grid")); + assert_eq!(result["verify"]["clean"], json!(true), "{result}"); + let jsx = std::fs::read_to_string(dir.join("src/App.jsx")).unwrap(); + assert!(!jsx.contains("data-impeccable"), "{jsx}"); + assert!(!jsx.contains("impeccable-variants"), "{jsx}"); + assert!(!jsx.contains("Simple pricing\n
\n
Starter
\n
\n "), "{jsx}"); + let css = std::fs::read_to_string(dir.join("src/styles.css")).unwrap(); + assert!(css.starts_with(".pricing-grid { display: grid; gap: 20px; }\n"), "existing rules untouched: {css}"); + assert!(css.contains("/* impeccable generate ab12cd34: accepted variant 2 */"), "{css}"); + assert!(css.contains(".pricing-grid { gap: 32px; }"), "{css}"); + assert!(css.contains("div.pricing-grid .pricing-card { border: 2px solid #111; }"), "{css}"); + assert!(!css.contains("8px") && !css.contains("gap: 0"), "other variants dropped: {css}"); + assert!(!css.contains(":scope") && !css.contains("data-impeccable"), "{css}"); + // Idempotent: the receipt answers a rerun. + let again = accept(&dir, &["--id", SESSION, "--variant", "2", "--bake"]); + assert_eq!(again["alreadyApplied"], json!(true), "{again}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn an_agent_started_session_bakes_by_default_and_plain_live_does_not() { + let dir = project("origin"); + let cwd = dir.to_string_lossy().into_owned(); + let env: Env = std::env::vars().collect(); + // Plain live: no origin, no flag -> the carbonize block, as before. + let plain = accept(&dir, &["--id", SESSION, "--variant", "2"]); + assert_eq!(plain["carbonize"], json!(true), "{plain}"); + assert!(plain.get("baked").is_none(), "{plain}"); + let jsx = std::fs::read_to_string(dir.join("src/App.jsx")).unwrap(); + assert!(jsx.contains("impeccable-carbonize-start"), "{jsx}"); + assert!(!std::fs::read_to_string(dir.join("src/styles.css")).unwrap().contains("32px")); + + // The generate verb's session: the journal says origin agent. + let dir2 = project("origin2"); + let cwd2 = dir2.to_string_lossy().into_owned(); + let store = create_live_session_store(&cwd2, &env, Some(SESSION)); + store + .append_event(&json!({ "type": "generate", "id": SESSION, "origin": "agent", "count": 3, "pageUrl": "/", "action": "bolder" })) + .unwrap(); + let baked = accept(&dir2, &["--id", SESSION, "--variant", "2"]); + assert_eq!(baked["baked"], json!(true), "{baked}"); + assert!(!std::fs::read_to_string(dir2.join("src/App.jsx")).unwrap().contains("data-impeccable")); + // --no-bake wins over the origin. + let dir3 = project("origin3"); + let cwd3 = dir3.to_string_lossy().into_owned(); + create_live_session_store(&cwd3, &env, Some(SESSION)) + .append_event(&json!({ "type": "generate", "id": SESSION, "origin": "agent", "count": 3, "pageUrl": "/", "action": "bolder" })) + .unwrap(); + let kept = accept(&dir3, &["--id", SESSION, "--variant", "2", "--no-bake"]); + assert_eq!(kept["carbonize"], json!(true), "{kept}"); + let _ = (cwd, cwd2, cwd3); + for d in [dir, dir2, dir3] { + let _ = std::fs::remove_dir_all(&d); + } + } + + #[test] + fn a_knob_session_falls_back_to_the_carbonize_block_with_the_reason() { + let dir = project("knobs"); + let src = std::fs::read_to_string(dir.join("src/App.jsx")).unwrap().replace("gap: 32px", "gap: var(--p-gap, 32px)"); + std::fs::write(dir.join("src/App.jsx"), src).unwrap(); + let result = accept(&dir, &["--id", SESSION, "--variant", "2", "--bake"]); + assert_eq!(result["carbonize"], json!(true), "{result}"); + assert!(result["bakeSkipped"].as_str().unwrap().contains("knobs"), "{result}"); + let jsx = std::fs::read_to_string(dir.join("src/App.jsx")).unwrap(); + assert!(jsx.contains("impeccable-carbonize-start"), "{jsx}"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/live/src/live_generate.rs b/crates/live/src/live_generate.rs index 529c820ef..b5554894f 100644 --- a/crates/live/src/live_generate.rs +++ b/crates/live/src/live_generate.rs @@ -4,9 +4,11 @@ //! Asks the live overlay to find an element by CSS selector, scroll to it, //! enter the picked state, and fire the normal Go pipeline with the given //! action and count. On success the browser starts a standard generate -//! session; the agent then handles the resulting `generate` event from the -//! poll loop exactly as live.md describes. Requires a running live helper -//! server (`impeccable live` boot) and an open page with the overlay attached. +//! session and the verb collects that session's `generate` event into its +//! own output, so the agent's next move is the edit. With `--boot` it runs +//! the lane's boot itself first (reusing a running helper), and with +//! `--open` it opens the dev URL in the browser when no page is connected: +//! one command from a cold project to a leased generate event. use crate::live_resume::self_cmd; use crate::paths::read_live_server_info; @@ -17,9 +19,25 @@ use impeccable_common::Io; use serde_json::{json, Map, Value}; use std::time::{Duration, Instant}; -const HELP: &str = "Usage: impeccable live-generate --selector [--text ] [--index ] [--action ] [--count ] [--prompt ] [--dry-run] [--wait-for-browser ] [--no-live-bar] +const HELP: &str = "Usage: impeccable live-generate --selector [--text ] [--index ] [--action ] [--count ] [--prompt ] [--dry-run] [--wait-for-browser ] [--no-live-bar] [--boot] [--open] [--target ] Flags: + --boot optional; run the generate lane's boot first (impeccable live + --allow-missing-context --dev-url --no-live-bar, with --target + when given), reusing a running helper; the boot's context and + devUrl ride along in the output as `boot` + --open optional; when no page with the overlay is connected, open the + dev URL in the browser (IMPECCABLE_BROWSER, then `browser` in + .impeccable/config.local.json or config.json, then BROWSER, then + the platform opener) and wait for it (60 s unless + --wait-for-browser says otherwise). On a harness with its own + browser (Cursor, Claude Code) the flag is ignored unless + IMPECCABLE_BROWSER or the config's `browser` names one: the + page comes from the harness browser, never a second window + --target optional; the file that renders the element (the boot's --target) + --dev-url optional; the dev server you already know (a server the + harness runs, a tab on the app, the user's message); probed + first, reported as devUrl either way --selector required; resolved with document.querySelectorAll --text optional; keeps only matches whose textContent contains it --index optional; 1-based pick among the remaining matches @@ -30,6 +48,15 @@ Flags: --wait-for-browser optional; poll the helper until a page with the overlay connects (or the budget runs out) before sending the target. + +With no page connected and neither --open nor --wait-for-browser, the verdict +is browser_needed with devUrl and the harness's way to open it (Cursor +browser_navigate, Claude Code's Browser pane, Codex: --open or the user), so no +second browser is ever launched behind a harness that has one. + +On success the output carries the session's generate event as `event` (already +leased, with its _instructions), so the next command is the edit, then +`live-poll --reply done --file --then-poll` for the accept. "; /// Client-side cap just above the server's 15s hold, so a hung helper still @@ -40,12 +67,16 @@ struct Flags { values: Map, dry_run: bool, no_live_bar: bool, + boot: bool, + open: bool, } fn parse_flags(argv: &[String]) -> Result { let mut values = Map::new(); let mut dry_run = false; let mut no_live_bar = false; + let mut boot = false; + let mut open = false; let mut i = 0; while i < argv.len() { let arg = &argv[i]; @@ -64,6 +95,34 @@ fn parse_flags(argv: &[String]) -> Result { i += 1; continue; } + if key == "boot" { + boot = true; + i += 1; + continue; + } + if key == "open" { + open = true; + i += 1; + continue; + } + // The boot's own opt-in, tolerated here so a caller that spells the + // lane's boot flags on this verb is not refused. + if key == "allow-missing-context" { + i += 1; + continue; + } + // `--dev-url` alone is the boot's probe flag (tolerated); with a + // value it is the dev server the caller already knows. + if key == "dev-url" { + match argv.get(i + 1) { + Some(v) if !v.starts_with("--") => { + values.insert(key.to_string(), json!(v)); + i += 2; + } + _ => i += 1, + } + continue; + } match argv.get(i + 1) { Some(v) if !v.starts_with("--") => { values.insert(key.to_string(), json!(v)); @@ -78,6 +137,8 @@ fn parse_flags(argv: &[String]) -> Result { values, dry_run, no_live_bar, + boot, + open, }) } @@ -130,12 +191,24 @@ fn instructions_for(result: &Map, self_cmd: &str) -> Option --then-poll", self_cmd, s("sessionId")); + if result.get("event").map(|e| e.is_object()).unwrap_or(false) { + return Some(format!( + "Session {} started: the browser scrolled to the target and fired Go (action \"{}\", count {}). Its generate event is in this output as `event`, already leased: follow event._instructions (identity from the event, ONE edit, no knobs). When the edit is written, reply and wait for the user's choice in one call: {}. The accept it returns is baked into source mechanically (_acceptResult.baked) and completes the session; then stop the helper.", + s("sessionId"), s("action"), n("count"), reply + )); + } return Some(format!( - "Session {} started: the browser scrolled to the target and fired Go (action \"{}\", count {}). Poll now with {} live-poll; the next event for this session is its generate event, and its _instructions carry the whole fast path (identity from the event, one edit, reply done). Follow them, then keep polling for the accept.", - s("sessionId"), s("action"), n("count"), self_cmd + "Session {} started: the browser scrolled to the target and fired Go (action \"{}\", count {}). Its generate event had not arrived yet: run {} live-poll to collect it (its _instructions carry the fast path: identity from the event, ONE edit, no knobs), then reply and wait for the accept in one call: {}.", + s("sessionId"), s("action"), n("count"), self_cmd, reply )); } let text = match s("error").as_str() { + "no_dev_server" => format!("No dev server is serving this app: none of the usual ports answered with the page carrying the helper's tag (pass --dev-url when you know where it runs). {}", start_dev_server_hint(&s("harness"))), + "browser_needed" => format!("{}The helper is up and no page is connected yet. {} Then rerun this exact command with --wait-for-browser 60000.", open_ignored_note(result), open_in_harness_hint(&s("harness"), &s("devUrl"), self_cmd)), + "browser_open_failed" => format!("The browser could not be launched ({}). Open {} yourself with your harness browser tool, or give the user the URL, then rerun this command with --wait-for-browser 120000.", s("detail"), s("url")), + "no_browser_connected" if result.get("opened").map(|o| o.is_object()).unwrap_or(false) => format!("The page was opened in the browser but no overlay connected within {} ms. The dev server may still be compiling, or the page does not carry the injected tag (check pageFiles). Reload the page, then rerun this command.", n("waitedMs")), + "no_browser_connected" if !s("devUrl").is_empty() => format!("{}No page with the live overlay connected within {} ms. {} Then rerun this exact command with --wait-for-browser 60000.", open_ignored_note(result), n("waitedMs"), open_in_harness_hint(&s("harness"), &s("devUrl"), self_cmd)), "no_browser_connected" => "No page with the live overlay is connected. Open the app URL that serves a pageFiles entry yourself with your harness browser tool, then rerun this command. Only when no browser tool exists: give the user the URL and rerun with --wait-for-browser 120000 so the command fires as soon as they open the page.".to_string(), "browser_timeout" => "The overlay did not answer in time, and no session was started for this request (a Go that lands late is refused). The page may be mid-reload: reload the app page, then rerun this command.".to_string(), "invalid_selector" => "The selector is not valid CSS. Fix the selector syntax and rerun.".to_string(), @@ -162,6 +235,37 @@ fn instructions_for(result: &Map, self_cmd: &str) -> Option) -> &'static str { + if result.get("openIgnored").is_some() { + "--open was ignored: this harness has its own browser, and a second window is exactly what the lane avoids. " + } else { + "" + } +} + +/// How this harness opens a page: its own browser when it has one (no second +/// browser behind it), the system browser or the user otherwise. +fn open_in_harness_hint(harness: &str, dev_url: &str, self_cmd: &str) -> String { + let _ = self_cmd; + match harness { + "cursor" => format!("Open {} with browser_navigate (Cursor's browser; it reuses the tab already on that origin).", dev_url), + "claude-code" => format!("Open {} in the Browser pane: navigate the tab already on that origin (tabs_context lists them), or preview_start with that URL when the pane is closed.", dev_url), + "codex" => format!("Codex has no browser tool: rerun this command with --open (the system browser opens {}), or give the user that URL.", dev_url), + _ => format!("Open {} with your harness's browser tool, reusing a tab already on that origin; without one, rerun this command with --open (the system browser), or give the user that URL.", dev_url), + } +} + +/// Where a dev server gets started in this harness, so the one already +/// running there is the one the page comes from. +fn start_dev_server_hint(harness: &str) -> String { + match harness { + "claude-code" => "Start it the way the harness runs servers (preview_start with the project's dev configuration, or the dev script in a background shell), wait for its URL, then rerun this exact command with --dev-url ; never kill or restart that server afterwards.".to_string(), + "cursor" => "Start the project's dev script in a background terminal (npm run dev or the framework's equivalent), wait for it to print its URL, then rerun this exact command with --dev-url ; never kill or restart that server afterwards.".to_string(), + _ => "Start the project's dev script in a background terminal (npm run dev or the framework's equivalent), wait for it to print its URL, then rerun this exact command with --dev-url ; never kill or restart that server afterwards.".to_string(), + } +} + fn server_died(self_cmd: &str, detail: Option, waiting: bool) -> Value { let mut v = Map::new(); v.insert("ok".into(), json!(false)); @@ -186,17 +290,157 @@ fn server_not_running(self_cmd: &str) -> Value { }) } +/// The lane's boot flags, run in-process from the caller's original cwd +/// (`--target` is a path relative to it). Ok: the boot payload. Err: a +/// verdict to print, exit 1. +fn run_boot(args: &[String], original_cwd: &std::path::Path, io: &Io, dev_url_hint: Option<&str>) -> Result, Value> { + let mut boot_args: Vec = Vec::new(); + if let Some(i) = args.iter().position(|a| a == "--target") { + if let Some(t) = args.get(i + 1).filter(|t| !t.starts_with("--")) { + boot_args.push("--target".into()); + boot_args.push(t.clone()); + } + } + for a in args { + if let Some(t) = a.strip_prefix("--target=") { + boot_args.push("--target".into()); + boot_args.push(t.to_string()); + } + } + boot_args.push("--allow-missing-context".into()); + boot_args.push("--dev-url".into()); + boot_args.push("--no-live-bar".into()); + let mut env = io.env.clone(); + if let Some(hint) = dev_url_hint { + // The known server first, the usual ports behind it, unless the + // caller already narrowed the list. + if !env.contains_key("IMPECCABLE_DEV_URL_CANDIDATES") { + let mut list = vec![hint.trim_end_matches('/').to_string() + "/"]; + list.extend(crate::dev_url::candidates(None)); + env.insert("IMPECCABLE_DEV_URL_CANDIDATES".into(), list.join(",")); + } + } + let (mut child, captured) = Io::captured("", original_cwd.to_path_buf(), env); + let code = crate::live_boot::run(&boot_args, &mut child); + let out = String::from_utf8_lossy(&captured.stdout.borrow()).into_owned(); + let err = String::from_utf8_lossy(&captured.stderr.borrow()).into_owned(); + let payload: Option> = serde_json::from_str::(out.trim()) + .ok() + .and_then(|v| v.as_object().cloned()); + let Some(mut payload) = payload else { + return Err(json!({ + "ok": false, + "error": "boot_failed", + "exitCode": code, + "detail": if err.trim().is_empty() { out.trim().to_string() } else { err.trim().to_string() }, + "_instructions": "The live boot did not produce a verdict. Run `impeccable live --allow-missing-context --dev-url --no-live-bar` on its own, read its output, and fix what it names before rerunning this command.", + })); + }; + if payload.get("ok").and_then(Value::as_bool) != Some(true) { + let error = payload.get("error").and_then(Value::as_str).unwrap_or("").to_string(); + let text = match error.as_str() { + "config_missing" | "config_invalid" => "The live config is missing or invalid: follow reference/live-setup.md to create .impeccable/live/config.json, then rerun this command.", + "target_selection_required" => "Several apps live here: ask the user which one, then rerun this command with --target .", + "context_missing" => "The boot refused for missing context even though this verb asks it to proceed; rerun with the boot's own flags to see why.", + _ => "The boot refused; its fields say why. Fix that, then rerun this command.", + }; + payload.insert("ok".into(), json!(false)); + payload.insert("bootError".into(), json!(error)); + payload.insert("_instructions".into(), json!(text)); + return Err(Value::Object(payload)); + } + Ok(payload) +} + +/// What the verdict repeats from the boot: the context the edit needs and +/// where the page is. Plumbing (token, roots, drift) stays out. +fn boot_summary(boot: &Map) -> Value { + let mut m = Map::new(); + for key in [ + "devUrl", "pageFiles", "projectRoot", "targetPath", "liveBarHidden", "contextMissing", "contextNote", + "hasProduct", "product", "productPath", "hasDesign", "design", "designPath", "hasSurfaceBrief", + "surfaceBrief", "surfaceBriefPath", + ] { + if let Some(v) = boot.get(key) { + m.insert(key.into(), v.clone()); + } + } + Value::Object(m) +} + +/// Collect the session's own generate event (`GET /poll?types=generate&id=`) +/// so the caller's next move is the edit. The event is leased exactly as a +/// poll would lease it; nothing else in the queue is touched. +fn fetch_generate_event(port: i64, token: &str, session_id: &str, budget: Duration, self_cmd: &str) -> Option { + let deadline = Instant::now() + budget; + while Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(Instant::now()).as_millis() as u64; + let slice = remaining.clamp(1_000, 5_000); + let url = format!( + "http://127.0.0.1:{}/poll?token={}&timeout={}&leaseMs={}&types=generate&id={}", + port, + crate::live_poll::form_encode(token), + slice, + crate::live_poll::DEFAULT_EVENT_LEASE_MS, + crate::live_poll::form_encode(session_id) + ); + let agent = ureq::AgentBuilder::new() + .timeout(Duration::from_millis(slice + 30_000)) + .build(); + let Ok(res) = agent.get(&url).call() else { return None }; + let Ok(mut event) = res.into_json::() else { return None }; + match event.get("type").and_then(Value::as_str) { + Some("generate") => { + if let Some(obj) = event.as_object_mut() { + match crate::instructions::instructions_for_event(obj, self_cmd) { + Some(text) if !text.is_empty() => { + obj.insert("_instructions".into(), json!(text)); + } + _ => { + obj.remove("_instructions"); + } + } + } + return Some(event); + } + Some("timeout") => continue, + _ => return None, + } + } + None +} + +/// How long the verb waits for the generate event after a started session. +const EVENT_BUDGET_MS: u64 = 20_000; +/// The wait `--open` implies when `--wait-for-browser` was not given. +const OPEN_WAIT_MS: u64 = 60_000; + pub fn run(args: &[String], io: &mut Io) -> i32 { + if args.iter().any(|a| a == "--help" || a == "-h") { + println(io, HELP); + return 0; + } + let flags_probe = match parse_flags(args) { + Ok(f) => f, + Err(v) => return fail(io, v), + }; + // `--boot` runs before the root switch: the boot writes the roots + // manifest the switch reads, and reads --target relative to this cwd. + let original_cwd = io.cwd.clone(); + let dev_url_hint: Option = flag(&flags_probe, "dev-url").map(str::trim).filter(|u| !u.is_empty()).map(str::to_string); + let mut boot: Option> = None; + if flags_probe.boot { + match run_boot(args, &original_cwd, io, dev_url_hint.as_deref()) { + Ok(b) => boot = Some(b), + Err(v) => return fail(io, v), + } + } let mut argv: Vec = args.to_vec(); if let Err(code) = enter_live_root(&mut argv, io) { return code; } let cwd = io.cwd.to_string_lossy().into_owned(); let env = io.env.clone(); - if argv.iter().any(|a| a == "--help" || a == "-h") { - println(io, HELP); - return 0; - } let me = self_cmd(io); let flags = match parse_flags(&argv) { Ok(f) => f, @@ -239,7 +483,7 @@ pub fn run(args: &[String], io: &mut Io) -> i32 { } }, }; - let wait_for_browser_ms = match flag(&flags, "wait-for-browser") { + let mut wait_for_browser_ms = match flag(&flags, "wait-for-browser") { None => 0, Some(raw) => match int_flag(raw) { Some(ms) if ms >= 1 => ms as u64, @@ -257,6 +501,113 @@ pub fn run(args: &[String], io: &mut Io) -> i32 { let (Some(port), Some(token)) = (port, token) else { return fail(io, server_not_running(&me)); }; + let harness = impeccable_context::provider::detect(&env, &cwd).id; + // Every verdict from here on repeats what the boot found, so a refusal + // still hands the caller its context and dev URL. + let with_boot = |mut v: Map| -> Value { + if let Some(b) = &boot { + v.insert("boot".into(), boot_summary(b)); + } + v.insert("harness".into(), json!(harness)); + Value::Object(v) + }; + + // Where the page is: the boot's probe, else a probe led by the caller's + // hint, else the hint itself (a server the harness runs that answers + // without our tag yet, before its first reload). + let resolve_dev_url = |boot: &Option>| -> (Option, bool) { + if let Some(u) = boot.as_ref().and_then(|b| b.get("devUrl")).and_then(Value::as_str).filter(|u| !u.is_empty()) { + return (Some(u.to_string()), true); + } + let mut candidates: Vec = Vec::new(); + if let Some(h) = &dev_url_hint { + candidates.push(h.trim_end_matches('/').to_string() + "/"); + } + candidates.extend(crate::dev_url::candidates(env.get("IMPECCABLE_DEV_URL_CANDIDATES").map(String::as_str))); + if let Some(u) = crate::dev_url::probe(&candidates, &token) { + return (Some(u), true); + } + (dev_url_hint.clone(), false) + }; + + // A harness with its own browser never gets a second window from this + // verb: `--open` there is ignored unless the user chose a browser + // explicitly (IMPECCABLE_BROWSER or the config's `browser`; the generic + // BROWSER variable is not that choice). + let harness_has_browser = matches!(harness.as_str(), "cursor" | "claude-code"); + let open_ignored = flags.open && harness_has_browser && crate::browser_open::explicit_browser(&cwd, &env).is_none(); + let open = flags.open && !open_ignored; + let with_open_note = |mut v: Map| -> Map { + if open_ignored { + v.insert("openIgnored".into(), json!("harness browser")); + } + v + }; + + // Nothing connected, nothing asked to open, nothing to wait for: the + // caller opens the page itself (its harness's browser, never a second + // one behind it) and comes back. + let mut opened: Option = None; + if !open && wait_for_browser_ms == 0 && !flags.dry_run { + let Some(status) = crate::server::fetch_status(port, &token) else { + return fail(io, server_died(&me, None, false)); + }; + if status.get("connectedClients").and_then(Value::as_i64).unwrap_or(0) == 0 { + let (dev_url, verified) = resolve_dev_url(&boot); + let mut v = Map::new(); + v.insert("ok".into(), json!(false)); + if let Some(u) = dev_url { + v.insert("error".into(), json!("browser_needed")); + v.insert("devUrl".into(), json!(u)); + v.insert("devUrlVerified".into(), json!(verified)); + } else { + v.insert("error".into(), json!("no_dev_server")); + } + v.insert("harness".into(), json!(harness)); + let mut v = with_open_note(v); + let text = instructions_for(&v, &me).unwrap_or_default(); + v.insert("_instructions".into(), json!(text)); + return fail(io, with_boot(v)); + } + } + + // `--open`: hand the page to the browser when nothing is connected yet. + if open { + let Some(status) = crate::server::fetch_status(port, &token) else { + return fail(io, server_died(&me, None, false)); + }; + let connected = status.get("connectedClients").and_then(Value::as_i64).unwrap_or(0) > 0; + if !connected { + let (dev_url, _) = resolve_dev_url(&boot); + let Some(url) = dev_url else { + let mut v = Map::new(); + v.insert("ok".into(), json!(false)); + v.insert("error".into(), json!("no_dev_server")); + v.insert("harness".into(), json!(harness)); + let text = instructions_for(&v, &me).unwrap_or_default(); + v.insert("_instructions".into(), json!(text)); + return fail(io, with_boot(v)); + }; + match crate::browser_open::open_url(&url, &cwd, &env) { + Ok(via) => { + opened = Some(json!({ "url": url, "via": via })); + if wait_for_browser_ms == 0 { + wait_for_browser_ms = OPEN_WAIT_MS; + } + } + Err(detail) => { + let mut v = Map::new(); + v.insert("ok".into(), json!(false)); + v.insert("error".into(), json!("browser_open_failed")); + v.insert("url".into(), json!(url)); + v.insert("detail".into(), json!(detail)); + let text = instructions_for(&v, &me).unwrap_or_default(); + v.insert("_instructions".into(), json!(text)); + return fail(io, with_boot(v)); + } + } + } + } if wait_for_browser_ms > 0 { let deadline = Instant::now() + Duration::from_millis(wait_for_browser_ms); @@ -272,9 +623,16 @@ pub fn run(args: &[String], io: &mut Io) -> i32 { v.insert("ok".into(), json!(false)); v.insert("error".into(), json!("no_browser_connected")); v.insert("waitedMs".into(), json!(wait_for_browser_ms)); + if let Some(o) = &opened { + v.insert("opened".into(), o.clone()); + } else if let (Some(u), _) = resolve_dev_url(&boot) { + v.insert("devUrl".into(), json!(u)); + } + v.insert("harness".into(), json!(harness)); + let mut v = with_open_note(v); let text = instructions_for(&v, &me).unwrap_or_default(); v.insert("_instructions".into(), json!(text)); - return fail(io, Value::Object(v)); + return fail(io, with_boot(v)); } std::thread::sleep(Duration::from_millis(1_000)); } @@ -297,7 +655,7 @@ pub fn run(args: &[String], io: &mut Io) -> i32 { if flags.dry_run { body.insert("dryRun".into(), json!(true)); } - if flags.no_live_bar { + if flags.no_live_bar || flags.boot { body.insert("hideLiveBar".into(), json!(true)); } @@ -353,9 +711,23 @@ pub fn run(args: &[String], io: &mut Io) -> i32 { v.insert(k, val); } } - return fail(io, Value::Object(v)); + return fail(io, with_boot(v)); } let ok = fields.get("ok").and_then(Value::as_bool) == Some(true); + let dry_run = fields.get("dryRun").and_then(Value::as_bool) == Some(true); + if let Some(b) = &boot { + fields.insert("boot".into(), boot_summary(b)); + } + if let Some(o) = &opened { + fields.insert("opened".into(), o.clone()); + } + if ok && !dry_run { + let session_id = fields.get("sessionId").and_then(Value::as_str).map(str::to_string); + if let Some(sid) = session_id.filter(|s| !s.is_empty()) { + let event = fetch_generate_event(port, &token, &sid, Duration::from_millis(EVENT_BUDGET_MS), &me); + fields.insert("event".into(), event.unwrap_or(Value::Null)); + } + } if let Some(text) = instructions_for(&fields, &me) { fields.insert("_instructions".into(), json!(text)); } @@ -389,6 +761,64 @@ mod tests { assert!(!parse_flags(&["--selector".to_string(), "h1".to_string()]).unwrap().no_live_bar); } + #[test] + fn dev_url_takes_a_value_and_still_works_bare() { + let with = parse_flags(&["--dev-url".to_string(), "http://127.0.0.1:5173/".to_string(), "--selector".to_string(), "h1".to_string()]).unwrap(); + assert_eq!(with.values.get("dev-url").and_then(Value::as_str), Some("http://127.0.0.1:5173/")); + let bare = parse_flags(&["--dev-url".to_string(), "--selector".to_string(), "h1".to_string()]).unwrap(); + assert!(bare.values.get("dev-url").is_none()); + assert_eq!(bare.values.get("selector").and_then(Value::as_str), Some("h1")); + } + + #[test] + fn browser_needed_names_the_harness_browser_and_never_a_second_one() { + let mut m = Map::new(); + m.insert("ok".into(), json!(false)); + m.insert("error".into(), json!("browser_needed")); + m.insert("devUrl".into(), json!("http://127.0.0.1:5173/")); + m.insert("harness".into(), json!("cursor")); + let cursor = instructions_for(&m, "impeccable").unwrap(); + assert!(cursor.contains("browser_navigate"), "{cursor}"); + assert!(cursor.contains("--wait-for-browser 60000"), "{cursor}"); + assert!(!cursor.contains("--open"), "a harness with a browser is never told to open a second one: {cursor}"); + m.insert("harness".into(), json!("claude-code")); + let claude = instructions_for(&m, "impeccable").unwrap(); + assert!(claude.contains("Browser pane") && claude.contains("navigate") && claude.contains("preview_start"), "{claude}"); + assert!(!claude.contains("--open"), "{claude}"); + m.insert("harness".into(), json!("codex")); + let codex = instructions_for(&m, "impeccable").unwrap(); + assert!(codex.contains("--open") && codex.contains("give the user"), "{codex}"); + m.insert("harness".into(), json!("source")); + let other = instructions_for(&m, "impeccable").unwrap(); + assert!(other.contains("browser tool") && other.contains("--open"), "{other}"); + } + + #[test] + fn an_ignored_open_says_so_before_the_harness_hint() { + let mut m = Map::new(); + m.insert("ok".into(), json!(false)); + m.insert("error".into(), json!("browser_needed")); + m.insert("devUrl".into(), json!("http://127.0.0.1:5173/")); + m.insert("harness".into(), json!("cursor")); + m.insert("openIgnored".into(), json!("harness browser")); + let text = instructions_for(&m, "impeccable").unwrap(); + assert!(text.starts_with("--open was ignored"), "{text}"); + assert!(text.contains("browser_navigate"), "{text}"); + } + + #[test] + fn a_missing_dev_server_points_at_the_harness_way_to_start_one() { + let mut m = Map::new(); + m.insert("ok".into(), json!(false)); + m.insert("error".into(), json!("no_dev_server")); + m.insert("harness".into(), json!("claude-code")); + let text = instructions_for(&m, "impeccable").unwrap(); + assert!(text.contains("preview_start") && text.contains("--dev-url"), "{text}"); + m.insert("harness".into(), json!("cursor")); + let text = instructions_for(&m, "impeccable").unwrap(); + assert!(text.contains("background terminal") && text.contains("--dev-url"), "{text}"); + } + #[test] fn timeout_instructions_promise_no_stray_session() { let mut m = Map::new(); diff --git a/crates/live/src/live_poll.rs b/crates/live/src/live_poll.rs index ebd7ef4ea..1a17ffafa 100644 --- a/crates/live/src/live_poll.rs +++ b/crates/live/src/live_poll.rs @@ -31,6 +31,9 @@ Modes: poll --reply error \"msg\" Reply with an error message poll --reply done --data '' Reply with a structured JSON result (manual_edit_apply) + poll --reply done --then-poll + Reply, then keep waiting for the next event in the + same call (the generate lane: done, then the accept) Options: --timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode @@ -38,6 +41,8 @@ Options: --ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000) --file PATH Attach a source file path to the reply (generate/steer flow) --data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON + --then-poll After a successful --reply, run the one-shot poll and print its event + (the reply's ack rides along as _replyAck). --timeout= bounds the wait --help Show this help message Harness note: @@ -324,7 +329,7 @@ fn normalize_poll_types(value: Option<&str>) -> Vec { out } -fn form_encode(s: &str) -> String { +pub(crate) fn form_encode(s: &str) -> String { // URLSearchParams serialization (application/x-www-form-urlencoded) let mut out = String::new(); for b in s.bytes() { @@ -729,12 +734,16 @@ pub fn run(args: &[String], io: &mut Io) -> i32 { return 1; } }; + let then_poll = argv.iter().any(|a| a == "--then-poll"); return match post_reply(&base, &token, &reply) { Ok(()) => { - println( - io, - &serde_json::to_string(&reply_ack_json(&reply)).unwrap_or_default(), - ); + let ack = reply_ack_json(&reply); + if then_poll { + // One round trip instead of two: the reply is in, so wait + // for what the browser does next (usually the accept). + return one_shot_poll(&argv, &base, &token, Some(ack), io); + } + println(io, &serde_json::to_string(&ack).unwrap_or_default()); 0 } Err(PollError::ConnRefused) => { @@ -803,7 +812,20 @@ pub fn run(args: &[String], io: &mut Io) -> i32 { } } - let total_timeout = arg_value_int(&argv, "--timeout=", 600_000); + one_shot_poll(&argv, &base, &token, None, io) +} + +/// The default mode: block until one event (or the `--timeout=` deadline), +/// handle it, print it. `reply_ack` is the `--then-poll` case: the reply +/// that just went out rides along as `_replyAck` on the printed event so +/// the caller sees both halves of its one call. +fn one_shot_poll(argv: &[String], base: &str, token: &str, reply_ack: Option, io: &mut Io) -> i32 { + let types_arg = argv + .iter() + .find(|a| a.starts_with("--types=")) + .map(|a| a["--types=".len()..].to_string()); + let types = normalize_poll_types(types_arg.as_deref()); + let total_timeout = arg_value_int(argv, "--timeout=", 600_000); // JS: Date.now() + NaN -> NaN deadline; comparisons are false, so the // loop never times out. Approximate with a far deadline. let deadline = if total_timeout == i64::MIN { @@ -811,12 +833,24 @@ pub fn run(args: &[String], io: &mut Io) -> i32 { } else { Instant::now() + Duration::from_millis(total_timeout.max(0) as u64) }; - match fetch_next_event(&base, &token, Some(deadline), &types) { - Ok(event) => { - handle_event(event, &base, &token, io); + match fetch_next_event(base, token, Some(deadline), &types) { + Ok(mut event) => { + if let (Some(mut ack), Some(obj)) = (reply_ack, event.as_object_mut()) { + if let Some(a) = ack.as_object_mut() { + a.remove("_instructions"); + } + obj.insert("_replyAck".into(), ack); + } + handle_event(event, base, token, io); 0 } - Err(e) => handle_poll_error(e, io), + Err(e) => { + if let Some(ack) = reply_ack { + // The reply itself succeeded; say so before the poll's error. + println(io, &serde_json::to_string(&ack).unwrap_or_default()); + } + handle_poll_error(e, io) + } } } diff --git a/crates/live/src/live_server.rs b/crates/live/src/live_server.rs index 48b832c10..3aa3afc67 100644 --- a/crates/live/src/live_server.rs +++ b/crates/live/src/live_server.rs @@ -663,9 +663,9 @@ fn handle_connection(shared: Shared, mut stream: TcpStream, mut ticket: Ticket) ); return; } - let (cwd, env, port, roots) = { + let (cwd, env, port, roots, live_bar_hidden) = { let st = lock(&shared); - (st.cwd.clone(), st.env.clone(), st.port, st.roots.clone()) + (st.cwd.clone(), st.env.clone(), st.port, st.roots.clone(), st.hide_live_bar) }; let parts = match read_live_browser_script_parts(scripts_dir(&env, &cwd).as_deref()) { Ok(p) => p, @@ -692,7 +692,7 @@ fn handle_connection(shared: Shared, mut stream: TcpStream, mut ticket: Ticket) roots.as_ref().and_then(|r| r.context_root.as_deref()), roots.as_ref().map(|r| r.repo_root.as_str()), ); - let body = assemble_live_browser_script(&token_now, port, &prefix, &cwd, &parts, &project_ignores); + let body = assemble_live_browser_script(&token_now, port, &prefix, &cwd, &parts, &project_ignores, live_bar_hidden); respond( &mut stream, &cors, @@ -1369,9 +1369,16 @@ fn handle_poll_get( let lease_raw = parse_int_or(req.query_get("leaseMs"), 30000); let lease_ms = if lease_raw == i64::MIN { 0 } else { lease_raw }; let types = parse_poll_types(req.query_get("types")); + // `id=`: only that session's events (the generate verb collecting its + // own generate event leaves every other session's queue alone). + let event_id = req + .query_get("id") + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); let mut st = lock(shared); st.last_poll_at = now_i64(); - if let Some(idx) = st.find_available_pending_event(types.as_deref()) { + if let Some(idx) = st.find_available_pending_event(types.as_deref(), event_id.as_deref()) { st.pending_events[idx].lease_until = now_i64() + lease_ms; let seq = st.pending_events[idx].seq; let event = st.pending_events[idx].event.clone(); @@ -1383,7 +1390,7 @@ fn handle_poll_get( respond(&mut stream, cors, json_res(200, Value::Object(event))); return; } - let (poll_id, rx) = st.park_poll(lease_ms, types); + let (poll_id, rx) = st.park_poll(lease_ms, types, event_id); drop(st); ticket.release(); let done = Arc::new(AtomicBool::new(false)); diff --git a/crates/live/src/server_state.rs b/crates/live/src/server_state.rs index 122f3f3b7..a04b627d4 100644 --- a/crates/live/src/server_state.rs +++ b/crates/live/src/server_state.rs @@ -34,6 +34,9 @@ pub struct ParkedPoll { pub tx: Sender, pub lease_ms: i64, pub types: Option>, + /// `GET /poll?id=`: lease only the events of that session (the + /// generate verb picks up its own event without touching another's). + pub event_id: Option, } pub struct SseClient { @@ -184,12 +187,18 @@ pub fn select_available_pending_event( entries: &[PendingEntry], now: i64, types: Option<&[String]>, + event_id: Option<&str>, ) -> Option { let mut best: Option = None; for (i, entry) in entries.iter().enumerate() { if is_leased_at(entry, now) { continue; } + if let Some(wanted) = event_id { + if entry.event.get("id").and_then(|v| v.as_str()) != Some(wanted) { + continue; + } + } if let Some(allowed) = types { let ty = entry .event @@ -276,8 +285,12 @@ impl ServerState { } } - pub fn find_available_pending_event(&self, types: Option<&[String]>) -> Option { - select_available_pending_event(&self.pending_events, now_i64(), types) + pub fn find_available_pending_event( + &self, + types: Option<&[String]>, + event_id: Option<&str>, + ) -> Option { + select_available_pending_event(&self.pending_events, now_i64(), types, event_id) } /// JS: recordAgentPhase(id, phase, details) @@ -425,9 +438,12 @@ impl ServerState { let mut found: Option<(usize, usize)> = None; let now = now_i64(); for (pi, poll) in self.pending_polls.iter().enumerate() { - if let Some(ei) = - select_available_pending_event(&self.pending_events, now, poll.types.as_deref()) - { + if let Some(ei) = select_available_pending_event( + &self.pending_events, + now, + poll.types.as_deref(), + poll.event_id.as_deref(), + ) { found = Some((pi, ei)); break; } @@ -641,6 +657,7 @@ impl ServerState { &mut self, lease_ms: i64, types: Option>, + event_id: Option, ) -> (u64, Receiver) { let (tx, rx) = channel(); let id = self.next_poll_id; @@ -650,6 +667,7 @@ impl ServerState { tx, lease_ms, types, + event_id, }); self.broadcast_agent_polling_if_changed(); self.schedule_lease_flush(); diff --git a/crates/live/src/session.rs b/crates/live/src/session.rs index d6405a635..e7d1b9d0d 100644 --- a/crates/live/src/session.rs +++ b/crates/live/src/session.rs @@ -508,6 +508,12 @@ pub fn apply_event(snapshot: &Map, entry: &Value) -> Map { set!("phase", json!("generate_requested")); + // `origin: "agent"` marks a Go the generate verb fired; the + // accept pipeline bakes those sessions itself. A plain Go + // carries no origin and its snapshot stays exactly as it was. + if let Some(origin) = ev("origin").filter(|v| truthy(v)) { + set!("origin", origin.clone()); + } set_if!("pageUrl", ev("pageUrl")); set_if!("expectedVariants", ev("count")); set_if!("pendingEventSeq", seq.as_ref()); diff --git a/docs/CLI-CONTRACT.md b/docs/CLI-CONTRACT.md index 8beed026c..16d2aeb0b 100644 --- a/docs/CLI-CONTRACT.md +++ b/docs/CLI-CONTRACT.md @@ -1414,7 +1414,7 @@ Schema (`validateConfig`, error messages verbatim): - Server picks port: `--port=N` or first free port from 8400 upward (bind 127.0.0.1). Token = `randomUUID()`. - Written on listen: `.impeccable/live/server.json` = `{"pid","port","token"}`. - `readLiveServerInfo(cwd)`: tries primary then legacy `.impeccable-live.json`; if `pid` recorded and `process.kill(pid,0)` throws ESRCH → unlink that file and continue; EPERM counts as alive. Returns `{info, path}` or null. -- Browser gets the token from the injected `