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 <id> 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=<sessionId>`, 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 <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 <noreply@anthropic.com>
This commit is contained in:
Abdul Wahab
2026-09-15 05:45:49 +05:00
committed by Abdul Wahab
co-authored by Claude Fable 5
parent 4de812f558
commit e06c152ad2
17 changed files with 1659 additions and 62 deletions
+218
View File
@@ -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 <project-root-relative path you wrote> --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"), "<html><body><h1>t</h1></body></html>").unwrap();
std::fs::write(dir.join(".impeccable/live/config.json"), "{\"files\":[\"index.html\"],\"insertBefore\":\"</body>\",\"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 = "<html><body><h1>t</h1></body></html>";
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}");
}
+484
View File
@@ -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
//! `<style data-impeccable-css>` block) can be made permanent without an
//! agent: the chosen variant's rules are rewritten from `:scope` to the
//! element's own selector and appended to the stylesheet that already styles
//! it, and the wrapper is unwrapped in source. Anything the rewrite cannot
//! decide mechanically (knobs, plumbing inside the variant, no stylesheet,
//! no stable selector) refuses, and accept falls back to the carbonize
//! block the agent bakes by hand.
use crate::accept_css::{parse_stylesheet, serialize_nodes, split_selector_list, CssNode};
use crate::accept_verify::verify_accepted_source;
use crate::source_search::{is_generated_file, read_dir_sorted, NEVER_SOURCE_DIRS};
use crate::util::jsp;
use impeccable_core::js::trim;
use once_cell::sync::Lazy;
use regex::Regex;
use serde_json::{json, Map, Value};
/// Directories a stylesheet search never enters (build output, caches, the
/// framework's own trees).
const SKIP_DIRS: [&str; 12] = [
"dist", "build", "coverage", ".next", ".nuxt", ".svelte-kit", ".astro", ".turbo", ".vercel", ".cache",
"out", "storybook-static",
];
const MAX_DEPTH: usize = 6;
const MAX_FILES: usize = 4000;
/// A bake that is ready to write.
#[derive(Debug)]
pub struct BakePlan {
/// The stylesheet the rules go to (absolute), or None when they go into
/// the source file's own `<style>` block.
pub css_file: Option<String>,
/// The rules, rewritten to the element's real selectors.
pub css: String,
/// The element's own selector the rewrite anchored on.
pub anchor: String,
/// The accepted variant's lines, at the wrapper's indentation.
pub restored: Vec<String>,
pub rules: usize,
}
static ROOT_TAG_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?s)<([A-Za-z][A-Za-z0-9-]*)((?:\s+[^<>]*?)?)>").unwrap());
static ATTR_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r#"(?:^|\s)(id|class|className)\s*=\s*(?:"([^"]*)"|'([^']*)')"#).unwrap());
static SCOPE_PRELUDE_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r#"data-impeccable-variant\s*=\s*["']?(\d+)["']?"#).unwrap());
static VARIANT_PREFIX_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r#"^\[data-impeccable-variant\s*=\s*["']?\d+["']?\]"#).unwrap());
/// The variant's root tag, as `#id` or `tag.class.class`: the selector every
/// `:scope` rule is rewritten against. None when neither an id nor a static
/// class is on the tag (a JSX expression, a bare `<section>`).
pub fn element_anchor(restored: &[String]) -> Option<String> {
let text = restored.join("\n");
let caps = ROOT_TAG_RE.captures(&text)?;
let tag = caps[1].to_ascii_lowercase();
let attrs = caps.get(2).map(|m| m.as_str()).unwrap_or("");
let mut id: Option<String> = None;
let mut classes: Vec<String> = Vec::new();
for a in ATTR_RE.captures_iter(attrs) {
let value = a.get(2).or_else(|| a.get(3)).map(|m| m.as_str()).unwrap_or("");
match &a[1] {
"id" => {
if !value.trim().is_empty() {
id = Some(value.trim().to_string());
}
}
_ => classes.extend(value.split_whitespace().map(str::to_string)),
}
}
if let Some(id) = id {
if is_css_ident(&id) {
return Some(format!("#{}", id));
}
}
let classes: Vec<String> = classes.into_iter().filter(|c| is_css_ident(c)).collect();
if classes.is_empty() {
return None;
}
Some(format!("{}.{}", tag, classes.join(".")))
}
fn is_css_ident(s: &str) -> bool {
!s.is_empty()
&& s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
&& !s.starts_with(|c: char| c.is_ascii_digit())
}
/// One selector out of a `:scope` (or `[data-impeccable-variant="N"]`)
/// prefixed rule, anchored on the element. Err when `:scope` survives.
pub fn rewrite_selector(selector: &str, anchor: &str) -> Result<String, String> {
let s = trim(selector).to_string();
let s = VARIANT_PREFIX_RE.replace(&s, ":scope").into_owned();
let out = if let Some(rest) = s.strip_prefix(":scope") {
let rest_trim = rest.trim_start();
if rest_trim.is_empty() {
anchor.to_string()
} else if let Some(child) = rest_trim.strip_prefix('>') {
// `:scope > .x`: the variant div's child is the element itself.
child.trim_start().to_string()
} else if rest_trim.starts_with(['+', '~']) {
return Err(format!("sibling combinator on :scope has no meaning after unwrap: {}", selector));
} else if rest.starts_with(char::is_whitespace) {
// `:scope .x`: a descendant of the element.
format!("{} {}", anchor, rest_trim)
} else {
// `:scope:hover > .x`, `:scope[data-x] .y`: the element with that state.
format!("{}{}", anchor, rest)
}
} else {
s
};
if out.contains(":scope") || out.contains("data-impeccable") {
return Err(format!("selector still names the preview wrapper: {}", selector));
}
if trim(&out).is_empty() {
return Err(format!("selector rewrote to nothing: {}", selector));
}
Ok(trim(&out).to_string())
}
fn rewrite_nodes(nodes: &[CssNode], anchor: &str, out: &mut Vec<CssNode>, rules: &mut usize) -> Result<(), String> {
for node in nodes {
match node {
CssNode::Comment { .. } => {}
CssNode::Rule { prelude, body } => {
let rewritten: Result<Vec<String>, String> =
split_selector_list(prelude).iter().map(|sel| rewrite_selector(sel, anchor)).collect();
let selectors = rewritten?;
if selectors.is_empty() {
continue;
}
out.push(CssNode::Rule { prelude: selectors.join(", "), body: body.clone() });
*rules += 1;
}
CssNode::At { name, prelude, children: Some(children), .. } => {
if name == "scope" {
return Err("nested @scope inside a variant block".to_string());
}
let mut inner = Vec::new();
rewrite_nodes(children, anchor, &mut inner, rules)?;
if !inner.is_empty() {
out.push(CssNode::At {
name: name.clone(),
prelude: prelude.clone(),
children: Some(inner),
body: None,
statement: false,
});
}
}
other => out.push(other.clone()),
}
}
Ok(())
}
/// The accepted variant's rules out of the whole preview stylesheet: its
/// `@scope ([data-impeccable-variant="N"])` block rewritten and flattened,
/// global at-rules (`@keyframes`, `@font-face`) kept, the other variants'
/// blocks dropped.
pub fn extract_variant_css(css: &str, variant_num: &str, anchor: &str) -> Result<(String, usize), String> {
let nodes = parse_stylesheet(css);
let mut kept: Vec<CssNode> = Vec::new();
let mut rules = 0usize;
for node in &nodes {
match node {
CssNode::At { name, prelude, children: Some(children), .. } if name == "scope" => {
let Some(caps) = SCOPE_PRELUDE_RE.captures(prelude) else {
return Err(format!("@scope block without a variant prelude: {}", prelude));
};
if &caps[1] == variant_num {
rewrite_nodes(children, anchor, &mut kept, &mut rules)?;
}
}
CssNode::Rule { prelude, body } => {
// Astro's global-prefixed mode: `[data-impeccable-variant="N"] > .x`.
let mine: Vec<String> = split_selector_list(prelude)
.into_iter()
.filter(|sel| {
SCOPE_PRELUDE_RE
.captures(sel)
.map(|c| &c[1] == variant_num)
.unwrap_or(false)
})
.collect();
if mine.is_empty() {
if prelude.contains("data-impeccable-variant") {
continue;
}
kept.push(CssNode::Rule { prelude: prelude.clone(), body: body.clone() });
rules += 1;
continue;
}
let selectors: Result<Vec<String>, String> = mine.iter().map(|sel| rewrite_selector(sel, anchor)).collect();
kept.push(CssNode::Rule { prelude: selectors?.join(", "), body: body.clone() });
rules += 1;
}
CssNode::At { prelude, children: Some(children), name, .. } => {
// A media/supports block at the top level: keep only what is
// ours, rewritten.
let mut inner = Vec::new();
let mut inner_rules = 0usize;
for child in children {
if let CssNode::At { name: cn, prelude: cp, children: Some(cc), .. } = child {
if cn == "scope" {
if SCOPE_PRELUDE_RE.captures(cp).map(|c| &c[1] == variant_num).unwrap_or(false) {
rewrite_nodes(cc, anchor, &mut inner, &mut inner_rules)?;
}
continue;
}
}
if let CssNode::Rule { prelude: rp, .. } = child {
if rp.contains("data-impeccable-variant") {
continue;
}
}
inner.push(child.clone());
}
if !inner.is_empty() {
kept.push(CssNode::At {
name: name.clone(),
prelude: prelude.clone(),
children: Some(inner),
body: None,
statement: false,
});
rules += inner_rules;
}
}
other => kept.push(other.clone()),
}
}
if rules == 0 {
return Err("the accepted variant declares no rules".to_string());
}
let text = serialize_nodes(&kept, "");
if text.contains("data-impeccable") || text.contains(":scope") {
return Err("rewritten CSS still names the preview wrapper".to_string());
}
Ok((text, rules))
}
static SELECTOR_TOKEN_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"[#.][A-Za-z_][A-Za-z0-9_-]*").unwrap());
/// The stylesheet that already styles the element: the `.css` file under
/// the app root with the most rules naming the anchor's id or classes, the
/// only `.css` file when there is exactly one, else none.
pub fn find_owning_stylesheet(cwd: &str, anchor: &str) -> Option<String> {
let tokens: Vec<String> = SELECTOR_TOKEN_RE.find_iter(anchor).map(|m| m.as_str().to_string()).collect();
let mut files: Vec<String> = Vec::new();
walk_css(cwd, 0, &mut files);
let mut scored: Vec<(usize, usize, String)> = Vec::new();
for f in &files {
if is_generated_file(f, cwd) {
continue;
}
let Some(text) = crate::util::safe_read(f) else { continue };
let mut score = 0usize;
for t in &tokens {
// No lookaround in this regex engine: the character after the
// token is consumed, which is fine for a count.
let re = Regex::new(&format!(r"(?:^|[\s,>+~{{}}\)]){}(?:[^A-Za-z0-9_-]|$)", regex::escape(t))).ok();
if let Some(re) = re {
score += re.find_iter(&text).count();
}
}
scored.push((score, f.len(), f.clone()));
}
if scored.is_empty() {
return None;
}
scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
let best = &scored[0];
if best.0 > 0 || scored.len() == 1 {
return Some(best.2.clone());
}
None
}
fn walk_css(dir: &str, depth: usize, out: &mut Vec<String>) {
if depth > MAX_DEPTH || out.len() >= MAX_FILES {
return;
}
let Some(entries) = read_dir_sorted(dir) else { return };
for e in entries {
if e.is_dir {
if NEVER_SOURCE_DIRS.contains(&e.name.as_str())
|| SKIP_DIRS.contains(&e.name.as_str())
|| (e.name.starts_with('.') && e.name != ".")
{
continue;
}
walk_css(&jsp::join(&[dir, &e.name]), depth + 1, out);
} else if e.is_file && e.name.to_ascii_lowercase().ends_with(".css") && !e.name.ends_with(".min.css") {
out.push(jsp::join(&[dir, &e.name]));
}
}
}
static HTML_STYLE_BLOCK_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?s)<style(\b[^>]*)>(.*?)</style>").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 `<style>` block
/// when the file is HTML-like).
pub fn plan(
cwd: &str,
target_file: &str,
is_jsx: bool,
variant_num: &str,
css_lines: Option<&[String]>,
restored: &[String],
param_values: Option<&Map<String, Value>>,
source_after_unwrap: &str,
) -> Result<BakePlan, String> {
if param_values.map(|p| !p.is_empty()).unwrap_or(false) {
return Err("the session has knobs (paramValues); knob baking needs the agent".into());
}
let variant_text = restored.join("\n");
if variant_text.contains("data-impeccable-") || variant_text.contains("data-p-") {
return Err("the accepted variant carries preview plumbing inside it".into());
}
let Some(css_lines) = css_lines else {
return Err("no preview stylesheet to bake".into());
};
let css = css_lines.join("\n");
if css.contains("var(--p-") || css.contains("data-p-") || css.contains("data-impeccable-params") {
return Err("the preview CSS is authored against knobs".into());
}
let anchor = element_anchor(restored).ok_or_else(|| "the variant's root tag has no id or static class to anchor selectors on".to_string())?;
let (rules_css, rules) = extract_variant_css(&css, variant_num, &anchor)?;
let css_file = if is_jsx {
Some(find_owning_stylesheet(cwd, &anchor).ok_or_else(|| "no stylesheet under the app root names the element".to_string())?)
} else if HTML_STYLE_BLOCK_RE.captures_iter(source_after_unwrap).any(|c| !c[1].contains("data-impeccable")) {
None
} else {
Some(find_owning_stylesheet(cwd, &anchor).ok_or_else(|| "no stylesheet names the element and the page has no <style> block".to_string())?)
};
let _ = target_file;
Ok(BakePlan { css_file, css: rules_css, anchor, restored: restored.to_vec(), rules })
}
/// The block appended to the stylesheet: a one-line provenance comment and
/// the rewritten rules.
pub fn appended_block(plan: &BakePlan, session_id: &str, variant_num: &str) -> String {
format!(
"\n/* impeccable generate {}: accepted variant {} */\n{}\n",
session_id, variant_num, plan.css
)
}
/// Write the bake: the stylesheet (or the page's last own `<style>` block)
/// gets the rules, the source file gets the unwrapped variant. The source
/// is verified clean before anything is written.
pub fn apply(
plan: &BakePlan,
session_id: &str,
variant_num: &str,
target_file: &str,
source_after_unwrap: &str,
) -> Result<Value, String> {
let block = appended_block(plan, session_id, variant_num);
let source_text = match &plan.css_file {
Some(_) => source_after_unwrap.to_string(),
None => {
// Into the last <style> block that is the page's own.
let mut last: Option<(usize, usize)> = None;
for c in HTML_STYLE_BLOCK_RE.captures_iter(source_after_unwrap) {
if !c[1].contains("data-impeccable") {
let inner = c.get(2).unwrap();
last = Some((inner.start(), inner.end()));
}
}
let (_, end) = last.ok_or_else(|| "the page's <style> block disappeared".to_string())?;
format!("{}{}{}", &source_after_unwrap[..end], block, &source_after_unwrap[end..])
}
};
let (clean, findings) = verify_accepted_source(&source_text);
if !clean {
return Err(format!(
"the baked source would still carry live-mode leftovers: {}",
findings.iter().filter_map(|f| f.get("why").and_then(Value::as_str)).collect::<Vec<_>>().join("; ")
));
}
if let Some(css_file) = &plan.css_file {
let existing = crate::util::safe_read(css_file).unwrap_or_default();
let joined = if existing.ends_with('\n') || existing.is_empty() {
format!("{}{}", existing, block.trim_start_matches('\n'))
} else {
format!("{}\n{}", existing, block.trim_start_matches('\n'))
};
std::fs::write(css_file, joined).map_err(|e| format!("could not write {}: {}", css_file, e))?;
}
std::fs::write(target_file, source_text).map_err(|e| format!("could not write {}: {}", target_file, e))?;
Ok(json!({ "rules": plan.rules, "anchor": plan.anchor }))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scope_selectors_rewrite_onto_the_element() {
let a = "div.pricing-grid";
assert_eq!(rewrite_selector(":scope > .pricing-grid", a).unwrap(), ".pricing-grid");
assert_eq!(rewrite_selector(":scope > .pricing-grid .pricing-card", a).unwrap(), ".pricing-grid .pricing-card");
assert_eq!(rewrite_selector(":scope .pricing-card", a).unwrap(), "div.pricing-grid .pricing-card");
assert_eq!(rewrite_selector(":scope", a).unwrap(), "div.pricing-grid");
assert_eq!(rewrite_selector(":scope:hover > .pricing-grid", a).unwrap(), "div.pricing-grid:hover > .pricing-grid");
assert_eq!(rewrite_selector("[data-impeccable-variant=\"2\"] > .x", a).unwrap(), ".x");
assert!(rewrite_selector(":scope + .x", a).is_err());
assert!(rewrite_selector(".a :scope", a).is_err());
}
#[test]
fn the_anchor_comes_from_the_root_tag() {
assert_eq!(element_anchor(&["<div className=\"pricing-grid wide\">".into(), "</div>".into()]), Some("div.pricing-grid.wide".into()));
assert_eq!(element_anchor(&["<section id=\"pricing\" class=\"pricing\">".into()]), Some("#pricing".into()));
assert_eq!(element_anchor(&["<section className={cls}>".into()]), None);
assert_eq!(element_anchor(&[" <h1 class='hero-heading'>Hi</h1>".into()]), Some("h1.hero-heading".into()));
}
#[test]
fn only_the_accepted_variants_block_survives_rewritten() {
let css = r#"
@scope ([data-impeccable-variant="1"]) { :scope > .pricing-grid { gap: 8px; } }
@scope ([data-impeccable-variant="2"]) {
:scope > .pricing-grid { gap: 32px; }
:scope .pricing-card { border: 2px solid #111; }
@media (max-width: 600px) { :scope > .pricing-grid { gap: 12px; } }
}
@keyframes rise { from { opacity: 0 } to { opacity: 1 } }
@scope ([data-impeccable-variant="3"]) { :scope > .pricing-grid { gap: 0; } }
"#;
let (out, rules) = extract_variant_css(css, "2", "div.pricing-grid").unwrap();
assert_eq!(rules, 3, "{out}");
assert!(out.contains(".pricing-grid { gap: 32px; }"), "{out}");
assert!(out.contains("div.pricing-grid .pricing-card { border: 2px solid #111; }"), "{out}");
assert!(out.contains("@media (max-width: 600px)"), "{out}");
assert!(out.contains("@keyframes rise"), "{out}");
assert!(!out.contains("8px") && !out.contains("gap: 0"), "{out}");
assert!(!out.contains("data-impeccable") && !out.contains(":scope"), "{out}");
}
#[test]
fn knobs_and_plumbing_refuse_the_bake() {
let restored = vec!["<div className=\"pricing-grid\">".to_string(), "</div>".to_string()];
let css = vec!["@scope ([data-impeccable-variant=\"1\"]) { :scope > .pricing-grid { gap: var(--p-gap, 8px); } }".to_string()];
let err = plan("/nonexistent", "src/App.jsx", true, "1", Some(&css), &restored, None, "").unwrap_err();
assert!(err.contains("knobs"), "{err}");
let mut pv = Map::new();
pv.insert("gap".into(), json!(1));
let err = plan("/nonexistent", "src/App.jsx", true, "1", Some(&css), &restored, Some(&pv), "").unwrap_err();
assert!(err.contains("paramValues"), "{err}");
let plumbing = vec!["<div className=\"pricing-grid\" data-impeccable-x=\"1\">".to_string()];
let err = plan("/nonexistent", "src/App.jsx", true, "1", Some(&css), &plumbing, None, "").unwrap_err();
assert!(err.contains("plumbing"), "{err}");
}
#[test]
fn the_owning_stylesheet_is_the_one_naming_the_element() {
let dir = std::env::temp_dir().join(format!("impeccable-bake-{}", 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("node_modules/x")).unwrap();
std::fs::write(dir.join("src/reset.css"), "* { margin: 0 }").unwrap();
std::fs::write(dir.join("src/styles.css"), ".pricing-grid { display: grid }\n.pricing-card { padding: 1px }").unwrap();
std::fs::write(dir.join("node_modules/x/x.css"), ".pricing-grid { color: red }").unwrap();
let cwd = dir.to_string_lossy().into_owned();
let found = find_owning_stylesheet(&cwd, "div.pricing-grid").unwrap();
assert!(found.ends_with("src/styles.css"), "{found}");
assert_eq!(find_owning_stylesheet(&cwd, "div.nothing-here"), None);
std::fs::remove_file(dir.join("src/styles.css")).unwrap();
// One stylesheet in the app: it is the one.
let only = find_owning_stylesheet(&cwd, "div.nothing-here").unwrap();
assert!(only.ends_with("src/reset.css"), "{only}");
let _ = std::fs::remove_dir_all(&dir);
}
}
+7
View File
@@ -81,10 +81,17 @@ pub fn assemble_live_browser_script(
app_root: &str,
parts: &[(&str, &str, String)],
project_ignores: &Value,
live_bar_hidden: bool,
) -> String {
let mut out = String::new();
out.push_str(&format!("window.__IMPECCABLE_TOKEN__ = '{}';\n", token));
out.push_str(&format!("window.__IMPECCABLE_PORT__ = {};\n", port));
// The generate lane's helper-wide bar preference, known before the
// overlay mounts anything, so the bar is never drawn and then hidden.
// A plain helper serves exactly the script it always served.
if live_bar_hidden {
out.push_str("window.__IMPECCABLE_LIVE_BAR_HIDDEN__ = true;\n");
}
out.push_str(&format!(
"window.__IMPECCABLE_APP_ROOT__ = {};\n",
serde_json::to_string(&Value::String(app_root.to_string())).unwrap_or_default()
+130
View File
@@ -0,0 +1,130 @@
//! Open a URL in the user's browser: the generate lane's one-shot start
//! (`live-generate --open`) hands the page to the browser itself instead of
//! spending an agent turn on it.
//!
//! Preference order (the one issue #611 settled on): `IMPECCABLE_BROWSER`,
//! then `browser` in `.impeccable/config.local.json` or
//! `.impeccable/config.json` at the app root, then `BROWSER`, then the
//! platform's default opener (`open`, `xdg-open`, `cmd /c start`). A value
//! that is a path to a program or script runs with the URL as its argument;
//! on macOS any other value is an application name (`open -a <name>`).
use crate::util::Env;
use serde_json::Value;
use std::path::Path;
use std::process::{Command, Stdio};
/// The browser the user chose for impeccable itself: `IMPECCABLE_BROWSER`,
/// else `browser` in `.impeccable/config.local.json` / `config.json`. This
/// is the one preference that may open a window beside a harness's own
/// browser, because the user asked for exactly that.
pub fn explicit_browser(cwd: &str, env: &Env) -> Option<String> {
let nonempty = |v: Option<&String>| v.map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
if let Some(b) = nonempty(env.get("IMPECCABLE_BROWSER")) {
return Some(b);
}
for name in ["config.local.json", "config.json"] {
let path = Path::new(cwd).join(".impeccable").join(name);
let Ok(text) = std::fs::read_to_string(&path) else { continue };
let Ok(v) = serde_json::from_str::<Value>(&text) else { continue };
if let Some(b) = v.get("browser").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty()) {
return Some(b.to_string());
}
}
None
}
/// The configured browser, if any (see the module doc for the order): the
/// explicit choice, else the generic `BROWSER` variable.
pub fn resolve_browser(cwd: &str, env: &Env) -> Option<String> {
explicit_browser(cwd, env).or_else(|| env.get("BROWSER").map(|s| s.trim().to_string()).filter(|s| !s.is_empty()))
}
/// Launch the browser on `url` without waiting for it. Returns a short
/// description of what was launched, for the verdict.
pub fn open_url(url: &str, cwd: &str, env: &Env) -> Result<String, String> {
let preference = resolve_browser(cwd, env);
let mut cmd = command_for(url, preference.as_deref());
let description = format!("{:?}", cmd).replace('"', "");
cmd.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null());
impeccable_common::proc::detach(&mut cmd);
cmd.spawn().map(|_| description).map_err(|e| format!("{}: {}", e, preference.unwrap_or_else(|| "default opener".to_string())))
}
fn command_for(url: &str, preference: Option<&str>) -> Command {
match preference {
Some(p) if Path::new(p).is_file() || p.contains('/') || p.contains('\\') => {
let mut c = Command::new(p);
c.arg(url);
c
}
Some(p) => {
if cfg!(target_os = "macos") {
let mut c = Command::new("open");
c.arg("-a").arg(p).arg(url);
c
} else if cfg!(windows) {
let mut c = Command::new("cmd");
c.arg("/c").arg("start").arg("").arg(p).arg(url);
c
} else {
let mut c = Command::new(p);
c.arg(url);
c
}
}
None => {
if cfg!(target_os = "macos") {
let mut c = Command::new("open");
c.arg(url);
c
} else if cfg!(windows) {
let mut c = Command::new("cmd");
c.arg("/c").arg("start").arg("").arg(url);
c
} else {
let mut c = Command::new("xdg-open");
c.arg(url);
c
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn env(pairs: &[(&str, &str)]) -> Env {
pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
}
#[test]
fn the_env_var_wins_then_the_config_then_browser() {
let dir = std::env::temp_dir().join(format!("impeccable-browser-open-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join(".impeccable")).unwrap();
let cwd = dir.to_string_lossy().into_owned();
assert_eq!(resolve_browser(&cwd, &env(&[])), None);
assert_eq!(resolve_browser(&cwd, &env(&[("BROWSER", "firefox")])), Some("firefox".into()));
std::fs::write(dir.join(".impeccable/config.json"), r#"{"browser":"Safari"}"#).unwrap();
assert_eq!(resolve_browser(&cwd, &env(&[("BROWSER", "firefox")])), Some("Safari".into()));
std::fs::write(dir.join(".impeccable/config.local.json"), r#"{"browser":"/tmp/my-opener"}"#).unwrap();
assert_eq!(resolve_browser(&cwd, &env(&[])), Some("/tmp/my-opener".into()));
assert_eq!(resolve_browser(&cwd, &env(&[("IMPECCABLE_BROWSER", " chrome ")])), Some("chrome".into()));
// BROWSER is a fallback for the opener, never the user's explicit choice.
std::fs::remove_file(dir.join(".impeccable/config.local.json")).unwrap();
std::fs::remove_file(dir.join(".impeccable/config.json")).unwrap();
assert_eq!(explicit_browser(&cwd, &env(&[("BROWSER", "firefox")])), None);
assert_eq!(explicit_browser(&cwd, &env(&[("IMPECCABLE_BROWSER", "chrome")])), Some("chrome".into()));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_path_runs_with_the_url_as_its_argument() {
let c = command_for("http://127.0.0.1:5173/", Some("/tmp/opener.sh"));
let shown = format!("{:?}", c);
assert!(shown.starts_with("\"/tmp/opener.sh\""), "{shown}");
assert!(shown.contains("http://127.0.0.1:5173/"), "{shown}");
}
}
+15 -1
View File
@@ -58,7 +58,7 @@ fn fast_path_instructions(event: &Map<String, Value>) -> String {
.map(|p| format!(" The user's prompt narrows every variant: \"{}\".", slice16(p, 200)))
.unwrap_or_default();
format!(
"Fast path (the user asked for {count} \"{action}\" variants to choose from, and is watching): do not read live.md, craft-floor.md, PRODUCT.md, or DESIGN.md now; the boot already handed you any design context, and this event carries element.computedStyles, element.cssCustomProperties, and element.parentContext. Lock the identity in ONE sentence from those (real colors, faces, corners, borders, shadows), then write {count} variants that each amplify a DIFFERENT dimension for {action}: {axes}. Keep the copy verbatim; no new fonts or hues beyond what the page already uses unless the prompt asks. No parameter knobs (no data-impeccable-params) unless the prompt asks for something tunable. Floors: body text contrast 4.5:1 or better, no text under 12px, controls at least 40px tall, focus states kept.{prompt}",
"Fast path (the user asked for {count} \"{action}\" variants to choose from, and is watching): do not read live.md, craft-floor.md, PRODUCT.md, or DESIGN.md now; the boot already handed you any design context, and this event carries element.computedStyles, element.cssCustomProperties, and element.parentContext. Lock the identity in ONE sentence from those (real colors, faces, corners, borders, shadows), then write {count} variants that each amplify a DIFFERENT dimension for {action}: {axes}. Keep the copy verbatim; no new fonts or hues beyond what the page already uses unless the prompt asks. When the boot printed a DESIGN.md, its tokens and named rules bound every variant: amplify inside them, never against them (a system that forbids fills, shadows, tints, or unequal columns gets its boldest allowed move on that axis instead, and tokens the axis does not need, such as radius, border, padding, and the number of bold weights, stay exactly as written); leaving the system is the user's call, not a variant. No parameter knobs (no data-impeccable-params): this lane bakes the accepted variant mechanically, and knobs belong to plain live. Floors: body text contrast 4.5:1 or better, no text under 12px, controls at least 40px tall, focus states kept.{prompt}",
count = count,
action = action,
axes = action_axes(action),
@@ -387,6 +387,20 @@ fn accept_instructions(event: &Map<String, Value>, self_cmd: &str) -> String {
id
);
}
if handled && result.get("baked") == Some(&Value::Bool(true)) {
let css_file = match result.get("css").and_then(|c| c.get("file")) {
Some(v) if truthy(Some(v)) => format!("appended to {}", js_str(Some(v))),
_ => "kept in the page's own <style> block".to_string(),
};
return format!(
"{}Variant {} is baked into {}: its CSS was {} with real selectors and the wrapper is gone; the session is complete and there is nothing to clean up (no live-complete needed). Generate lane: stop the helper now with {} stop. Otherwise poll again.",
prefix,
js_str(result.get("variant")),
js_str(result.get("file")),
css_file,
script_cmd(self_cmd, "live-server")
);
}
if handled {
return format!(
"{}Accept was merged into source mechanically; nothing to clean up. Poll again.",
+2
View File
@@ -6,7 +6,9 @@
pub mod accept_css;
pub mod accept_verify;
pub mod bake;
pub mod browser_assets;
pub mod browser_open;
pub mod config;
pub mod copy_edit_agent;
pub mod design_md;
+260 -15
View File
@@ -5,6 +5,7 @@
use crate::paths::{live_dir, safe_session_id};
use crate::pending_edits::{read_buffer, write_buffer};
use crate::roots::enter_live_root;
use crate::session::create_live_session_store;
use crate::source_lock::with_source_lock;
use crate::source_search::{find_source_file, is_generated_file, resolve_live_template_extensions};
use crate::svelte_component::{
@@ -33,12 +34,42 @@ Required:
Options:
--page-url URL Current browser page URL; scopes staged copy-edit cleanup
--bake Bake a knob-free HTML/JSX accept mechanically (rules to the
owning stylesheet, wrapper unwrapped) instead of leaving
the carbonize block; the default for sessions the
generate verb started (origin \"agent\")
--no-bake Never bake; always leave the carbonize block
--defer-source-write
Deprecated compatibility flag. Svelte component accepts
now write the real source immediately.
Output (JSON):
{ handled, file, carbonize }";
{ handled, file, carbonize, baked?, css?, bakeSkipped? }";
/// What a bake needs beyond the file: where to look for the stylesheet and
/// which session to name in it.
struct BakeRequest {
cwd: String,
session_id: String,
}
/// The two ways an HTML/JSX accept can end.
enum AcceptOutcome {
/// The carbonize block is in source (or nothing was needed).
Carbonized {
carbonize: bool,
original: String,
bake_skipped: Option<String>,
},
/// The variant is permanent: rules appended to `css_file` (None: the
/// page's own `<style>` block), wrapper gone.
Baked {
original: String,
css_file: Option<String>,
rules: usize,
anchor: String,
},
}
pub fn run(args: &[String], io: &mut Io) -> i32 {
let mut argv: Vec<String> = args.to_vec();
@@ -145,6 +176,8 @@ fn accept_cli(args: &[String], io: &mut Io) -> i32 {
let param_values_raw = nonempty(arg_val(args, "--param-values"));
let page_url = nonempty(arg_val(args, "--page-url"));
let is_discard = args.iter().any(|a| a == "--discard");
let bake_flag = args.iter().any(|a| a == "--bake");
let no_bake = args.iter().any(|a| a == "--no-bake");
let Some(id) = id else {
eprintln(io, "Missing --id");
@@ -374,6 +407,22 @@ fn accept_cli(args: &[String], io: &mut Io) -> i32 {
}
}
} else {
// A session the generate verb started is baked mechanically unless
// told otherwise; anything else only on --bake. Plain live keeps
// its carbonize block.
let agent_origin = !no_bake
&& !bake_flag
&& create_live_session_store(&cwd, &env, Some(&id))
.get_snapshot(&id, true)
.ok()
.flatten()
.and_then(|s| s.get("origin").and_then(|o| o.as_str()).map(|o| o == "agent"))
.unwrap_or(false);
let bake = if !no_bake && (bake_flag || agent_origin) {
Some(BakeRequest { cwd: cwd.clone(), session_id: id.clone() })
} else {
None
};
let owner = format!("accept:{}", id);
let locked = with_source_lock(
&target_file,
@@ -393,6 +442,7 @@ fn accept_cli(args: &[String], io: &mut Io) -> i32 {
&lines,
&target_file,
param_values.as_ref(),
bake.as_ref(),
)
},
);
@@ -411,20 +461,44 @@ fn accept_cli(args: &[String], io: &mut Io) -> i32 {
m.insert("error".into(), Value::String(err));
emit_result(io, Value::Object(m));
}
Ok(Ok((carbonize, accepted_original_text))) => {
Ok(Ok(outcome)) => {
let mut m = Map::new();
m.insert("handled".into(), Value::Bool(true));
m.insert("file".into(), Value::String(rel_file.clone()));
m.insert("carbonize".into(), Value::Bool(carbonize));
if carbonize {
m.insert(
"todo".into(),
Value::String(format!(
"REQUIRED before next poll: carbonize cleanup in {}. See reference/live.md \"Required after accept\".",
rel_file
)),
);
}
let accepted_original_text = match outcome {
AcceptOutcome::Carbonized { carbonize, original, bake_skipped } => {
m.insert("carbonize".into(), Value::Bool(carbonize));
if carbonize {
m.insert(
"todo".into(),
Value::String(format!(
"REQUIRED before next poll: carbonize cleanup in {}. See reference/live.md \"Required after accept\".",
rel_file
)),
);
}
if let Some(why) = bake_skipped {
m.insert("bakeSkipped".into(), Value::String(why));
}
original
}
AcceptOutcome::Baked { original, css_file, rules, anchor } => {
m.insert("carbonize".into(), Value::Bool(false));
m.insert("baked".into(), Value::Bool(true));
m.insert("variant".into(), Value::String(variant_num.clone()));
m.insert(
"css".into(),
json!({
"file": css_file.as_deref().map(|f| jsp::relative("/", &cwd, f)),
"rules": rules,
"anchor": anchor,
}),
);
let (clean, findings, _) = crate::accept_verify::verify_accepted_file(&target_file);
m.insert("verify".into(), json!({ "clean": clean, "findings": findings }));
original
}
};
scrub_manual_edits_against_original_block(
&accepted_original_text,
&cwd,
@@ -679,14 +753,17 @@ fn reindent_content(content: &[String], from_indent: &str, to_indent: &str) -> V
.collect()
}
/// JS: handleAcceptUnlocked → Ok((carbonize, acceptedOriginalText)) or Err(error)
/// JS: handleAcceptUnlocked → the outcome, or Err(error). With `bake`, a
/// knob-free variant is made permanent here (see `bake.rs`); when that is
/// not mechanical the carbonize block is left as before, with the reason.
fn handle_accept_unlocked(
id: &str,
variant_num: &str,
lines: &[String],
target_file: &str,
param_values: Option<&Map<String, Value>>,
) -> Result<(bool, String), String> {
bake: Option<&BakeRequest>,
) -> Result<AcceptOutcome, String> {
let Some(block) = find_marker_block(id, lines) else {
return Err("Markers not found".to_string());
};
@@ -703,6 +780,38 @@ fn handle_accept_unlocked(
let has_helper_attrs = variant_text.contains("data-impeccable-variant");
let needs_carbonize = css_content.is_some() || has_helper_attrs;
let restored = deindent_content(&variant_content, &indent);
let mut bake_skipped: Option<String> = None;
if let Some(req) = bake {
let mut unwrapped: Vec<String> = Vec::new();
unwrapped.extend_from_slice(&lines[..rs]);
unwrapped.extend(restored.iter().cloned());
unwrapped.extend_from_slice(&lines[(re + 1).min(lines.len())..]);
let source_after = unwrapped.join("\n");
let planned = crate::bake::plan(
&req.cwd,
target_file,
is_jsx,
variant_num,
css_content.as_deref(),
&restored,
param_values,
&source_after,
);
match planned {
Ok(plan) => match crate::bake::apply(&plan, &req.session_id, variant_num, target_file, &source_after) {
Ok(_) => {
return Ok(AcceptOutcome::Baked {
original: original_content.join("\n"),
css_file: plan.css_file.clone(),
rules: plan.rules,
anchor: plan.anchor.clone(),
});
}
Err(e) => bake_skipped = Some(e),
},
Err(e) => bake_skipped = Some(e),
}
}
let replacement = build_carbonize_replacement(
&indent,
cs,
@@ -718,7 +827,11 @@ fn handle_accept_unlocked(
new_lines.extend(replacement);
new_lines.extend_from_slice(&lines[(re + 1).min(lines.len())..]);
let _ = std::fs::write(target_file, new_lines.join("\n"));
Ok((needs_carbonize, original_content.join("\n")))
Ok(AcceptOutcome::Carbonized {
carbonize: needs_carbonize,
original: original_content.join("\n"),
bake_skipped,
})
}
/// JS: readSourceShadowPreviewMeta(content, id) → whether the wrapper carries
@@ -1123,3 +1236,135 @@ fn find_session_file(id: &str, cwd: &str) -> Option<(String, String, Vec<String>
let lines: Vec<String> = content.split('\n').map(String::from).collect();
Some((file, content, lines))
}
#[cfg(test)]
mod bake_tests {
use super::*;
use std::path::PathBuf;
const SESSION: &str = "ab12cd34";
/// The source as the generate lane's edit leaves it: the wrapper the
/// preflight wrote, three variants, one preview stylesheet.
fn app_jsx() -> String {
let variant = |n: &str, hidden: bool| {
let style = if hidden { " style={{ display: 'none' }}" } else { "" };
format!(
" <div data-impeccable-variant=\"{n}\"{style}>\n <div className=\"pricing-grid\">\n <article className=\"pricing-card\">Starter</article>\n </div>\n </div>\n"
)
};
format!(
"export default function App() {{\n return (\n <main>\n <section className=\"pricing\" id=\"pricing\">\n <h2 className=\"pricing-title\">Simple pricing</h2>\n <div data-impeccable-variants=\"{s}\" data-impeccable-variant-count=\"3\" style={{{{ display: \"contents\" }}}}>\n {{/* impeccable-variants-start {s} */}}\n {{/* Original */}}\n <div data-impeccable-variant=\"original\">\n <div className=\"pricing-grid\">\n <article className=\"pricing-card\">Starter</article>\n </div>\n </div>\n {{/* Variants: insert below this line */}}\n <style data-impeccable-css=\"{s}\">{{`\n @scope ([data-impeccable-variant=\"1\"]) {{ :scope > .pricing-grid {{ gap: 8px; }} }}\n @scope ([data-impeccable-variant=\"2\"]) {{\n :scope > .pricing-grid {{ gap: 32px; }}\n :scope .pricing-card {{ border: 2px solid #111; }}\n }}\n @scope ([data-impeccable-variant=\"3\"]) {{ :scope > .pricing-grid {{ gap: 0; }} }}\n `}}</style>\n{v1}{v2}{v3} {{/* impeccable-variants-end {s} */}}\n </div>\n </section>\n </main>\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"), "<html><body><div id=\"root\"></div></body></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<String> = 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("<style"), "{jsx}");
assert_eq!(jsx.matches("className=\"pricing-grid\"").count(), 1, "{jsx}");
// The element sits where the wrapper started, at its indentation.
assert!(jsx.contains(" <h2 className=\"pricing-title\">Simple pricing</h2>\n <div className=\"pricing-grid\">\n <article className=\"pricing-card\">Starter</article>\n </div>\n </section>"), "{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);
}
}
+444 -14
View File
@@ -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 <css> [--text <snippet>] [--index <n>] [--action <name>] [--count <n>] [--prompt <text>] [--dry-run] [--wait-for-browser <ms>] [--no-live-bar]
const HELP: &str = "Usage: impeccable live-generate --selector <css> [--text <snippet>] [--index <n>] [--action <name>] [--count <n>] [--prompt <text>] [--dry-run] [--wait-for-browser <ms>] [--no-live-bar] [--boot] [--open] [--target <path>]
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 <path> optional; the file that renders the element (the boot's --target)
--dev-url <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 <css> required; resolved with document.querySelectorAll
--text <snippet> optional; keeps only matches whose textContent contains it
--index <n> optional; 1-based pick among the remaining matches
@@ -30,6 +48,15 @@ Flags:
--wait-for-browser <ms> 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 <id> done --file <path> --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<String, Value>,
dry_run: bool,
no_live_bar: bool,
boot: bool,
open: bool,
}
fn parse_flags(argv: &[String]) -> Result<Flags, Value> {
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<Flags, Value> {
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<Flags, Value> {
values,
dry_run,
no_live_bar,
boot,
open,
})
}
@@ -130,12 +191,24 @@ fn instructions_for(result: &Map<String, Value>, self_cmd: &str) -> Option<Strin
tag, id
));
}
let reply = format!("{} live-poll --reply {} done --file <project-root-relative path you wrote> --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 <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<String, Value>, self_cmd: &str) -> Option<Strin
Some(text)
}
/// Said first when `--open` was passed on a harness with its own browser.
fn open_ignored_note(result: &Map<String, Value>) -> &'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 <that 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 <that 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 <that url>; never kill or restart that server afterwards.".to_string(),
}
}
fn server_died(self_cmd: &str, detail: Option<String>, 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<Map<String, Value>, Value> {
let mut boot_args: Vec<String> = 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<Map<String, Value>> = serde_json::from_str::<Value>(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 <a file inside that app>.",
"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<String, Value>) -> 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<Value> {
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::<Value>() 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<String> = flag(&flags_probe, "dev-url").map(str::trim).filter(|u| !u.is_empty()).map(str::to_string);
let mut boot: Option<Map<String, Value>> = 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<String> = 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<String, Value>| -> 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<Map<String, Value>>| -> (Option<String>, 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<String> = 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<String, Value>| -> Map<String, Value> {
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<Value> = 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();
+44 -10
View File
@@ -31,6 +31,9 @@ Modes:
poll --reply <id> error \"msg\" Reply with an error message
poll --reply <id> done --data '<json>'
Reply with a structured JSON result (manual_edit_apply)
poll --reply <id> 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<String> {
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<Value>, 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)
}
}
}
+12 -5
View File
@@ -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));
+23 -5
View File
@@ -34,6 +34,9 @@ pub struct ParkedPoll {
pub tx: Sender<Value>,
pub lease_ms: i64,
pub types: Option<Vec<String>>,
/// `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<String>,
}
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<usize> {
let mut best: Option<usize> = 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<usize> {
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<usize> {
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<Vec<String>>,
event_id: Option<String>,
) -> (u64, Receiver<Value>) {
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();
+6
View File
@@ -508,6 +508,12 @@ pub fn apply_event(snapshot: &Map<String, Value>, entry: &Value) -> Map<String,
match evt_type.as_str() {
"generate" => {
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());
+10 -8
View File
@@ -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 `<script src="http://localhost:PORT/live.js?token=TOKEN">`; server prepends to /live.js body: `window.__IMPECCABLE_TOKEN__='…'; window.__IMPECCABLE_PORT__=N; window.__IMPECCABLE_APP_ROOT__=<json abs appRoot>; window.__IMPECCABLE_COMMAND_PREFIX__="/"; window.__IMPECCABLE_VOCAB__=[…LIVE_COMMANDS]; window.__IMPECCABLE_LIVE_UI_SURFACES__=[…]; window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__=["root","transport","state","actions"];` followed by, in order, `// --- impeccable live script part: session-state (live-browser-session.js) ---`, `dom-helpers (live-browser-dom.js)`, `browser-ui (live-browser.js)` each preceded by that comment line. Parts are re-read from disk on every request.
- Browser gets the token from the injected `<script src="http://localhost:PORT/live.js?token=TOKEN">`; server prepends to /live.js body: `window.__IMPECCABLE_TOKEN__='…'; window.__IMPECCABLE_PORT__=N; window.__IMPECCABLE_LIVE_BAR_HIDDEN__=true` (only while the helper's `hideLiveBar` preference is set, so the overlay mounts its global bar hidden instead of drawing it and hiding it on `connected`; a plain helper's script carries no such line) `; window.__IMPECCABLE_APP_ROOT__=<json abs appRoot>; window.__IMPECCABLE_COMMAND_PREFIX__="/"; window.__IMPECCABLE_VOCAB__=[…LIVE_COMMANDS]; window.__IMPECCABLE_LIVE_UI_SURFACES__=[…]; window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__=["root","transport","state","actions"];` followed by, in order, `// --- impeccable live script part: session-state (live-browser-session.js) ---`, `dom-helpers (live-browser-dom.js)`, `browser-ui (live-browser.js)` each preceded by that comment line. Parts are re-read from disk on every request.
- Browser sends the token BOTH as `?token=` query (authorizes CORS preflight) and in JSON body `token` for POSTs.
#### 4. Injection per framework (`live-inject.mjs` + `live/frameworks/*`)
@@ -1547,7 +1547,7 @@ Manual-edit activity types broadcast: `manual_edit_stashed, manual_edit_discarde
Browser handling of `done`: for svelte-component sessions → re-read manifest via `/source` and (re)mount; else if `arrivedVariants>=expected` → CYCLING; else if `msg.file` and state GENERATING → after 750 ms `injectVariantsFromSource(file)` (fetch `/source?path=`, parse, extract wrapper between `<!-- impeccable-variants-start ID -->``<!-- impeccable-variants-end ID -->` and inject); else toast after 2 s.
#### 6.3 Agent poll: `GET /poll`
Query: `token`, `timeout` (ms, default 600000), `leaseMs` (default 30000; live-poll.mjs sends 600000), `types` (comma list filter). Records `lastPollAt`. If an available (unleased or lease-expired, type-allowed) event exists: lease it (see below) and answer 200 with the event JSON. Else park; on timeout answer `{"type":"timeout"}`; on shutdown `{"type":"exit"}`. Selection order: priority 0 `accept|discard|exit`, 1 `manual_edit_apply|steer|carbonize_cleanup`, 2 `generate`, 3 others; then by `seq`. Queue dedupes by (id,type) (mount failures also by variant).
Query: `token`, `timeout` (ms, default 600000), `leaseMs` (default 30000; live-poll.mjs sends 600000), `types` (comma list filter), `id` (only that session's events; `live-generate` collects its own generate event with `types=generate&id=<sessionId>` and leaves every other session's queue alone; a parked poll keeps the filter). Records `lastPollAt`. If an available (unleased or lease-expired, type-allowed, id-matching) event exists: lease it (see below) and answer 200 with the event JSON. Else park; on timeout answer `{"type":"timeout"}`; on shutdown `{"type":"exit"}`. Selection order: priority 0 `accept|discard|exit`, 1 `manual_edit_apply|steer|carbonize_cleanup`, 2 `generate`, 3 others; then by `seq`. Queue dedupes by (id,type) (mount failures also by variant).
Lease: `leaseUntil = now+leaseMs`; for `generate` events not yet `scaffoldAttempted`: record agent phases `picked_up`, `scaffolding`, run preflight (spawns `live-wrap.mjs`/`live-insert.mjs --defer-source-write …` with 15 s timeout, see 7), then event gets `scaffoldAttempted:true, scaffoldDurationMs, scaffold:{…helper JSON}` or `scaffoldError:'<last stderr line or message ≤500>'` (`insufficient_locator` when neither id nor classes), phase `source_ready`/`scaffold_fallback` `{durationMs, previewMode}`; re-stamp lease; add `generationReadyAt` and phase `generation_ready`; broadcast `agent_polling`. Events without an id (`exit`) are removed from the queue when leased.
@@ -1575,7 +1575,7 @@ Journal line: `{"seq":N,"id":"…","type":"…","ts":"ISO","event":{…full even
Base snapshot: `{id, phase:'new', pageUrl:null, sourceFile:null, previewFile:null, previewMode:null, expectedVariants:0, arrivedVariants:0, visibleVariant:null, paramValues:{}, pendingEventSeq:null, pendingEvent:null, deliveryLease:null, checkpointRevision:0, browserCheckpointRevision:0, publicationCheckpointRevision:0, activeOwner:null, sourceMarkers:{}, fallbackMode:null, generationPhase:null, generationCompletedAt:null, generationTimings:{}, variantPlan:null, generationCanceled:false, generationCanceledAt:null, cancelReason:null, annotationArtifacts:[], mountedVariants:[], mountFailures:[], renderState:null, diagnostics:[], updatedAt:null}` (+ `detectorWaivers`, `message` when set).
Reducer per event type (`updatedAt = entry.ts`):
- `generate`: phase `generate_requested`; pageUrl; expectedVariants=count; pendingEventSeq=seq; pendingEvent=event; variantPlan=null; mounted/mountFailures cleared, renderState null; screenshotPath → push `{type:'screenshot', path}` artifact.
- `generate`: phase `generate_requested`; `origin` (the server stamps `"agent"` on a Go the generate verb fired; accept reads it back to decide the mechanical bake); pageUrl; expectedVariants=count; pendingEventSeq=seq; pendingEvent=event; variantPlan=null; mounted/mountFailures cleared, renderState null; screenshotPath → push `{type:'screenshot', path}` artifact.
- `variant_plan` (unless canceled/fenced): variantPlan=plan. `detector_waivers`: append waivers.
- `agent_phase`: generationPhase=phase; `generationTimings[phase]={at, durationMs}`.
- `variants_ready`|`agent_done`: if canceled/fenced and not (agent_done carbonize in `accept_requested`) → diagnostic `late_generation_event_ignored`; else phase = `carbonize_required` if carbonize else `variants_ready`; generationCompletedAt; sourceFile=event.sourceFile??event.file; previewFile/previewMode; arrivedVariants = event.arrivedVariants ?? expected; clear pending; carbonize → diagnostic `carbonize_cleanup_required`; renderState derived (`mounted` if any mounted, `failed` if failures only, `pending` if completed, else null).
@@ -1684,7 +1684,9 @@ Order in `live-accept.mjs`: receipt check → find `impeccable-variants-start <i
<indent></div>
```
- JSX: everything above wrapped in `<indent><div data-impeccable-carbonize="ID" style={{ display: "contents" }}>``</div>` with body indented 2 more, `<style …>{\`` / `\`}</style>`, `{/* … */}` comments, `style={{ display: 'contents' }}` on the variant div.
Result `{handled:true, file: rel, carbonize:boolean, todo?:'REQUIRED before next poll: carbonize cleanup in <file>. See reference/live.md "Required after accept".'}`. Discard: replace range with deindented original → `{handled:true, file, carbonize:false}`. After accept with `--page-url`, buffered manual-edit ops whose original/new text appears as an exact text segment in the replaced original block are dropped from `pending-manual-edits.json`.
Result `{handled:true, file: rel, carbonize:boolean, todo?:'REQUIRED before next poll: carbonize cleanup in <file>. See reference/live.md "Required after accept".', bakeSkipped?}`. Discard: replace range with deindented original → `{handled:true, file, carbonize:false}`.
**Mechanical bake** (`bake.rs`): for a session whose snapshot carries `origin:'agent'` (a Go fired by `live-generate`) or on `--bake` (never on `--no-bake`; plain live sessions are untouched), a knob-free HTML/JSX accept is made permanent instead of leaving the carbonize block. Refused (falls back to the carbonize block, with `bakeSkipped:<reason>`) when: `--param-values` is non-empty; the accepted variant carries `data-impeccable-*` or `data-p-*` inside it; the preview CSS uses `var(--p-*)`, `data-p-*`, or `data-impeccable-params`; the variant's root tag has neither an id nor a static class (`className={expr}`); a `:scope` cannot be rewritten (sibling combinators, `:scope` not at the front, nested `@scope`); the accepted variant declares no rule; or no destination stylesheet exists. The rewrite: the accepted `@scope ([data-impeccable-variant="N"])` block is flattened and every selector re-anchored on the root tag's selector (`#id`, else `tag.class.class`): `:scope > .x``.x`, `:scope .x``<anchor> .x`, `:scope:hover > .x``<anchor>:hover > .x`, bare `:scope``<anchor>`; Astro's `[data-impeccable-variant="N"] > .x` prefix the same way; nested `@media`/`@supports` inside the block keep their prelude; top-level `@keyframes`/`@font-face` are kept, other variants' blocks dropped. Destination: for `.jsx`/`.tsx` the `.css` file under the app root (skipping node_modules/.git/.impeccable/dist/build/coverage/framework caches, depth ≤ 6, `.min.css` and generated or git-ignored files excluded) with the most rules naming the anchor's id or classes, else the only `.css` file; for other files the page's own last `<style>` block when it has one, else the same search. The rules are **appended** under `/* impeccable generate <id>: accepted variant N */` (existing rules are never rewritten; a same-selector rule later in the cascade overrides declaration by declaration). The source is verified clean (`verifyAcceptedSource`) before anything is written; the stylesheet is written first, then the source with the variant unwrapped at the wrapper's indentation. Result `{handled:true, file, carbonize:false, baked:true, variant:'N', css:{file: rel|null (null = the page's own <style>), rules, anchor}, verify:{clean, findings}}`; the poll's completion for it is `complete`, so the session ends without `live-complete`. After accept with `--page-url`, buffered manual-edit ops whose original/new text appears as an exact text segment in the replaced original block are dropped from `pending-manual-edits.json`.
Receipt: on any `handled!==false` result write `accept-receipts/<id>.json` = `{id, operation:'accept'|'discard', variantId:'N'|null, result, completedAt}` (tmp+rename). Re-run with same op/variant → prior `result` + `{handled:true, alreadyApplied:true}`; different → `{handled:false, mode:'error', error:'accept_receipt_conflict', priorOperation, priorVariantId}`.
@@ -1746,7 +1748,7 @@ Conventions: every script's "run directly" guard is `process.argv[1]` ending wit
#### `live-poll.mjs` -> `impeccable poll`
- Invoked from live.md poll loop; `--reply` forms quoted in `_instructions` (see instructions.mjs strings in 6.3/below).
- Args: `--stream`, `--timeout=MS` (one-shot total, default 600000), `--types=A,B`, `--ack-timeout=MS` (stream, default 600000), `--reply <id> <status> [--file PATH] [--data JSON] [message]`, `--help`. `--reply` success (stdout, exit 0): one compact JSON line `{ok:true,id,status,file? (only when --file was passed),_instructions:'Poll again now.'}`. `--reply` errors (stderr, exit 1): `Usage: node "<abs>/live-poll.mjs" --reply <id> <status> [--file path] [--data '<json>'] [message]` + `Missing event id after --reply.` / `The value after --reply must be the event id, not the status "done". Use --reply EVENT_ID done.` / `Missing reply status after event id "X".`; `--data must be valid JSON: <err>`.
- Args: `--stream`, `--timeout=MS` (one-shot total, default 600000), `--types=A,B`, `--ack-timeout=MS` (stream, default 600000), `--reply <id> <status> [--file PATH] [--data JSON] [--then-poll] [message]`, `--help`. `--reply` success (stdout, exit 0): one compact JSON line `{ok:true,id,status,file? (only when --file was passed),_instructions:'Poll again now.'}`. With `--then-poll` a successful reply is followed by the one-shot poll in the same call (`--timeout=` and `--types=` apply to it): the output is that poll's event line with the reply's ack folded in as `_replyAck` `{ok:true,id,status,file?}` (accept/discard handling and banners as usual); when the poll itself fails, the ack line is printed first and the poll's error follows on stderr, exit 1. `--reply` errors (stderr, exit 1): `Usage: node "<abs>/live-poll.mjs" --reply <id> <status> [--file path] [--data '<json>'] [message]` + `Missing event id after --reply.` / `The value after --reply must be the event id, not the status "done". Use --reply EVENT_ID done.` / `Missing reply status after event id "X".`; `--data must be valid JSON: <err>`.
- Needs `server.json`; else stderr `No running live server found. Start one with: node "<abs>/live.mjs"` exit 1.
- One-shot: loops `GET /poll?token&timeout=<slice ≤270000>&leaseMs=600000[&types]` until an event or total deadline; prints one JSON line (`console.log(JSON.stringify(event))`) with `_instructions` added by `instructionsForEvent` (unless already present). For `accept`/`discard`: spawns `node live-accept.mjs --id ID (--discard | --variant N) [--page-url U] [--param-values JSON]` (30 s), sets `event._acceptResult` (parse failure/throw → `{handled:false, mode:'error', error}`), then POSTs completion `{id, type: completionType, sourceEventType: event.type, message: _acceptResult.error, file: _acceptResult.file, data: {carbonize:true}?}` where completionType = discard: `discarded` if handled else `error`; accept: `agent_done` if handled&carbonize, `complete` if handled, `error` if mode error or (svelte-component unhandled), else `agent_done`; sets `event._completionAck = {ok:true, type}` (+ `final:false, requiresComplete:true, nextCommand:'live-complete.mjs --id <id>', message:'Carbonize cleanup must be verified, then the session must be completed explicitly before polling again.'` for carbonize) or `{ok:false, error}`. Stderr banners: manual_edit_apply → 4-line banner starting `Manual Apply action required: edit source, then reply with \`live-poll.mjs --reply <id> done --data '<json>'\`.`; carbonize → `⚠ Carbonize cleanup REQUIRED before next poll. After cleanup, run live-complete.mjs --id <id>. See reference/live.md "Required after accept".`
- Stream: stderr `[impeccable-poll] stream mode: one JSON object per line on stdout; use --reply while this process stays running`; after each reply-needing event waits (poll `/status` every 400 ms) until the id leaves `pendingEvents` (else `Timed out waiting for --reply on event <id>` exit 1); returns on `exit`.
@@ -1799,10 +1801,10 @@ Conventions: every script's "run directly" guard is `process.argv[1]` ending wit
- Library only (`resolveLiveTarget(cwd,args)``{originalCwd, projectRoot, targetPath, absoluteTargetPath, targetOptions}`); used by `live.mjs`. Tests: `tests/live-target-context.test.mjs`.
#### `live-generate.mjs` -> `impeccable live-generate`
- **Invoked from**: `skill/reference/generate.md` (the `generate` command), after `impeccable live` booted the helper and the agent opened the app page: `impeccable live-generate --selector "section.pricing" --action bolder --count 3`.
- **Args**: `--selector <css>` (required), `--text <snippet>`, `--index <n>` (1-based), `--action <name>` (default `impeccable`), `--count <n>` (default 3, 1-8), `--prompt <text>`, `--dry-run`, `--wait-for-browser <ms>`, `--no-live-bar` (body `hideLiveBar:true`: the helper sets its lifetime-wide `hideLiveBar` preference, broadcasts `{type:'live_bar', hidden:true}` to every connected overlay before the target goes out, and answers `hideLiveBar:true` on every later `connected` frame; the overlay hides its global bar accordingly and skips its "No PRODUCT.md found" connect notice, the variant controls still show, and only the helper stopping ends it), `--target <path>` (consumed by `enterLiveRoot`), `--help`. A flag without a value → stdout `{"ok":false,"error":"missing_flag_value","flag":"--x"}`, exit 1.
- **Invoked from**: `skill/reference/generate.md` (the `generate` command), as the lane's one start command after the agent opened the page in its harness's own browser: `impeccable live-generate --target src/App.jsx --dev-url http://127.0.0.1:5173/ --selector ".pricing-grid" --action bolder --count 3 --boot --wait-for-browser 60000` (`--open` only on a harness with no browser tool; without `--dev-url` and without a wait, the verdict is `browser_needed` and the agent comes back with the page open).
- **Args**: `--selector <css>` (required), `--text <snippet>`, `--index <n>` (1-based), `--action <name>` (default `impeccable`), `--count <n>` (default 3, 1-8), `--prompt <text>`, `--dry-run`, `--wait-for-browser <ms>`, `--no-live-bar` (body `hideLiveBar:true`: the helper sets its lifetime-wide `hideLiveBar` preference, broadcasts `{type:'live_bar', hidden:true}` to every connected overlay before the target goes out, and answers `hideLiveBar:true` on every later `connected` frame; the overlay hides its global bar accordingly and skips its "No PRODUCT.md found" connect notice, the variant controls still show, and only the helper stopping ends it), `--target <path>` (consumed by `enterLiveRoot`, and the boot's --target under `--boot`), `--boot` (run `live --allow-missing-context --dev-url --no-live-bar [--target]` in-process from the caller's cwd first, reusing a running helper; implies `hideLiveBar:true` on the target; the boot's `devUrl, pageFiles, projectRoot, targetPath, liveBarHidden, contextMissing, contextNote, hasProduct, product, productPath, hasDesign, design, designPath, hasSurfaceBrief, surfaceBrief, surfaceBriefPath` ride along as `boot`; a refusing boot is printed as its own payload plus `ok:false`, `bootError:<its error>` and `_instructions`, exit 1; an unparseable boot → `boot_failed` (+`exitCode`, `detail`)), `--open` (ignored, with `openIgnored:'harness browser'` on the verdict and an `_instructions` prefix saying so, when the provider id is `cursor` or `claude-code` and neither `IMPECCABLE_BROWSER` nor the config's `browser` names a browser: a harness with its own browser never gets a second window from this verb, and the generic `BROWSER` variable is not that choice; otherwise, when `/status` reports no connected client: the dev URL is the boot's `devUrl` or a fresh `dev_url::probe`; none → `no_dev_server`, exit 1; else the URL is opened without waiting for the browser (`IMPECCABLE_BROWSER`, then `browser` in `.impeccable/config.local.json` / `.impeccable/config.json` at the app root, then `BROWSER`, then `open` / `xdg-open` / `cmd /c start`; a value with a path separator runs as a program with the URL as its argument, on macOS any other value is `open -a <name>`), recorded as `opened:{url, via}`, and `--wait-for-browser` defaults to 60000; a launch failure → `browser_open_failed` (+`url`, `detail`)), `--allow-missing-context` (accepted and ignored), `--dev-url [<url>]` (bare: ignored, the boot's own flag; with a value: the dev server the caller already knows, put first in the boot's probe list (`IMPECCABLE_DEV_URL_CANDIDATES` still wins when set) and in this verb's own probe, and reported as `devUrl` with `devUrlVerified:false` when no probe confirmed the tag on it), `--help`. A flag without a value → stdout `{"ok":false,"error":"missing_flag_value","flag":"--x"}`, exit 1.
- **Env**: `IMPECCABLE_SELF` (how the boot and poll verbs are spelled in `_instructions`).
- **Behavior**: `enterLiveRoot`; local verdicts first, each pretty-printed JSON on stdout with `_instructions`, exit 1: `selector_required`, `invalid_action` (+`action`, `validActions`), `invalid_count` (+`count`), `invalid_index` (+`index`), `invalid_wait` (+`wait`); no `server.json` (or one without port/token) → `server_not_running`. With `--wait-for-browser`, `GET /status` once a second until `connectedClients > 0` or the budget ends (`no_browser_connected` + `waitedMs`); an unanswered `/status``server_unreachable`. Then `POST /agent-target` with `{token, selector, action, count, text?, index?, prompt?, dryRun?}` under a 20 s client cap: a transport timeout → `request_timeout` (+`detail`, browser_timeout instructions), any other transport failure → `server_unreachable` (+`detail`); a non-2xx answer → `{ok:false, error:<body.error or http_<status>>, ...body}`; an unparseable body → `bad_server_response` (+`status`). A 2xx answer is printed as received plus `_instructions` for `ok` (dry run or started session, naming `impeccable live-poll`), `no_browser_connected`, `browser_timeout`, `invalid_selector`, `no_match` (wording depends on `rawMatchCount`), `ambiguous`, `index_out_of_range`, `busy`, `go_failed`, `server_stopping`; exit 0 when `ok:true`, else 1. `_instructions` are regenerated locally from the verdict, never taken from the wire.
- **Behavior**: `enterLiveRoot`; local verdicts first, each pretty-printed JSON on stdout with `_instructions`, exit 1: `selector_required`, `invalid_action` (+`action`, `validActions`), `invalid_count` (+`count`), `invalid_index` (+`index`), `invalid_wait` (+`wait`); no `server.json` (or one without port/token) → `server_not_running`. With `--wait-for-browser`, `GET /status` once a second until `connectedClients > 0` or the budget ends (`no_browser_connected` + `waitedMs`); an unanswered `/status``server_unreachable`. Then `POST /agent-target` with `{token, selector, action, count, text?, index?, prompt?, dryRun?}` under a 20 s client cap: a transport timeout → `request_timeout` (+`detail`, browser_timeout instructions), any other transport failure → `server_unreachable` (+`detail`); a non-2xx answer → `{ok:false, error:<body.error or http_<status>>, ...body}`; an unparseable body → `bad_server_response` (+`status`). Before the target goes out, with no page connected (`/status` `connectedClients` 0), no `--open`, no `--wait-for-browser`, and no `--dry-run`: `browser_needed` (`devUrl` from the boot's probe, else a probe led by the hint, else the hint unverified; `devUrlVerified`; `harness`, the provider id) or `no_dev_server` when no URL is known, exit 1. Its `_instructions` name the harness's own browser and never a second one: `cursor``browser_navigate` (reuse the tab on that origin); `claude-code` → the Browser pane (`navigate` the tab already on that origin, `tabs_context`, `preview_start` with the URL when the pane is closed); `codex` → rerun with `--open` or give the user the URL; others → the harness browser tool, else `--open` or the user; then rerun with `--wait-for-browser 60000`. `no_dev_server` names where this harness starts a server (`claude-code`: `preview_start` or the dev script; others: the dev script in a background terminal) and asks for `--dev-url <url>` on the rerun. Every verdict from the boot on carries `harness`. A started session (`ok:true`, not a dry run) then collects its own generate event: `GET /poll?types=generate&id=<sessionId>` in ≤5 s slices for up to 20 s, leased exactly as a poll leases it (the preflight scaffold runs on lease), printed as `event` with locally generated `_instructions` (the fast path), or `event:null` when it did not arrive. A 2xx answer is printed as received plus `boot`/`opened` when those ran, plus `_instructions` for `ok` (dry run; started session with `event`, pointing at the edit and at `live-poll --reply <id> done --file <path> --then-poll`; started session without it, pointing at `live-poll` first), `no_dev_server`, `browser_open_failed`, `no_browser_connected` (a variant when `opened` is present), `browser_timeout`, `invalid_selector`, `no_match` (wording depends on `rawMatchCount`), `ambiguous`, `index_out_of_range`, `busy`, `go_failed`, `server_stopping`; exit 0 when `ok:true`, else 1. `_instructions` are regenerated locally from the verdict, never taken from the wire.
- **Tests**: `tests/oracle/cases/live-generate.mjs` (local verdicts, no-browser), `tests/live-agent-target.test.mjs` (protocol matrix against the binary), `crates/cli/tests/agent_target.rs`, `tests/live-e2e.test.mjs` (`agentTargetScenario`).
#### `live-commit-manual-edits.mjs` -> `impeccable commit-manual-edits`
+1 -1
View File
@@ -1,5 +1,5 @@
{
"stdout": "Usage: impeccable live-accept [options]\n\nDeterministic accept/discard for live variant sessions.\n\nModes:\n --discard Remove variants, restore original\n --variant N Accept variant N, discard the rest\n\nRequired:\n --id SESSION_ID Session ID of the variant wrapper\n\nOptions:\n --page-url URL Current browser page URL; scopes staged copy-edit cleanup\n --defer-source-write\n Deprecated compatibility flag. Svelte component accepts\n now write the real source immediately.\n\nOutput (JSON):\n { handled, file, carbonize }\n",
"stdout": "Usage: impeccable live-accept [options]\n\nDeterministic accept/discard for live variant sessions.\n\nModes:\n --discard Remove variants, restore original\n --variant N Accept variant N, discard the rest\n\nRequired:\n --id SESSION_ID Session ID of the variant wrapper\n\nOptions:\n --page-url URL Current browser page URL; scopes staged copy-edit cleanup\n --bake Bake a knob-free HTML/JSX accept mechanically (rules to the\n owning stylesheet, wrapper unwrapped) instead of leaving\n the carbonize block; the default for sessions the\n generate verb started (origin \"agent\")\n --no-bake Never bake; always leave the carbonize block\n --defer-source-write\n Deprecated compatibility flag. Svelte component accepts\n now write the real source immediately.\n\nOutput (JSON):\n { handled, file, carbonize, baked?, css?, bakeSkipped? }\n",
"stderr": "",
"exit": 0,
"signal": null,
@@ -1,7 +1,7 @@
{
"steps": [
{
"stdout": "Usage: impeccable live-generate --selector <css> [--text <snippet>] [--index <n>] [--action <name>] [--count <n>] [--prompt <text>] [--dry-run] [--wait-for-browser <ms>] [--no-live-bar]\n\nFlags:\n --selector <css> required; resolved with document.querySelectorAll\n --text <snippet> optional; keeps only matches whose textContent contains it\n --index <n> optional; 1-based pick among the remaining matches\n --action <name> optional; one of the live action vocabulary (default: impeccable)\n --count <n> optional; variants to request, 1-8 (default: 3)\n --prompt <text> optional; freeform direction, same as typing before Go\n --dry-run optional; resolve and report without starting anything\n --wait-for-browser <ms> optional; poll the helper until a page with the\n overlay connects (or the budget runs out) before sending\n the target.\n\n",
"stdout": "Usage: impeccable live-generate --selector <css> [--text <snippet>] [--index <n>] [--action <name>] [--count <n>] [--prompt <text>] [--dry-run] [--wait-for-browser <ms>] [--no-live-bar] [--boot] [--open] [--target <path>]\n\nFlags:\n --boot optional; run the generate lane's boot first (impeccable live\n --allow-missing-context --dev-url --no-live-bar, with --target\n when given), reusing a running helper; the boot's context and\n devUrl ride along in the output as `boot`\n --open optional; when no page with the overlay is connected, open the\n dev URL in the browser (IMPECCABLE_BROWSER, then `browser` in\n .impeccable/config.local.json or config.json, then BROWSER, then\n the platform opener) and wait for it (60 s unless\n --wait-for-browser says otherwise). On a harness with its own\n browser (Cursor, Claude Code) the flag is ignored unless\n IMPECCABLE_BROWSER or the config's `browser` names one: the\n page comes from the harness browser, never a second window\n --target <path> optional; the file that renders the element (the boot's --target)\n --dev-url <url> optional; the dev server you already know (a server the\n harness runs, a tab on the app, the user's message); probed\n first, reported as devUrl either way\n --selector <css> required; resolved with document.querySelectorAll\n --text <snippet> optional; keeps only matches whose textContent contains it\n --index <n> optional; 1-based pick among the remaining matches\n --action <name> optional; one of the live action vocabulary (default: impeccable)\n --count <n> optional; variants to request, 1-8 (default: 3)\n --prompt <text> optional; freeform direction, same as typing before Go\n --dry-run optional; resolve and report without starting anything\n --wait-for-browser <ms> optional; poll the helper until a page with the\n overlay connects (or the budget runs out) before sending\n the target.\n\nWith no page connected and neither --open nor --wait-for-browser, the verdict\nis browser_needed with devUrl and the harness's way to open it (Cursor\nbrowser_navigate, Claude Code's Browser pane, Codex: --open or the user), so no\nsecond browser is ever launched behind a harness that has one.\n\nOn success the output carries the session's generate event as `event` (already\nleased, with its _instructions), so the next command is the edit, then\n`live-poll --reply <id> done --file <path> --then-poll` for the accept.\n\n",
"stderr": "",
"exit": 0,
"signal": null
@@ -8,7 +8,7 @@
"daemon": true
},
{
"stdout": "{\n \"ok\": false,\n \"error\": \"no_browser_connected\",\n \"_instructions\": \"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.\"\n}\n",
"stdout": "{\n \"ok\": false,\n \"error\": \"no_dev_server\",\n \"harness\": \"source\",\n \"_instructions\": \"No dev server is serving this app: none of the usual ports answered with the page carrying the helper's tag (pass --dev-url <url> when you know where it runs). 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 <that url>; never kill or restart that server afterwards.\"\n}\n",
"stderr": "",
"exit": 1,
"signal": null
+1 -1
View File
@@ -1,5 +1,5 @@
{
"stdout": "Usage: impeccable poll [options]\n\nWait for a browser event from the live variant server, or reply to one.\n\nModes:\n poll Block until a browser event arrives, print JSON, exit\n poll --stream Keep polling; print one JSON line per event (see live.md)\n poll --reply <id> done Reply \"done\" to event <id> (replace or insert generate)\n poll --reply <id> steer_done Reply after handling a steer event (unlocks Steer bar)\n poll --reply <id> error \"msg\" Reply with an error message\n poll --reply <id> done --data '<json>'\n Reply with a structured JSON result (manual_edit_apply)\n\nOptions:\n --timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode\n --types=A,B Lease only these event types\n --ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000)\n --file PATH Attach a source file path to the reply (generate/steer flow)\n --data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON\n --help Show this help message\n\nHarness note:\n Default one-shot mode is the primary contract, including Codex foreground polling.\n Claude Code may run it as a background task; Cursor uses a background terminal with exit notification.\n --stream is retained for harnesses with measured, reliable incremental stdout.\n Do not use --stream on Cursor.\n",
"stdout": "Usage: impeccable poll [options]\n\nWait for a browser event from the live variant server, or reply to one.\n\nModes:\n poll Block until a browser event arrives, print JSON, exit\n poll --stream Keep polling; print one JSON line per event (see live.md)\n poll --reply <id> done Reply \"done\" to event <id> (replace or insert generate)\n poll --reply <id> steer_done Reply after handling a steer event (unlocks Steer bar)\n poll --reply <id> error \"msg\" Reply with an error message\n poll --reply <id> done --data '<json>'\n Reply with a structured JSON result (manual_edit_apply)\n poll --reply <id> done --then-poll\n Reply, then keep waiting for the next event in the\n same call (the generate lane: done, then the accept)\n\nOptions:\n --timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode\n --types=A,B Lease only these event types\n --ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000)\n --file PATH Attach a source file path to the reply (generate/steer flow)\n --data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON\n --then-poll After a successful --reply, run the one-shot poll and print its event\n (the reply's ack rides along as _replyAck). --timeout= bounds the wait\n --help Show this help message\n\nHarness note:\n Default one-shot mode is the primary contract, including Codex foreground polling.\n Claude Code may run it as a background task; Cursor uses a background terminal with exit notification.\n --stream is retained for harnesses with measured, reliable incremental stdout.\n Do not use --stream on Cursor.\n",
"stderr": "",
"exit": 0,
"signal": null,