mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-15 07:36:50 +03:00
Port /impeccable generate to the engine crates
The Node-era server, CLI, hook, and pin halves of the generate command move into the Rust workspace, with the protocol unchanged: - crates/live: POST /agent-target is held open on a channel plus a timer thread (the manual-apply deferred pattern), releasing its turnstile ticket before it parks like /poll; /agent-target-result resolves it; /agent-target-claim is the roll call with its renewable lease. SSE connections carry the overlay's clientId: a late overlay is replayed every pending target, and a disconnect retires that overlay's report, releases its lease, and re-judges each roll call. Shutdown drains held requests with server_stopping. - crates/live/src/live_generate.rs: the live-generate verb (the router already forwards every live* verb), same flags, verdicts, and _instructions, spelled with the engine's self command. - crates/hook: every entry stands down on live preview markers (skipped: live-preview), checking the proposed content and the file on disk for hook-before-edit. - crates/context: pin accepts generate; the crate's command-metadata.json copy carries its entry. Tests: crates/cli/tests/agent_target.rs (six HTTP cases with an SSE reader), tests/live-agent-target.test.mjs rewritten to drive the binary (28 cases, registered in the live suite), hook stand-down cases, oracle goldens for live-generate plus the re-recorded pin list goldens, the e2e prompt assertion waiting for the journaled event, and the contract documented in docs/CLI-CONTRACT.md. AI-assisted: implemented and tested with Claude Code under maintainer direction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
committed by
Abdul Wahab
co-authored by
Claude Fable 5
parent
fc89b0ed62
commit
d397140a77
@@ -0,0 +1,333 @@
|
||||
//! Agent-initiated element targeting (the `generate` command) against a real
|
||||
//! `live-server`: the held-open `POST /agent-target`, the overlay's
|
||||
//! `/agent-target-result`, and the `/agent-target-claim` roll call with its
|
||||
//! leases. The full 28-case protocol matrix runs from Node
|
||||
//! (tests/live-agent-target.test.mjs); this covers the core paths so
|
||||
//! `cargo test --workspace` gates them on every platform.
|
||||
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::TcpStream;
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn http(port: u16, method: &str, target: &str, body: Option<&str>) -> (u16, String) {
|
||||
let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect");
|
||||
s.set_read_timeout(Some(Duration::from_secs(20))).unwrap();
|
||||
let body = body.unwrap_or("");
|
||||
let req = format!(
|
||||
"{} {} HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
method,
|
||||
target,
|
||||
port,
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
s.write_all(req.as_bytes()).unwrap();
|
||||
let mut out = Vec::new();
|
||||
let _ = s.read_to_end(&mut out);
|
||||
let text = String::from_utf8_lossy(&out).into_owned();
|
||||
let status: u16 = text.split_whitespace().nth(1).and_then(|c| c.parse().ok()).unwrap_or(0);
|
||||
let body = text.split_once("\r\n\r\n").map(|(_, b)| b.to_string()).unwrap_or_default();
|
||||
let body = if text.to_ascii_lowercase().contains("transfer-encoding: chunked") {
|
||||
let mut rest = body.as_str();
|
||||
let mut assembled = String::new();
|
||||
while let Some((size_line, after)) = rest.split_once("\r\n") {
|
||||
let size = usize::from_str_radix(size_line.trim(), 16).unwrap_or(0);
|
||||
if size == 0 {
|
||||
break;
|
||||
}
|
||||
assembled.push_str(&after[..size.min(after.len())]);
|
||||
rest = after.get(size + 2..).unwrap_or("");
|
||||
}
|
||||
assembled
|
||||
} else {
|
||||
body
|
||||
};
|
||||
(status, body)
|
||||
}
|
||||
|
||||
fn post_json(port: u16, path: &str, body: serde_json::Value) -> (u16, serde_json::Value) {
|
||||
let (status, text) = http(port, "POST", path, Some(&body.to_string()));
|
||||
let parsed = serde_json::from_str(&text).unwrap_or(serde_json::json!({ "raw": text }));
|
||||
(status, parsed)
|
||||
}
|
||||
|
||||
/// A minimal fake overlay: holds the SSE stream open and yields `data:` frames.
|
||||
struct Overlay {
|
||||
reader: BufReader<TcpStream>,
|
||||
}
|
||||
|
||||
impl Overlay {
|
||||
fn connect(port: u16, token: &str, client_id: &str) -> Overlay {
|
||||
let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect sse");
|
||||
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
|
||||
let req = format!(
|
||||
"GET /events?token={}&clientId={} HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nAccept: text/event-stream\r\n\r\n",
|
||||
token, client_id, port
|
||||
);
|
||||
s.write_all(req.as_bytes()).unwrap();
|
||||
let mut reader = BufReader::new(s);
|
||||
// Consume the response head.
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
let n = reader.read_line(&mut line).expect("sse head");
|
||||
if n == 0 || line == "\r\n" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Overlay { reader }
|
||||
}
|
||||
|
||||
/// The next `data:` frame whose parsed JSON satisfies `matches`; chunk
|
||||
/// size lines and keepalives are skipped.
|
||||
fn next(&mut self, matches: impl Fn(&serde_json::Value) -> bool) -> serde_json::Value {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while Instant::now() < deadline {
|
||||
let mut line = String::new();
|
||||
match self.reader.read_line(&mut line) {
|
||||
Ok(0) => panic!("sse stream closed"),
|
||||
Ok(_) => {}
|
||||
Err(e) => panic!("sse read: {e}"),
|
||||
}
|
||||
let line = line.trim_end_matches(['\r', '\n']);
|
||||
if let Some(json) = line.strip_prefix("data: ") {
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(json) {
|
||||
if matches(&v) {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
panic!("no matching sse frame within 10s");
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_for(p: &Path, secs: u64) -> bool {
|
||||
let deadline = Instant::now() + Duration::from_secs(secs);
|
||||
while !p.exists() && Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
p.exists()
|
||||
}
|
||||
|
||||
fn free_port() -> u16 {
|
||||
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = l.local_addr().unwrap().port();
|
||||
drop(l);
|
||||
port
|
||||
}
|
||||
|
||||
struct Server {
|
||||
child: std::process::Child,
|
||||
dir: std::path::PathBuf,
|
||||
port: u16,
|
||||
token: String,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
fn start(tag: &str) -> Server {
|
||||
let dir = std::env::temp_dir().join(format!("impeccable-agent-target-{}-{}", tag, 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();
|
||||
let port = free_port();
|
||||
let child = std::process::Command::new(env!("CARGO_BIN_EXE_impeccable"))
|
||||
.args(["live-server", &format!("--port={}", port)])
|
||||
.current_dir(&dir)
|
||||
.env("IMPECCABLE_LIVE_COPY_AGENT", "off")
|
||||
// A short timeout keeps the browser_timeout case fast; the env
|
||||
// override exists exactly for this. The lease shrinks with it.
|
||||
.env("IMPECCABLE_AGENT_TARGET_TIMEOUT_MS", "400")
|
||||
.env("IMPECCABLE_AGENT_TARGET_CLAIM_LEASE_MS", "250")
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.expect("spawn live-server");
|
||||
let pid_file = dir.join(".impeccable/live/server.json");
|
||||
assert!(wait_for(&pid_file, 10), "server pid file never appeared");
|
||||
let info: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&pid_file).unwrap()).unwrap();
|
||||
let port = info["port"].as_u64().expect("port") as u16;
|
||||
let token = info["token"].as_str().expect("token").to_string();
|
||||
Server { child, dir, port, token }
|
||||
}
|
||||
|
||||
fn target(&self, extra: serde_json::Value) -> serde_json::Value {
|
||||
let mut body = serde_json::json!({ "token": self.token, "selector": "h1", "action": "bolder", "count": 3 });
|
||||
if let (Some(b), Some(e)) = (body.as_object_mut(), extra.as_object()) {
|
||||
for (k, v) in e {
|
||||
b.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
/// POST /agent-target on a thread: the server holds it until a verdict.
|
||||
fn hold(&self, extra: serde_json::Value) -> std::thread::JoinHandle<(u16, serde_json::Value)> {
|
||||
let port = self.port;
|
||||
let body = self.target(extra);
|
||||
std::thread::spawn(move || post_json(port, "/agent-target", body))
|
||||
}
|
||||
|
||||
fn claim(&self, target_id: &str, client_id: &str, eligible: bool) -> serde_json::Value {
|
||||
let body = if eligible {
|
||||
serde_json::json!({ "token": self.token, "targetId": target_id, "clientId": client_id, "eligible": true })
|
||||
} else {
|
||||
serde_json::json!({ "token": self.token, "targetId": target_id, "clientId": client_id, "eligible": false, "state": "CYCLING", "reason": "session_active" })
|
||||
};
|
||||
post_json(self.port, "/agent-target-claim", body).1
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Server {
|
||||
fn drop(&mut self) {
|
||||
let _ = http(self.port, "GET", &format!("/stop?token={}", self.token), None);
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
let _ = std::fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_target_validates_and_answers_no_browser() {
|
||||
let s = Server::start("validate");
|
||||
let (st, body) = post_json(s.port, "/agent-target", serde_json::json!({ "token": "nope", "selector": "h1", "action": "bolder", "count": 3 }));
|
||||
assert_eq!(st, 401, "{body}");
|
||||
let (st, body) = post_json(s.port, "/agent-target", s.target(serde_json::json!({ "action": "bold" })));
|
||||
assert_eq!(st, 400);
|
||||
assert!(body["error"].as_str().unwrap().contains("invalid action"), "{body}");
|
||||
assert!(body["error"].as_str().unwrap().contains("bolder"), "{body}");
|
||||
let (st, body) = post_json(s.port, "/agent-target", s.target(serde_json::json!({ "count": 9 })));
|
||||
assert_eq!(st, 400);
|
||||
assert_eq!(body["error"], serde_json::json!("agent_target: count must be 1-8"));
|
||||
let (st, body) = post_json(s.port, "/agent-target", serde_json::json!({ "token": s.token, "action": "bolder", "count": 3 }));
|
||||
assert_eq!(st, 400);
|
||||
assert_eq!(body["error"], serde_json::json!("agent_target: selector is required"));
|
||||
// No overlay attached: answered at once, not held.
|
||||
let (st, body) = post_json(s.port, "/agent-target", s.target(serde_json::json!({})));
|
||||
assert_eq!(st, 200);
|
||||
assert_eq!(body["ok"], serde_json::json!(false));
|
||||
assert_eq!(body["error"], serde_json::json!("no_browser_connected"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_target_broadcasts_and_resolves_with_the_browser_result() {
|
||||
let s = Server::start("resolve");
|
||||
let mut tab = Overlay::connect(s.port, &s.token, "tab-a");
|
||||
tab.next(|m| m["type"] == "connected");
|
||||
let held = s.hold(serde_json::json!({ "text": "Studio", "index": 2, "prompt": "warmer", "dryRun": true }));
|
||||
let pushed = tab.next(|m| m["type"] == "agent_target");
|
||||
assert_eq!(pushed["selector"], serde_json::json!("h1"));
|
||||
assert_eq!(pushed["text"], serde_json::json!("Studio"));
|
||||
assert_eq!(pushed["index"], serde_json::json!(2));
|
||||
assert_eq!(pushed["prompt"], serde_json::json!("warmer"));
|
||||
assert_eq!(pushed["dryRun"], serde_json::json!(true));
|
||||
let target_id = pushed["targetId"].as_str().expect("targetId").to_string();
|
||||
assert_eq!(target_id.len(), 8);
|
||||
let claim = s.claim(&target_id, "tab-a", true);
|
||||
assert_eq!(claim, serde_json::json!({ "ok": true, "granted": true, "pending": true }));
|
||||
// A second tab is denied while the lease is held, and told the request
|
||||
// is still pending.
|
||||
let denied = s.claim(&target_id, "tab-b", true);
|
||||
assert_eq!(denied, serde_json::json!({ "ok": true, "granted": false, "pending": true }));
|
||||
let (st, ack) = post_json(
|
||||
s.port,
|
||||
"/agent-target-result",
|
||||
serde_json::json!({ "token": s.token, "targetId": target_id, "ok": true, "dryRun": true, "matchCount": 1, "element": { "tag": "h1" } }),
|
||||
);
|
||||
assert_eq!(st, 200);
|
||||
assert_eq!(ack, serde_json::json!({ "ok": true, "delivered": true }));
|
||||
let (st, verdict) = held.join().unwrap();
|
||||
assert_eq!(st, 200, "{verdict}");
|
||||
assert_eq!(verdict["targetId"], serde_json::json!(target_id));
|
||||
assert_eq!(verdict["ok"], serde_json::json!(true));
|
||||
assert_eq!(verdict["matchCount"], serde_json::json!(1));
|
||||
// Resolved: a late result reports delivered:false, a late claim says gone.
|
||||
let (_, late) = post_json(s.port, "/agent-target-result", serde_json::json!({ "token": s.token, "targetId": target_id, "ok": true }));
|
||||
assert_eq!(late, serde_json::json!({ "ok": true, "delivered": false }));
|
||||
assert_eq!(s.claim(&target_id, "tab-b", true), serde_json::json!({ "ok": true, "granted": false, "pending": false }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_target_times_out_when_the_overlay_never_answers() {
|
||||
let s = Server::start("timeout");
|
||||
let mut tab = Overlay::connect(s.port, &s.token, "tab-a");
|
||||
tab.next(|m| m["type"] == "connected");
|
||||
let started = Instant::now();
|
||||
let held = s.hold(serde_json::json!({}));
|
||||
tab.next(|m| m["type"] == "agent_target");
|
||||
let (st, verdict) = held.join().unwrap();
|
||||
assert_eq!(st, 200);
|
||||
assert_eq!(verdict["error"], serde_json::json!("browser_timeout"));
|
||||
assert_eq!(verdict["timeoutMs"], serde_json::json!(400));
|
||||
assert!(started.elapsed() < Duration::from_secs(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_target_roll_call_answers_busy_once_every_overlay_declined() {
|
||||
let s = Server::start("busy");
|
||||
let mut a = Overlay::connect(s.port, &s.token, "tab-a");
|
||||
let mut b = Overlay::connect(s.port, &s.token, "tab-b");
|
||||
a.next(|m| m["type"] == "connected");
|
||||
b.next(|m| m["type"] == "connected");
|
||||
let started = Instant::now();
|
||||
let held = s.hold(serde_json::json!({}));
|
||||
let pushed = a.next(|m| m["type"] == "agent_target");
|
||||
let target_id = pushed["targetId"].as_str().unwrap().to_string();
|
||||
assert_eq!(s.claim(&target_id, "tab-a", false), serde_json::json!({ "ok": true, "granted": false }));
|
||||
assert_eq!(s.claim(&target_id, "tab-b", false), serde_json::json!({ "ok": true, "granted": false }));
|
||||
let (_, verdict) = held.join().unwrap();
|
||||
assert_eq!(verdict["error"], serde_json::json!("busy"));
|
||||
assert_eq!(verdict["state"], serde_json::json!("CYCLING"));
|
||||
assert_eq!(verdict["reason"], serde_json::json!("session_active"));
|
||||
assert!(started.elapsed() < Duration::from_millis(350), "the busy verdict did not wait for the timeout");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_target_lease_lapses_and_a_disconnect_releases_it() {
|
||||
let s = Server::start("lease");
|
||||
let mut a = Overlay::connect(s.port, &s.token, "tab-a");
|
||||
let mut b = Overlay::connect(s.port, &s.token, "tab-b");
|
||||
a.next(|m| m["type"] == "connected");
|
||||
b.next(|m| m["type"] == "connected");
|
||||
let held = s.hold(serde_json::json!({}));
|
||||
let target_id = a.next(|m| m["type"] == "agent_target")["targetId"].as_str().unwrap().to_string();
|
||||
// A holds the lease; B is denied inside it.
|
||||
assert_eq!(s.claim(&target_id, "tab-a", true)["granted"], serde_json::json!(true));
|
||||
assert_eq!(s.claim(&target_id, "tab-b", true)["granted"], serde_json::json!(false));
|
||||
// A leaves without a result: its lease is handed back at once, well
|
||||
// inside the 250ms lease, and B rescues the request.
|
||||
drop(a);
|
||||
let mut granted = false;
|
||||
for _ in 0..20 {
|
||||
std::thread::sleep(Duration::from_millis(15));
|
||||
if s.claim(&target_id, "tab-b", true)["granted"] == serde_json::json!(true) {
|
||||
granted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(granted, "the disconnect released the lease");
|
||||
post_json(s.port, "/agent-target-result", serde_json::json!({ "token": s.token, "targetId": target_id, "ok": true, "sessionId": "aabbccdd" }));
|
||||
let (_, verdict) = held.join().unwrap();
|
||||
assert_eq!(verdict["ok"], serde_json::json!(true));
|
||||
assert_eq!(verdict["sessionId"], serde_json::json!("aabbccdd"));
|
||||
let _ = &mut b;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_target_replays_pending_targets_to_a_late_overlay() {
|
||||
let s = Server::start("replay");
|
||||
let mut a = Overlay::connect(s.port, &s.token, "tab-a");
|
||||
a.next(|m| m["type"] == "connected");
|
||||
let held = s.hold(serde_json::json!({}));
|
||||
let pushed = a.next(|m| m["type"] == "agent_target");
|
||||
let target_id = pushed["targetId"].as_str().unwrap().to_string();
|
||||
// B connects after the broadcast and still hears the pending target.
|
||||
let mut b = Overlay::connect(s.port, &s.token, "tab-b");
|
||||
let replayed = b.next(|m| m["type"] == "agent_target");
|
||||
assert_eq!(replayed["targetId"], serde_json::json!(target_id));
|
||||
assert_eq!(s.claim(&target_id, "tab-b", true)["granted"], serde_json::json!(true));
|
||||
post_json(s.port, "/agent-target-result", serde_json::json!({ "token": s.token, "targetId": target_id, "ok": true, "sessionId": "aabbccdd" }));
|
||||
let (_, verdict) = held.join().unwrap();
|
||||
assert_eq!(verdict["sessionId"], serde_json::json!("aabbccdd"));
|
||||
}
|
||||
@@ -19,6 +19,10 @@
|
||||
"description": "Interactive live variant mode. Select elements in the browser, pick a design action, and get AI-generated HTML+CSS variants hot-swapped via HMR. Requires a running dev server. Use when you want to visually experiment with design alternatives in real time.",
|
||||
"argumentHint": ""
|
||||
},
|
||||
"generate": {
|
||||
"description": "Agent-driven live variant generation. Boots live mode, finds the named element on the open page, scrolls the browser to it, and delivers N variants in the requested direction for the user to cycle and accept. Use for requests that name an element and a direction, like 'generate 3 bold variants of the pricing cards', skipping manual element picking.",
|
||||
"argumentHint": "[count] [direction] variants of [element]"
|
||||
},
|
||||
"adapt": {
|
||||
"description": "Adapt designs to work across different screen sizes, devices, contexts, or platforms. Implements breakpoints, fluid layouts, and touch targets. Use when the user mentions responsive design, mobile layouts, breakpoints, viewport adaptation, or cross-device compatibility.",
|
||||
"argumentHint": "[target] [context (mobile, tablet, print...)]"
|
||||
|
||||
@@ -13,10 +13,10 @@ const HARNESS_DIRS: [&str; 18] = [
|
||||
".pi", ".opencode", ".kiro", ".rovodev", ".vibe", ".qoder",
|
||||
];
|
||||
const CODEX_HARNESSES: [&str; 2] = [".codex", ".agents"];
|
||||
pub const VALID_COMMANDS: [&str; 23] = [
|
||||
pub const VALID_COMMANDS: [&str; 24] = [
|
||||
"craft", "init", "extract", "document", "shape", "critique", "audit", "polish", "bolder", "quieter", "distill",
|
||||
"harden", "onboard", "live", "animate", "colorize", "typeset", "layout", "delight", "overdrive", "clarify",
|
||||
"adapt", "optimize",
|
||||
"adapt", "optimize", "generate",
|
||||
];
|
||||
const PIN_MARKER: &str = "<!-- impeccable-pinned-skill -->";
|
||||
const OPENCODE_PIN_MARKER: &str = "<!-- impeccable-pinned-command -->";
|
||||
|
||||
@@ -777,6 +777,17 @@ fn main_flow(rt: &Runtime, stdin: &str) -> Out {
|
||||
if content.len() as u64 > MAX_SCANNED_BYTES {
|
||||
return skip(&audit, "content-too-large");
|
||||
}
|
||||
// A live variant session owns files carrying preview scaffolding. Check
|
||||
// the proposed content AND the file on disk: the very first variants
|
||||
// write introduces the markers, and later fragment edits (variant CSS
|
||||
// tweaks) touch a file that already carries them.
|
||||
if crate::hook_lib::has_live_preview_markers(&content)
|
||||
|| read_existing_project_file(rt, &file_path, &cwd)
|
||||
.map(|on_disk| crate::hook_lib::has_live_preview_markers(&on_disk))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return skip(&audit, "live-preview");
|
||||
}
|
||||
if !config.enabled {
|
||||
return skip(&audit, "config-disabled");
|
||||
}
|
||||
|
||||
@@ -276,6 +276,10 @@ pub fn run_hook(rt: &Runtime, stdin: &str) -> RunResult {
|
||||
};
|
||||
}
|
||||
};
|
||||
if crate::hook_lib::has_live_preview_markers(&content) {
|
||||
last_skip = "live-preview";
|
||||
continue;
|
||||
}
|
||||
let scan = scans.entry(file_path.clone()).or_insert_with(|| {
|
||||
design_system_options_for_file(rt, &config, &project_cwd, file_path)
|
||||
});
|
||||
@@ -707,6 +711,9 @@ pub fn run_stop_hook(rt: &Runtime, stdin: &str) -> RunResult {
|
||||
Ok(b) => String::from_utf8_lossy(&b).into_owned(),
|
||||
Err(_) => continue,
|
||||
};
|
||||
if crate::hook_lib::has_live_preview_markers(&content) {
|
||||
continue;
|
||||
}
|
||||
let use_html_engine = match configured {
|
||||
Some(c) => c.engine == "html",
|
||||
None => ext == ".html" || ext == ".htm",
|
||||
|
||||
@@ -2463,3 +2463,13 @@ pub fn normalize_rule_id(v: &str) -> String {
|
||||
pub fn js_slice(s: &str, start: usize, end: usize) -> String {
|
||||
slice_utf16(s, start, end)
|
||||
}
|
||||
|
||||
/// A live variant session owns files carrying preview scaffolding: the
|
||||
/// wrapper a generate publishes and the carbonize block an accept leaves
|
||||
/// until cleanup. Findings on those files are noise (variants are meant to
|
||||
/// be tried, not audited) and acting on them derails the session mid-cycle,
|
||||
/// so every hook entry stands down on the markers; `live-complete` verifies
|
||||
/// the file once the accepted variant is permanent.
|
||||
pub fn has_live_preview_markers(content: &str) -> bool {
|
||||
content.contains("data-impeccable-variants=") || content.contains("impeccable-carbonize-start")
|
||||
}
|
||||
|
||||
@@ -2199,3 +2199,62 @@ fn codex_stop_emits_decision_block() {
|
||||
assert!(out["reason"].as_str().unwrap().contains("[side-tab]"));
|
||||
assert!(out.get("hookSpecificOutput").is_none());
|
||||
}
|
||||
|
||||
// ── live-preview stand-down ───────────────────────────────────────────────
|
||||
//
|
||||
// A live variant session owns files carrying preview scaffolding
|
||||
// (`data-impeccable-variants=` wrappers, `impeccable-carbonize-start`
|
||||
// blocks). Every hook entry stands down on them: findings there are noise
|
||||
// and acting on them derails the session; live-complete verifies the file
|
||||
// once the accepted variant is permanent.
|
||||
|
||||
#[test]
|
||||
fn run_hook_stands_down_on_live_preview_markers() {
|
||||
let t = Tmp::new();
|
||||
let cwd = t.path();
|
||||
let r = rt(&cwd);
|
||||
// Control: the same slop without markers is reported.
|
||||
let plain = t.write("src/plain.css", GRADIENT_CSS);
|
||||
let reported = hook::run_hook(&r, &edit_event(&cwd, &plain, "s1"));
|
||||
assert!(reported.stdout.contains("[gradient-text]"), "{}", reported.stdout);
|
||||
// A carbonize block in flight: skipped, nothing emitted.
|
||||
let carbonized = t.write(
|
||||
"src/carbonized.css",
|
||||
&format!("/* impeccable-carbonize-start ab12cd34 */\n{GRADIENT_CSS}/* impeccable-carbonize-end ab12cd34 */\n"),
|
||||
);
|
||||
let skipped = hook::run_hook(&r, &edit_event(&cwd, &carbonized, "s1"));
|
||||
assert_eq!(skipped.stdout, "", "no findings while live markers are in the file");
|
||||
assert_eq!(skipped.audit["skipped"], json!("live-preview"));
|
||||
// A published variants wrapper, same stand-down.
|
||||
let wrapped = t.write(
|
||||
"src/wrapped.html",
|
||||
"<!-- impeccable-variants-start ab12cd34 --><div data-impeccable-variants=\"ab12cd34\" data-impeccable-variant-count=\"3\"></div>\n",
|
||||
);
|
||||
let skipped = hook::run_hook(&r, &edit_event(&cwd, &wrapped, "s1"));
|
||||
assert_eq!(skipped.stdout, "");
|
||||
assert_eq!(skipped.audit["skipped"], json!("live-preview"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn before_edit_stands_down_on_live_preview_markers() {
|
||||
let t = Tmp::new();
|
||||
let cwd = t.path();
|
||||
t.write("package.json", "{}");
|
||||
let r = rt(&cwd);
|
||||
let slop = ".t { background: linear-gradient(90deg,#f00,#00f); -webkit-background-clip: text; color: transparent; }\n";
|
||||
// Control: denied at normal size without markers.
|
||||
let (out, _) = hbe(&r, &cursor(&cwd, "Write", json!({"file_path": "src/x.css", "content": slop})));
|
||||
assert!(out.starts_with("{\"permission\":\"deny\""), "{out}");
|
||||
// The very first variants write introduces the markers in the proposed
|
||||
// content itself.
|
||||
let proposed = format!("/* impeccable-carbonize-start ab12cd34 */\n{slop}");
|
||||
let (out, code) = hbe(&r, &cursor(&cwd, "Write", json!({"file_path": "src/x.css", "content": proposed})));
|
||||
assert_eq!(code, 0);
|
||||
assert_eq!(out, "{\"permission\":\"allow\"}");
|
||||
// Later fragment edits touch a file that already carries them on disk:
|
||||
// the proposed content alone looks like plain slop.
|
||||
t.write("src/y.css", "/* impeccable-carbonize-start ab12cd34 */\n.v { color: red; }\n");
|
||||
let (out, code) = hbe(&r, &cursor(&cwd, "Write", json!({"file_path": "src/y.css", "content": slop})));
|
||||
assert_eq!(code, 0);
|
||||
assert_eq!(out, "{\"permission\":\"allow\"}");
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ pub mod live_boot;
|
||||
pub mod live_commit_manual_edits;
|
||||
pub mod live_complete;
|
||||
pub mod live_discard_manual_edits;
|
||||
pub mod live_generate;
|
||||
pub mod live_inject;
|
||||
pub mod live_insert;
|
||||
pub mod live_poll;
|
||||
@@ -68,6 +69,7 @@ pub fn run(verb: &str, args: &[String], io: &mut Io) -> i32 {
|
||||
"live-insert" | "insert" => live_insert::run(args, io),
|
||||
"live-accept" | "accept" => live_accept::run(args, io),
|
||||
"live-server" => live_server::run(args, io),
|
||||
"live-generate" => live_generate::run(args, io),
|
||||
"live-poll" | "poll" => live_poll::run(args, io),
|
||||
"live-commit-manual-edits" | "commit-manual-edits" => {
|
||||
live_commit_manual_edits::run(args, io)
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
//! `impeccable live-generate`: agent-initiated element targeting for the
|
||||
//! `generate` command.
|
||||
//!
|
||||
//! 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.
|
||||
|
||||
use crate::live_resume::self_cmd;
|
||||
use crate::paths::read_live_server_info;
|
||||
use crate::roots::enter_live_root;
|
||||
use crate::util::println;
|
||||
use crate::vocabulary::VISUAL_ACTIONS;
|
||||
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>]
|
||||
|
||||
Flags:
|
||||
--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
|
||||
--action <name> optional; one of the live action vocabulary (default: impeccable)
|
||||
--count <n> optional; variants to request, 1-8 (default: 3)
|
||||
--prompt <text> optional; freeform direction, same as typing before Go
|
||||
--dry-run optional; resolve and report without starting anything
|
||||
--wait-for-browser <ms> optional; poll the helper until a page with the
|
||||
overlay connects (or the budget runs out) before sending
|
||||
the target.
|
||||
";
|
||||
|
||||
/// Client-side cap just above the server's 15s hold, so a hung helper still
|
||||
/// fails fast.
|
||||
const REQUEST_TIMEOUT_MS: u64 = 20_000;
|
||||
|
||||
struct Flags {
|
||||
values: Map<String, Value>,
|
||||
dry_run: bool,
|
||||
}
|
||||
|
||||
fn parse_flags(argv: &[String]) -> Result<Flags, Value> {
|
||||
let mut values = Map::new();
|
||||
let mut dry_run = false;
|
||||
let mut i = 0;
|
||||
while i < argv.len() {
|
||||
let arg = &argv[i];
|
||||
if !arg.starts_with("--") {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let key = &arg[2..];
|
||||
if key == "dry-run" {
|
||||
dry_run = true;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
match argv.get(i + 1) {
|
||||
Some(v) if !v.starts_with("--") => {
|
||||
values.insert(key.to_string(), json!(v));
|
||||
i += 2;
|
||||
}
|
||||
_ => {
|
||||
return Err(json!({ "ok": false, "error": "missing_flag_value", "flag": arg }));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Flags { values, dry_run })
|
||||
}
|
||||
|
||||
fn flag<'a>(flags: &'a Flags, key: &str) -> Option<&'a str> {
|
||||
flags.values.get(key).and_then(Value::as_str)
|
||||
}
|
||||
|
||||
/// JS `Number(v)` then `Number.isInteger`: an integer literal only.
|
||||
fn int_flag(v: &str) -> Option<i64> {
|
||||
let t = v.trim();
|
||||
if t.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Ok(i) = t.parse::<i64>() {
|
||||
return Some(i);
|
||||
}
|
||||
t.parse::<f64>()
|
||||
.ok()
|
||||
.filter(|f| f.is_finite() && f.fract() == 0.0)
|
||||
.map(|f| f as i64)
|
||||
}
|
||||
|
||||
fn print_json(io: &mut Io, v: &Value) {
|
||||
println(io, &serde_json::to_string_pretty(v).unwrap_or_default());
|
||||
}
|
||||
|
||||
fn fail(io: &mut Io, v: Value) -> i32 {
|
||||
print_json(io, &v);
|
||||
1
|
||||
}
|
||||
|
||||
/// The follow-up the agent runs after each verdict. Like the poll loop's
|
||||
/// `_instructions`, regenerated locally from the verdict, never taken from
|
||||
/// the wire.
|
||||
fn instructions_for(result: &Map<String, Value>, self_cmd: &str) -> Option<String> {
|
||||
let s = |k: &str| result.get(k).and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let n = |k: &str| result.get(k).and_then(Value::as_i64).unwrap_or(0);
|
||||
if result.get("ok").and_then(Value::as_bool) == Some(true) {
|
||||
if result.get("dryRun").and_then(Value::as_bool) == Some(true) {
|
||||
let el = result.get("element").and_then(Value::as_object);
|
||||
let tag = el.and_then(|e| e.get("tag")).and_then(Value::as_str).unwrap_or("");
|
||||
let id = el
|
||||
.and_then(|e| e.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.filter(|i| !i.is_empty())
|
||||
.map(|i| format!("#{}", i))
|
||||
.unwrap_or_default();
|
||||
return Some(format!(
|
||||
"Dry run only: the selector resolves to one element ({}{}) and no session was started. Rerun without --dry-run to generate.",
|
||||
tag, id
|
||||
));
|
||||
}
|
||||
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. Handle it exactly per live.md's Handle generate, then reply done and keep polling.",
|
||||
s("sessionId"), s("action"), n("count"), self_cmd
|
||||
));
|
||||
}
|
||||
let text = match s("error").as_str() {
|
||||
"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" => format!("The overlay did not answer in time. The page may be mid-reload: run {} live-status to check whether a session started anyway, reload the app page, then rerun this command.", self_cmd),
|
||||
"invalid_selector" => "The selector is not valid CSS. Fix the selector syntax and rerun.".to_string(),
|
||||
"no_match" => {
|
||||
if n("rawMatchCount") > 0 {
|
||||
format!("The selector hit {} node(s) but none is pickable (too small, chrome, or filtered by --text). Target a larger element or adjust --text.", n("rawMatchCount"))
|
||||
} else {
|
||||
"The selector matched nothing on the open page. Derive a better selector from the page source (an id, a unique class, or a landmark), or add --text with a snippet of the element's visible text.".to_string()
|
||||
}
|
||||
}
|
||||
"ambiguous" => format!("The selector matched {} elements. Either target their common container instead, or disambiguate with --text \"<visible text>\" or --index <1-based position>. The candidates are listed in this output.", n("matchCount")),
|
||||
"index_out_of_range" => format!("--index is out of range: only {} match(es). Use an index from 1 to {}.", n("matchCount"), n("matchCount")),
|
||||
"busy" => format!("A live session is already mid-flight (browser state {}). Let the user finish or discard it in the browser, or handle the pending event in your poll loop, then rerun.", s("state")),
|
||||
"go_failed" => format!("The overlay could not start generation from the picked state (browser state {}). Reload the app page and rerun this command.", s("state")),
|
||||
"server_stopping" => format!("The live helper server is shutting down. Re-run the live boot ({} live), reopen the page, then rerun this command.", self_cmd),
|
||||
_ => return None,
|
||||
};
|
||||
Some(text)
|
||||
}
|
||||
|
||||
fn server_died(self_cmd: &str, detail: Option<String>, waiting: bool) -> Value {
|
||||
let mut v = Map::new();
|
||||
v.insert("ok".into(), json!(false));
|
||||
v.insert("error".into(), json!("server_unreachable"));
|
||||
if let Some(d) = detail {
|
||||
v.insert("detail".into(), json!(d));
|
||||
}
|
||||
let text = if waiting {
|
||||
format!("The recorded live server did not answer while waiting for a browser; it likely died. Re-run the live boot ({} live), reopen the app page, then rerun this command.", self_cmd)
|
||||
} else {
|
||||
format!("The recorded live server did not answer; it likely died. Re-run the live boot ({} live), reopen the app page, then rerun this command.", self_cmd)
|
||||
};
|
||||
v.insert("_instructions".into(), json!(text));
|
||||
Value::Object(v)
|
||||
}
|
||||
|
||||
fn server_not_running(self_cmd: &str) -> Value {
|
||||
json!({
|
||||
"ok": false,
|
||||
"error": "server_not_running",
|
||||
"_instructions": format!("No live helper server is recorded for this project. Run the live boot first ({} live), open the app URL that serves a pageFiles entry, then rerun this command.", self_cmd),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn run(args: &[String], io: &mut Io) -> i32 {
|
||||
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,
|
||||
Err(v) => return fail(io, v),
|
||||
};
|
||||
|
||||
let selector = flag(&flags, "selector").map(str::trim).unwrap_or("").to_string();
|
||||
if selector.is_empty() {
|
||||
return fail(io, json!({
|
||||
"ok": false,
|
||||
"error": "selector_required",
|
||||
"_instructions": "Pass --selector with a CSS selector for the element to target. Derive it from the page source: prefer an id, a unique class, or a landmark section, and add --text \"<visible text>\" when the class repeats.",
|
||||
}));
|
||||
}
|
||||
let action = flag(&flags, "action").unwrap_or("impeccable").to_string();
|
||||
if !VISUAL_ACTIONS.contains(&action.as_str()) {
|
||||
return fail(io, json!({
|
||||
"ok": false,
|
||||
"error": "invalid_action",
|
||||
"action": action,
|
||||
"validActions": VISUAL_ACTIONS,
|
||||
"_instructions": "Map the request wording onto the closest listed action (bold -> bolder, quiet/calmer -> quieter, simplify -> distill). When no action fits, use --action impeccable and carry the wording via --prompt.",
|
||||
}));
|
||||
}
|
||||
let count = match flag(&flags, "count") {
|
||||
None => 3,
|
||||
Some(raw) => match int_flag(raw) {
|
||||
Some(c) if (1..=8).contains(&c) => c,
|
||||
_ => {
|
||||
return fail(io, json!({ "ok": false, "error": "invalid_count", "count": raw, "_instructions": "Pass --count as an integer from 1 to 8." }));
|
||||
}
|
||||
},
|
||||
};
|
||||
let index = match flag(&flags, "index") {
|
||||
None => None,
|
||||
Some(raw) => match int_flag(raw) {
|
||||
Some(i) if i >= 1 => Some(i),
|
||||
_ => {
|
||||
return fail(io, json!({ "ok": false, "error": "invalid_index", "index": raw, "_instructions": "Pass --index as a 1-based integer position among the matches." }));
|
||||
}
|
||||
},
|
||||
};
|
||||
let 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,
|
||||
_ => {
|
||||
return fail(io, json!({ "ok": false, "error": "invalid_wait", "wait": raw, "_instructions": "Pass --wait-for-browser as a positive integer of milliseconds, e.g. --wait-for-browser 120000." }));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let Some((info, _)) = read_live_server_info(&cwd, &env) else {
|
||||
return fail(io, server_not_running(&me));
|
||||
};
|
||||
let port = info.raw.get("port").and_then(Value::as_i64);
|
||||
let token = info.raw.get("token").and_then(Value::as_str).map(str::to_string);
|
||||
let (Some(port), Some(token)) = (port, token) else {
|
||||
return fail(io, server_not_running(&me));
|
||||
};
|
||||
|
||||
if wait_for_browser_ms > 0 {
|
||||
let deadline = Instant::now() + Duration::from_millis(wait_for_browser_ms);
|
||||
loop {
|
||||
let Some(status) = crate::server::fetch_status(port, &token) else {
|
||||
return fail(io, server_died(&me, None, true));
|
||||
};
|
||||
if status.get("connectedClients").and_then(Value::as_i64).unwrap_or(0) > 0 {
|
||||
break;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
let mut v = Map::new();
|
||||
v.insert("ok".into(), json!(false));
|
||||
v.insert("error".into(), json!("no_browser_connected"));
|
||||
v.insert("waitedMs".into(), json!(wait_for_browser_ms));
|
||||
let text = instructions_for(&v, &me).unwrap_or_default();
|
||||
v.insert("_instructions".into(), json!(text));
|
||||
return fail(io, Value::Object(v));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(1_000));
|
||||
}
|
||||
}
|
||||
|
||||
let mut body = Map::new();
|
||||
body.insert("token".into(), json!(token));
|
||||
body.insert("selector".into(), json!(selector));
|
||||
body.insert("action".into(), json!(action));
|
||||
body.insert("count".into(), json!(count));
|
||||
if let Some(text) = flag(&flags, "text").filter(|t| !t.is_empty()) {
|
||||
body.insert("text".into(), json!(text));
|
||||
}
|
||||
if let Some(i) = index {
|
||||
body.insert("index".into(), json!(i));
|
||||
}
|
||||
if let Some(prompt) = flag(&flags, "prompt").filter(|p| !p.is_empty()) {
|
||||
body.insert("prompt".into(), json!(prompt));
|
||||
}
|
||||
if flags.dry_run {
|
||||
body.insert("dryRun".into(), json!(true));
|
||||
}
|
||||
|
||||
let url = format!("http://127.0.0.1:{}/agent-target", port);
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout(Duration::from_millis(REQUEST_TIMEOUT_MS))
|
||||
.build();
|
||||
let sent = agent
|
||||
.post(&url)
|
||||
.set("Content-Type", "application/json")
|
||||
.send_string(&serde_json::to_string(&Value::Object(body)).unwrap_or_default());
|
||||
let (status, result) = match sent {
|
||||
Ok(res) => {
|
||||
let status = res.status();
|
||||
match res.into_json::<Value>() {
|
||||
Ok(v) => (status, v),
|
||||
Err(_) => return fail(io, json!({ "ok": false, "error": "bad_server_response", "status": status })),
|
||||
}
|
||||
}
|
||||
Err(ureq::Error::Status(status, res)) => match res.into_json::<Value>() {
|
||||
Ok(v) => (status, v),
|
||||
Err(_) => return fail(io, json!({ "ok": false, "error": "bad_server_response", "status": status })),
|
||||
},
|
||||
Err(ureq::Error::Transport(t)) => {
|
||||
let detail = t.to_string();
|
||||
let lower = detail.to_ascii_lowercase();
|
||||
if lower.contains("timed out") || lower.contains("timeout") {
|
||||
let mut v = Map::new();
|
||||
v.insert("ok".into(), json!(false));
|
||||
v.insert("error".into(), json!("request_timeout"));
|
||||
v.insert("detail".into(), json!(detail));
|
||||
let mut probe = Map::new();
|
||||
probe.insert("error".into(), json!("browser_timeout"));
|
||||
let text = instructions_for(&probe, &me).unwrap_or_default();
|
||||
v.insert("_instructions".into(), json!(text));
|
||||
return fail(io, Value::Object(v));
|
||||
}
|
||||
return fail(io, server_died(&me, Some(detail), false));
|
||||
}
|
||||
};
|
||||
let mut fields = result.as_object().cloned().unwrap_or_default();
|
||||
if !(200..300).contains(&status) {
|
||||
let mut v = Map::new();
|
||||
v.insert("ok".into(), json!(false));
|
||||
let code = fields
|
||||
.get("error")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| format!("http_{}", status));
|
||||
v.insert("error".into(), json!(code));
|
||||
for (k, val) in fields {
|
||||
if k != "ok" && k != "error" {
|
||||
v.insert(k, val);
|
||||
}
|
||||
}
|
||||
return fail(io, Value::Object(v));
|
||||
}
|
||||
let ok = fields.get("ok").and_then(Value::as_bool) == Some(true);
|
||||
if let Some(text) = instructions_for(&fields, &me) {
|
||||
fields.insert("_instructions".into(), json!(text));
|
||||
}
|
||||
print_json(io, &Value::Object(fields));
|
||||
if ok {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
@@ -182,6 +182,8 @@ pub fn run(args: &[String], io: &mut Io) -> i32 {
|
||||
next_poll_id: 1,
|
||||
next_client_id: 1,
|
||||
next_apply_timer_gen: 0,
|
||||
pending_agent_targets: Vec::new(),
|
||||
next_agent_target_timer_gen: 0,
|
||||
shutting_down: false,
|
||||
cleaned_up: false,
|
||||
log_tx,
|
||||
@@ -518,6 +520,9 @@ fn shutdown(shared: &Shared) {
|
||||
for poll in st.pending_polls.drain(..) {
|
||||
let _ = poll.tx.send(json!({ "type": "exit" }));
|
||||
}
|
||||
for (_, pending) in st.pending_agent_targets.drain(..) {
|
||||
let _ = pending.tx.send(json!({ "ok": false, "error": "server_stopping" }));
|
||||
}
|
||||
// Give response writers a moment to flush before the process exits.
|
||||
drop(st);
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
@@ -907,7 +912,14 @@ fn handle_connection(shared: Shared, mut stream: TcpStream, mut ticket: Ticket)
|
||||
text_res(200, Some("text/html; charset=utf-8"), &content),
|
||||
);
|
||||
}
|
||||
("/events", "GET") => handle_sse(&shared, stream, &cors, token_ok, &mut ticket),
|
||||
("/events", "GET") => handle_sse(
|
||||
&shared,
|
||||
stream,
|
||||
&cors,
|
||||
token_ok,
|
||||
req.query_get("clientId").map(|s| s.to_string()),
|
||||
&mut ticket,
|
||||
),
|
||||
("/manual-edit-stash", "POST")
|
||||
| ("/manual-edit-stash", "GET")
|
||||
| ("/manual-edit-commit", "POST")
|
||||
@@ -943,6 +955,16 @@ fn handle_connection(shared: Shared, mut stream: TcpStream, mut ticket: Ticket)
|
||||
("/poll", "POST") => {
|
||||
handle_poll_post(&shared, &mut stream, &cors, &req, &token_now, &mut ticket)
|
||||
}
|
||||
// --- Agent-initiated targeting (the `generate` command) ---
|
||||
("/agent-target", "POST") => {
|
||||
handle_agent_target_post(&shared, stream, &cors, &req, &token_now, &mut ticket)
|
||||
}
|
||||
("/agent-target-result", "POST") => {
|
||||
handle_agent_target_result_post(&shared, &mut stream, &cors, &req, &token_now)
|
||||
}
|
||||
("/agent-target-claim", "POST") => {
|
||||
handle_agent_target_claim_post(&shared, &mut stream, &cors, &req, &token_now)
|
||||
}
|
||||
_ => respond(&mut stream, &cors, text_res(404, None, "Not found")),
|
||||
}
|
||||
}
|
||||
@@ -1028,6 +1050,7 @@ fn handle_sse(
|
||||
stream: TcpStream,
|
||||
cors: &[(String, String)],
|
||||
token_ok: bool,
|
||||
agent_client_id: Option<String>,
|
||||
ticket: &mut Ticket,
|
||||
) {
|
||||
let mut stream = stream;
|
||||
@@ -1050,7 +1073,7 @@ fn handle_sse(
|
||||
}))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
let (id, rx, tx) = st.add_sse_client();
|
||||
let (id, rx, tx) = st.add_sse_client(agent_client_id);
|
||||
(id, rx, tx, frame)
|
||||
};
|
||||
// Registered; the stream now parks, so let later requests through.
|
||||
@@ -2588,6 +2611,196 @@ fn handle_manual_edit_commit(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent-initiated element targeting (the `generate` command)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// JS: validateAgentTargetRequest(msg)
|
||||
fn validate_agent_target_request(msg: &Value) -> Option<String> {
|
||||
let selector_ok = matches!(msg.get("selector"), Some(Value::String(s)) if !s.trim().is_empty());
|
||||
if !selector_ok {
|
||||
return Some("agent_target: selector is required".into());
|
||||
}
|
||||
if msg.get("selector").and_then(Value::as_str).map(|s| s.chars().count()).unwrap_or(0) > 1000 {
|
||||
return Some("agent_target: selector too long".into());
|
||||
}
|
||||
let action_ok = matches!(msg.get("action"), Some(Value::String(a)) if crate::vocabulary::VISUAL_ACTIONS.contains(&a.as_str()));
|
||||
if !action_ok {
|
||||
return Some(format!(
|
||||
"agent_target: invalid action (valid: {})",
|
||||
crate::vocabulary::VISUAL_ACTIONS.join(", ")
|
||||
));
|
||||
}
|
||||
let count_ok = match msg.get("count") {
|
||||
Some(Value::Number(n)) => n.as_i64().map(|c| (1..=8).contains(&c)).unwrap_or(false),
|
||||
_ => false,
|
||||
};
|
||||
if !count_ok {
|
||||
return Some("agent_target: count must be 1-8".into());
|
||||
}
|
||||
if let Some(text) = msg.get("text") {
|
||||
if !matches!(text, Value::String(t) if t.chars().count() <= 500) {
|
||||
return Some("agent_target: text must be a string of at most 500 chars".into());
|
||||
}
|
||||
}
|
||||
if let Some(index) = msg.get("index") {
|
||||
if !index.as_i64().map(|i| i >= 1).unwrap_or(false) {
|
||||
return Some("agent_target: index must be a positive integer (1-based)".into());
|
||||
}
|
||||
}
|
||||
if let Some(prompt) = msg.get("prompt") {
|
||||
if !matches!(prompt, Value::String(p) if p.chars().count() <= 2000) {
|
||||
return Some("agent_target: prompt must be a string of at most 2000 chars".into());
|
||||
}
|
||||
}
|
||||
if let Some(dry) = msg.get("dryRun") {
|
||||
if !dry.is_boolean() {
|
||||
return Some("agent_target: dryRun must be a boolean".into());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Parse the body and check its token; answers the request itself on failure.
|
||||
fn agent_target_body(
|
||||
stream: &mut TcpStream,
|
||||
cors: &[(String, String)],
|
||||
req: &Request,
|
||||
token: &str,
|
||||
) -> Option<Map<String, Value>> {
|
||||
let Some(msg) = parse_json_body(req) else {
|
||||
respond(stream, cors, json_res(400, json!({ "error": "Invalid JSON" })));
|
||||
return None;
|
||||
};
|
||||
let obj = msg.as_object().cloned().unwrap_or_default();
|
||||
if obj.get("token").and_then(Value::as_str) != Some(token) {
|
||||
respond(stream, cors, json_res(401, json!({ "error": "Unauthorized" })));
|
||||
return None;
|
||||
}
|
||||
Some(obj)
|
||||
}
|
||||
|
||||
/// JS: handleAgentTargetPost: hold the response until the overlay answers.
|
||||
fn handle_agent_target_post(
|
||||
shared: &Shared,
|
||||
stream: TcpStream,
|
||||
cors: &[(String, String)],
|
||||
req: &Request,
|
||||
token: &str,
|
||||
ticket: &mut Ticket,
|
||||
) {
|
||||
let mut stream = stream;
|
||||
let Some(msg) = agent_target_body(&mut stream, cors, req, token) else {
|
||||
return;
|
||||
};
|
||||
if let Some(error) = validate_agent_target_request(&Value::Object(msg.clone())) {
|
||||
respond(&mut stream, cors, json_res(400, json!({ "error": error })));
|
||||
return;
|
||||
}
|
||||
let mut st = lock(shared);
|
||||
if st.sse_clients.is_empty() {
|
||||
drop(st);
|
||||
respond(
|
||||
&mut stream,
|
||||
cors,
|
||||
json_res(200, json!({ "ok": false, "error": "no_browser_connected" })),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let mut payload = Map::new();
|
||||
payload.insert("selector".into(), msg.get("selector").cloned().unwrap_or(Value::Null));
|
||||
if let Some(text) = msg.get("text").and_then(Value::as_str).filter(|t| !t.is_empty()) {
|
||||
payload.insert("text".into(), json!(text));
|
||||
}
|
||||
if let Some(index) = msg.get("index").and_then(Value::as_i64) {
|
||||
payload.insert("index".into(), json!(index));
|
||||
}
|
||||
payload.insert("action".into(), msg.get("action").cloned().unwrap_or(Value::Null));
|
||||
payload.insert("count".into(), msg.get("count").cloned().unwrap_or(Value::Null));
|
||||
if let Some(prompt) = msg.get("prompt").and_then(Value::as_str).filter(|p| !p.is_empty()) {
|
||||
payload.insert("prompt".into(), json!(prompt));
|
||||
}
|
||||
if msg.get("dryRun").and_then(Value::as_bool) == Some(true) {
|
||||
payload.insert("dryRun".into(), json!(true));
|
||||
}
|
||||
let (target_id, rx) = st.register_agent_target(payload);
|
||||
drop(st);
|
||||
// Registered and broadcast; the response now parks, so let the claims
|
||||
// and the result through.
|
||||
ticket.release();
|
||||
let result = rx
|
||||
.recv()
|
||||
.unwrap_or_else(|_| json!({ "ok": false, "error": "server_stopping" }));
|
||||
let mut out = Map::new();
|
||||
out.insert("targetId".into(), json!(target_id));
|
||||
if let Value::Object(fields) = result {
|
||||
for (k, v) in fields {
|
||||
out.insert(k, v);
|
||||
}
|
||||
}
|
||||
respond(&mut stream, cors, json_res(200, Value::Object(out)));
|
||||
}
|
||||
|
||||
/// JS: handleAgentTargetResultPost
|
||||
fn handle_agent_target_result_post(
|
||||
shared: &Shared,
|
||||
stream: &mut TcpStream,
|
||||
cors: &[(String, String)],
|
||||
req: &Request,
|
||||
token: &str,
|
||||
) {
|
||||
let Some(msg) = agent_target_body(stream, cors, req, token) else {
|
||||
return;
|
||||
};
|
||||
let target_id = match msg.get("targetId") {
|
||||
Some(Value::String(id)) if !id.is_empty() => id.clone(),
|
||||
_ => {
|
||||
respond(
|
||||
stream,
|
||||
cors,
|
||||
json_res(400, json!({ "error": "agent_target_result: missing targetId" })),
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut result = Map::new();
|
||||
for (k, v) in msg {
|
||||
if k != "token" && k != "targetId" {
|
||||
result.insert(k, v);
|
||||
}
|
||||
}
|
||||
let delivered = lock(shared).resolve_agent_target(&target_id, Value::Object(result));
|
||||
respond(stream, cors, json_res(200, json!({ "ok": true, "delivered": delivered })));
|
||||
}
|
||||
|
||||
/// JS: handleAgentTargetClaimPost
|
||||
fn handle_agent_target_claim_post(
|
||||
shared: &Shared,
|
||||
stream: &mut TcpStream,
|
||||
cors: &[(String, String)],
|
||||
req: &Request,
|
||||
token: &str,
|
||||
) {
|
||||
let Some(msg) = agent_target_body(stream, cors, req, token) else {
|
||||
return;
|
||||
};
|
||||
let target_id = msg.get("targetId").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let client_id = msg.get("clientId").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
if target_id.is_empty() || client_id.is_empty() {
|
||||
respond(
|
||||
stream,
|
||||
cors,
|
||||
json_res(400, json!({ "error": "agent_target_claim: missing targetId or clientId" })),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let eligible = msg.get("eligible").and_then(Value::as_bool) == Some(true);
|
||||
let state = msg.get("state").cloned().unwrap_or(Value::Null);
|
||||
let reason = msg.get("reason").cloned().unwrap_or(Value::Null);
|
||||
let body = lock(shared).claim_agent_target(&target_id, &client_id, eligible, state, reason);
|
||||
respond(stream, cors, json_res(200, body));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod content_type_tests {
|
||||
use super::*;
|
||||
@@ -2633,6 +2846,12 @@ mod content_type_tests {
|
||||
assert!(!releases_ticket_up_front("/events", "OPTIONS"));
|
||||
assert!(!releases_ticket_up_front("/poll", "POST"));
|
||||
assert!(!releases_ticket_up_front("/stop", "GET"));
|
||||
// The agent-target routes mutate the roll call and must keep arrival
|
||||
// order too: a claim answered before the target it claims registers
|
||||
// would deny a tab that should have been granted.
|
||||
assert!(!releases_ticket_up_front("/agent-target", "POST"));
|
||||
assert!(!releases_ticket_up_front("/agent-target-result", "POST"));
|
||||
assert!(!releases_ticket_up_front("/agent-target-claim", "POST"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -39,6 +39,29 @@ pub struct ParkedPoll {
|
||||
pub struct SseClient {
|
||||
pub id: u64,
|
||||
pub tx: Sender<String>,
|
||||
/// The overlay's per-page-load id (`/events?clientId=`), so a disconnect
|
||||
/// can retire its word in any agent-target roll call it took part in.
|
||||
pub agent_client_id: Option<String>,
|
||||
}
|
||||
|
||||
/// One overlay's roll-call report on an agent target: its busy state and why.
|
||||
pub struct AgentTargetReport {
|
||||
pub client_id: String,
|
||||
pub state: Value,
|
||||
pub reason: Value,
|
||||
}
|
||||
|
||||
/// A held-open `POST /agent-target` (the `generate` command): resolved by
|
||||
/// `POST /agent-target-result`, by a complete busy roll call, by its timeout,
|
||||
/// or by shutdown. The claim lease decides which overlay acts.
|
||||
pub struct AgentTargetPending {
|
||||
pub tx: Sender<Value>,
|
||||
/// The `agent_target` SSE payload, replayed to overlays that connect late.
|
||||
pub payload: Value,
|
||||
pub owner: Option<String>,
|
||||
pub claimed_until: i64,
|
||||
pub reports: Vec<AgentTargetReport>,
|
||||
pub timer_gen: u64,
|
||||
}
|
||||
|
||||
/// One pre-apply file snapshot entry (`{ exists, content }`).
|
||||
@@ -84,6 +107,9 @@ pub struct ServerState {
|
||||
pub manual_edit_activity: Option<Value>,
|
||||
pub next_manual_edit_seq: i64,
|
||||
pub pending_apply_deferreds: Vec<(String, ApplyDeferred)>,
|
||||
/// Held-open agent targets keyed by targetId, in arrival order.
|
||||
pub pending_agent_targets: Vec<(String, AgentTargetPending)>,
|
||||
pub next_agent_target_timer_gen: u64,
|
||||
pub last_poll_at: i64,
|
||||
pub timed_out_apply_ids: Vec<(String, TimedOutApply)>,
|
||||
pub next_poll_id: u64,
|
||||
@@ -603,26 +629,252 @@ impl ServerState {
|
||||
before != self.pending_polls.len()
|
||||
}
|
||||
|
||||
/// Register an SSE client; returns (id, receiver).
|
||||
pub fn add_sse_client(&mut self) -> (u64, Receiver<String>, Sender<String>) {
|
||||
/// Register an SSE client; returns (id, receiver). An overlay that
|
||||
/// connects after an agent target was broadcast (a reload mid-request is
|
||||
/// the common case) joins its roll call: every pending target is replayed
|
||||
/// to it, so it claims or declines like the others instead of silently
|
||||
/// widening the count the roll call is judged against.
|
||||
pub fn add_sse_client(
|
||||
&mut self,
|
||||
agent_client_id: Option<String>,
|
||||
) -> (u64, Receiver<String>, Sender<String>) {
|
||||
let (tx, rx) = channel();
|
||||
let id = self.next_client_id;
|
||||
self.next_client_id += 1;
|
||||
self.sse_clients.push(SseClient { id, tx: tx.clone() });
|
||||
for (_, pending) in &self.pending_agent_targets {
|
||||
let _ = tx.send(format!(
|
||||
"data: {}\n\n",
|
||||
serde_json::to_string(&pending.payload).unwrap_or_else(|_| "null".into())
|
||||
));
|
||||
}
|
||||
self.sse_clients.push(SseClient {
|
||||
id,
|
||||
tx: tx.clone(),
|
||||
agent_client_id,
|
||||
});
|
||||
(id, rx, tx)
|
||||
}
|
||||
|
||||
/// Remove an SSE client; when none remain arm the exit timer (JS
|
||||
/// `req.on('close')`).
|
||||
/// `req.on('close')`). A departed overlay's word no longer counts in any
|
||||
/// agent-target roll call.
|
||||
pub fn remove_sse_client(&mut self, id: u64) {
|
||||
let before = self.sse_clients.len();
|
||||
let agent_client_id = self
|
||||
.sse_clients
|
||||
.iter()
|
||||
.find(|c| c.id == id)
|
||||
.and_then(|c| c.agent_client_id.clone());
|
||||
self.sse_clients.retain(|c| c.id != id);
|
||||
if before != self.sse_clients.len() && self.sse_clients.is_empty() {
|
||||
self.clear_exit_timer();
|
||||
self.arm_exit_timer();
|
||||
if before != self.sse_clients.len() {
|
||||
self.drop_agent_target_client(agent_client_id.as_deref());
|
||||
if self.sse_clients.is_empty() {
|
||||
self.clear_exit_timer();
|
||||
self.arm_exit_timer();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Agent-initiated element targeting (the `generate` command)
|
||||
// ---------------------------------------------------------------------
|
||||
//
|
||||
// POST /agent-target lets the AGENT start a variant session: the server
|
||||
// pushes an `agent_target` SSE message, the overlay resolves the selector,
|
||||
// scrolls to the element, enters the same picked state a user click
|
||||
// produces, and fires the normal Go pipeline. The HTTP response is held
|
||||
// open until the overlay POSTs /agent-target-result (or the timeout
|
||||
// fires), so the CLI gets a synchronous verdict. No session exists until
|
||||
// the browser's own generate event creates one.
|
||||
|
||||
/// Browser must answer an agent_target push within this window. The env
|
||||
/// override exists for tests; real sessions keep the default.
|
||||
pub fn agent_target_timeout_ms(&self) -> u64 {
|
||||
env_positive_ms(&self.env, "IMPECCABLE_AGENT_TARGET_TIMEOUT_MS").unwrap_or(15_000)
|
||||
}
|
||||
|
||||
/// A granted claim is a lease, not a lock: if the winning tab dies before
|
||||
/// posting its result (reload, crash), the lease lapses and a surviving
|
||||
/// tab's retry rescues the request instead of letting it wait out the
|
||||
/// browser timeout. The lease comfortably exceeds a healthy winner's
|
||||
/// worst case (claim RTT + smooth-scroll settle + Go, under 2s).
|
||||
pub fn agent_target_lease_ms(&self) -> i64 {
|
||||
env_positive_ms(&self.env, "IMPECCABLE_AGENT_TARGET_CLAIM_LEASE_MS")
|
||||
.map(|v| v as i64)
|
||||
.unwrap_or(3_000)
|
||||
}
|
||||
|
||||
/// Hold a new agent target: mint its id, broadcast the push, arm the
|
||||
/// timeout. Returns the id and the receiver the route blocks on.
|
||||
pub fn register_agent_target(&mut self, mut payload: Map<String, Value>) -> (String, Receiver<Value>) {
|
||||
let target_id = crate::random::random_id8();
|
||||
payload.insert("targetId".into(), json!(target_id));
|
||||
// JS spread order: type, targetId, then the request fields.
|
||||
let mut ordered = Map::new();
|
||||
ordered.insert("type".into(), json!("agent_target"));
|
||||
ordered.insert("targetId".into(), json!(target_id));
|
||||
for (k, v) in payload {
|
||||
if k != "type" && k != "targetId" {
|
||||
ordered.insert(k, v);
|
||||
}
|
||||
}
|
||||
let payload = Value::Object(ordered);
|
||||
let (tx, rx) = channel();
|
||||
self.next_agent_target_timer_gen += 1;
|
||||
let timer_gen = self.next_agent_target_timer_gen;
|
||||
self.pending_agent_targets.push((
|
||||
target_id.clone(),
|
||||
AgentTargetPending {
|
||||
tx,
|
||||
payload: payload.clone(),
|
||||
owner: None,
|
||||
claimed_until: 0,
|
||||
reports: Vec::new(),
|
||||
timer_gen,
|
||||
},
|
||||
));
|
||||
self.broadcast(&payload);
|
||||
let timeout_ms = self.agent_target_timeout_ms();
|
||||
let weak = self.self_ref.clone();
|
||||
let id = target_id.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(timeout_ms));
|
||||
if let Some(shared) = weak.upgrade() {
|
||||
let mut st = lock(&shared);
|
||||
let Some((_, pending)) = st
|
||||
.pending_agent_targets
|
||||
.iter()
|
||||
.find(|(k, p)| *k == id && p.timer_gen == timer_gen)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let verdict = if pending.reports.is_empty() {
|
||||
json!({ "ok": false, "error": "browser_timeout", "timeoutMs": timeout_ms })
|
||||
} else {
|
||||
agent_target_busy_verdict(pending)
|
||||
};
|
||||
st.resolve_agent_target(&id, verdict);
|
||||
}
|
||||
});
|
||||
(target_id, rx)
|
||||
}
|
||||
|
||||
/// Deliver a verdict to the held request; false when nothing awaits it.
|
||||
pub fn resolve_agent_target(&mut self, target_id: &str, result: Value) -> bool {
|
||||
let Some(pos) = self
|
||||
.pending_agent_targets
|
||||
.iter()
|
||||
.position(|(k, _)| k == target_id)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let (_, pending) = self.pending_agent_targets.remove(pos);
|
||||
let _ = pending.tx.send(result);
|
||||
true
|
||||
}
|
||||
|
||||
/// Every connected overlay has declined: answer busy now, not at the
|
||||
/// timeout. Judged against the connections of this moment, so it runs
|
||||
/// whenever a report lands and whenever an overlay leaves.
|
||||
pub fn maybe_complete_agent_target_roll_call(&mut self, target_id: &str) {
|
||||
let connected = self.sse_clients.len();
|
||||
let verdict = self
|
||||
.pending_agent_targets
|
||||
.iter()
|
||||
.find(|(k, _)| k == target_id)
|
||||
.and_then(|(_, p)| {
|
||||
if p.owner.is_some() || p.reports.is_empty() || p.reports.len() < connected {
|
||||
None
|
||||
} else {
|
||||
Some(agent_target_busy_verdict(p))
|
||||
}
|
||||
});
|
||||
if let Some(verdict) = verdict {
|
||||
self.resolve_agent_target(target_id, verdict);
|
||||
}
|
||||
}
|
||||
|
||||
/// A disconnected overlay's word no longer counts: drop its busy report,
|
||||
/// hand back a lease it held (a rescuer's next claim is granted at once
|
||||
/// instead of after the lease lapses), and re-judge each roll call
|
||||
/// against the overlays that remain.
|
||||
pub fn drop_agent_target_client(&mut self, client_id: Option<&str>) {
|
||||
let ids: Vec<String> = self
|
||||
.pending_agent_targets
|
||||
.iter()
|
||||
.map(|(k, _)| k.clone())
|
||||
.collect();
|
||||
for id in ids {
|
||||
if let Some(cid) = client_id {
|
||||
if let Some((_, p)) = self.pending_agent_targets.iter_mut().find(|(k, _)| *k == id) {
|
||||
p.reports.retain(|r| r.client_id != cid);
|
||||
if p.owner.as_deref() == Some(cid) {
|
||||
p.owner = None;
|
||||
p.claimed_until = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.maybe_complete_agent_target_roll_call(&id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Roll call plus a first-wins lease. Every connected overlay claims once.
|
||||
/// A busy tab claims with eligible:false and is only counted: the moment
|
||||
/// every connected overlay has reported busy, the held request answers
|
||||
/// `busy` without waiting on a timer or guessing about a slower idle tab.
|
||||
/// An eligible tab is granted when nobody holds the lease, when it
|
||||
/// already holds it (a renew, which the holder does right before it
|
||||
/// fires Go, so a lapsed lease can never leave two tabs acting), or when
|
||||
/// the previous holder's lease lapsed without a result (a rescue).
|
||||
/// Unknown or resolved targets deny and say so (`pending: false`), which
|
||||
/// ends a rescuer's retry loop. Returns the response body.
|
||||
pub fn claim_agent_target(
|
||||
&mut self,
|
||||
target_id: &str,
|
||||
client_id: &str,
|
||||
eligible: bool,
|
||||
state: Value,
|
||||
reason: Value,
|
||||
) -> Value {
|
||||
let lease_ms = self.agent_target_lease_ms();
|
||||
let now = now_i64();
|
||||
let Some((_, pending)) = self
|
||||
.pending_agent_targets
|
||||
.iter_mut()
|
||||
.find(|(k, _)| k == target_id)
|
||||
else {
|
||||
return json!({ "ok": true, "granted": false, "pending": false });
|
||||
};
|
||||
if !eligible {
|
||||
pending.reports.retain(|r| r.client_id != client_id);
|
||||
pending.reports.push(AgentTargetReport {
|
||||
client_id: client_id.to_string(),
|
||||
state,
|
||||
reason,
|
||||
});
|
||||
// A holder that turned busy hands the lease back, so the roll
|
||||
// call can complete and an eligible tab's retry is granted at
|
||||
// once instead of waiting for the lease to lapse.
|
||||
if pending.owner.as_deref() == Some(client_id) {
|
||||
pending.owner = None;
|
||||
pending.claimed_until = 0;
|
||||
}
|
||||
self.maybe_complete_agent_target_roll_call(target_id);
|
||||
return json!({ "ok": true, "granted": false });
|
||||
}
|
||||
// An eligible claim is the client's latest word: drop any earlier
|
||||
// busy report, so a busy verdict only ever counts tabs still busy.
|
||||
pending.reports.retain(|r| r.client_id != client_id);
|
||||
let granted = pending.owner.is_none()
|
||||
|| pending.owner.as_deref() == Some(client_id)
|
||||
|| pending.claimed_until <= now;
|
||||
if granted {
|
||||
pending.owner = Some(client_id.to_string());
|
||||
pending.claimed_until = now + lease_ms;
|
||||
}
|
||||
json!({ "ok": true, "granted": granted, "pending": true })
|
||||
}
|
||||
|
||||
/// JS: generationIsFenced(id)
|
||||
pub fn generation_is_fenced(&self, id: &str) -> bool {
|
||||
if id.is_empty() {
|
||||
@@ -1188,3 +1440,22 @@ pub fn strip_poller_owned_event_fields(event: &mut Map<String, Value>) {
|
||||
event.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Number(process.env.X || '') || default`: a positive integer wins, anything
|
||||
/// else falls back to the default.
|
||||
fn env_positive_ms(env: &Env, key: &str) -> Option<u64> {
|
||||
env.get(key)
|
||||
.and_then(|v| v.trim().parse::<u64>().ok())
|
||||
.filter(|v| *v > 0)
|
||||
}
|
||||
|
||||
/// The busy verdict for a held target: the first report's state and reason.
|
||||
pub fn agent_target_busy_verdict(pending: &AgentTargetPending) -> Value {
|
||||
let first = pending.reports.first();
|
||||
json!({
|
||||
"ok": false,
|
||||
"error": "busy",
|
||||
"state": first.map(|r| r.state.clone()).unwrap_or(Value::Null),
|
||||
"reason": first.map(|r| r.reason.clone()).unwrap_or(Value::Null),
|
||||
})
|
||||
}
|
||||
|
||||
+18
-6
@@ -685,7 +685,7 @@ Tier 2 (`staleness-deep.mjs`, doctor only):
|
||||
#### `pin.mjs` -> `impeccable pin`
|
||||
|
||||
- **Invoked from**: `SKILL.src.md`: `node {{scripts_path}}/pin.mjs <pin|unpin> <command>`; "Report the script's result concisely; relay stderr verbatim on error."
|
||||
- **CLI args**: exactly `argv[2]` = action (`pin`|`unpin`), `argv[3]` = command. Missing either -> stdout `Usage: node pin.mjs <pin|unpin> <command>` + `\nAvailable commands: <VALID_COMMANDS joined ', '>`, exit 1. Bad action -> stderr `Unknown action: <a>. Use 'pin' or 'unpin'.`, exit 1. Bad command -> stderr `Unknown command: <c>` and `Available commands: ...`, exit 1. `VALID_COMMANDS = craft, init, extract, document, shape, critique, audit, polish, bolder, quieter, distill, harden, onboard, live, animate, colorize, typeset, layout, delight, overdrive, clarify, adapt, optimize` (23; `doctor`, `teach` not included).
|
||||
- **CLI args**: exactly `argv[2]` = action (`pin`|`unpin`), `argv[3]` = command. Missing either -> stdout `Usage: node pin.mjs <pin|unpin> <command>` + `\nAvailable commands: <VALID_COMMANDS joined ', '>`, exit 1. Bad action -> stderr `Unknown action: <a>. Use 'pin' or 'unpin'.`, exit 1. Bad command -> stderr `Unknown command: <c>` and `Available commands: ...`, exit 1. `VALID_COMMANDS = craft, init, extract, document, shape, critique, audit, polish, bolder, quieter, distill, harden, onboard, live, animate, colorize, typeset, layout, delight, overdrive, clarify, adapt, optimize, generate` (24; `doctor`, `teach` not included).
|
||||
- **Env vars**: none.
|
||||
- **Inputs**: project root = walk up from cwd until a dir containing `package.json`, `.git`, or `skills-lock.json` (stops at `/`; falls back to cwd). Harness dirs `HARNESS_DIRS = .claude .cursor .gemini .codex .agents .agent .github .grok .hermes .trae .trae-cn .pi .opencode .kiro .rovodev .vibe .qoder`; a harness is used only if `<root>/<h>/skills/impeccable` or `<root>/<h>/skills/i-impeccable` exists. `command-metadata.json` next to the script (`{ [command]: { description, argumentHint } }`).
|
||||
- **Outputs/side effects** (`pin`): no harness dirs -> stdout `No harness directories with impeccable installed found.`, exit 0. For each harness skills dir: `<skillsDir>/<command>/SKILL.md`; if it exists without the marker `<!-- impeccable-pinned-skill -->` -> ` SKIP: <dir> (non-pinned skill already exists)`; else mkdir + write, print ` + <dir>`. Then if any created: `\nPinned '<command>' as a standalone shortcut in <n> location(s).` and `Use the pinned command directly in each harness.`. Content (prefix `$` and codex frontmatter when the harness dir basename is `.codex` or `.agents`, else `/`):
|
||||
@@ -862,6 +862,8 @@ getCachePath(cwd) = <cwd>/.impeccable/hook.cache.json
|
||||
getPendingPath(cwd) = <cwd>/.impeccable/hook.pending.json (only ever deleted by hook-admin reset; never written)
|
||||
```
|
||||
|
||||
LIVE_PREVIEW_MARKERS: content containing `data-impeccable-variants=` or `impeccable-carbonize-start` (the wrapper a live generate publishes and the block a carbonize accept leaves until cleanup). Every hook entry stands down on it: `hook` records `skipped:'live-preview'` for the per-edit pass and skips the file silently in the Stop pass; `hook-before-edit` allows with `skipped:'live-preview'`.
|
||||
|
||||
#### 0.2 Config: `readConfig(cwd)`
|
||||
|
||||
Reads, in order, `config.json` then `config.local.json` (later wins for scalars, arrays are unioned). Each file is parsed with `JSON.parse`; a missing or malformed file is treated as `null` (silently ignored). For each file:
|
||||
@@ -1103,12 +1105,12 @@ Candidates in order: `<scripts>/detector/detect-antipatterns.mjs` (built skill l
|
||||
4. `config = readConfig(projectCwd)`; `enabled === false` → `'config-disabled'`.
|
||||
5. native platform → `skipped:'native-platform', platform`.
|
||||
6. `cache = readCache(projectCwd)`; `sessionId = event.session_id || 'unknown'`; detector missing → `'detector-missing'`; `scanOptions = designSystemOptions(...)`; `tiered = perEditTieringActive(config, harness)`; `quietMode = truthy(IMPECCABLE_HOOK_QUIET) || config.quiet`.
|
||||
7. For each target file (audit.file updated each iteration): skip with `lastSkip` = `'sensitive'` (contains `..` or SENSITIVE_PATH), `'generated'`, `'extension'` (not ALLOWED and not configured), `'config-ignore-file'` (`matchesAnyGlob(relativized)` or `(absolute)` vs `config.ignoreFiles`), `'file-missing'`, `'outside-project'`, `'too-large'` (records `skippedBytes`). If the file is a PRIMARY (not co-scanned): `editCount = bumpEditCount(...)`; if `editCount > 6` → if `=== 7` and no suppression winner yet → `suppressionWinner={filePath}`; `lastSkip='suppressed'`, `suppressedHit=true`, continue. Read content, run detector (throw → `findings=[]`, `detectorThrew=true`). `filtered = filterFindings(...)`; if tiered split into immediate/deferred else all immediate. If deferred non-empty → `touchFile`, `deferredTotal += n`. `fresh = dedupeAgainstCache(immediate, ...)`. `audit.findings = raw count`, `audit.freshFindings = fresh.length`, `audit.deferred = deferredTotal` (if >0). If detectorThrew → `detectorThrewAny=true`, continue (cache untouched for that file). `rememberFindings(cache, sid, file, immediate)` (replace). If fresh>0 → push `{filePath, findings: fresh}` to `freshGroups`, continue. Else if immediate>0 and no pendingWinner → `pendingWinner={filePath, known: immediate.map(findingCacheKey)}`; else if immediate==0 and no cleanWinner: if quiet or not ack-eligible → `cleanWinner={filePath}` (without consuming `cleanAcked`); else if `fileEntry.cleanAcked` → `cleanAckDeduped=true` (keep scanning); else set `cleanAcked=true`, `cleanWinner={filePath}`, `cleanAckDeduped=false`.
|
||||
7. For each target file (audit.file updated each iteration): skip with `lastSkip` = `'sensitive'` (contains `..` or SENSITIVE_PATH), `'generated'`, `'extension'` (not ALLOWED and not configured), `'config-ignore-file'` (`matchesAnyGlob(relativized)` or `(absolute)` vs `config.ignoreFiles`), `'file-missing'`, `'outside-project'`, `'too-large'` (records `skippedBytes`), and, once the content is read, `'live-preview'` (LIVE_PREVIEW_MARKERS). If the file is a PRIMARY (not co-scanned): `editCount = bumpEditCount(...)`; if `editCount > 6` → if `=== 7` and no suppression winner yet → `suppressionWinner={filePath}`; `lastSkip='suppressed'`, `suppressedHit=true`, continue. Read content, run detector (throw → `findings=[]`, `detectorThrew=true`). `filtered = filterFindings(...)`; if tiered split into immediate/deferred else all immediate. If deferred non-empty → `touchFile`, `deferredTotal += n`. `fresh = dedupeAgainstCache(immediate, ...)`. `audit.findings = raw count`, `audit.freshFindings = fresh.length`, `audit.deferred = deferredTotal` (if >0). If detectorThrew → `detectorThrewAny=true`, continue (cache untouched for that file). `rememberFindings(cache, sid, file, immediate)` (replace). If fresh>0 → push `{filePath, findings: fresh}` to `freshGroups`, continue. Else if immediate>0 and no pendingWinner → `pendingWinner={filePath, known: immediate.map(findingCacheKey)}`; else if immediate==0 and no cleanWinner: if quiet or not ack-eligible → `cleanWinner={filePath}` (without consuming `cleanAcked`); else if `fileEntry.cleanAcked` → `cleanAckDeduped=true` (keep scanning); else set `cleanAcked=true`, `cleanWinner={filePath}`, `cleanAckDeduped=false`.
|
||||
8. If `freshGroups` non-empty: `text = appendDesignSystemNoteOnce(renderGroupedTemplate(freshGroups, config, {cwd:projectCwd, footer: footerModeForSession, reserveChars: designNoteReserve}), ...)`; `commitFooterShown`; **`persistCache` always** (creates `.impeccable/` if needed); return `stdout = payload(text,'PostToolUse',harness)`, audit `{..., file: firstGroup.filePath, emitted:true, freshFiles, freshFindings(total), chars, durationMs}`, `emission:{kind:'fresh', file, findings, groups}`.
|
||||
9. Else compute `ack`: not quiet AND pendingWinner AND ack-eligible → `{kind:'pending', text: appendDesignSystemNoteOnce(renderPendingAck(...))}`; else not quiet AND no suppressionWinner AND cleanWinner AND !cleanAckDeduped AND ack-eligible → `{kind:'clean', text: appendDesignSystemNoteOnce(renderCleanAck(...))}`.
|
||||
10. Persist cache only if `deferredTotal > 0 || (cacheDirty && exists(<projectCwd>/.impeccable))` (a clean edit in a project with no `.impeccable/` footprint writes nothing to disk).
|
||||
11. Return precedence: `detectorThrewAny && !pendingWinner && !cleanWinner` → audit `{emitted:false, error:'detector-threw'}`; quiet → `{emitted:false, quiet:true}`; pending ack → stdout payload, audit `{file, emitted:true, kind:'pending', pending:<n>, chars}`; suppressionWinner → stdout `payload(suppressionNotice(relativize(file)))`, audit `{file, suppressed:true, emitted:true}`; clean ack → stdout payload, audit `{file, emitted:true, kind:'clean', chars}`; pendingWinner (non-UI) → `{emitted:false, skipped:'non-ui-ack'}`; cleanWinner → same `'non-ui-ack'`; cleanAckDeduped → `skipped:'clean-ack-deduped'`; suppressedHit → `{suppressed:true, emitted:false}`; else `{skipped:lastSkip, bytes?:skippedBytes (only when 'too-large')}`. Any exception → `{exitCode:0, stdout:'', audit:{..., error}}`.
|
||||
- **Stop algorithm (`runStopHook`)**: re-entrancy/disabled/malformed/empty as above; `event.stop_hook_active === true` → `skipped:'stop-hook-active'` (no scan, no output; prevents Claude Code re-invocation loops, issue #400). `projectCwd = resolve(event.cwd || cwd)` (no file-based re-keying); `sessionId = event.session_id || 'unknown'`; config disabled → `'config-disabled'`; `touched = keys(cache.sessions[sid].files)`; empty → `'no-touched-files'`; native → `'native-platform'`; detector missing → `'detector-missing'`. Iterate touched files (max 20 scanned): same skips (sensitive/generated/extension/ignoreFiles/missing/outside-project) silently; read (unreadable → skip); detect with full rule set (no tiering); `filtered = filterFindings`; `fresh = dedupeAgainstCache`; if fresh → `rememberFindings(cache, sid, file, fresh)` (NOTE: replaces the file's remembered set with only the fresh ones), push group. `audit.scannedFiles`. No groups → `{emitted:false, skipped:'stop-clean'}`. Else render grouped with footer mode + reserve, `appendDesignSystemNoteOnce`, `commitFooterShown`, `persistCache`, stdout `payload(text,'Stop',harness)`, audit `{emitted:true, freshFiles, freshFindings, chars, durationMs}`, `emission:{kind:'stop-deep-pass', groups}`.
|
||||
- **Stop algorithm (`runStopHook`)**: re-entrancy/disabled/malformed/empty as above; `event.stop_hook_active === true` → `skipped:'stop-hook-active'` (no scan, no output; prevents Claude Code re-invocation loops, issue #400). `projectCwd = resolve(event.cwd || cwd)` (no file-based re-keying); `sessionId = event.session_id || 'unknown'`; config disabled → `'config-disabled'`; `touched = keys(cache.sessions[sid].files)`; empty → `'no-touched-files'`; native → `'native-platform'`; detector missing → `'detector-missing'`. Iterate touched files (max 20 scanned): same skips (sensitive/generated/extension/ignoreFiles/missing/outside-project) silently; read (unreadable → skip; LIVE_PREVIEW_MARKERS in the content → skip); detect with full rule set (no tiering); `filtered = filterFindings`; `fresh = dedupeAgainstCache`; if fresh → `rememberFindings(cache, sid, file, fresh)` (NOTE: replaces the file's remembered set with only the fresh ones), push group. `audit.scannedFiles`. No groups → `{emitted:false, skipped:'stop-clean'}`. Else render grouped with footer mode + reserve, `appendDesignSystemNoteOnce`, `commitFooterShown`, `persistCache`, stdout `payload(text,'Stop',harness)`, audit `{emitted:true, freshFiles, freshFindings, chars, durationMs}`, `emission:{kind:'stop-deep-pass', groups}`.
|
||||
- **Outputs**:
|
||||
- stdout: exactly one JSON document (no trailing newline) when something is emitted, else nothing. Claude/Codex/Grok: `{"hookSpecificOutput":{"hookEventName":"PostToolUse"|"Stop","additionalContext":"<text>"}}`. Cursor-shaped: `{"additional_context":"<text>"}`. GitHub: `{"additionalContext":"<text>"}`.
|
||||
- stderr: only `[impeccable-hook] <err>` when `IMPECCABLE_HOOK_DEBUG` and an unexpected top-level error.
|
||||
@@ -1157,7 +1159,7 @@ DOM scans, design-system findings, co-scanned stylesheets without their own base
|
||||
2. stdin parse error → `'stdin-malformed'`; empty/non-object → `'stdin-empty'`.
|
||||
3. no filePath → `'no-file-path'`; outside project → `'outside-project'`; SENSITIVE → `'sensitive'`; GENERATED → `'generated'`.
|
||||
4. `config = readConfig(cwd)`; ext not allowed and not configured → `'extension'`.
|
||||
5. content skip object → that reason; empty content → `'no-proposed-content'`.
|
||||
5. content skip object → that reason; empty content → `'no-proposed-content'`; content over the cap → `'content-too-large'`; LIVE_PREVIEW_MARKERS in the proposed content OR in the file on disk → `'live-preview'` (a live variant session owns files carrying preview scaffolding; nagging mid-cycle derails it, and `live-complete` verifies the file once the accepted variant is permanent).
|
||||
6. `config.enabled === false` → `'config-disabled'`; native platform → `'native-platform'` (+`platform`).
|
||||
7. ignoreFiles glob (relative or absolute) → `'config-ignore-file'`.
|
||||
8. detector missing → `'detector-missing'`; `scanOptions = designSystemOptions`.
|
||||
@@ -1466,7 +1468,7 @@ Binds `127.0.0.1:PORT`. CORS: if request has `Origin` and (origin is loopback ht
|
||||
| `GET /design-system.json?token=` | 401 `Unauthorized` | 404 `{present:false}` if neither DESIGN.md nor `.impeccable/design.json`; else `{present:true, hasMd, hasSidecar, mdNewerThanJson, parsed?, parseError?, sidecar?, sidecarError?}` (`parsed` = parseDesignMd output; `sidecarError` = `'Failed to parse .impeccable/design.json: '+msg`) |
|
||||
| `GET /design-system/raw?token=` | 401 | 200 `text/markdown; charset=utf-8` DESIGN.md verbatim; 404 `Not found` |
|
||||
| `GET /source?token=&path=` | 401 | path required and no `..` else 400 `Bad path`; resolved must be inside cwd (relative check, not root itself) else 403 `Forbidden`; 404 `File not found`; 200 `text/html; charset=utf-8` raw file. Used by browser to read source, svelte manifest and `params.json`. |
|
||||
| `GET /events?token=` (SSE) | 401 | headers `text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive`; first frame `data: {"type":"connected","hasProjectContext":b,"agentPolling":b,"activeSessions":[…]}\n\n`; `: keepalive\n\n` every 30s; on connect: cancels exit timer and removes queued anonymous `exit` events. On close: if 0 clients, after 8000 ms (still 0) enqueue `{type:'exit'}`. |
|
||||
| `GET /events?token=&clientId=` (SSE) | 401 | `clientId` (optional) is the overlay's per-page-load id; on close the server retires that client's agent-target roll-call report and releases a lease it held, then re-judges each pending roll call against the remaining clients. Headers `text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive`; first frame `data: {"type":"connected","hasProjectContext":b,"agentPolling":b,"activeSessions":[…]}\n\n`; `: keepalive\n\n` every 30s; on connect: cancels exit timer and removes queued anonymous `exit` events. On close: if 0 clients, after 8000 ms (still 0) enqueue `{type:'exit'}`. |
|
||||
| `POST /events` | body JSON `token` mismatch → 401 `{"error":"Unauthorized"}`; invalid JSON → 400 `{"error":"Invalid JSON"}` | see 6.1 |
|
||||
| `GET /stop?token=` | 401 | 200 text `stopping`, then shutdown |
|
||||
| `GET /poll?token=&timeout=&leaseMs=&types=` | 401 `{"error":"Unauthorized"}` | see 6.3 |
|
||||
@@ -1477,6 +1479,9 @@ Binds `127.0.0.1:PORT`. CORS: if request has `Origin` and (origin is loopback ht
|
||||
| `POST /manual-edit-repair-decision` (token body or query) | 401 | see 10 |
|
||||
| `POST /manual-edit-discard?token=&pageUrl=` | 401 | see 10 |
|
||||
| `POST /manual-edit` | | 410 `{"error":"/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits."}` |
|
||||
| `POST /agent-target` | body JSON `token` mismatch → 401 `{"error":"Unauthorized"}`; invalid JSON → 400 `{"error":"Invalid JSON"}` | Agent-initiated targeting (the `generate` command). Validation (400 `{"error":<msg>}`, messages verbatim): `agent_target: selector is required`, `agent_target: selector too long` (>1000 chars), `agent_target: invalid action (valid: <VISUAL_ACTIONS joined ', '>)`, `agent_target: count must be 1-8`, `agent_target: text must be a string of at most 500 chars`, `agent_target: index must be a positive integer (1-based)`, `agent_target: prompt must be a string of at most 2000 chars`, `agent_target: dryRun must be a boolean`. No SSE client → 200 `{ok:false, error:'no_browser_connected'}`. Otherwise mint an 8-hex `targetId`, broadcast `agent_target` (see 6.2), and **hold the response** until `/agent-target-result` resolves it, every connected overlay has declined (busy roll call, see `/agent-target-claim`), or `IMPECCABLE_AGENT_TARGET_TIMEOUT_MS` (default 15000) elapses: busy verdict `{ok:false, error:'busy', state, reason}` from the first report when any report exists, else `{ok:false, error:'browser_timeout', timeoutMs}`. The held reply is 200 `{targetId, ...result}`; shutdown resolves every held request with `{ok:false, error:'server_stopping'}`. |
|
||||
| `POST /agent-target-result` | 401 / 400 Invalid JSON | `targetId` (non-empty string) required else 400 `{"error":"agent_target_result: missing targetId"}`; the remaining body fields (minus `token`) resolve the held request; 200 `{ok:true, delivered:boolean}` (`delivered:false` when nothing awaits that id). |
|
||||
| `POST /agent-target-claim` | 401 / 400 Invalid JSON | `targetId` and `clientId` (non-empty strings) required else 400 `{"error":"agent_target_claim: missing targetId or clientId"}`. Roll call plus a first-wins lease, so exactly one overlay acts on a broadcast target. Unknown or resolved target → `{ok:true, granted:false, pending:false}` (ends a rescuer's retry loop). `eligible !== true` → record `{state, reason}` under `clientId` (replacing an earlier report), release the lease if this client holds it, answer `{ok:true, granted:false}`, then complete the roll call when no owner holds the lease and reports ≥ connected SSE clients (verdict from the first report). `eligible === true` → drop this client's earlier report; `granted` when no owner, the same owner (renew), or the lease lapsed (`IMPECCABLE_AGENT_TARGET_CLAIM_LEASE_MS`, default 3000); answer `{ok:true, granted, pending:true}`. |
|
||||
| anything else | | 404 `Not found` |
|
||||
|
||||
Pending-event summary in `/status.pendingEvents[]`: `{id, type, leased:boolean, leaseUntil:number|null}` plus for `manual_edit_apply`: `pageUrl, chunk, repair, evidencePath, agentAction, manualApplySummary:{pageUrl, chunk, entryCount, opCount, files[]}`.
|
||||
@@ -1534,7 +1539,7 @@ Missed-completion redelivery: on a `checkpoint` with `phase==='generating'` and
|
||||
Generation checkpoint recording (only for reasons `variants_progress|variants_ready`, arrived>0, expected>0, session not canceled): broadcast `{type:'variant_progress', id, file: previewFile||file, sourceFile, previewFile, previewMode ('source' default), arrivedVariants, expectedVariants, publicationKind: event.publicationKind||'variants'}` and record agent phases `first_reviewable` (once), `second_reviewable` (arrived≥2 && expected≥3, once), `all_variants_ready` (arrived≥expected, once), each `{arrivedVariants, expectedVariants, checkpointReason, at}`.
|
||||
|
||||
#### 6.2 Server → browser (SSE `data:` JSON frames)
|
||||
`connected`, `agent_polling {connected}`, `agent_phase {id, phase, at, durationMs?, previewMode?, owner?}`, `variant_progress {…}`, `done`/`steer_done`/`complete`/`agent_done`/`discarded`/`error`/`discard`/any reply type: `{type: msg.type||'done', id, message, file, sourceFile, previewFile, previewMode, data}` (forwarded from `POST /poll`), redelivered `done`, manual-edit activity entries `{seq, type:'manual_edit_*', ts, …details}`.
|
||||
`connected`, `agent_polling {connected}`, `agent_phase {id, phase, at, durationMs?, previewMode?, owner?}`, `variant_progress {…}`, `agent_target {targetId, selector, text?, index?, action, count, prompt?, dryRun?}` (pushed by `POST /agent-target` and replayed to every overlay that connects while the target is pending), `done`/`steer_done`/`complete`/`agent_done`/`discarded`/`error`/`discard`/any reply type: `{type: msg.type||'done', id, message, file, sourceFile, previewFile, previewMode, data}` (forwarded from `POST /poll`), redelivered `done`, manual-edit activity entries `{seq, type:'manual_edit_*', ts, …details}`.
|
||||
|
||||
Manual-edit activity types broadcast: `manual_edit_stashed, manual_edit_discarded, manual_edit_commit_started, manual_edit_apply_dispatched, manual_edit_apply_reply_received, manual_edit_apply_reply_invalid, manual_edit_apply_stale_reply_rejected, manual_edit_apply_timeout, manual_edit_repair_needs_decision, manual_edit_repair_rollback_done, manual_edit_commit_done, manual_edit_commit_failed, manual_edit_transaction_rolled_back, manual_edit_poll_reply_unknown`.
|
||||
|
||||
@@ -1792,6 +1797,13 @@ Conventions: every script's "run directly" guard is `process.argv[1]` ending wit
|
||||
#### `live-target.mjs`
|
||||
- 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>`, `--target <path>` (consumed by `enterLiveRoot`), `--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.
|
||||
- **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`
|
||||
- Invoked by `/manual-edit-commit` (server) and manually (`node live-commit-manual-edits.mjs [--page-url=<url>] [--provider=auto|codex|claude|mock]`). live.md/status hint: never run it for a leased chat Apply event.
|
||||
- Env: `IMPECCABLE_LIVE_COPY_AGENT`, `IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS`, `IMPECCABLE_LIVE_COPY_AGENT_MODEL`, `IMPECCABLE_LIVE_COPY_AGENT_EFFORT`, `IMPECCABLE_LIVE_COPY_AGENT_MOCK_RESULT`, `IMPECCABLE_LIVE_COPY_AGENT_MOCK_WRITES`, `IMPECCABLE_LIVE_COPY_AGENT_MOCK_DELAY_MS`, `IMPECCABLE_LIVE_MANUAL_EDIT_REPAIR_ATTEMPTS`.
|
||||
|
||||
@@ -140,12 +140,14 @@ export const SUITES = {
|
||||
/^skill\/(reference\/live\.md|scripts\/live-browser)/,
|
||||
/^tests\/live-e2e\//,
|
||||
/^tests\/lib\/engine-bin\.mjs$/,
|
||||
/^tests\/live-agent-target\.test\.mjs$/,
|
||||
],
|
||||
commands: [
|
||||
{
|
||||
runner: 'node',
|
||||
files: [
|
||||
'tests/live-reference.test.mjs',
|
||||
'tests/live-agent-target.test.mjs',
|
||||
'tests/live-browser-ignores.test.mjs',
|
||||
'tests/live-browser-source.test.mjs',
|
||||
'tests/live-e2e-agent-output.test.mjs',
|
||||
|
||||
@@ -8,7 +8,7 @@ Three prohibitions cover the known ways this command goes wrong. Each names the
|
||||
|
||||
- The poll shows no generate event yet, and writing variants straight into source feels faster. **Never hand-write a variants wrapper or invent a session id.** Only the browser mints session ids (8 hex characters, at Go), and the server refuses events for any other id; a missing event is fixed in Step 2 or Step 3, never with a direct source edit.
|
||||
- Handing the user a link to click feels polite. **Open the page yourself** (Step 2); a pasted link usually means no page ever connects.
|
||||
- The design hook may flag the preview scaffolding you just published. **Do not act on hook findings while live markers are in the file**, and do not restyle variants to appease them; `live-complete.mjs` verifies the file once the accepted variant is permanent. Current hooks stand down on the markers themselves; older installed hooks may still nag.
|
||||
- The design hook may flag the preview scaffolding you just published. **Do not act on hook findings while live markers are in the file**, and do not restyle variants to appease them; `impeccable live-complete` verifies the file once the accepted variant is permanent. Current hooks stand down on the markers themselves; older installed hooks may still nag.
|
||||
|
||||
## Step 1: Parse the request
|
||||
|
||||
@@ -38,7 +38,7 @@ Done when you hold an action from the vocabulary, a count from 1 to 8, and the e
|
||||
Run the boot exactly as [live.md](live.md)'s Start section describes:
|
||||
|
||||
```bash
|
||||
node {{scripts_path}}/live.mjs
|
||||
{{scripts_path}}/impeccable live
|
||||
```
|
||||
|
||||
**`config_missing` / `config_invalid`**: follow [live-setup.md](live-setup.md) first.
|
||||
@@ -56,7 +56,7 @@ Done when the boot printed `"ok": true` and a page with the overlay is connected
|
||||
Derive the selector from project source, not from guesswork: an id first, then a unique class, then a landmark tag plus class. **The request names a repeated component in plural** ("the pricing cards"): target the container that holds the set, so scoped CSS restyles every instance at once. **Unsure the selector resolves uniquely**: probe with `--dry-run`; it resolves and reports without starting anything, and it works even mid-session.
|
||||
|
||||
```bash
|
||||
node {{scripts_path}}/live-generate.mjs --selector "section.pricing" --action bolder --count 3
|
||||
{{scripts_path}}/impeccable live-generate --selector "section.pricing" --action bolder --count 3
|
||||
```
|
||||
|
||||
Flags: `--selector` (required), `--action`, `--count`, `--prompt`, `--text` (keep only matches whose visible text contains a snippet), `--index` (1-based pick among matches), `--dry-run`, `--wait-for-browser <ms>`.
|
||||
@@ -78,10 +78,10 @@ Then tell the user, in one line, where their variants are: *"Three [bolder] vari
|
||||
|
||||
## Step 5: Close the session
|
||||
|
||||
Generate is a one-shot command; this is where it diverges from an open-ended `live` session. Once the accept (or discard) completes, wrap up without being asked: carbonize cleanup is done and `live-complete.mjs` printed `phase: "completed"` (a discard needs no cleanup), so kill your background poll and run live.md's Cleanup:
|
||||
Generate is a one-shot command; this is where it diverges from an open-ended `live` session. Once the accept (or discard) completes, wrap up without being asked: carbonize cleanup is done and `impeccable live-complete` printed `phase: "completed"` (a discard needs no cleanup), so kill your background poll and run live.md's Cleanup:
|
||||
|
||||
```bash
|
||||
node {{scripts_path}}/live-server.mjs stop
|
||||
{{scripts_path}}/impeccable live-server stop
|
||||
```
|
||||
|
||||
Stopping removes the injected live script, and that removal reloads the page one last time: the user's browser now shows the accepted design with no overlay chrome, still served by their dev server.
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Agent-initiated element targeting for the `generate` command.
|
||||
*
|
||||
* 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 (live.mjs boot) and an open page with the overlay attached.
|
||||
*
|
||||
* Usage:
|
||||
* node <scripts_path>/live-generate.mjs --selector "section.pricing" --action bolder --count 3
|
||||
* node <scripts_path>/live-generate.mjs --selector ".card" --text "Studio" --action impeccable --prompt "warmer"
|
||||
*
|
||||
* Flags:
|
||||
* --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
|
||||
* --action <name> optional; one of the live action vocabulary (default: impeccable)
|
||||
* --count <n> optional; variants to request, 1-8 (default: 3)
|
||||
* --prompt <text> optional; freeform direction, same as typing before Go
|
||||
* --dry-run optional; resolve and report without starting anything
|
||||
* --wait-for-browser <ms> optional; poll the helper until a page with the
|
||||
* overlay connects (or the budget runs out) before
|
||||
* sending the target. For harnesses with no browser
|
||||
* tool: hand the user the URL, run with this flag, and
|
||||
* the command fires as soon as they open the page.
|
||||
*/
|
||||
|
||||
import process from 'node:process';
|
||||
import { enterLiveRoot } from './live/roots.mjs';
|
||||
import { VISUAL_ACTIONS } from './live/vocabulary.mjs';
|
||||
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
||||
|
||||
enterLiveRoot(process.cwd());
|
||||
|
||||
// Destroy fetch's global undici dispatcher before process.exit(): a live
|
||||
// keep-alive socket trips a libuv assertion on Windows/Node 24 after a
|
||||
// successful print (nodejs/node#56645, issue #573), matching context.mjs.
|
||||
async function destroyFetchDispatcher() {
|
||||
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
|
||||
if (dispatcher && typeof dispatcher.destroy === 'function') {
|
||||
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
|
||||
}
|
||||
}
|
||||
|
||||
async function fail(payload) {
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
await destroyFetchDispatcher();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {};
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (!arg.startsWith('--')) continue;
|
||||
const key = arg.slice(2);
|
||||
if (key === 'dry-run') { args['dry-run'] = true; continue; }
|
||||
const value = argv[i + 1];
|
||||
if (value === undefined || value.startsWith('--')) {
|
||||
// Pre-fetch validation inside a sync helper: no socket can exist yet,
|
||||
// so a plain synchronous exit is safe here.
|
||||
console.log(JSON.stringify({ ok: false, error: 'missing_flag_value', flag: arg }, null, 2));
|
||||
process.exit(1);
|
||||
}
|
||||
args[key] = value;
|
||||
i += 1;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
const selector = (args.selector || '').trim();
|
||||
if (!selector) {
|
||||
await fail({
|
||||
ok: false,
|
||||
error: 'selector_required',
|
||||
_instructions: 'Pass --selector with a CSS selector for the element to target. Derive it from the page source: prefer an id, a unique class, or a landmark section, and add --text "<visible text>" when the class repeats.',
|
||||
});
|
||||
}
|
||||
|
||||
const action = args.action || 'impeccable';
|
||||
if (!VISUAL_ACTIONS.includes(action)) {
|
||||
await fail({
|
||||
ok: false,
|
||||
error: 'invalid_action',
|
||||
action,
|
||||
validActions: VISUAL_ACTIONS,
|
||||
_instructions: 'Map the request wording onto the closest listed action (bold -> bolder, quiet/calmer -> quieter, simplify -> distill). When no action fits, use --action impeccable and carry the wording via --prompt.',
|
||||
});
|
||||
}
|
||||
|
||||
const count = args.count === undefined ? 3 : Number(args.count);
|
||||
if (!Number.isInteger(count) || count < 1 || count > 8) {
|
||||
await fail({ ok: false, error: 'invalid_count', count: args.count, _instructions: 'Pass --count as an integer from 1 to 8.' });
|
||||
}
|
||||
|
||||
let index;
|
||||
if (args.index !== undefined) {
|
||||
index = Number(args.index);
|
||||
if (!Number.isInteger(index) || index < 1) {
|
||||
await fail({ ok: false, error: 'invalid_index', index: args.index, _instructions: 'Pass --index as a 1-based integer position among the matches.' });
|
||||
}
|
||||
}
|
||||
|
||||
let waitForBrowserMs = 0;
|
||||
if (args['wait-for-browser'] !== undefined) {
|
||||
waitForBrowserMs = Number(args['wait-for-browser']);
|
||||
if (!Number.isInteger(waitForBrowserMs) || waitForBrowserMs < 1) {
|
||||
await fail({ ok: false, error: 'invalid_wait', wait: args['wait-for-browser'], _instructions: 'Pass --wait-for-browser as a positive integer of milliseconds, e.g. --wait-for-browser 120000.' });
|
||||
}
|
||||
}
|
||||
|
||||
const found = readLiveServerInfo(process.cwd());
|
||||
if (!found || !found.info || !found.info.port || !found.info.token) {
|
||||
await fail({
|
||||
ok: false,
|
||||
error: 'server_not_running',
|
||||
_instructions: 'No live helper server is recorded for this project. Run the live boot first (node <scripts_path>/live.mjs), open the app URL that serves a pageFiles entry, then rerun this command.',
|
||||
});
|
||||
}
|
||||
|
||||
const { port, token } = found.info;
|
||||
|
||||
const INSTRUCTIONS = {
|
||||
ok: (r) => (r.dryRun
|
||||
? `Dry run only: the selector resolves to one element (${r.element?.tag}${r.element?.id ? '#' + r.element.id : ''}) and no session was started. Rerun without --dry-run to generate.`
|
||||
: `Session ${r.sessionId} started: the browser scrolled to the target and fired Go (action "${r.action}", count ${r.count}). Poll now with live-poll.mjs; the next event for this session is its generate event. Handle it exactly per live.md's Handle generate, then reply done and keep polling.`),
|
||||
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.',
|
||||
browser_timeout: () => 'The overlay did not answer in time. The page may be mid-reload: run live-status.mjs to check whether a session started anyway, reload the app page, then rerun this command.',
|
||||
invalid_selector: () => 'The selector is not valid CSS. Fix the selector syntax and rerun.',
|
||||
no_match: (r) => (r.rawMatchCount > 0
|
||||
? `The selector hit ${r.rawMatchCount} node(s) but none is pickable (too small, chrome, or filtered by --text). Target a larger element or adjust --text.`
|
||||
: 'The selector matched nothing on the open page. Derive a better selector from the page source (an id, a unique class, or a landmark), or add --text with a snippet of the element\'s visible text.'),
|
||||
ambiguous: (r) => `The selector matched ${r.matchCount} elements. Either target their common container instead, or disambiguate with --text "<visible text>" or --index <1-based position>. The candidates are listed in this output.`,
|
||||
index_out_of_range: (r) => `--index is out of range: only ${r.matchCount} match(es). Use an index from 1 to ${r.matchCount}.`,
|
||||
busy: (r) => `A live session is already mid-flight (browser state ${r.state}). Let the user finish or discard it in the browser, or handle the pending event in your poll loop, then rerun.`,
|
||||
go_failed: (r) => `The overlay could not start generation from the picked state (browser state ${r.state}). Reload the app page and rerun this command.`,
|
||||
server_stopping: () => 'The live helper server is shutting down. Re-run the live boot (live.mjs), reopen the page, then rerun this command.',
|
||||
};
|
||||
|
||||
async function waitForBrowserConnection(budgetMs) {
|
||||
const deadline = Date.now() + budgetMs;
|
||||
for (;;) {
|
||||
let status;
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/status?token=${token}`, {
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
status = await res.json();
|
||||
} catch (err) {
|
||||
await fail({
|
||||
ok: false,
|
||||
error: 'server_unreachable',
|
||||
detail: err?.message,
|
||||
_instructions: 'The recorded live server did not answer while waiting for a browser; it likely died. Re-run the live boot (node <scripts_path>/live.mjs), reopen the app page, then rerun this command.',
|
||||
});
|
||||
}
|
||||
if ((status.connectedClients || 0) > 0) return;
|
||||
if (Date.now() >= deadline) {
|
||||
await fail({
|
||||
ok: false,
|
||||
error: 'no_browser_connected',
|
||||
waitedMs: budgetMs,
|
||||
_instructions: INSTRUCTIONS.no_browser_connected(),
|
||||
});
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 1_000));
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (waitForBrowserMs > 0) await waitForBrowserConnection(waitForBrowserMs);
|
||||
const body = {
|
||||
token,
|
||||
selector,
|
||||
action,
|
||||
count,
|
||||
...(args.text ? { text: args.text } : {}),
|
||||
...(index !== undefined ? { index } : {}),
|
||||
...(args.prompt ? { prompt: args.prompt } : {}),
|
||||
...(args['dry-run'] ? { dryRun: true } : {}),
|
||||
};
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`http://127.0.0.1:${port}/agent-target`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
// Client-side cap just above the server's 15s hold, so a hung helper
|
||||
// still fails fast.
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
});
|
||||
} catch (err) {
|
||||
const timedOut = err?.name === 'TimeoutError' || err?.name === 'AbortError';
|
||||
await fail({
|
||||
ok: false,
|
||||
error: timedOut ? 'request_timeout' : 'server_unreachable',
|
||||
detail: err?.message,
|
||||
_instructions: timedOut
|
||||
? INSTRUCTIONS.browser_timeout()
|
||||
: 'The recorded live server did not answer; it likely died. Re-run the live boot (node <scripts_path>/live.mjs), reopen the app page, then rerun this command.',
|
||||
});
|
||||
}
|
||||
let result;
|
||||
try {
|
||||
result = await res.json();
|
||||
} catch {
|
||||
await fail({ ok: false, error: 'bad_server_response', status: res.status });
|
||||
}
|
||||
if (!res.ok) {
|
||||
await fail({ ok: false, error: result.error || `http_${res.status}`, ...result });
|
||||
}
|
||||
const instructions = INSTRUCTIONS[result.ok ? 'ok' : result.error];
|
||||
const output = {
|
||||
...result,
|
||||
...(instructions ? { _instructions: instructions(result) } : {}),
|
||||
};
|
||||
console.log(JSON.stringify(output, null, 2));
|
||||
await destroyFetchDispatcher();
|
||||
process.exit(result.ok ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((err) => fail({ ok: false, error: 'unexpected_failure', detail: err?.message }));
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Tests for agent-initiated element targeting (the `generate` command):
|
||||
* POST /agent-target held-open pairing with POST /agent-target-result,
|
||||
* validation, the no-browser and timeout verdicts, and the live-generate CLI's
|
||||
* local failure modes.
|
||||
* Protocol tests for agent-initiated element targeting (the `generate`
|
||||
* command), driven against the engine binary: POST /agent-target held-open
|
||||
* pairing with POST /agent-target-result, validation, the roll call and its
|
||||
* leases, the no-browser and timeout verdicts, and the live-generate verb's
|
||||
* local failure modes. Skips cleanly without a binary (tests/lib/engine-bin.mjs).
|
||||
*
|
||||
* Run with: node --test tests/live-agent-target.test.mjs
|
||||
*/
|
||||
@@ -14,37 +15,66 @@ import { dirname, join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { execFile, execFileSync, spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { getLiveServerPath } from '../skill/scripts/lib/impeccable-paths.mjs';
|
||||
import { VISUAL_ACTIONS } from '../skill/scripts/live/vocabulary.mjs';
|
||||
import { ENGINE_MISSING_MESSAGE, engineEnv, findEngineBinary } from './lib/engine-bin.mjs';
|
||||
|
||||
// Resolve the repo from this file, not from cwd: the runner may be invoked
|
||||
// from tests/ or anywhere else.
|
||||
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const SERVER_SCRIPT = join(REPO_ROOT, 'skill/scripts/live-server.mjs');
|
||||
const GENERATE_SCRIPT = join(REPO_ROOT, 'skill/scripts/live-generate.mjs');
|
||||
const ENGINE_BIN = findEngineBinary();
|
||||
|
||||
// The action vocabulary lives in the engine (crates/live/src/vocabulary.rs);
|
||||
// read it from the Rust source so the matrix below can never drift from what
|
||||
// the live server accepts.
|
||||
function readVisualActions() {
|
||||
const rust = readFileSync(join(REPO_ROOT, 'crates/live/src/vocabulary.rs'), 'utf-8');
|
||||
const block = rust.match(/pub const VISUAL_ACTIONS: \[&str; (\d+)\] = \[([\s\S]*?)\];/);
|
||||
if (!block) throw new Error('VISUAL_ACTIONS not found in crates/live/src/vocabulary.rs');
|
||||
return [...block[2].matchAll(/"([a-z]+)"/g)].map((m) => m[1]);
|
||||
}
|
||||
const VISUAL_ACTIONS = readVisualActions();
|
||||
|
||||
function liveServerPath(cwd) {
|
||||
return join(cwd, '.impeccable/live/server.json');
|
||||
}
|
||||
|
||||
/** Run the live-generate verb; the JSON verdict is on stdout on every exit code. */
|
||||
function runGenerate(cwd, args) {
|
||||
return execFileSync(ENGINE_BIN, ['live-generate', ...args], {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
env: engineEnv(ENGINE_BIN, {}),
|
||||
});
|
||||
}
|
||||
|
||||
function startServer(port, { cwd, env = {} } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn('node', [SERVER_SCRIPT, '--port=' + port], {
|
||||
const proc = spawn(ENGINE_BIN, ['live-server', '--port=' + port], {
|
||||
cwd,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env, IMPECCABLE_LIVE_COPY_AGENT: 'off', ...env },
|
||||
env: engineEnv(ENGINE_BIN, { IMPECCABLE_LIVE_COPY_AGENT: 'off', ...env }),
|
||||
});
|
||||
let output = '';
|
||||
proc.stdout.on('data', (d) => {
|
||||
output += d.toString();
|
||||
if (output.includes('running on')) {
|
||||
try {
|
||||
const info = JSON.parse(readFileSync(getLiveServerPath(cwd), 'utf-8'));
|
||||
resolve({ proc, port: info.port, token: info.token, cwd });
|
||||
} catch {
|
||||
reject(new Error('Server started but PID file not readable'));
|
||||
}
|
||||
}
|
||||
});
|
||||
proc.stdout.on('data', (d) => { output += d.toString(); });
|
||||
proc.stderr.on('data', (d) => { output += d.toString(); });
|
||||
proc.on('error', reject);
|
||||
setTimeout(() => reject(new Error('Server start timeout. Output: ' + output)), 5000);
|
||||
// The server writes server.json on listen; poll for it rather than
|
||||
// parsing the banner, so a slow first start still resolves.
|
||||
const deadline = Date.now() + 10_000;
|
||||
const tick = () => {
|
||||
try {
|
||||
const info = JSON.parse(readFileSync(liveServerPath(cwd), 'utf-8'));
|
||||
if (info.port && info.token) {
|
||||
resolve({ proc, port: info.port, token: info.token, cwd });
|
||||
return;
|
||||
}
|
||||
} catch { /* not yet */ }
|
||||
if (Date.now() > deadline) {
|
||||
reject(new Error('Server start timeout. Output: ' + output));
|
||||
return;
|
||||
}
|
||||
setTimeout(tick, 50);
|
||||
};
|
||||
tick();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -116,7 +146,7 @@ async function openSseClient(server, { clientId } = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
describe('POST /agent-target', () => {
|
||||
describe('POST /agent-target', { skip: ENGINE_BIN ? false : ENGINE_MISSING_MESSAGE }, () => {
|
||||
let tmp;
|
||||
let server;
|
||||
|
||||
@@ -606,9 +636,9 @@ describe('POST /agent-target', () => {
|
||||
for (const action of VISUAL_ACTIONS) {
|
||||
const cli = new Promise((resolve) => {
|
||||
execFile(
|
||||
process.execPath,
|
||||
[GENERATE_SCRIPT, '--selector', 'h1', '--action', action, '--dry-run'],
|
||||
{ cwd: tmp, encoding: 'utf-8' },
|
||||
ENGINE_BIN,
|
||||
['live-generate', '--selector', 'h1', '--action', action, '--dry-run'],
|
||||
{ cwd: tmp, encoding: 'utf-8', env: engineEnv(ENGINE_BIN, {}) },
|
||||
(err, stdout) => resolve({ code: err ? err.code : 0, stdout }),
|
||||
);
|
||||
});
|
||||
@@ -666,7 +696,7 @@ describe('POST /agent-target', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('live-generate CLI --wait-for-browser', () => {
|
||||
describe('live-generate CLI --wait-for-browser', { skip: ENGINE_BIN ? false : ENGINE_MISSING_MESSAGE }, () => {
|
||||
let tmp;
|
||||
let server;
|
||||
|
||||
@@ -687,10 +717,7 @@ describe('live-generate CLI --wait-for-browser', () => {
|
||||
|
||||
function runCli(cwd, args) {
|
||||
try {
|
||||
const stdout = execFileSync(process.execPath, [GENERATE_SCRIPT, ...args], {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
const stdout = runGenerate(cwd, args);
|
||||
return { code: 0, json: JSON.parse(stdout) };
|
||||
} catch (err) {
|
||||
return { code: err.status, json: JSON.parse(err.stdout) };
|
||||
@@ -721,9 +748,9 @@ describe('live-generate CLI --wait-for-browser', () => {
|
||||
// and the delayed connect would never happen.
|
||||
const child = new Promise((resolve) => {
|
||||
execFile(
|
||||
process.execPath,
|
||||
[GENERATE_SCRIPT, '--selector', 'h1', '--action', 'bolder', '--wait-for-browser', '10000'],
|
||||
{ cwd: tmp, encoding: 'utf-8' },
|
||||
ENGINE_BIN,
|
||||
['live-generate', '--selector', 'h1', '--action', 'bolder', '--wait-for-browser', '10000'],
|
||||
{ cwd: tmp, encoding: 'utf-8', env: engineEnv(ENGINE_BIN, {}) },
|
||||
(err, stdout) => resolve({ code: err ? err.code : 0, stdout }),
|
||||
);
|
||||
});
|
||||
@@ -737,13 +764,10 @@ describe('live-generate CLI --wait-for-browser', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('live-generate CLI local failure modes', () => {
|
||||
describe('live-generate CLI local failure modes', { skip: ENGINE_BIN ? false : ENGINE_MISSING_MESSAGE }, () => {
|
||||
function runCli(cwd, args) {
|
||||
try {
|
||||
const stdout = execFileSync(process.execPath, [GENERATE_SCRIPT, ...args], {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
const stdout = runGenerate(cwd, args);
|
||||
return { code: 0, json: JSON.parse(stdout) };
|
||||
} catch (err) {
|
||||
return { code: err.status, json: JSON.parse(err.stdout) };
|
||||
@@ -756,7 +780,7 @@ describe('live-generate CLI local failure modes', () => {
|
||||
const { code, json } = runCli(tmp, ['--selector', 'h1', '--action', 'bolder']);
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error, 'server_not_running');
|
||||
assert.match(json._instructions, /live\.mjs/);
|
||||
assert.match(json._instructions, / live\)/, 'names the boot verb');
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -885,10 +885,11 @@ for (const { name, fixture } of fixtures) {
|
||||
if (scenario.prompt) {
|
||||
// The configure bar rebuild once discarded the preset prompt, so
|
||||
// pin the regression at the wire: the journaled generate event
|
||||
// must carry the prompt the CLI was given.
|
||||
const journalPath = join(appRoot, '.impeccable/live/sessions', `${res.sessionId}.jsonl`);
|
||||
const journaled = readFileSync(journalPath, 'utf-8').trim().split('\n').map((l) => JSON.parse(l));
|
||||
const generateEvent = journaled.find((entry) => entry.type === 'generate')?.event;
|
||||
// must carry the prompt the CLI was given. The engine journals a
|
||||
// generate event when the agent leases it from /poll, so wait
|
||||
// for the entry instead of reading the journal right away.
|
||||
const [journaled] = await waitForJournalEvent(appRoot, res.sessionId, 'generate');
|
||||
const generateEvent = journaled?.event ?? journaled;
|
||||
assert.equal(
|
||||
generateEvent?.freeformPrompt,
|
||||
scenario.prompt,
|
||||
|
||||
@@ -3,7 +3,6 @@ import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { compileProviderBlocks } from '../scripts/lib/utils.js';
|
||||
import { VISUAL_ACTIONS } from '../skill/scripts/live/vocabulary.mjs';
|
||||
|
||||
const ROOT = process.cwd();
|
||||
|
||||
@@ -178,8 +177,20 @@ describe('live reference authoring contract', () => {
|
||||
// value the picker offers but the reference never names is a request
|
||||
// the agent cannot route.
|
||||
const generateMd = readFileSync(join(ROOT, 'skill/reference/generate.md'), 'utf-8');
|
||||
for (const action of VISUAL_ACTIONS) {
|
||||
for (const action of readVisualActions()) {
|
||||
assert.match(generateMd, new RegExp('`' + action + '`'), `generate.md must name \`${action}\``);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// The action vocabulary lives in the engine (crates/live/src/vocabulary.rs);
|
||||
// read it from the Rust source so the parity check needs no binary and can
|
||||
// never drift from what the live server accepts.
|
||||
function readVisualActions() {
|
||||
const rust = readFileSync(join(ROOT, 'crates/live/src/vocabulary.rs'), 'utf-8');
|
||||
const block = rust.match(/pub const VISUAL_ACTIONS: \[&str; (\d+)\] = \[([\s\S]*?)\];/);
|
||||
if (!block) throw new Error('VISUAL_ACTIONS not found in crates/live/src/vocabulary.rs');
|
||||
const actions = [...block[2].matchAll(/"([a-z]+)"/g)].map((m) => m[1]);
|
||||
if (actions.length !== Number(block[1])) throw new Error('VISUAL_ACTIONS length mismatch');
|
||||
return actions;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* `live-generate` (the `generate` command's agent-initiated targeting): the
|
||||
* verdicts the verb decides locally, without a browser, plus the one the
|
||||
* helper answers when no overlay is attached. Everything that needs an
|
||||
* overlay (the roll call, leases, replay) is covered by
|
||||
* tests/live-agent-target.test.mjs and crates/cli/tests/agent_target.rs.
|
||||
*/
|
||||
import { LIVE_FILES } from '../live-helpers.mjs';
|
||||
|
||||
const NORM = [
|
||||
['localhost:\\d{4,5}', 'g', 'localhost:<PORT>'],
|
||||
['"(port|serverPort)":(\\s*)\\d{4,5}', 'g', '"$1":$2<PORT>'],
|
||||
['Stopped live server on port \\d+\\.', 'g', 'Stopped live server on port <PORT>.'],
|
||||
];
|
||||
|
||||
export default [
|
||||
{
|
||||
id: 'live-generate-local-verdicts', workspace: 'live-html', files: [...LIVE_FILES],
|
||||
// No helper is recorded in the staged workspace, so every step short
|
||||
// of a valid request ends in the verb's own verdict, and the valid one
|
||||
// ends in server_not_running.
|
||||
steps: [
|
||||
{ verb: 'live-generate', args: ['--help'] },
|
||||
{ verb: 'live-generate', args: [] },
|
||||
{ verb: 'live-generate', args: ['--selector'] },
|
||||
{ verb: 'live-generate', args: ['--selector', 'h1', '--action', 'bold'] },
|
||||
{ verb: 'live-generate', args: ['--selector', 'h1', '--count', '9'] },
|
||||
{ verb: 'live-generate', args: ['--selector', 'h1', '--count', 'three'] },
|
||||
{ verb: 'live-generate', args: ['--selector', 'h1', '--index', '0'] },
|
||||
{ verb: 'live-generate', args: ['--selector', 'h1', '--wait-for-browser', 'soon'] },
|
||||
{ verb: 'live-generate', args: ['--selector', 'h1', '--action', 'bolder'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'live-generate-no-browser-connected', workspace: 'live-html', files: [...LIVE_FILES], normalize: NORM,
|
||||
// A running helper with no overlay attached answers at once instead of
|
||||
// holding the request.
|
||||
steps: [
|
||||
{ verb: 'live-server', daemon: true, readyFile: '.impeccable/live/server.json', readyTimeoutMs: 15000 },
|
||||
{ verb: 'live-generate', args: ['--selector', 'h1', '--action', 'bolder', '--count', '2', '--prompt', 'warmer'] },
|
||||
{ verb: 'live-server', args: ['stop'] },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"steps": [
|
||||
{
|
||||
"stdout": "Usage: impeccable live-generate --selector <css> [--text <snippet>] [--index <n>] [--action <name>] [--count <n>] [--prompt <text>] [--dry-run] [--wait-for-browser <ms>]\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",
|
||||
"stderr": "",
|
||||
"exit": 0,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"stdout": "{\n \"ok\": false,\n \"error\": \"selector_required\",\n \"_instructions\": \"Pass --selector with a CSS selector for the element to target. Derive it from the page source: prefer an id, a unique class, or a landmark section, and add --text \\\"<visible text>\\\" when the class repeats.\"\n}\n",
|
||||
"stderr": "",
|
||||
"exit": 1,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"stdout": "{\n \"ok\": false,\n \"error\": \"missing_flag_value\",\n \"flag\": \"--selector\"\n}\n",
|
||||
"stderr": "",
|
||||
"exit": 1,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"stdout": "{\n \"ok\": false,\n \"error\": \"invalid_action\",\n \"action\": \"bold\",\n \"validActions\": [\n \"impeccable\",\n \"bolder\",\n \"quieter\",\n \"distill\",\n \"polish\",\n \"typeset\",\n \"colorize\",\n \"layout\",\n \"adapt\",\n \"animate\",\n \"delight\",\n \"overdrive\"\n ],\n \"_instructions\": \"Map the request wording onto the closest listed action (bold -> bolder, quiet/calmer -> quieter, simplify -> distill). When no action fits, use --action impeccable and carry the wording via --prompt.\"\n}\n",
|
||||
"stderr": "",
|
||||
"exit": 1,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"stdout": "{\n \"ok\": false,\n \"error\": \"invalid_count\",\n \"count\": \"9\",\n \"_instructions\": \"Pass --count as an integer from 1 to 8.\"\n}\n",
|
||||
"stderr": "",
|
||||
"exit": 1,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"stdout": "{\n \"ok\": false,\n \"error\": \"invalid_count\",\n \"count\": \"three\",\n \"_instructions\": \"Pass --count as an integer from 1 to 8.\"\n}\n",
|
||||
"stderr": "",
|
||||
"exit": 1,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"stdout": "{\n \"ok\": false,\n \"error\": \"invalid_index\",\n \"index\": \"0\",\n \"_instructions\": \"Pass --index as a 1-based integer position among the matches.\"\n}\n",
|
||||
"stderr": "",
|
||||
"exit": 1,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"stdout": "{\n \"ok\": false,\n \"error\": \"invalid_wait\",\n \"wait\": \"soon\",\n \"_instructions\": \"Pass --wait-for-browser as a positive integer of milliseconds, e.g. --wait-for-browser 120000.\"\n}\n",
|
||||
"stderr": "",
|
||||
"exit": 1,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"stdout": "{\n \"ok\": false,\n \"error\": \"server_not_running\",\n \"_instructions\": \"No live helper server is recorded for this project. Run the live boot first (<IMPECCABLE> live), open the app URL that serves a pageFiles entry, then rerun this command.\"\n}\n",
|
||||
"stderr": "",
|
||||
"exit": 1,
|
||||
"signal": null
|
||||
}
|
||||
],
|
||||
"files": {
|
||||
".impeccable/live/config.json": "{\n \"files\": [\"index.html\", \"public/**/*.html\"],\n \"insertBefore\": \"</body>\",\n \"commentSyntax\": \"html\"\n}\n"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"steps": [
|
||||
{
|
||||
"stdout": "",
|
||||
"stderr": "",
|
||||
"exit": null,
|
||||
"signal": null,
|
||||
"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",
|
||||
"stderr": "",
|
||||
"exit": 1,
|
||||
"signal": null
|
||||
},
|
||||
{
|
||||
"stdout": "Stopped live server on port <PORT>.\n",
|
||||
"stderr": "",
|
||||
"exit": 0,
|
||||
"signal": null
|
||||
}
|
||||
],
|
||||
"files": {
|
||||
".impeccable/live/config.json": "{\n \"files\": [\"index.html\", \"public/**/*.html\"],\n \"insertBefore\": \"</body>\",\n \"commentSyntax\": \"html\"\n}\n"
|
||||
},
|
||||
"daemon": [
|
||||
{
|
||||
"stdout": "\nImpeccable live server running on http://localhost:<PORT>\nToken: <UUID>\n\nScript: http://localhost:<PORT>/live.js\nInject: managed by impeccable live-inject; Astro source tags use is:inline automatically.\nStop: impeccable live-server stop\n",
|
||||
"stderr": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"stdout": "",
|
||||
"stderr": "Unknown command: teach\nAvailable commands: craft, init, extract, document, shape, critique, audit, polish, bolder, quieter, distill, harden, onboard, live, animate, colorize, typeset, layout, delight, overdrive, clarify, adapt, optimize\n",
|
||||
"stderr": "Unknown command: teach\nAvailable commands: craft, init, extract, document, shape, critique, audit, polish, bolder, quieter, distill, harden, onboard, live, animate, colorize, typeset, layout, delight, overdrive, clarify, adapt, optimize, generate\n",
|
||||
"exit": 1,
|
||||
"signal": null,
|
||||
"files": {}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"stdout": "",
|
||||
"stderr": "Unknown command: doctor\nAvailable commands: craft, init, extract, document, shape, critique, audit, polish, bolder, quieter, distill, harden, onboard, live, animate, colorize, typeset, layout, delight, overdrive, clarify, adapt, optimize\n",
|
||||
"stderr": "Unknown command: doctor\nAvailable commands: craft, init, extract, document, shape, critique, audit, polish, bolder, quieter, distill, harden, onboard, live, animate, colorize, typeset, layout, delight, overdrive, clarify, adapt, optimize, generate\n",
|
||||
"exit": 1,
|
||||
"signal": null,
|
||||
"files": {}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"stdout": "Usage: impeccable pin <pin|unpin> <command>\n\nAvailable commands: craft, init, extract, document, shape, critique, audit, polish, bolder, quieter, distill, harden, onboard, live, animate, colorize, typeset, layout, delight, overdrive, clarify, adapt, optimize\n",
|
||||
"stdout": "Usage: impeccable pin <pin|unpin> <command>\n\nAvailable commands: craft, init, extract, document, shape, critique, audit, polish, bolder, quieter, distill, harden, onboard, live, animate, colorize, typeset, layout, delight, overdrive, clarify, adapt, optimize, generate\n",
|
||||
"stderr": "",
|
||||
"exit": 1,
|
||||
"signal": null,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"stdout": "Usage: impeccable pin <pin|unpin> <command>\n\nAvailable commands: craft, init, extract, document, shape, critique, audit, polish, bolder, quieter, distill, harden, onboard, live, animate, colorize, typeset, layout, delight, overdrive, clarify, adapt, optimize\n",
|
||||
"stdout": "Usage: impeccable pin <pin|unpin> <command>\n\nAvailable commands: craft, init, extract, document, shape, critique, audit, polish, bolder, quieter, distill, harden, onboard, live, animate, colorize, typeset, layout, delight, overdrive, clarify, adapt, optimize, generate\n",
|
||||
"stderr": "",
|
||||
"exit": 1,
|
||||
"signal": null,
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import { detectProvider, getModel, hasKey, resolveModelList, PROVIDERS } from './providers.mjs';
|
||||
import { assertLauncherDenialWarningBeforeNextTool, assertPlanningFallbackWarning, LAUNCHER_FAILURE_WARNING, assertAdviceOnly, assertWorkflowAdvice, assertCommandComparison, missingReferences } from './assertions.mjs';
|
||||
import { assertCompleted } from '../skill-workflow/assertions.mjs';
|
||||
import { findEngineBinary } from '../lib/engine-bin.mjs';
|
||||
import {
|
||||
PRODUCT_MD_SAMPLE,
|
||||
PRODUCT_MD_SAMPLE_NO_REGISTER,
|
||||
@@ -106,10 +107,16 @@ function loadedBefore(trace, first, second) {
|
||||
*/
|
||||
function stopLiveHelper(workspace) {
|
||||
try {
|
||||
const engineBin = findEngineBinary();
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[path.join(workspace, '.claude/skills/impeccable/scripts/live-server.mjs'), 'stop'],
|
||||
{ cwd: workspace, stdio: 'ignore', timeout: 10_000 },
|
||||
path.join(workspace, '.claude/skills/impeccable/scripts/impeccable'),
|
||||
['live-server', 'stop'],
|
||||
{
|
||||
cwd: workspace,
|
||||
stdio: 'ignore',
|
||||
timeout: 10_000,
|
||||
env: { ...process.env, ...(engineBin ? { IMPECCABLE_BIN: engineBin } : {}) },
|
||||
},
|
||||
);
|
||||
} catch { /* nothing was running */ }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user