diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..a7d54f5
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,92 @@
+# Changelog
+
+All notable changes to BrowserSkill will be documented in this file.
+The format is based on [Keep a Changelog](https://keepachangelog.com/).
+
+Starting from 0.2.0, CLI / Extension / DSH Plugin share the same version number.
+
+## [Unreleased]
+
+## [0.2.0] - 2026-09-02
+
+### Added
+- File transfer: upload and download support across CLI, Extension, and DSH Plugin
+- File transfer: drag-and-drop upload (`drop-to-upload`)
+- VOM semantic graph, name enrichment, and hover perception modules
+- VOM hover probing (opt-in via `observe` parameter)
+- DSH Plugin: browser tool parity with CLI commands
+- Edge Add-ons automated publishing in CI
+- Protocol upgrade reminder when CLI / Extension protocol versions differ
+- Browser evaluation harness (`evals/browser/`)
+- Unified release script (`scripts/release.mjs`)
+
+### Changed
+- **Version scheme**: all three components now share the same semver
+- VOM rendering algorithm optimizations
+- Leaner SKILL.md agent instructions
+- DSH Plugin: simplified browser commands
+- Borrow confirmation UX — proactive focus and longer timeout
+- PiP window now has a close button
+
+### Fixed
+- Screenshot media type detection (was hard-coded to `image/png`)
+- DSH Plugin session lifecycle stability
+- DSH Plugin Cordis package ID mismatch
+- Observation thumbnail media type sniffing
+- Upload/download race conditions and layout bypass issues
+- VOM repeated name and safety policy issues
+
+---
+
+*Previous releases used independent version numbers per component.*
+
+## CLI 0.1.11 / Extension 0.1.7 / DSH Plugin 0.1.2 — 2026-08-26 ~ 2026-08-29
+
+### Added
+- DSH Plugin sidebar integration, session lifecycle, and archive cleanup
+- Recorder iframe and OOPIF support
+
+### Changed
+- VOM functional refactor
+
+### Fixed
+- Recorder safety policy and bug fixes
+
+## CLI 0.1.10 / Extension 0.1.6 — 2026-08-08
+
+### Added
+- VOM observation recording and settled-state detection
+- Record overlay timer
+
+### Fixed
+- Browser keepalive disconnect handling
+
+## CLI 0.1.9 / Extension 0.1.5 — 2026-07-29
+
+### Added
+- CLI auto-update mechanism
+- Trace v3 protocol and recorder
+
+### Fixed
+- MV3 keepalive disconnect
+
+## CLI 0.1.8 / Extension 0.1.4 — 2026-07-22
+
+### Added
+- More browser interaction actions
+
+### Fixed
+- Windows named-pipe hash-only path issue
+
+## CLI 0.1.7 / Extension 0.1.3 — 2026-07-07
+
+Initial public release pair.
+
+## CLI 0.1.6 — 2026-06-30
+
+### Fixed
+- Minor CLI fixes
+
+## CLI 0.1.5 / Extension 0.1.2 — 2026-06-22
+
+First tagged releases.
diff --git a/Cargo.lock b/Cargo.lock
index eeb978b..d4ff61c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -134,7 +134,7 @@ dependencies = [
[[package]]
name = "bsk"
-version = "0.1.11"
+version = "0.2.0"
dependencies = [
"anyhow",
"base64",
@@ -169,7 +169,7 @@ dependencies = [
[[package]]
name = "bsk-protocol"
-version = "0.1.11"
+version = "0.2.0"
dependencies = [
"schemars",
"semver",
diff --git a/Cargo.toml b/Cargo.toml
index e123726..78ee9cd 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -7,7 +7,7 @@ members = [
default-members = ["crates/bsk-cli"]
[workspace.package]
-version = "0.1.11"
+version = "0.2.0"
edition = "2024"
rust-version = "1.85"
license = "MIT"
@@ -43,7 +43,7 @@ rand = "0.8"
uuid = { version = "1", features = ["v4", "serde"] }
# Workspace crates (version required for `cargo publish` of dependents)
-bsk-protocol = { version = "0.1.9", path = "crates/bsk-protocol" }
+bsk-protocol = { version = "0.2.0", path = "crates/bsk-protocol" }
[profile.release]
strip = "symbols"
diff --git a/apps/extension/package.json b/apps/extension/package.json
index 546a019..fdd9d4d 100644
--- a/apps/extension/package.json
+++ b/apps/extension/package.json
@@ -1,6 +1,6 @@
{
"name": "@browser-skill/extension",
- "version": "0.1.7",
+ "version": "0.2.0",
"private": true,
"type": "module",
"scripts": {
diff --git a/apps/extension/src/entrypoints/popup/App.test.tsx b/apps/extension/src/entrypoints/popup/App.test.tsx
index daec637..2d71235 100644
--- a/apps/extension/src/entrypoints/popup/App.test.tsx
+++ b/apps/extension/src/entrypoints/popup/App.test.tsx
@@ -122,8 +122,8 @@ describe("App", () => {
expect(screen.queryByText(/^扩展 v/)).toBeNull();
expect(screen.queryByText(/^daemon v/)).toBeNull();
- expect(screen.getByTitle("扩展版本").textContent).toBe(EXTENSION_VERSION);
- expect(screen.getByTitle("bsk 版本").textContent).toBe(mockDaemonVersion);
+ expect(screen.getByTitle("扩展版本").textContent).toBe(`Ext ${EXTENSION_VERSION}`);
+ expect(screen.getByTitle("bsk 版本").textContent).toBe(`CLI ${mockDaemonVersion}`);
expect(screen.getByText("03c3e47f")).toBeTruthy();
const copyButton = screen.getByRole("button", { name: "复制实例 ID" });
diff --git a/apps/extension/src/entrypoints/popup/App.tsx b/apps/extension/src/entrypoints/popup/App.tsx
index 0756e9c..cc9287b 100644
--- a/apps/extension/src/entrypoints/popup/App.tsx
+++ b/apps/extension/src/entrypoints/popup/App.tsx
@@ -267,9 +267,9 @@ export function App() {
data-slot="popup-meta"
>
- {extensionVersion}
+ CLI {daemonVersion}
/
- {daemonVersion}
+ Ext {extensionVersion}
{t("popup.instanceTitle")}
diff --git a/packages/dsh-plugin-browserskill/package.json b/packages/dsh-plugin-browserskill/package.json
index b1ebf92..427b435 100644
--- a/packages/dsh-plugin-browserskill/package.json
+++ b/packages/dsh-plugin-browserskill/package.json
@@ -1,7 +1,7 @@
{
"name": "@wxg-prc-cpg/browser-skill-dsh-plugin",
"description": "DeepSeek Harness tool plugin that exposes BrowserSkill browser automation (browser_* tools) to the model",
- "version": "0.1.2",
+ "version": "0.2.0",
"license": "MIT",
"publishConfig": {
"access": "public",
diff --git a/scripts/release.mjs b/scripts/release.mjs
new file mode 100755
index 0000000..f7a6e95
--- /dev/null
+++ b/scripts/release.mjs
@@ -0,0 +1,299 @@
+#!/usr/bin/env node
+/**
+ * Unified release script for BrowserSkill.
+ *
+ * Bumps CLI, Extension, and DSH Plugin to the same version, updates
+ * CHANGELOG.md, commits, tags, and optionally pushes.
+ *
+ * Usage:
+ * node scripts/release.mjs # bump + commit + tag
+ * node scripts/release.mjs --dry-run # preview changes only
+ * node scripts/release.mjs --push # also push to origin
+ * node scripts/release.mjs --push --only ext # push only ext tag
+ * node scripts/release.mjs --push --only cli,dsh # push only cli + dsh tags
+ * node scripts/release.mjs --push-tags --only ext # push tag(s) for an already-committed release
+ *
+ * Options:
+ * --dry-run Show what would change without writing anything
+ * --push After commit+tag, push the branch and all release tags
+ * --push-tags Push release tags only (skip version bump; useful for staged rollout)
+ * --only Comma-separated subset of: cli, ext, dsh (default: all three)
+ * --no-commit Write files but skip git commit and tagging
+ */
+
+import { execSync } from "node:child_process";
+import { readFileSync, writeFileSync } from "node:fs";
+import { parseArgs } from "node:util";
+
+const TAG_PREFIX = { cli: "cli-v", ext: "ext-v", dsh: "dsh-plugin-v" };
+const ALL_COMPONENTS = ["cli", "ext", "dsh"];
+
+const VERSION_FILES = {
+ cargo: "Cargo.toml",
+ extension: "apps/extension/package.json",
+ dshPlugin: "packages/dsh-plugin-browserskill/package.json",
+};
+
+// ---------------------------------------------------------------------------
+// CLI argument parsing
+// ---------------------------------------------------------------------------
+
+const { values, positionals } = parseArgs({
+ allowPositionals: true,
+ options: {
+ "dry-run": { type: "boolean", default: false },
+ push: { type: "boolean", default: false },
+ "push-tags": { type: "boolean", default: false },
+ "no-commit": { type: "boolean", default: false },
+ only: { type: "string", default: "" },
+ help: { type: "boolean", short: "h", default: false },
+ },
+});
+
+if (values.help) {
+ console.log(
+ readFileSync(new URL(import.meta.url), "utf8").match(/\/\*\*([\s\S]*?)\*\//)?.[1] ?? "",
+ );
+ process.exit(0);
+}
+
+const dryRun = values["dry-run"];
+const pushAfter = values.push;
+const pushTagsOnly = values["push-tags"];
+const noCommit = values["no-commit"];
+const onlyRaw = values.only;
+const components = onlyRaw ? onlyRaw.split(",").map((s) => s.trim().toLowerCase()) : ALL_COMPONENTS;
+
+for (const c of components) {
+ if (!ALL_COMPONENTS.includes(c)) {
+ console.error(`Unknown component "${c}". Valid: ${ALL_COMPONENTS.join(", ")}`);
+ process.exit(1);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Push-tags-only mode (no version bump)
+// ---------------------------------------------------------------------------
+
+if (pushTagsOnly) {
+ const currentVersion = readCurrentVersions();
+ const ver = positionals[0] ?? currentVersion.cargo;
+ for (const c of components) {
+ const tag = `${TAG_PREFIX[c]}${ver}`;
+ console.log(` pushing tag ${tag}`);
+ if (!dryRun) {
+ run(`git push origin ${tag}`);
+ }
+ }
+ console.log("\nDone.");
+ process.exit(0);
+}
+
+// ---------------------------------------------------------------------------
+// Normal mode — require version argument
+// ---------------------------------------------------------------------------
+
+const newVersion = positionals[0];
+if (!newVersion) {
+ console.error(
+ "Usage: node scripts/release.mjs [--dry-run] [--push] [--only cli,ext,dsh]",
+ );
+ process.exit(1);
+}
+
+if (!/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(newVersion)) {
+ console.error(`Invalid semver: "${newVersion}"`);
+ process.exit(1);
+}
+
+// ---------------------------------------------------------------------------
+// Read current versions
+// ---------------------------------------------------------------------------
+
+function readCurrentVersions() {
+ const cargo = readFileSync(VERSION_FILES.cargo, "utf8");
+ const cargoVer =
+ cargo.match(/^\[workspace\.package\]\s*\nversion\s*=\s*"([^"]+)"/m)?.[1] ??
+ cargo.match(/^version\s*=\s*"([^"]+)"/m)?.[1];
+
+ const extPkg = JSON.parse(readFileSync(VERSION_FILES.extension, "utf8"));
+ const dshPkg = JSON.parse(readFileSync(VERSION_FILES.dshPlugin, "utf8"));
+
+ return { cargo: cargoVer, extension: extPkg.version, dshPlugin: dshPkg.version };
+}
+
+const current = readCurrentVersions();
+
+console.log("Current versions:");
+console.log(` CLI/Daemon (Cargo.toml): ${current.cargo}`);
+console.log(` Extension (package.json): ${current.extension}`);
+console.log(` DSH Plugin (package.json): ${current.dshPlugin}`);
+console.log(`\nBumping all → ${newVersion}`);
+if (dryRun) console.log(" (dry-run mode — no files will be written)\n");
+
+// ---------------------------------------------------------------------------
+// Bump Cargo.toml
+// ---------------------------------------------------------------------------
+
+function bumpCargo(version) {
+ let content = readFileSync(VERSION_FILES.cargo, "utf8");
+
+ // workspace.package version
+ content = content.replace(
+ /^(\[workspace\.package\]\s*\nversion\s*=\s*")([^"]+)(")/m,
+ `$1${version}$3`,
+ );
+
+ // workspace.dependencies.bsk-protocol version (for cargo publish)
+ content = content.replace(
+ /(bsk-protocol\s*=\s*\{\s*version\s*=\s*")([^"]+)(")/,
+ `$1${version}$3`,
+ );
+
+ return content;
+}
+
+function bumpPackageJson(filePath, version) {
+ const pkg = JSON.parse(readFileSync(filePath, "utf8"));
+ pkg.version = version;
+ return JSON.stringify(pkg, null, 2) + "\n";
+}
+
+// ---------------------------------------------------------------------------
+// Update CHANGELOG.md
+// ---------------------------------------------------------------------------
+
+function updateChangelog(version) {
+ const changelogPath = "CHANGELOG.md";
+ let content;
+ try {
+ content = readFileSync(changelogPath, "utf8");
+ } catch {
+ return null;
+ }
+
+ const today = new Date().toISOString().slice(0, 10);
+ const escapedVer = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+
+ // First: fill in any date-placeholder line for this version (e.g. "## [0.2.0] - 2026-09-XX")
+ const versionPlaceholder = new RegExp(`^## \\[${escapedVer}\\]\\s*-\\s*\\d{4}-\\d{2}-XX`, "m");
+ if (versionPlaceholder.test(content)) {
+ content = content.replace(versionPlaceholder, `## [${version}] - ${today}`);
+ } else {
+ // Otherwise: insert a new section after [Unreleased]
+ const unreleased = /^## \[Unreleased\]/m;
+ if (unreleased.test(content)) {
+ content = content.replace(unreleased, `## [Unreleased]\n\n## [${version}] - ${today}`);
+ }
+ }
+
+ return { path: changelogPath, content };
+}
+
+// ---------------------------------------------------------------------------
+// Write files
+// ---------------------------------------------------------------------------
+
+const writes = [
+ { path: VERSION_FILES.cargo, content: bumpCargo(newVersion), label: "Cargo.toml" },
+ {
+ path: VERSION_FILES.extension,
+ content: bumpPackageJson(VERSION_FILES.extension, newVersion),
+ label: "Extension package.json",
+ },
+ {
+ path: VERSION_FILES.dshPlugin,
+ content: bumpPackageJson(VERSION_FILES.dshPlugin, newVersion),
+ label: "DSH Plugin package.json",
+ },
+];
+
+const changelogUpdate = updateChangelog(newVersion);
+if (changelogUpdate) {
+ writes.push({
+ path: changelogUpdate.path,
+ content: changelogUpdate.content,
+ label: "CHANGELOG.md",
+ });
+}
+
+for (const w of writes) {
+ console.log(` ✓ ${w.label}`);
+ if (!dryRun) {
+ writeFileSync(w.path, w.content);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Update Cargo.lock (cargo check triggers it)
+// ---------------------------------------------------------------------------
+
+if (!dryRun) {
+ console.log("\n Updating Cargo.lock...");
+ run("cargo check --workspace 2>&1 || true");
+}
+
+// ---------------------------------------------------------------------------
+// Git commit + tag
+// ---------------------------------------------------------------------------
+
+if (noCommit || dryRun) {
+ if (dryRun) {
+ console.log(`\nWould commit: "chore(release): ${newVersion}"`);
+ console.log("Would create tags:");
+ for (const c of components) {
+ console.log(` ${TAG_PREFIX[c]}${newVersion}`);
+ }
+ }
+ console.log("\nDone.");
+ process.exit(0);
+}
+
+console.log("\nCommitting...");
+run(
+ "git add Cargo.toml Cargo.lock apps/extension/package.json packages/dsh-plugin-browserskill/package.json CHANGELOG.md 2>/dev/null || true",
+);
+run(`git commit -m "chore(release): ${newVersion}"`);
+
+console.log("Tagging...");
+for (const c of components) {
+ const tag = `${TAG_PREFIX[c]}${newVersion}`;
+ run(`git tag ${tag}`);
+ console.log(` ✓ ${tag}`);
+}
+
+// ---------------------------------------------------------------------------
+// Push (optional)
+// ---------------------------------------------------------------------------
+
+if (pushAfter) {
+ console.log("\nPushing...");
+ run("git push origin HEAD");
+ for (const c of components) {
+ const tag = `${TAG_PREFIX[c]}${newVersion}`;
+ console.log(` pushing tag ${tag}`);
+ run(`git push origin ${tag}`);
+ }
+}
+
+console.log(`\nRelease ${newVersion} prepared successfully.`);
+if (!pushAfter) {
+ console.log("\nNext steps:");
+ console.log(" # Push branch");
+ console.log(" git push origin main");
+ console.log("");
+ console.log(" # Push extension tag first (store review takes time)");
+ console.log(` git push origin ext-v${newVersion}`);
+ console.log("");
+ console.log(" # Then push CLI and DSH plugin tags");
+ console.log(` git push origin cli-v${newVersion}`);
+ console.log(` git push origin dsh-plugin-v${newVersion}`);
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function run(cmd) {
+ execSync(cmd, { stdio: "inherit" });
+}