diff --git a/crates/bsk-cli/src/cli/doctor.rs b/crates/bsk-cli/src/cli/doctor.rs index 3d0c57a9..ce020edc 100644 --- a/crates/bsk-cli/src/cli/doctor.rs +++ b/crates/bsk-cli/src/cli/doctor.rs @@ -281,6 +281,19 @@ fn check_skill_up_to_date() -> CheckResult { ); } + if !report.preserved.is_empty() { + let names = report + .preserved + .iter() + .map(|h| h.cli_name()) + .collect::>() + .join(", "); + return CheckResult::na( + name, + format!("custom or edited skill preserved in: {names}"), + ); + } + CheckResult::na(name, "no agent skill installed") } diff --git a/crates/bsk-cli/src/cli/install_skill.rs b/crates/bsk-cli/src/cli/install_skill.rs index 96675a42..cc441408 100644 --- a/crates/bsk-cli/src/cli/install_skill.rs +++ b/crates/bsk-cli/src/cli/install_skill.rs @@ -11,7 +11,7 @@ use serde::Serialize; use crate::cli::error::CliError; use crate::cli::status::Output; use crate::skill_install::{ - InstallOptions, all_harness_reports, + InstallOptions, InstallSourceKind, all_harness_reports, harness::{HarnessId, parse_harness_id}, load_source, print_harness_table, run_interactive_prompt, }; @@ -56,11 +56,17 @@ pub fn dispatch(args: InstallSkillArgs, output: Output) -> Result<(), CliError> } let harnesses = resolve_targets(&args, &reports).map_err(CliError::Local)?; + let source_kind = if args.source.is_some() { + InstallSourceKind::Custom + } else { + InstallSourceKind::Bundled + }; let source = load_source(args.source.as_deref()).map_err(CliError::Local)?; let install_output = crate::skill_install::install_to_harnesses(&InstallOptions { harnesses: &harnesses, source: &source, + source_kind, force: args.force, home: None, }); diff --git a/crates/bsk-cli/src/daemon/start.rs b/crates/bsk-cli/src/daemon/start.rs index 582cb4c8..cbce6e25 100644 --- a/crates/bsk-cli/src/daemon/start.rs +++ b/crates/bsk-cli/src/daemon/start.rs @@ -282,6 +282,12 @@ pub fn run_foreground(cfg: DaemonConfig) -> Result<()> { if !report.up_to_date.is_empty() { debug!(count = report.up_to_date.len(), "skill already up to date"); } + if !report.preserved.is_empty() { + debug!( + count = report.preserved.len(), + "custom or edited skill preserved" + ); + } } Ok(None) => { /* home_dir() already warned inside the closure */ } Err(join_err) => { diff --git a/crates/bsk-cli/src/skill_install/mod.rs b/crates/bsk-cli/src/skill_install/mod.rs index b52432e3..3c60b742 100644 --- a/crates/bsk-cli/src/skill_install/mod.rs +++ b/crates/bsk-cli/src/skill_install/mod.rs @@ -10,11 +10,30 @@ use anyhow::{Context, Result, bail}; use console::{Style, style}; use dialoguer::{MultiSelect, theme::ColorfulTheme}; use serde::Serialize; +use sha2::{Digest, Sha256}; pub use harness::{HarnessId, HarnessReport, all_harness_reports, parse_harness_id}; pub const SKILL_DIR_NAME: &str = "browser-skill"; pub const DEFAULT_SKILL_MD: &str = include_str!("../../skill/SKILL.md"); +pub const SOURCE_MARKER_FILE: &str = ".bsk-source"; +pub const SOURCE_CUSTOM: &str = "custom\n"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InstallSourceKind { + Bundled, + Custom, +} + +pub(crate) fn bundled_source_marker(source: &str) -> String { + let digest = Sha256::digest(source.as_bytes()); + let mut hex = String::with_capacity(digest.len() * 2); + for byte in digest { + use std::fmt::Write as _; + let _ = write!(hex, "{byte:02x}"); + } + format!("bundled:{hex}\n") +} #[derive(Debug, Clone, Serialize)] pub struct InstallResult { @@ -74,6 +93,7 @@ pub struct InstallError { pub struct InstallOptions<'a> { pub harnesses: &'a [HarnessId], pub source: &'a str, + pub source_kind: InstallSourceKind, pub force: bool, /// When `Some`, installs under this home instead of the real `$HOME`. pub home: Option<&'a Path>, @@ -103,7 +123,7 @@ pub fn install_to_harnesses_at_home(home: &Path, opts: &InstallOptions<'_>) -> I let mut errors = Vec::new(); for harness in opts.harnesses { - match install_one_at_home(home, *harness, opts.source, opts.force) { + match install_one_at_home(home, *harness, opts.source, opts.source_kind, opts.force) { Ok((path, status)) => results.push(InstallResult { harness: harness.cli_name().to_string(), path, @@ -123,6 +143,7 @@ fn install_one_at_home( home: &Path, harness: HarnessId, source: &str, + source_kind: InstallSourceKind, force: bool, ) -> Result<(PathBuf, InstallStatus)> { let dest_dir = harness.skill_dest_dir_for_home(home); @@ -134,7 +155,18 @@ fn install_one_at_home( let existed = dest_file.exists(); fs::create_dir_all(&dest_dir).with_context(|| format!("create {}", dest_dir.display()))?; + let marker = dest_dir.join(SOURCE_MARKER_FILE); + // Mark custom ownership before replacing the skill. If the process + // stops between these writes, automatic sync fails safe and preserves + // the previous file instead of treating custom content as managed. + if source_kind == InstallSourceKind::Custom { + fs::write(&marker, SOURCE_CUSTOM).with_context(|| format!("write {}", marker.display()))?; + } fs::write(&dest_file, source).with_context(|| format!("write {}", dest_file.display()))?; + if source_kind == InstallSourceKind::Bundled { + fs::write(&marker, bundled_source_marker(source)) + .with_context(|| format!("write {}", marker.display()))?; + } let status = if existed { InstallStatus::Updated @@ -310,6 +342,7 @@ mod tests { &InstallOptions { harnesses: &[harness], source: "# test skill\n", + source_kind: InstallSourceKind::Custom, force: false, home: Some(&home), }, @@ -317,6 +350,10 @@ mod tests { assert!(out.errors.is_empty()); assert_eq!(out.results.len(), 1); assert!(skills.join(SKILL_DIR_NAME).join("SKILL.md").is_file()); + assert_eq!( + fs::read_to_string(skills.join(SKILL_DIR_NAME).join(SOURCE_MARKER_FILE)).unwrap(), + SOURCE_CUSTOM + ); } #[test] @@ -336,6 +373,7 @@ mod tests { &InstallOptions { harnesses: &[harness], source: "new", + source_kind: InstallSourceKind::Custom, force: false, home: Some(&home), }, @@ -361,12 +399,69 @@ mod tests { &InstallOptions { harnesses: &[harness], source: "new", + source_kind: InstallSourceKind::Custom, force: true, home: Some(&home), }, ); assert_eq!(out.results[0].status, InstallStatus::Updated); assert_eq!(fs::read_to_string(&dest).unwrap(), "new"); + assert_eq!( + fs::read_to_string(dest.parent().unwrap().join(SOURCE_MARKER_FILE)).unwrap(), + SOURCE_CUSTOM + ); + } + + #[test] + fn bundled_install_records_the_written_content_hash() { + let tmp = TempDir::new().unwrap(); + let home = tmp.path().to_path_buf(); + let harness = HarnessId::Cursor; + + let out = install_to_harnesses_at_home( + &home, + &InstallOptions { + harnesses: &[harness], + source: DEFAULT_SKILL_MD, + source_kind: InstallSourceKind::Bundled, + force: false, + home: Some(&home), + }, + ); + + assert!(out.errors.is_empty()); + let marker = harness + .skill_dest_dir_for_home(&home) + .join(SOURCE_MARKER_FILE); + assert_eq!( + fs::read_to_string(marker).unwrap(), + bundled_source_marker(DEFAULT_SKILL_MD) + ); + } + + #[test] + fn custom_install_survives_automatic_bundled_sync() { + let tmp = TempDir::new().unwrap(); + let home = tmp.path().to_path_buf(); + let harness = HarnessId::Cursor; + let dest = harness.skill_dest_dir_for_home(&home).join("SKILL.md"); + + let out = install_to_harnesses_at_home( + &home, + &InstallOptions { + harnesses: &[harness], + source: "custom instructions", + source_kind: InstallSourceKind::Custom, + force: false, + home: Some(&home), + }, + ); + assert!(out.errors.is_empty()); + + let report = sync::sync_with_source(&home, "new bundled instructions"); + + assert_eq!(report.preserved, vec![HarnessId::Cursor]); + assert_eq!(fs::read_to_string(dest).unwrap(), "custom instructions"); } #[test] diff --git a/crates/bsk-cli/src/skill_install/sync.rs b/crates/bsk-cli/src/skill_install/sync.rs index dbd7e68b..e33b9a0d 100644 --- a/crates/bsk-cli/src/skill_install/sync.rs +++ b/crates/bsk-cli/src/skill_install/sync.rs @@ -3,7 +3,44 @@ use std::path::Path; -use super::{DEFAULT_SKILL_MD, harness::HarnessId}; +use super::{ + DEFAULT_SKILL_MD, SOURCE_CUSTOM, SOURCE_MARKER_FILE, bundled_source_marker, harness::HarnessId, +}; + +/// SHA-256 hex digests of `SKILL.md` content that `bsk` shipped in +/// earlier releases, before `.bsk-source` markers existed. When a +/// marker-less installed `SKILL.md` matches one of these +/// fingerprints (or the current bundle), the daemon recognises it +/// as a managed bundled install and rewrites it to the current +/// bundle in the same sync pass. Without this list, anyone who +/// installed before markers landed would be silently preserved as +/// "unknown" forever, frozen on the version they first installed. +const HISTORICAL_BUNDLED_FINGERPRINTS: &[&str] = &[ + // Initial public release + "28d39215d1a6f658d55a41b8ecc2c6692bed628093824442927581f4a3e1bc5a", + // feat(record): add popup quick-actions launcher and make record --url optional + "875affb61f68c62712e038bb2a3a54efeaa20974aeab516aaaacd9605e25aa5a", + // fix(record): use example.com as the default record start URL + "c3d933f6390199b9a250e6fc66e441c0817fbceb16403d760606ff9bcd3c25ba", + // fix(runtime): fix early control return and update lifecycle + "735bd85400e725c33ebb1146bf55d1f997fd4e7f6b785da52fe600a5cd81d4ca", + // fix(vom): bug fix + "352d51bb05f38f71a88b2bfe87fb39295e2a3165bb7e56ff505e68b25336b581", + // feat(request-help): add BSK_REQUEST_HELP=off to disable blocking help requests + "f93715bfa657922e0753793552f69aa7077dc06109466a9770f1016881e7d962", + // feat: support Agent Window sizing + "df897c39d725123e3fd8884b3ab911fa2c21694844c16cde56899d4e3b55033b", + // feat(emulate): add mobile device emulation via CDP Emulation domain + "7556a415ec6144b5aa45afa4218687bb3fbda48936748c63e093504f6be13ab0", + // feat(extension): add bsk hover and adjust vom to support + "d5eb9c2e826828ee380c76c24afffb593ef13b8fb7d6a596df71a719e7b1d83a", + // feat(update): auto-upgrade bsk when daemon finds a newer version (#77) + "4d357816676a989cc28f930951f4d1259d1655c00af85d4c0c429eca398d243d", + // docs(skill): list bsk console and bsk network in SKILL.md (#84) + "cfb4934ff5647b3d9220c287bfde4f813948dc562dc5915e1ce3362a9e631b5c", + // feat(session): support unfocused agent windows (--no-focus) (#87) + "a4c6a616f2b52a23f66de700fe7c41f11e854cd51997c85f03f3f3a22a1b7279", +]; /// Per-harness outcome of a sync pass. #[derive(Debug, Clone, Default, PartialEq, Eq)] @@ -13,6 +50,12 @@ pub struct SyncReport { /// Harnesses whose on-disk `SKILL.md` already matched the bundled /// content; no write happened, mtime preserved. pub up_to_date: Vec, + /// Harnesses whose skill is custom, manually edited, or otherwise + /// marker-less installations we don't recognise. Automatic sync + /// preserves them; legacy bundled installs whose content matches a + /// known historical fingerprint are upgraded instead (see + /// `HISTORICAL_BUNDLED_FINGERPRINTS`). + pub preserved: Vec, /// Harnesses that have an installed `SKILL.md` but the sync attempt /// failed with an I/O error. The string is a human-readable detail. pub errors: Vec<(HarnessId, String)>, @@ -33,6 +76,7 @@ pub(crate) fn sync_with_source(home: &Path, source: &str) -> SyncReport { SyncOne::Missing => continue, SyncOne::UpToDate => report.up_to_date.push(harness), SyncOne::Updated => report.updated.push(harness), + SyncOne::Preserved => report.preserved.push(harness), SyncOne::Error(msg) => report.errors.push((harness, msg)), } } @@ -43,6 +87,7 @@ enum SyncOne { Missing, UpToDate, Updated, + Preserved, Error(String), } @@ -54,27 +99,72 @@ fn sync_one(dest: &Path, source: &str) -> SyncOne { Ok(s) => s, Err(err) => return SyncOne::Error(format!("read {}: {err}", dest.display())), }; + let marker = dest + .parent() + .expect("SKILL.md destination must have a parent") + .join(SOURCE_MARKER_FILE); + + // Atomic replace + marker refresh. Used for both stale-managed + // upgrades and legacy bundled adoptions. Returns Err with a + // human-readable detail on any I/O failure. + let atomic_replace = |dest: &Path, source: &str, marker: &Path| -> Result<(), String> { + let tmp = dest.with_extension(format!("md.tmp.{}", std::process::id())); + std::fs::write(&tmp, source).map_err(|err| format!("write {}: {err}", tmp.display()))?; + // Best-effort cleanup if the rename below fails. + let rename_result = std::fs::rename(&tmp, dest) + .map_err(|err| format!("rename {} -> {}: {err}", tmp.display(), dest.display())); + if let Err(err) = rename_result { + let _ = std::fs::remove_file(&tmp); + return Err(err); + } + std::fs::write(marker, bundled_source_marker(source)) + .map_err(|err| format!("write {}: {err}", marker.display())) + }; + + let managed_marker = match std::fs::read_to_string(&marker) { + Ok(value) if value == SOURCE_CUSTOM => return SyncOne::Preserved, + Ok(value) if value.starts_with("bundled:") => value, + Ok(_) => return SyncOne::Preserved, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + // Marker-less file: adopt as managed when the content + // matches the current bundle or any historical bundled + // fingerprint. Without this list, anyone who installed + // before `.bsk-source` markers existed would never be + // auto-upgraded again. + let on_disk_marker = bundled_source_marker(&on_disk); + let is_known_bundle = on_disk == source + || HISTORICAL_BUNDLED_FINGERPRINTS + .iter() + .any(|fp| on_disk_marker == format!("bundled:{fp}\n")); + if !is_known_bundle { + return SyncOne::Preserved; + } + if on_disk == source { + if let Err(err) = std::fs::write(&marker, bundled_source_marker(source)) { + return SyncOne::Error(format!("write {}: {err}", marker.display())); + } + return SyncOne::UpToDate; + } + // Legacy bundled install: rewrite to current bundle. + return match atomic_replace(dest, source, &marker) { + Ok(()) => SyncOne::Updated, + Err(msg) => SyncOne::Error(msg), + }; + } + Err(err) => return SyncOne::Error(format!("read {}: {err}", marker.display())), + }; + if managed_marker != bundled_source_marker(&on_disk) { + return SyncOne::Preserved; + } if on_disk == source { return SyncOne::UpToDate; } // Atomic replace: write tmp, rename over. Including pid in the // tmp suffix avoids concurrent processes racing on the same path. - let tmp = dest.with_extension(format!("md.tmp.{}", std::process::id())); - if let Err(err) = std::fs::write(&tmp, source) { - // Best-effort cleanup of any partial tmp left by a half-written attempt. - let _ = std::fs::remove_file(&tmp); - return SyncOne::Error(format!("write {}: {err}", tmp.display())); + match atomic_replace(dest, source, &marker) { + Ok(()) => SyncOne::Updated, + Err(msg) => SyncOne::Error(msg), } - if let Err(err) = std::fs::rename(&tmp, dest) { - // Best-effort cleanup of the orphan tmp file. - let _ = std::fs::remove_file(&tmp); - return SyncOne::Error(format!( - "rename {} -> {}: {err}", - tmp.display(), - dest.display() - )); - } - SyncOne::Updated } #[cfg(test)] @@ -82,12 +172,21 @@ mod tests { use super::*; use tempfile::TempDir; + fn mark_bundled(dest: &Path, content: &str) { + std::fs::write( + dest.parent().unwrap().join(SOURCE_MARKER_FILE), + bundled_source_marker(content), + ) + .unwrap(); + } + #[test] fn sync_skips_uninstalled_harness() { let tmp = TempDir::new().unwrap(); let report = sync_with_source(tmp.path(), "anything"); assert!(report.updated.is_empty()); assert!(report.up_to_date.is_empty()); + assert!(report.preserved.is_empty()); assert!(report.errors.is_empty()); // Defensive: sync must not silently create files in harnesses that // never had the skill installed. This guards Task 2's real impl. @@ -108,6 +207,7 @@ mod tests { std::fs::create_dir_all(&dest_dir).unwrap(); let dest = dest_dir.join("SKILL.md"); std::fs::write(&dest, b"old content").unwrap(); + mark_bundled(&dest, "old content"); let report = sync_with_source(home, "fresh content"); @@ -137,6 +237,7 @@ mod tests { std::fs::create_dir_all(&dest_dir).unwrap(); let dest = dest_dir.join("SKILL.md"); std::fs::write(&dest, "frozen content").unwrap(); + mark_bundled(&dest, "frozen content"); let mtime_before = std::fs::metadata(&dest).unwrap().modified().unwrap(); // Sleep enough that any rewrite would visibly change mtime on @@ -167,12 +268,14 @@ mod tests { let cursor_dir = HarnessId::Cursor.skill_dest_dir_for_home(home); std::fs::create_dir_all(&cursor_dir).unwrap(); std::fs::write(cursor_dir.join("SKILL.md"), "old").unwrap(); + mark_bundled(&cursor_dir.join("SKILL.md"), "old"); // Codex: parent dir set to r-x. Reads still succeed, but creating // SKILL.md.tmp fails → exercises sync_one's write-tmp error branch. let codex_dir = HarnessId::Codex.skill_dest_dir_for_home(home); std::fs::create_dir_all(&codex_dir).unwrap(); std::fs::write(codex_dir.join("SKILL.md"), "old").unwrap(); + mark_bundled(&codex_dir.join("SKILL.md"), "old"); let mut perms = std::fs::metadata(&codex_dir).unwrap().permissions(); perms.set_mode(0o500); // r-x: blocks tmp creation in this dir std::fs::set_permissions(&codex_dir, perms).unwrap(); @@ -188,4 +291,105 @@ mod tests { assert_eq!(report.errors.len(), 1); assert_eq!(report.errors[0].0, HarnessId::Codex); } + + #[test] + fn sync_preserves_custom_edited_and_unknown_skills() { + let tmp = TempDir::new().unwrap(); + let home = tmp.path(); + + let custom_dir = HarnessId::Cursor.skill_dest_dir_for_home(home); + std::fs::create_dir_all(&custom_dir).unwrap(); + std::fs::write(custom_dir.join("SKILL.md"), "custom content").unwrap(); + std::fs::write(custom_dir.join(SOURCE_MARKER_FILE), SOURCE_CUSTOM).unwrap(); + + let edited_dir = HarnessId::Codex.skill_dest_dir_for_home(home); + std::fs::create_dir_all(&edited_dir).unwrap(); + std::fs::write(edited_dir.join("SKILL.md"), "managed then edited").unwrap(); + mark_bundled(&edited_dir.join("SKILL.md"), "original managed content"); + + let unknown_dir = HarnessId::ClaudeCode.skill_dest_dir_for_home(home); + std::fs::create_dir_all(&unknown_dir).unwrap(); + std::fs::write(unknown_dir.join("SKILL.md"), "historical content").unwrap(); + + let report = sync_with_source(home, "new bundled content"); + + assert_eq!( + report.preserved, + vec![HarnessId::Codex, HarnessId::ClaudeCode, HarnessId::Cursor] + ); + assert_eq!( + std::fs::read_to_string(custom_dir.join("SKILL.md")).unwrap(), + "custom content" + ); + assert_eq!( + std::fs::read_to_string(edited_dir.join("SKILL.md")).unwrap(), + "managed then edited" + ); + assert_eq!( + std::fs::read_to_string(unknown_dir.join("SKILL.md")).unwrap(), + "historical content" + ); + } + + #[test] + fn sync_adopts_markerless_current_bundle_then_updates_it() { + let tmp = TempDir::new().unwrap(); + let home = tmp.path(); + let dest_dir = HarnessId::Cursor.skill_dest_dir_for_home(home); + std::fs::create_dir_all(&dest_dir).unwrap(); + let dest = dest_dir.join("SKILL.md"); + std::fs::write(&dest, "current bundle").unwrap(); + + let first = sync_with_source(home, "current bundle"); + assert_eq!(first.up_to_date, vec![HarnessId::Cursor]); + assert_eq!( + std::fs::read_to_string(dest_dir.join(SOURCE_MARKER_FILE)).unwrap(), + bundled_source_marker("current bundle") + ); + + let second = sync_with_source(home, "next bundle"); + assert_eq!(second.updated, vec![HarnessId::Cursor]); + assert_eq!(std::fs::read_to_string(dest).unwrap(), "next bundle"); + } + + #[test] + fn sync_adopts_markerless_legacy_bundle_and_upgrades_it() { + // Historical bundled SKILL.md content from the initial public + // release (sha256 28d39215...d4a3e1bc5a, listed in + // HISTORICAL_BUNDLED_FINGERPRINTS). A user with this exact + // on-disk content and no marker would, before this fix, be + // silently preserved as "unknown" forever once the bundled + // SKILL.md changed. + const LEGACY_SKILL_MD: &str = "---\nname: browser-skill\ndescription: |\n Use when the user asks to perform browser automation tasks against their\n logged-in browser: visit and read pages, fill forms, scrape data, click\n through a flow, regression-test a PR's UI, validate a deployed page.\n Requires the bsk CLI installed and the browser-skill extension loaded.\n---\n\n# browser-skill\n\nDrive the user's **real Chromium browser** (with their logins and cookies) through the `bsk` CLI. The extension opens an isolated **Agent Window** for automation; the user's normal windows stay protected unless you explicitly borrow a tab.\n\n## When to use\n\n- Open pages, read titles/text, scrape structured data from sites the user can already access\n- Fill forms, click through multi-step flows, smoke-test a UI change\n- Understand pages with `bsk snapshot` first; use `bsk get-html` or `bsk screenshot` only when the snapshot is insufficient\n- Operate on a specific user tab they point you at (after `bsk tab borrow`)\n\n## When NOT to use\n\n- Tasks with **no browser** involved (files, APIs, databases only)\n- Installing or configuring the extension (point the user to setup docs instead)\n- **Credential harvesting** — never run `bsk evaluate` on banking, SSO, or password-manager pages to extract tokens, cookies, or secrets\n- Long-lived control of a user's personal login window — borrow only for the immediate step, then `bsk tab return` or end the session\n- Replacing the user's manual browsing when they only wanted an explanation\n\n## Prerequisites\n\n1. `bsk` on `PATH` (Rust CLI from browser-skill)\n2. browser-skill **extension** loaded in Chromium and connected (popup shows green)\n3. Any `bsk` command auto-starts background services as needed; use `bsk doctor` if anything fails\n\n## Mandatory workflow\n\nEvery automation task **must** follow this lifecycle. Do **not** rely on idle timeouts (default session idle is 5 minutes).\n\n```\n1. bsk session start → capture the 4-letter session id printed on stdout\n2. … every tool command … → always pass --session \n3. bsk session stop → REQUIRED when done (even on error paths)\n```\n\nOptional: `bsk session start --browser ` when multiple browsers are connected (`bsk browsers` / error output lists them).\n\nEmergency cleanup: `bsk session stop --all` or the Agent Window overlay **Stop all**.\n\n## Core interaction loop\n\nWrite operations only affect tabs in the **Agent Window** (or tabs you **borrowed** into it).\n\n```\nbsk navigate --session \nbsk snapshot --session → aria tree with @e1, @e2, … refs\nbsk click @e3 --session → or bsk fill, bsk select, bsk press\nbsk snapshot --session → again after navigation / DOM change\n```\n\n**Refs invalidate after navigation** — always re-snapshot before clicking, filling, or selecting on a new page.\n\nPrefer `@eN` refs from the latest snapshot over raw CSS selectors. Use `--ref` / `--selector` when ambiguous (`bsk click --help`).\n\n## Observation priority\n\nStart with `bsk snapshot` to understand page structure, text, controls, and element refs. Only escalate when the latest snapshot cannot answer the question:\n\n1. `bsk snapshot` — default for page understanding and interaction planning\n2. `bsk get-html` — when hidden DOM, metadata, or markup details are required\n3. `bsk screenshot` — when visual layout, canvas/image content, or styling cannot be inferred from the snapshot. Use `--ref @eN` (from the latest snapshot) to crop to one element; omit `--ref` for the full visible tab.\n\nDo **not** call `bsk get-html` or `bsk screenshot` first just to inspect a page.\n\n## Sandbox rules\n\n| Rule | Detail |\n|------|--------|\n| Agent Window | `bsk tab create`, `bsk navigate`, `bsk click`, etc. work on agent tabs by default |\n| User tabs | Read-only until borrowed: `bsk tab list --session --scope user` then `bsk tab borrow --session ` |\n| Return borrowed tabs | Call `bsk tab return --session ` when finished; unreturned tabs are **auto-returned** on `bsk session stop` |\n| Writes off-agent | Commands that mutate the page fail if the tab is not in the Agent Window — borrow or create a tab first |\n\n## Global flags\n\n| Flag | Purpose |\n|------|---------|\n| `--json` | Machine-readable JSON on stdout (errors too) |\n| `--quiet` | Suppress informational stderr |\n| `-v` / `-vv` | More verbose logging |\n\nCommand-specific flags (timeouts, `--tab-id`, `--wait-until`, …): **`bsk --help`**\n\n## CLI command reference (one line each)\n\nDetails and flags: **`bsk --help`**\n\n### Diagnostics\n\n| Command | Summary |\n|---------|---------|\n| `bsk status` | Connection health, connected browsers, active sessions |\n| `bsk doctor` | Deep diagnostics and repair hints |\n| `bsk browsers` | List connected browser instances (ids, labels, versions) |\n\n### Session\n\n| Command | Summary |\n|---------|---------|\n| `bsk session start` | Open Agent Window; prints **4-letter session id** |\n| `bsk session stop ` | End session, close Agent Window, auto-return borrowed tabs |\n| `bsk session stop --all` | Stop every active session |\n| `bsk session list` | List active sessions |\n\n### Tabs (require `--session `)\n\n| Command | Summary |\n|---------|---------|\n| `bsk tab list` | List tabs (`--scope user\\|agent\\|all`, default `all`) |\n| `bsk tab create` | New tab in Agent Window (`--url`, `--no-active`, `--index`) |\n| `bsk tab close ` | Close an agent tab |\n| `bsk tab select ` | Focus an agent tab |\n| `bsk tab borrow ` | Move a user tab into the Agent Window |\n| `bsk tab return ` | Return a borrowed tab to its original window |\n\n### Observation (require `--session` unless noted)\n\n| Command | Summary |\n|---------|---------|\n| `bsk snapshot` | First-choice page understanding: accessibility tree with `@eN` element refs |\n| `bsk get-html` | Raw HTML dump after snapshot is insufficient (high token cost) |\n| `bsk screenshot` | PNG capture after snapshot is insufficient: full visible tab, or `--ref @eN` to crop to one element (`--out` path optional) |\n\n### Navigation\n\n| Command | Summary |\n|---------|---------|\n| `bsk navigate ` | Go to URL in agent tab (`--wait-until`, `--timeout`) |\n| `bsk navigate-back` | History back one step |\n| `bsk navigate-forward` | History forward one step |\n| `bsk reload` | Reload current tab (`--hard` bypass cache) |\n\n(`bsk navigate back` / `bsk navigate forward` are equivalent subcommands.)\n\n### Interaction\n\n| Command | Summary |\n|---------|---------|\n| `bsk click ` | Click element (`--button`, `--click-count`, `--modifiers`) |\n| `bsk fill --value ` | Clear and type into input |\n| `bsk select --value ` | Set `