Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/add-replay-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"agent-browser": minor
---

Add `replay` command for interactive DOM session recording via rrweb

New commands:
- `replay start` - Inject rrweb recorder into the current page (auto re-injects on navigation)
- `replay stop [path]` - Stop recording and generate self-contained replay HTML
- `replay status` - Show event count and recording state

Unlike video recording (`record`), DOM replays are lightweight, inspectable, and produce
self-contained HTML files with play/pause, timeline scrubbing, and speed controls (1x-8x).
The export automatically extracts CSS custom properties for accurate visual replay.
20 changes: 20 additions & 0 deletions cli/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1184,6 +1184,26 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
}),
}
}
// === Replay (rrweb DOM recording) ===
"replay" => {
const VALID: &[&str] = &["start", "stop", "status"];
match rest.first().copied() {
Some("start") => Ok(json!({ "id": id, "action": "replay_start" })),
Some("stop") => {
let path = rest.get(1).unwrap_or(&"/tmp/replay");
Ok(json!({ "id": id, "action": "replay_stop", "path": path }))
}
Some("status") => Ok(json!({ "id": id, "action": "replay_status" })),
Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(),
valid_options: VALID,
}),
None => Err(ParseError::MissingArguments {
context: "replay".to_string(),
usage: "replay <start|stop|status> [path]",
}),
}
}
"console" => {
let clear = rest.contains(&"--clear");
Ok(json!({ "id": id, "action": "console", "clear": clear }))
Expand Down
29 changes: 29 additions & 0 deletions cli/src/native/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ use super::network::{self, DomainFilter, EventTracker};
use super::policy::{ActionPolicy, ConfirmActions, PolicyResult};
use super::providers;
use super::recording::{self, RecordingState};
use super::replay::{self, ReplayState};
use super::screenshot::{self, ScreenshotOptions};
use super::snapshot::{self, SnapshotOptions};
use super::state;
Expand Down Expand Up @@ -179,6 +180,7 @@ pub struct DaemonState {
pub session_id: String,
pub tracing_state: TracingState,
pub recording_state: RecordingState,
pub replay_state: ReplayState,
event_rx: Option<broadcast::Receiver<CdpEvent>>,
pub screencasting: bool,
pub policy: Option<ActionPolicy>,
Expand Down Expand Up @@ -236,6 +238,7 @@ impl DaemonState {
session_id: env::var("AGENT_BROWSER_SESSION").unwrap_or_else(|_| "default".to_string()),
tracing_state: TracingState::new(),
recording_state: RecordingState::new(),
replay_state: ReplayState::new(),
event_rx: None,
screencasting: false,
policy: ActionPolicy::load_if_exists(),
Expand Down Expand Up @@ -1131,6 +1134,9 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
"recording_start" => handle_recording_start(cmd, state).await,
"recording_stop" => handle_recording_stop(state).await,
"recording_restart" => handle_recording_restart(cmd, state).await,
"replay_start" => handle_replay_start(state).await,
"replay_stop" => handle_replay_stop(cmd, state).await,
"replay_status" => handle_replay_status(state).await,
"pdf" => handle_pdf(cmd, state).await,
"tab_list" => handle_tab_list(state).await,
"tab_new" => handle_tab_new(cmd, state).await,
Expand Down Expand Up @@ -3746,6 +3752,29 @@ async fn handle_recording_restart(cmd: &Value, state: &mut DaemonState) -> Resul
Ok(result)
}

// ---------------------------------------------------------------------------
// Replay (rrweb DOM recording)
// ---------------------------------------------------------------------------

async fn handle_replay_start(state: &mut DaemonState) -> Result<Value, String> {
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
replay::replay_start(&mut state.replay_state, mgr).await
}

async fn handle_replay_stop(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
let path = cmd
.get("path")
.and_then(|v| v.as_str())
.unwrap_or("/tmp/replay");
replay::replay_stop(&mut state.replay_state, mgr, path).await
}

async fn handle_replay_status(state: &DaemonState) -> Result<Value, String> {
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
replay::replay_status(&state.replay_state, mgr).await
}

async fn handle_pdf(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
let session_id = mgr.active_session_id()?.to_string();
Expand Down
2 changes: 2 additions & 0 deletions cli/src/native/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ pub mod providers;
#[allow(dead_code)]
pub mod recording;
#[allow(dead_code)]
pub mod replay;
#[allow(dead_code)]
pub mod screenshot;
#[allow(dead_code)]
pub mod snapshot;
Expand Down
Loading