From e06c152ad297563080c7394e45cb31a92dead370 Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Fri, 11 Sep 2026 00:07:15 +0500 Subject: [PATCH] generate lane: one-shot start, event in hand, mechanical bake The generate command's fast lane spent most of its time on agent round trips, not on the engine. Four engine changes take them out, all behind the lane's own flags so a plain `live` session is untouched: - `live-poll --reply done --then-poll` replies and waits for the next event in one call; the reply's ack rides along as `_replyAck`. - `live-generate` collects the session's own generate event into its output (`GET /poll?types=generate&id=`, a new id filter the parked-poll flush honours too), so the pickup poll is gone. - `live-generate --boot` runs the lane's boot in-process and reuses a running helper; `--dev-url ` names the dev server the agent already knows and leads the probe; with no page connected the verdict is `browser_needed` with the harness's own way to open the page (Cursor browser_navigate, Claude Code's Browser pane, Codex --open or the user). `--open` launches the system browser only on a harness without one: on cursor and claude-code it is ignored unless IMPECCABLE_BROWSER or the config's `browser` names a browser, so a second window never opens beside the harness's. The served /live.js carries the helper-wide bar preference in its prelude. - The accept of a session the generate verb started (journaled with origin "agent", or `--bake`) is baked mechanically: the accepted variant's @scope rules are re-anchored on the element's own selector and appended to the stylesheet that names it, the wrapper is unwrapped, the source verified clean. Knobs, plumbing inside the variant, no stylesheet, or a selector the rewrite cannot decide fall back to the carbonize block with `bakeSkipped`. `--no-bake` refuses. Goldens re-recorded for the three help texts and the no-browser case. Written with AI assistance (Claude). Co-Authored-By: Claude Fable 5 --- crates/cli/tests/agent_target.rs | 218 ++++++++ crates/live/src/bake.rs | 484 ++++++++++++++++++ crates/live/src/browser_assets.rs | 7 + crates/live/src/browser_open.rs | 130 +++++ crates/live/src/instructions.rs | 16 +- crates/live/src/lib.rs | 2 + crates/live/src/live_accept.rs | 275 +++++++++- crates/live/src/live_generate.rs | 458 ++++++++++++++++- crates/live/src/live_poll.rs | 54 +- crates/live/src/live_server.rs | 17 +- crates/live/src/server_state.rs | 28 +- crates/live/src/session.rs | 6 + docs/CLI-CONTRACT.md | 18 +- tests/oracle/golden/live-accept-help.json | 2 +- .../golden/live-generate-local-verdicts.json | 2 +- .../live-generate-no-browser-connected.json | 2 +- tests/oracle/golden/live-poll-help.json | 2 +- 17 files changed, 1659 insertions(+), 62 deletions(-) create mode 100644 crates/live/src/bake.rs create mode 100644 crates/live/src/browser_open.rs 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 `