diff --git a/CHANGELOG.md b/CHANGELOG.md
index 59285eb..ac91e79 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,9 @@
# Changelog
## Unreleased
+- Fixed a task tab with a "Leave site?" prompt stalling the extension. Chrome's `tabs.remove` waits until the tab is gone, and a page's beforeunload prompt, which a background tab shows only once activated, can hold the tab open indefinitely. `browser_finish` and task close waited on that removal inside the ownership queue, so every later ownership change, `browser_open`, `browser_tabs`, tab event, and native reconnect stalled behind it until the extension was reloaded. Task cleanup still removes ownership before closing tabs, now waits at most two seconds for Chrome to close them, and leaves a tab still waiting on its prompt out of both `closed_tab_ids` and `retained_tab_ids`; the tab closes if the user confirms leaving. A `browser_act` `close` waits the same two seconds and then returns `close_pending: true` instead of holding its request open.
+- No single extension operation can stall the operations queued behind it any more. An ownership change or attention-notice change still running after 10 seconds, or a background tab or alarm event after 30 seconds, keeps running and is logged, but the next operation starts. Ten seconds covers the two-second tab removal wait with room for normal Chrome calls and stays well under the host's 30-second command deadline, so a command queued behind a stuck operation can still answer.
+- `browser_finish` and task close now report an extension that does not answer in time as `extension_timeout`, telling the agent that the task is still open and to reload the extension if it stays unresponsive, then finish or close again. They previously reported `state_store_error` and suggested repairing `~/.agenttab` or freeing disk space. When the extension refuses cleanup, such as for a `keep_tab_ids` entry the task does not own, the error now carries the extension's own code and message instead of `native_protocol_error`. Disconnects and protocol or transport failures during cleanup keep their own error codes too.
- `scripts/reload_unpacked_extension.sh` now reaches the Chrome instance and profile that have the extension. It previously handed the wake page to whichever same-named Chrome instance macOS picked, so a second Chrome (such as a headless test browser) could receive it, nothing reloaded, and `dev:deploy` still reported success. The script now finds the profiles whose preferences list the extension in the application's default user data directory, opens the wake page in each through Chrome's own instance handoff, and exits with an error when no profile has the extension. Use `--user-data-dir` or `--profile-directory` (or `AGENTTAB_CHROME_USER_DATA_DIR` and `AGENTTAB_CHROME_PROFILE_DIRECTORY`) to override the detection.
- `context_click` no longer opens Chrome's native context menu. On macOS that menu stalled every AgentTab request until someone dismissed it. Page `contextmenu` handlers still run, though they see the event as already handled. The suppression applies only to that one right click and expires on its own after five seconds.
- Snapshot refs now survive a service worker restart or extension reload. The extension stores each snapshot's refs before returning it, drops them when their tab closes or navigates to a newer page revision, and clears them when Chrome starts. A ref still resolves only while its page document is the same one the snapshot saw. A ref the extension never stored fails with `stale_ref`, saying it is unknown or expired and asking for a new snapshot.
diff --git a/docs/mcp.md b/docs/mcp.md
index e5edce1..8772cc7 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -64,14 +64,14 @@ For MCP, the capability store namespace is `mcp`; OMP uses `omp`; Pi uses `pi`.
|---|---|
| `browser_open` | `mode: "create"` optionally accepts an `http`, `https`, or `about` URL, `background`, and `placement`. The default `placement: "task"` creates a tab in the task's existing window when possible. `placement: "new_window"` creates the first tab of an otherwise empty task in a separate unfocused normal window and rejects `background: false`. `mode: "adopt_active"` explicitly adopts only the currently active tab. The result includes task, tab, window, page-revision, and `automation_route` identifiers. It returns after the tab's first load completes, up to 10 seconds, so its `page_revision` and `url` describe the loaded page and can be passed straight to `browser_act`. |
| `browser_snapshot` | Requires `tab_id`. Modes are `accessibility`, `text`, `html`, and `screenshot`; each may scope to an opaque CDP `frame_id`. Only accessibility snapshots return revisioned node references, each bound to its originating frame; full-tree accessibility snapshots also append `dom_fallback` textbox nodes for editable elements that never entered Chrome's accessibility tree, so those refs still address the existing typed actions. Screenshot requests may select `png`, `jpeg`, or `webp`, set JPEG/WebP `quality`, bound dimensions with `max_width`/`max_height`, and cap compressed image size with `max_bytes`. Snapshots require the `full` automation route. |
-| `browser_act` | Requires `tab_id`, `expected_page_revision`, and one to 64 typed actions. Actions are click, hover, double-click, context-click, press, type, fill, select, scroll, drag, navigate, history movement, reload, close, dialog decision, and staged file upload. Element-target actions accept exactly one `ref` or `selector` and optional `frame_id`; `press` accepts the Standard navigation/editing keys plus optional unique `Alt`, `Control`, `Meta`, and `Shift` modifiers. Lowercase `a`, `z`, and `y` require exactly one of Control or Meta, optionally Shift, never Alt. No coordinate action, generic key injection, clipboard shortcut, or cross-frame target is exposed in Standard mode. When a step fails, the error `details` carry `failed_action_index` (zero-based) and `completed_actions`, the results of the steps that ran first; in a batch of more than one action, the message also names the failing step. A failure after at least one completed step has `outcome: "unknown"`, because those steps already changed the page. An optional `intent`, one line of 1 to 120 characters, replaces the agent pointer's generated caption for every action in the batch, for example "Open the bookmarks folder". It is visible on screen, so it must never carry secrets or field values. |
+| `browser_act` | Requires `tab_id`, `expected_page_revision`, and one to 64 typed actions. Actions are click, hover, double-click, context-click, press, type, fill, select, scroll, drag, navigate, history movement, reload, close, dialog decision, and staged file upload. Element-target actions accept exactly one `ref` or `selector` and optional `frame_id`; `press` accepts the Standard navigation/editing keys plus optional unique `Alt`, `Control`, `Meta`, and `Shift` modifiers. Lowercase `a`, `z`, and `y` require exactly one of Control or Meta, optionally Shift, never Alt. No coordinate action, generic key injection, clipboard shortcut, or cross-frame target is exposed in Standard mode. When a step fails, the error `details` carry `failed_action_index` (zero-based) and `completed_actions`, the results of the steps that ran first; in a batch of more than one action, the message also names the failing step. A failure after at least one completed step has `outcome: "unknown"`, because those steps already changed the page. A `close` releases the tab from the task and returns `completed: true` once Chrome closes it, or `started: true, close_pending: true` when the tab is still open two seconds later, as when a "Leave site?" prompt holds it; the tab then closes if the user confirms leaving. An optional `intent`, one line of 1 to 120 characters, replaces the agent pointer's generated caption for every action in the batch, for example "Open the bookmarks folder". It is visible on screen, so it must never carry secrets or field values. |
| `browser_wait` | Requires `tab_id` and one load, URL, text, selector, value, network-idle, download, or `challenge_cleared` condition. Selector waits may set `attached`, `detached`, `visible`, `hidden`, or `enabled` state and scope to a `frame_id`; value waits require selector and expected value and may scope to a frame. A download wait may resume after only its opaque tab-owned `after` cursor. A `challenge_cleared` wait takes the `challenge_id` of an active challenge episode on the tab and completes with `matched: true` once the episode clears or is superseded; if the episode reports it no longer expects automatic clearing, the wait returns early with outcome `needs_user` and result `status: "challenge_needs_user"`; an unknown, ended, or other-task episode fails with `challenge_not_found` and outcome `not_started`, and a timeout carries the latest event in `error.details.challenge`. `timeout_ms` is at most 120 seconds. Load and URL waits also follow Chrome's top-frame navigation events, with polling as a fallback: a load condition matches a top-frame load that completes after the wait starts, and a URL condition also matches committed navigations, same-document history changes (`pushState` and `replaceState`), and fragment changes seen during the wait. A top-frame navigation error during a load or URL wait fails with `navigation_failed`, `outcome: "unknown"`, and Chrome's network error name, never the URL; a superseded or cancelled navigation does not fail the wait. A `tab_only` route accepts load and URL conditions only; frame-scoped reads, value/selector state, network-idle, and download attribution, and challenge waits require the tab-scoped debugger connection available on the `full` route. |
| `browser_tabs` | Takes an empty object and lists only the current task's tabs, including each tab's `automation_route`. |
| `browser_handoff` | Takes one of four operations. `request` requires a task tab, expected page revision, and prompt; optional `completion: {kind: "url"\|"selector"\|"challenge_cleared", value}` and `timeout_ms` (default five minutes, reminder expiry only). It creates a `notice_id` scoped to the task and tab and returns immediately without pausing, focusing, or blocking any agent work. `status`, `resolve`, and `dismiss` take that `notice_id`. `status` also reports `user_idle_state` as `active`, `idle`, or `locked` from Chrome's idle detection, where `idle` means no keyboard or mouse input for 60 seconds; the field is omitted when Chrome cannot report it. The agent tells the user in chat, verifies the page itself, then resolves or dismisses; a `resolve` against an unmet condition fails with `completion_not_met` and leaves the notice open. Works on a `tab_only` route, but selector and `challenge_cleared` completion require the `full` route. A `challenge_cleared` completion takes the `challenge_id` of an active challenge episode on the request's tab; an unknown, ended, or other-task episode, or one on another tab, fails with `challenge_not_found` and outcome `not_started` and creates no notice. The notice then binds to the episode, so each later challenge event carries its `notice_id`. `resolve` rescans the tab first and succeeds only when the episode has cleared or been superseded, returning the terminal event as `challenge`; otherwise it fails with `completion_not_met`, `error.details.challenge` holds the current active event, and the notice stays open. |
| `browser_commit` | Requires the staged token returned by a prior `commit_required` action and executes that one staged operation. On a `tab_only` route, only a staged close can execute; page-dependent staged actions require the `full` route. A `browser_profile` bookmark removal stage is bound to the task rather than a tab. |
| `browser_credentials` | `prepare` requires a task tab and expected page revision, then returns an opaque short-lived token when the default-enabled 1Password broker is available and one through three Login items match the host-derived current origin. Owner-only policy can disable it. `fill` consumes that token and selected username, password, or one-time-code field refs without returning any value. `next` advances to another bounded candidate. It never submits the form. |
| `browser_profile` | Requires one `operation` over the Chrome profile rather than a task tab: bookmark search, tree, update, create, move, and remove; history search; recently closed and synced sessions, plus restore; download search, cancel, pause, and resume. Read operations take no idempotency key and write operations require one. `bookmarks_remove` always returns `commit_required`. Never focuses Chrome. See [Profile operations](#profile-operations). |
-| `browser_finish` | Accepts `disposition: "auto" | "close" | "keep"` and optional task-owned `keep_tab_ids`. Automatic mode follows the popup cleanup policy: close task-created tabs while retaining adopted tabs, ask for confirmation, or retain all tabs. Successful finalization ungroups retained tabs, releases ownership, closes the Core connection, and returns closed and retained tab IDs. An open handoff notice never defers finalization; staged Commit review and other in-flight work defer it without destroying resumability. |
+| `browser_finish` | Accepts `disposition: "auto" | "close" | "keep"` and optional task-owned `keep_tab_ids`. Automatic mode follows the popup cleanup policy: close task-created tabs while retaining adopted tabs, ask for confirmation, or retain all tabs. Successful finalization ungroups retained tabs, releases ownership, closes the Core connection, and returns closed and retained tab IDs. A tab Chrome has not closed two seconds after removal, as when a "Leave site?" prompt holds it, appears in neither list and closes if the user confirms leaving. An open handoff notice never defers finalization; staged Commit review and other in-flight work defer it without destroying resumability. When the extension refuses to finish, such as for a `keep_tab_ids` entry the task does not own, the error carries the extension's own code and message, and the task stays open. |
For `click`, `hover`, `double_click`, `context_click`, `press`, `type`, `fill`, and `upload_file`, supply exactly one of `ref` or `selector`; `frame_id` scopes selector resolution when supplied. A CSS selector is limited to 1–2048 characters and must match exactly one element; missing and ambiguous targets fail before mutation. The same task ownership, revision, sensitive-field, and Commit checks apply to either target form. Before Commit, the selector is resolved again and checked against the staged element identity. Execute against that checked element, never a later selector match. Upload selectors now reject duplicate matches, including duplicates introduced between staging and Commit.
A dialog action can provide `prompt_text` only when accepting a JavaScript prompt; the exact payload is included in review binding. Only click, press, double-click, context-click, navigation, history, and reload actions may set `expect_download: true`, which is also review-visible and fingerprint-bound. A successful opted-in action may return an opaque `download_cursor`; pass it only as `browser_wait` download `after`, never to another task or tab. AgentTab reports completion metadata, not a local download path. Gesture dispatch, site acceptance, and external side effects are not proven by accepting or staging a request.
diff --git a/host-rs/crates/agenttab-host/src/native.rs b/host-rs/crates/agenttab-host/src/native.rs
index 714c18f..ec7d8cb 100644
--- a/host-rs/crates/agenttab-host/src/native.rs
+++ b/host-rs/crates/agenttab-host/src/native.rs
@@ -30,6 +30,9 @@ pub enum NativeError {
Protocol(String),
#[error("native transport error: {0}")]
Transport(String),
+ /// The extension answered a lifecycle command with its own failure, such as a refused finish.
+ #[error("extension error {}: {}", .0.code, .0.message)]
+ Extension(RpcError),
}
#[derive(Debug, Clone)]
@@ -382,20 +385,23 @@ impl NativeTransport for StdioNative {
}
Err(mpsc::RecvTimeoutError::Disconnected) => return Err(NativeError::Disconnected),
};
+ if response.outcome != Outcome::Completed {
+ return Err(lifecycle_failure(
+ response,
+ "native close_task cleanup was not confirmed by the extension",
+ ));
+ }
let result = response.result.as_ref().and_then(Value::as_object);
let expected_task_id = task_id.to_string();
let closed_tab_ids_are_valid = result
.and_then(|value| value.get("closed_tab_ids"))
.and_then(Value::as_array)
.is_some_and(|tab_ids| tab_ids.iter().all(Value::is_u64));
- if response.outcome != Outcome::Completed
- || result.is_none_or(|value| {
- value.len() != 2
- || value.get("task_id").and_then(Value::as_str)
- != Some(expected_task_id.as_str())
- || !closed_tab_ids_are_valid
- })
- {
+ if result.is_none_or(|value| {
+ value.len() != 2
+ || value.get("task_id").and_then(Value::as_str) != Some(expected_task_id.as_str())
+ || !closed_tab_ids_are_valid
+ }) {
return Err(NativeError::Protocol(
"native close_task cleanup was not confirmed by the extension".into(),
));
@@ -436,8 +442,9 @@ impl NativeTransport for StdioNative {
Err(mpsc::RecvTimeoutError::Disconnected) => return Err(NativeError::Disconnected),
};
if response.outcome != Outcome::Completed {
- return Err(NativeError::Protocol(
- "native finish_task cleanup was not confirmed by the extension".into(),
+ return Err(lifecycle_failure(
+ response,
+ "native finish_task cleanup was not confirmed by the extension",
));
}
let result: NativeTaskFinish =
@@ -480,6 +487,15 @@ impl NativeTransport for StdioNative {
}
}
+/// Keeps the extension's own error for a failed lifecycle command, so the caller sees why the
+/// extension refused, and reports a protocol error only when the failure carries none.
+fn lifecycle_failure(response: NativeResponse, unconfirmed: &str) -> NativeError {
+ response.error.map_or_else(
+ || NativeError::Protocol(unconfirmed.into()),
+ NativeError::Extension,
+ )
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -627,6 +643,68 @@ mod tests {
assert_eq!(acknowledgement["outcome"], "completed");
}
+ #[test]
+ fn finish_refusal_keeps_the_extension_error() {
+ let lifecycle = Arc::new(Lifecycle::default());
+ lifecycle.begin_reconciliation();
+ lifecycle.complete_reconciliation(false);
+ let output = SharedWriter::default();
+ let native = StdioNative::new(output.clone(), lifecycle);
+ native.disconnected.store(false, Ordering::Release);
+ let task_id = Uuid::now_v7();
+ let finishing = {
+ let native = native.clone();
+ std::thread::spawn(move || {
+ native.finish_task(
+ task_id,
+ FinishDisposition::Auto,
+ &[99],
+ Duration::from_secs(5),
+ )
+ })
+ };
+
+ let deadline = std::time::Instant::now() + Duration::from_secs(1);
+ let command = loop {
+ let bytes = output.bytes.lock().clone();
+ if let Ok(Some(command)) =
+ read_frame(&mut bytes.as_slice(), HOST_TO_EXTENSION_MAX_BYTES)
+ {
+ break command;
+ }
+ assert!(
+ std::time::Instant::now() < deadline,
+ "finish_task was never sent to the extension"
+ );
+ std::thread::sleep(Duration::from_millis(5));
+ };
+ assert_eq!(command["kind"], "finish_task");
+ native
+ .handle_inbound(json!({
+ "protocol": NATIVE_PROTOCOL,
+ "version": PROTOCOL_VERSION,
+ "kind": "response",
+ "request_id": command["request_id"],
+ "outcome": "unknown",
+ "error": {
+ "code": "ownership_denied",
+ "message": format!("Tab 99 is not owned by task {task_id}"),
+ },
+ }))
+ .unwrap();
+
+ match finishing.join().unwrap() {
+ Err(NativeError::Extension(refusal)) => {
+ assert_eq!(refusal.code, "ownership_denied");
+ assert_eq!(
+ refusal.message,
+ format!("Tab 99 is not owned by task {task_id}")
+ );
+ }
+ other => panic!("expected the extension's refusal, got {other:?}"),
+ }
+ }
+
#[test]
fn version_mismatch_is_terminal() {
let lifecycle = Arc::new(Lifecycle::default());
diff --git a/host-rs/crates/agenttab-host/src/runtime.rs b/host-rs/crates/agenttab-host/src/runtime.rs
index d71364d..77a3a65 100644
--- a/host-rs/crates/agenttab-host/src/runtime.rs
+++ b/host-rs/crates/agenttab-host/src/runtime.rs
@@ -502,37 +502,41 @@ impl Runtime {
disposition,
keep_tab_ids,
}),
- ) => self
- .native
- .finish_task(task_id, disposition, &keep_tab_ids, CLOSE_TASK_TIMEOUT)
- .map_err(|error| JournalError::NativeTaskCleanup(error.to_string()))
- .and_then(|result| {
- if result.finished {
- self.journal.close_task(task_id)?;
- connection.cancel();
- self.native.cancel_connection(connection.connection_id);
+ ) => match self.native.finish_task(
+ task_id,
+ disposition,
+ &keep_tab_ids,
+ CLOSE_TASK_TIMEOUT,
+ ) {
+ Err(error) => native_cleanup_failure(&request.request_id, error),
+ Ok(result) => {
+ let finalized = if result.finished {
+ self.journal.close_task(task_id).map(|()| {
+ connection.cancel();
+ self.native.cancel_connection(connection.connection_id);
+ })
+ } else {
+ Ok(())
+ };
+ match finalized {
+ Ok(()) => RpcResponse::success(
+ request.request_id.clone(),
+ if result.finished {
+ Outcome::Completed
+ } else {
+ Outcome::NeedsUser
+ },
+ serde_json::to_value(result)
+ .expect("native task finish result serializes"),
+ ),
+ Err(error) => RpcResponse::failure(
+ request.request_id.clone(),
+ Outcome::Unknown,
+ journal_rpc_error(error),
+ ),
}
- Ok(result)
- })
- .map(|result| {
- RpcResponse::success(
- request.request_id.clone(),
- if result.finished {
- Outcome::Completed
- } else {
- Outcome::NeedsUser
- },
- serde_json::to_value(result)
- .expect("native task finish result serializes"),
- )
- })
- .unwrap_or_else(|error| {
- RpcResponse::failure(
- request.request_id.clone(),
- Outcome::Unknown,
- journal_rpc_error(error),
- )
- }),
+ }
+ },
(None, MethodParams::Finish(_)) => RpcResponse::success(
request.request_id.clone(),
Outcome::Completed,
@@ -559,27 +563,25 @@ impl Runtime {
if request.method == RpcMethod::AgenttabClose {
let task_id = connection.task_id().ok().flatten();
let response = match task_id {
- Some(task_id) => self
- .native
- .close_task(task_id, CLOSE_TASK_TIMEOUT)
- .map_err(|error| JournalError::NativeTaskCleanup(error.to_string()))
- .and_then(|()| self.journal.close_task(task_id))
- .map(|()| {
- connection.cancel();
- self.native.cancel_connection(connection.connection_id);
- RpcResponse::success(
- request.request_id.clone(),
- Outcome::Completed,
- json!({ "closed": true }),
- )
- })
- .unwrap_or_else(|error| {
- RpcResponse::failure(
+ Some(task_id) => match self.native.close_task(task_id, CLOSE_TASK_TIMEOUT) {
+ Err(error) => native_cleanup_failure(&request.request_id, error),
+ Ok(()) => match self.journal.close_task(task_id) {
+ Ok(()) => {
+ connection.cancel();
+ self.native.cancel_connection(connection.connection_id);
+ RpcResponse::success(
+ request.request_id.clone(),
+ Outcome::Completed,
+ json!({ "closed": true }),
+ )
+ }
+ Err(error) => RpcResponse::failure(
request.request_id.clone(),
Outcome::Unknown,
journal_rpc_error(error),
- )
- }),
+ ),
+ },
+ },
None => RpcResponse::success(
request.request_id.clone(),
Outcome::Completed,
@@ -1825,6 +1827,9 @@ fn native_event_result(response: RpcResponse) -> NativeEventResult {
fn native_failure(request_id: &str, error: NativeError) -> RpcResponse {
let (code, recovery) = match error {
+ NativeError::Extension(refusal) => {
+ return RpcResponse::failure(request_id, Outcome::Unknown, refusal);
+ }
NativeError::Disconnected => (
"extension_disconnected",
"Wait for Chrome to reconnect AgentTab, inspect task state, then retry safely.",
@@ -1849,6 +1854,41 @@ fn native_failure(request_id: &str, error: NativeError) -> RpcResponse {
RpcError::new(code, error.to_string()).with_recovery(recovery),
)
}
+
+/// Maps a failed native `finish_task` or `close_task` command. The journal still lists the
+/// task as open, so the recovery is to finish or close it again once the extension answers.
+fn native_cleanup_failure(request_id: &str, error: NativeError) -> RpcResponse {
+ let (code, recovery) = match error {
+ // The extension refused the cleanup itself; its code says why, so keep it.
+ NativeError::Extension(mut refusal) => {
+ refusal.recovery.get_or_insert_with(|| {
+ "The task is still open. Resolve the extension's error, then finish or close the task again.".into()
+ });
+ return RpcResponse::failure(request_id, Outcome::Unknown, refusal);
+ }
+ NativeError::Disconnected => (
+ "extension_disconnected",
+ "The task is still open. Wait for Chrome to reconnect AgentTab, then finish or close the task again.",
+ ),
+ NativeError::Timeout => (
+ "extension_timeout",
+ "The task is still open. If the AgentTab extension stays unresponsive, reload it, then finish or close the task again.",
+ ),
+ NativeError::Protocol(_) => (
+ "native_protocol_error",
+ "The task is still open. Check the finish arguments, update AgentTab if the host and extension versions differ, then finish or close the task again.",
+ ),
+ NativeError::Transport(_) => (
+ "native_transport_error",
+ "The task is still open. Repair the AgentTab native host connection, then finish or close the task again.",
+ ),
+ };
+ RpcResponse::failure(
+ request_id,
+ Outcome::Unknown,
+ RpcError::new(code, error.to_string()).with_recovery(recovery),
+ )
+}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) enum RequestLockScope {
Global,
@@ -1982,6 +2022,7 @@ mod tests {
last_params: Mutex