diff --git a/.github/workflows/release-dsh-plugin.yml b/.github/workflows/release-dsh-plugin.yml new file mode 100644 index 0000000..69ae0bd --- /dev/null +++ b/.github/workflows/release-dsh-plugin.yml @@ -0,0 +1,105 @@ +name: Release dsh plugin + +on: + push: + tags: + - dsh-plugin-v* + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: release-dsh-plugin-${{ github.ref }} + cancel-in-progress: false + +env: + PACKAGE_NAME: "@wxg-prc-cpg/browser-skill-dsh-plugin" + PACKAGE_DIR: packages/dsh-plugin-browserskill + +jobs: + resolve: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + tag: ${{ steps.version.outputs.tag }} + steps: + - uses: actions/checkout@v6 + + - name: Resolve plugin version + id: version + shell: bash + run: | + set -euo pipefail + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION="$(node -p "require('./${{ env.PACKAGE_DIR }}/package.json').version")" + else + VERSION="${GITHUB_REF_NAME#dsh-plugin-v}" + fi + VERSION="${VERSION#v}" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "tag=dsh-plugin-v${VERSION}" >> "$GITHUB_OUTPUT" + + guard: + needs: resolve + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Verify tag matches package.json name and version + shell: bash + run: | + set -euo pipefail + PKG_NAME="$(node -p "require('./${{ env.PACKAGE_DIR }}/package.json').name")" + PKG_VERSION="$(node -p "require('./${{ env.PACKAGE_DIR }}/package.json').version")" + RELEASE_VERSION="${{ needs.resolve.outputs.version }}" + if [ "$PKG_NAME" != "$PACKAGE_NAME" ]; then + echo "package.json name (${PKG_NAME}) does not match expected ${PACKAGE_NAME}" + exit 1 + fi + if [ "$PKG_VERSION" != "$RELEASE_VERSION" ]; then + echo "${PACKAGE_DIR}/package.json version (${PKG_VERSION}) does not match release version (${RELEASE_VERSION})" + exit 1 + fi + echo "Version guard passed: ${PKG_NAME}@${RELEASE_VERSION}" + + publish: + needs: [resolve, guard] + runs-on: ubuntu-latest + # Secret NPM_TOKEN is stored on the GitHub Environment also named NPM_TOKEN. + environment: NPM_TOKEN + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm + registry-url: https://registry.npmjs.org + scope: "@wxg-prc-cpg" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Typecheck plugin + run: pnpm --filter "${{ env.PACKAGE_NAME }}" typecheck + + - name: Test plugin + run: pnpm --filter "${{ env.PACKAGE_NAME }}" test + + - name: Publish to npm + working-directory: ${{ env.PACKAGE_DIR }} + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + set -euo pipefail + if [ -z "${NODE_AUTH_TOKEN:-}" ]; then + echo "::error::Environment secret NPM_TOKEN is not configured" + exit 1 + fi + pnpm publish --access public --no-git-checks + echo "Published ${PACKAGE_NAME}@${{ needs.resolve.outputs.version }}" diff --git a/.stylelintignore b/.stylelintignore index adfcf64..b96eb12 100644 --- a/.stylelintignore +++ b/.stylelintignore @@ -3,3 +3,6 @@ **/.output/** **/target/** **/node_modules/** +# Generated client CSS (tailwind compile output); built by +# packages/dsh-plugin-browserskill/scripts/build-client-css.mjs. +**/dsh-plugin-browserskill/src/client/bsk-ui.nomodule.css diff --git a/README.md b/README.md index 6b91ae6..16d755b 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@

**BrowserSkill** connects Cursor, Claude Code, Codex, OpenClaw, CodeBuddy, -WorkBuddy, Pi, Hermes Agent, and other shell-capable AI agents to your already logged-in +WorkBuddy, Pi, Hermes Agent, DeepSeek Harness, and other AI agents to your already logged-in browser. Need the agent to touch a tab you already have open? It must borrow that tab @@ -123,7 +123,8 @@ internal variants and install paths. Other shell-capable agent harnesses are supported too. Copy [`skill/SKILL.md`](skill/SKILL.md) into your harness's skills directory as -`browser-skill/SKILL.md` to install the skill manually. +`browser-skill/SKILL.md` to install the skill manually. DeepSeek Harness uses a +dedicated plugin instead — see [DeepSeek Harness plugin](#deepseek-harness-plugin). @@ -133,6 +134,16 @@ Start a new Agent session and write a prompt that needs the browser, for example /browser-skill open example.com and summarize what is on the page. ``` +## DeepSeek Harness plugin + +A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) plugin that +injects native `browser_*` tools (no shelling out to `bsk`) and a live Web UI +overlay of each Agent Window. + +```sh +dsh plugin --profile web add @wxg-prc-cpg/browser-skill-dsh-plugin +``` + ## How It Works BrowserSkill is a local bridge between your agent harness and your browser. @@ -166,7 +177,9 @@ flowchart TB The agent never talks to the browser directly. It asks the `bsk` CLI to perform a browser task; the local daemon routes that request to the extension; the -extension runs it in an Agent Window. +extension runs it in an Agent Window. DeepSeek Harness takes the same path +through the [plugin](#deepseek-harness-plugin): the agent calls injected +`browser_*` tools, and the plugin invokes `bsk` on its behalf. ## For Developers @@ -176,6 +189,7 @@ The repository is a Cargo + pnpm workspace: - `crates/bsk-protocol` — shared wire types and JSON schemas - `apps/extension` — browser extension - `packages/ui` and `packages/i18n` — shared extension UI support +- `packages/dsh-plugin-browserskill` — DeepSeek Harness plugin (`@wxg-prc-cpg/browser-skill-dsh-plugin`) ## License diff --git a/README.zh-CN.md b/README.zh-CN.md index 7c0c5d4..0ca6f81 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -12,7 +12,7 @@ English · 中文

-**BrowserSkill** 把 Cursor、Claude Code、Codex、OpenClaw、CodeBuddy、WorkBuddy、Pi、Hermes Agent 等支持 Shell 的 AI Agent 连接到你已登录的浏览器。 +**BrowserSkill** 把 Cursor、Claude Code、Codex、OpenClaw、CodeBuddy、WorkBuddy、Pi、Hermes Agent、DeepSeek Harness 等 AI Agent 连接到你已登录的浏览器。 需要 Agent 操作你已打开的标签页?必须显式借用该标签,任务结束后归还,其余浏览器窗口不受影响。 @@ -102,7 +102,7 @@ bsk install-skill 用 Space 选择需要安装的 Agent harness,然后按 Enter 安装 skill。运行 `bsk install-skill --list` 可查看 internal 变体及安装路径。 -其他支持 Shell 的 Agent harness 也可使用 BrowserSkill,但需手动将 [`skill/SKILL.md`](skill/SKILL.md) 复制到对应 skills 目录下的 `browser-skill/SKILL.md`。 +其他支持 Shell 的 Agent harness 也可使用 BrowserSkill,但需手动将 [`skill/SKILL.md`](skill/SKILL.md) 复制到对应 skills 目录下的 `browser-skill/SKILL.md`。DeepSeek Harness 走独立插件,见 [DeepSeek Harness 插件](#deepseek-harness-插件)。 @@ -112,6 +112,14 @@ bsk install-skill /browser-skill open example.com and summarize what is on the page. ``` +## DeepSeek Harness 插件 + +[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 插件:注入原生 `browser_*` 工具(无需再通过 Shell 调用 `bsk`),并在 Web UI 中实时观察每个 Agent Window。 + +```sh +dsh plugin --profile web add @wxg-prc-cpg/browser-skill-dsh-plugin +``` + ## 工作原理 BrowserSkill 是 Agent 运行时与浏览器之间的本地桥接层。 @@ -143,7 +151,7 @@ flowchart TB style UserWindows fill:#f8fafc,stroke:#cbd5e1,color:#334155 ``` -Agent 不直接与浏览器通信。它通过 `bsk` CLI 下发浏览器任务;本地 daemon 把请求路由到扩展;扩展在 Agent Window 中执行。 +Agent 不直接与浏览器通信。它通过 `bsk` CLI 下发浏览器任务;本地 daemon 把请求路由到扩展;扩展在 Agent Window 中执行。DeepSeek Harness 走同一条链路,只是经由 [插件](#deepseek-harness-插件):Agent 调用注入的 `browser_*` 工具,由插件代为执行 `bsk`。 ## 面向开发者 @@ -153,6 +161,7 @@ Agent 不直接与浏览器通信。它通过 `bsk` CLI 下发浏览器任务; - `crates/bsk-protocol` — 共享协议类型与 JSON Schema - `apps/extension` — 浏览器扩展 - `packages/ui` 和 `packages/i18n` — 扩展 UI 共享支持 +- `packages/dsh-plugin-browserskill` — DeepSeek Harness 插件(`@wxg-prc-cpg/browser-skill-dsh-plugin`) ## 许可证 diff --git a/biome.json b/biome.json index f8e3994..50401a9 100644 --- a/biome.json +++ b/biome.json @@ -39,6 +39,11 @@ "**", "!**/node_modules/**", "!**/dist/**", + "!packages/dsh-plugin-browserskill/lib/**", + "!packages/dsh-plugin-browserskill/src/skill-content.generated.ts", + "!packages/dsh-plugin-browserskill/src/client/bsk-ui.nomodule.css", + "!packages/dsh-plugin-browserskill/.bsk-ui.raw.css", + "!packages/dsh-plugin-browserskill/.bsk-ui.input.css", "!**/target/**", "!**/.wxt/**", "!**/.output/**", diff --git a/package.json b/package.json index 9c72f35..214c278 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "ext:dev": "pnpm --filter @browser-skill/extension dev", "ext:test": "pnpm --filter @browser-skill/extension test", "vom:test": "pnpm --filter @browser-skill/vom test", - "lint": "biome check . && stylelint \"**/*.css\"", + "lint": "biome check . && stylelint \"**/*.css\" && pnpm --filter @wxg-prc-cpg/browser-skill-dsh-plugin typecheck && pnpm --filter @wxg-prc-cpg/browser-skill-dsh-plugin test", "format": "biome format --write ." }, "devDependencies": { diff --git a/packages/dsh-plugin-browserskill/.gitignore b/packages/dsh-plugin-browserskill/.gitignore new file mode 100644 index 0000000..e1e2da4 --- /dev/null +++ b/packages/dsh-plugin-browserskill/.gitignore @@ -0,0 +1,5 @@ +lib/ +src/client/bsk-ui.nomodule.css +.bsk-ui.input.css +.bsk-ui.raw.css +src/skill-content.generated.ts diff --git a/packages/dsh-plugin-browserskill/LICENSE b/packages/dsh-plugin-browserskill/LICENSE new file mode 100644 index 0000000..0d72eb2 --- /dev/null +++ b/packages/dsh-plugin-browserskill/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Tencent + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/dsh-plugin-browserskill/README.md b/packages/dsh-plugin-browserskill/README.md new file mode 100644 index 0000000..592bdad --- /dev/null +++ b/packages/dsh-plugin-browserskill/README.md @@ -0,0 +1,194 @@ +# dsh-plugin-browserskill + +npm: [`@wxg-prc-cpg/browser-skill-dsh-plugin`](https://www.npmjs.com/package/@wxg-prc-cpg/browser-skill-dsh-plugin) + +A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh) tool plugin that exposes +[BrowserSkill](https://github.com/Tencent/BrowserSkill) (`bsk`) browser automation to the model. + +Each tool maps to one `bsk --json` invocation: the plugin spawns the bsk CLI, parses its +structured JSON output, and returns a canonical typed value. The bsk daemon, browser, and Chrome +extension keep owning the actual browser control — this package is a thin, well-typed bridge. + +## Tools + +| Tool | bsk command | Purpose | +| --- | --- | --- | +| `browser_session_start` | `bsk session start` | Open an Agent Window session; optional initial URL, window size, and device emulation preset. Returns the session id and makes it the current session. | +| `browser_session_stop` | `bsk session stop` | Stop a session (the current one by default) and close its Agent Window. Only plugin-created sessions can be stopped. | +| `browser_session_list` | — (registry only) | List the sessions this plugin created, marking the current one. Foreign daemon sessions are never shown. | +| `browser_navigate` | `bsk navigate` | Navigate the active tab, with `waitUntil` / timeout control. | +| `browser_snapshot` | `bsk snapshot` | Indented aria-tree snapshot with `@eN` refs for interaction tools. | +| `browser_observe` | `bsk observe` | Semantic VOM observation (read-only) with `@eN` refs. | +| `browser_click` | `bsk click` | Click a snapshot ref or CSS selector (button / click-count options). | +| `browser_fill` | `bsk fill` | Fill an input / textarea / contenteditable (clears first by default). | +| `browser_press` | `bsk press` | Dispatch a key or combo, optionally focusing a target first. | +| `browser_screenshot` | `bsk screenshot` | PNG capture of the tab or a ref-cropped element; inlines the image when the deployment supports image input, otherwise returns a file path. | +| `browser_emulate` | `bsk emulate` | Apply or clear mobile device emulation on the active tab. | + +## Agent skill (progressive disclosure) + +Beyond the tools, the plugin publishes the **`browser-skill` agent skill** through the harness's +official skill seam (`ctx.skills.register`): the catalog entry (name + routing description) is +resident in ``, and the body is loaded only when the model invokes the `skill` +tool. The body is assembled at build time from two parts, so there is exactly one source of +truth: a dsh-specific prelude (`skill/prelude.md` — tool↔CLI map, owned-session semantics, +plugin-only overrides) followed verbatim by the canonical CLI skill (`skill/SKILL.md` at the +repo root — the same file `crates/bsk-cli/build.rs` mirrors for the CLI package; workflows, +stop-when-done rules, refs usage, sandbox rules). Registration and every pre-step catalog +snapshot are pure in-memory reads (no disk/process/daemon); compositions without the skill +seam degrade silently. + +## Multi-session model + +One agent conversation can drive several browser sessions at once: + +- `browser_session_start` returns the session id and makes it the **current session**. +- Every operation tool accepts an optional `session` argument. When omitted, the call acts on the + current session (the one most recently started or used); when given, that session becomes current. +- Every tool result echoes the session it actually acted on, so the model never has to guess. +- The number of concurrent sessions started through the plugin is capped (`maxSessions`, default 5). +- Unloading the plugin stops every session it started and kills in-flight bsk processes. + +**Ownership boundary**: the bsk daemon may be shared with other agents, terminals, or dsh +instances. The plugin therefore only ever sees and operates on sessions it created itself — +an explicit `session` argument naming a foreign or unknown id is rejected, `browser_session_list` +shows plugin-created sessions only (no daemon-wide view), and stop/unload cleanup can never touch +a session owned by another program. + +## Installation + +The plugin follows the standard dsh bundle layout (`dsh.bundle` manifest + `cordis.patch.yml`): + +```sh +dsh plugin --profile add @wxg-prc-cpg/browser-skill-dsh-plugin +dsh --profile +``` + +Prerequisite: the `bsk` CLI must be installed and on `PATH`, and the BrowserSkill Chrome extension +must be connected — see the [BrowserSkill README](https://github.com/Tencent/BrowserSkill). When bsk +is missing, tool calls fail with install guidance instead of a bare spawn error. + +## Configuration + +All fields are optional and validated through the plugin's Schemastery `Config`: + +```yaml +# cordis.patch.yml override example +- insert: + - id: browserskill + name: dsh-plugin-browserskill + config: + bskPath: bsk # path to the bsk binary (default: resolve from PATH) + defaultTimeoutMs: 120000 + maxSessions: 5 + # observationEnabled: true # live PiP/overlay observation (below) + # thumbnailIntervalMs: 1500 # frame cadence while a session is active + # idleIntervalMs: 8000 # idle cadence / recent-activity window + # lazyTools: true # reveal browser_* tools only after the skill is invoked +``` + +- **`lazyTools` (default `true`)** — the final progressive-disclosure stage: the eleven + `browser_*` tool schemas stay OUT of the system prompt (zero schema tokens) until the + `browser-skill` skill is actually invoked — the skill catalog entry is the only + advertisement. One successful invocation (model tool call, or a `/browser-skill` user + gesture) registers the whole suite for the rest of the process; repeated invocations are + no-ops, and sessions resumed with a past invocation in their durable log reveal the suite + on entry. Set `false` for the legacy always-on registration. + +## Observation overlay (PiP mini-window) + +When the plugin runs inside the dsh Web UI, an **observation overlay** floats over the app +(registered into the `shell.overlay` seat): a breathing thumbnail per owned session plus its +current action and elapsed time. The card docks at the top-right of the content area (clear of +the composer and the shell's header controls) and wears the **BrowserSkill product family's own +look**: the overlay reuses `@browser-skill/ui` components (`Button`, `cn`) and its oklch design +tokens (`--card`, `--primary`, `--destructive`, `--ring`, …), status dots spec'd after the +extension popup's connection indicator, and Remix icons. The BSK utility sheet is compiled +scoped under the `.bsk-obs` root class (`scripts/build-client-css.mjs`), so the overlay looks +like the BrowserSkill extension without leaking a single selector into the host shell — and the +shell's theme cannot bleed back in. + +- **Lifecycle**: hidden while the plugin owns no sessions; appears on the first + `browser_session_start`; disappears when all sessions stop (or the plugin unloads). +- **Focus view**: status row (green/idle/red dot + session + action + mm:ss), the latest page + frame (refreshes every ~1.5s while active, ~8s when idle; the last good frame stays on + stage while the next one loads, and is kept on errors so the card does not flash), + and a compact icon toolbar (Interrupt + Pop out, hover for the label). +- **Interrupt**: one click kills the in-flight bsk command of the focus session (same semantics + as the chat Stop button — the current action fails, the agent run may continue). Strip items + carry their own hover interrupt button. +- **Multi-session strip**: every session gets a tile (mini frame + id + status dot); focus + auto-follows the most recently active session; clicking a tile pins focus (pin badge, click + again to release); errored sessions get a red edge without stealing focus; sessions the daemon + lost are greyed out; prolonged daemon/browser outage shows "browser unavailable" and greys + the interrupt button until captures recover. +- **Drag & resize**: drag the header to move the card; drag any of the four + corners to resize (min 240×180, max 80% of the viewport; no visible grip). + Both are remembered for the page lifetime. +- **Pop out (PiP)**: upgrades the card into a native Document PiP window (requires a user + gesture, per browser rules), sized from the current card; closing the PiP falls back to the + in-page card with state intact. Browsers without Document PiP simply hide the button. +- **Wire**: the host serves `GET /bsk-observation/state`, `GET /bsk-observation/events` (SSE), + `POST /bsk-observation/interrupt`, and `GET /bsk-observation/thumbnail/` over + the dsh `webServer` route seam (dsh 0.1's Typert Remote pipeline is closed to out-of-tree + packages). All commands for one session — tool calls and frame captures alike — run through a + per-session FIFO, because the daemon accepts only one unfinished command per session. +- **Trust model**: these routes expose live screenshots (and an interrupt write), so they + replicate the browser-trust fence dsh applies to its own `/api` routes: the request Host + must be a loopback authority (`localhost`, `127.0.0.0/8`, `[::1]`), a present Origin must + match the Host, `sec-fetch-site: cross-site` is refused, and POST requires an + `application/json` body (cross-site simple requests can never satisfy that). The channel is + therefore built for **loopback-only serving** — binding the dsh web server to `0.0.0.0` and + reaching it through a LAN address will (deliberately) fail the fence; do not put these + routes behind a non-loopback reverse proxy without adding your own authentication. +- Configure with `observationEnabled` / `thumbnailIntervalMs` / `idleIntervalMs`. + +## Behavior notes + +- **Cancellation**: aborting a tool call (`exec.signal`) kills the underlying bsk child process, + matching BrowserSkill's cooperative tool-cancellation model. +- **UI cards**: calls render as terminal cards (command line as title, output as the completed + card). Screenshots additionally attach the image itself when the host mounts an attachment store + and the active model route declares image input; otherwise the PNG path is returned. +- **Web UI toolview (browser half)**: the package is dual-face. `dsh.client` (platform `web`) ships + `lib/client.js`, which registers a keyed `tool.call.toolview` view for `browser_screenshot`. The + custom view keeps the terminal block (command + output) and, when the settled result carries an + image block, resolves the durable attachment through the client session's authorized + `readAttachment` RPC and renders it with the shared `MessageImage` thumbnail/lightbox atoms. + Every other `browser_*` tool keeps the stock terminal card. The bundle follows the dsh client + contract: a CJS closure factory handed to `window.__ModuleLoader__.load`, platform modules + (`react`, `dsh-client-ui-*`) external, everything else inlined, CSS Modules compiled by + lightningcss. +- **Errors**: non-zero bsk exits surface the CLI's JSON error envelope (`code`, `message`, `hint`) + so the model gets the daemon's actionable guidance. +- **Long-running work** (e.g. `bsk record`) is not backgrounded via `ctx.jobs` yet — tracked as a + follow-up. + +## Development + +```sh +pnpm install +pnpm --filter @wxg-prc-cpg/browser-skill-dsh-plugin typecheck +pnpm --filter @wxg-prc-cpg/browser-skill-dsh-plugin test # unit tests mock bsk; no browser needed +pnpm --filter @wxg-prc-cpg/browser-skill-dsh-plugin build # tsdown -> lib/ +``` + +## Publishing + +The GitHub Actions workflow **Release dsh plugin** publishes this package to npm +as `@wxg-prc-cpg/browser-skill-dsh-plugin`. The Cordis plugin id stays +`dsh-plugin-browserskill`. + +Trigger it by pushing a tag that matches `package.json`'s `version`: + +```sh +git tag dsh-plugin-v0.1.0 +git push origin dsh-plugin-v0.1.0 +``` + +Or run the workflow from the Actions tab (`workflow_dispatch`). The job reads +`NPM_TOKEN` from the GitHub Environment of the same name. + +## License + +MIT diff --git a/packages/dsh-plugin-browserskill/cordis.patch.yml b/packages/dsh-plugin-browserskill/cordis.patch.yml new file mode 100644 index 0000000..8c04006 --- /dev/null +++ b/packages/dsh-plugin-browserskill/cordis.patch.yml @@ -0,0 +1,3 @@ +- insert: + - id: browserskill + name: dsh-plugin-browserskill diff --git a/packages/dsh-plugin-browserskill/package.json b/packages/dsh-plugin-browserskill/package.json new file mode 100644 index 0000000..8fd52b5 --- /dev/null +++ b/packages/dsh-plugin-browserskill/package.json @@ -0,0 +1,130 @@ +{ + "name": "@wxg-prc-cpg/browser-skill-dsh-plugin", + "description": "DeepSeek Harness tool plugin that exposes BrowserSkill (bsk) browser automation to the model", + "version": "0.1.0", + "license": "MIT", + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/Tencent/BrowserSkill.git", + "directory": "packages/dsh-plugin-browserskill" + }, + "homepage": "https://github.com/Tencent/BrowserSkill/tree/main/packages/dsh-plugin-browserskill#readme", + "bugs": { + "url": "https://github.com/Tencent/BrowserSkill/issues" + }, + "keywords": [ + "dsh-plugin", + "deepseek-harness", + "browserskill", + "browser-automation" + ], + "type": "module", + "main": "lib/index.mjs", + "types": "lib/index.d.mts", + "exports": { + ".": { + "types": "./lib/index.d.mts", + "default": "./lib/index.mjs" + }, + "./client": { + "default": "./lib/client.cjs" + }, + "./package.json": "./package.json" + }, + "files": [ + "lib", + "cordis.patch.yml", + "LICENSE" + ], + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + }, + "client": { + "platform": "web", + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-tool", + "@deepseek-ai/dsh-client-ui-layout" + ] + } + }, + "scripts": { + "build": "node scripts/build-client-css.mjs && node scripts/build-skill-content.mjs && tsdown", + "typecheck": "pnpm run build:skill && tsc --noEmit", + "test": "pnpm run build:css && pnpm run build:skill && vitest run", + "build:css": "node scripts/build-client-css.mjs", + "build:skill": "node scripts/build-skill-content.mjs", + "prepack": "pnpm run build", + "lint:publint": "publint" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-attachment": "^0.1.0-rc.6", + "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6", + "@deepseek-ai/dsh-client-ui-attachment": "^0.1.0-rc.6", + "@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6", + "@deepseek-ai/dsh-client-ui-tool": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6", + "@deepseek-ai/schemastery": "^3.18.1", + "react": "^18.2.0" + }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-attachment": { + "optional": true + }, + "@deepseek-ai/dsh-client-runtime": { + "optional": true + }, + "@deepseek-ai/dsh-client-ui-attachment": { + "optional": true + }, + "@deepseek-ai/dsh-client-ui-primitives": { + "optional": true + }, + "@deepseek-ai/dsh-client-ui-tool": { + "optional": true + }, + "@deepseek-ai/dsh-llm": { + "optional": true + }, + "react": { + "optional": true + } + }, + "devDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-attachment": "^0.1.0-rc.6", + "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6", + "@deepseek-ai/dsh-client-ui-attachment": "^0.1.0-rc.6", + "@deepseek-ai/dsh-client-ui-layout": "^0.1.0-rc.6", + "@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6", + "@deepseek-ai/dsh-client-ui-tool": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6", + "@deepseek-ai/schemastery": "^3.18.1", + "@tailwindcss/cli": "^4.3.3", + "@testing-library/react": "^16.1.0", + "@types/node": "^22.10.0", + "@types/react": "~18.3.1", + "@types/react-dom": "^18.3.0", + "happy-dom": "^15.11.7", + "lightningcss": "^1.30.0", + "postcss": "^8.5.0", + "postcss-prefix-selector": "^2.1.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "tailwindcss": "^4.3.3", + "tsdown": "^0.22.14", + "typescript": "^5.8.0", + "vitest": "^4.1.6", + "@browser-skill/ui": "workspace:*", + "@remixicon/react": "^4.9.0" + } +} diff --git a/packages/dsh-plugin-browserskill/scripts/build-client-css.mjs b/packages/dsh-plugin-browserskill/scripts/build-client-css.mjs new file mode 100644 index 0000000..dc55950 --- /dev/null +++ b/packages/dsh-plugin-browserskill/scripts/build-client-css.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node +/** + * Build the scoped BrowserSkill-UI utility sheet for the dsh client bundle. + * + * The overlay reuses @browser-skill/ui components (shadcn-style, Tailwind v4 + * utility classes). Those classes need a stylesheet — but a global utility + * sheet inside the dsh page would collide with the host UI, so the compiled + * output is scope-prefixed under `.bsk-obs` (the overlay root class): + * 1. tailwindcss compiles the utilities used by packages/ui + our client; + * 2. postcss-prefix-selector nests every rule under .bsk-obs. + * Token values themselves live in src/client/bsk-tokens.nomodule.css + * (hand-mirrored from packages/ui/src/styles/tailwind.css). + * + * Output: src/client/bsk-ui.nomodule.css (gitignored build artifact). + */ +import { execFileSync } from "node:child_process"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import postcss from "postcss"; +import prefixer from "postcss-prefix-selector"; + +const pkgRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const input = join(pkgRoot, ".bsk-ui.input.css"); +const rawOut = join(pkgRoot, ".bsk-ui.raw.css"); +const finalOut = join(pkgRoot, "src/client/bsk-ui.nomodule.css"); + +// The canonical sheet can't be imported directly (its own `@import +// "tailwindcss"` must resolve from a package that has it), so we inline the +// canonical @theme block read from packages/ui at build time. +const canonical = readFileSync(join(pkgRoot, "..", "ui", "src", "styles", "tailwind.css"), "utf8"); +const themeBlock = canonical.match(/@theme inline \{[\s\S]*?\n\}/)?.[0]; +if (themeBlock === undefined) throw new Error("canonical @theme inline block not found"); +writeFileSync( + input, + [ + '@import "tailwindcss";', + "@custom-variant dark (&:is(.dark *));", + themeBlock, + '@source "../ui/src";', + '@source "./src/client";', + "", + ].join("\n"), +); + +execFileSync( + process.execPath, + [ + join(pkgRoot, "node_modules", "@tailwindcss", "cli", "dist", "index.mjs"), + "-i", + input, + "-o", + rawOut, + ], + { cwd: pkgRoot, stdio: "inherit" }, +); + +const result = await postcss([ + prefixer({ + prefix: ".bsk-obs", + transform(prefix, selector, prefixedSelector) { + // Root-level anchors collapse onto the scope root itself. + if (selector === ":root" || selector === "html" || selector === "body") return prefix; + if (selector.startsWith(prefix)) return selector; + return prefixedSelector; + }, + }), +]).process(readFileSync(rawOut, "utf8"), { from: rawOut }); + +mkdirSync(dirname(finalOut), { recursive: true }); +writeFileSync(finalOut, result.css); +console.log(`bsk-ui sheet: ${result.css.length} bytes -> ${finalOut}`); diff --git a/packages/dsh-plugin-browserskill/scripts/build-skill-content.mjs b/packages/dsh-plugin-browserskill/scripts/build-skill-content.mjs new file mode 100644 index 0000000..47042f4 --- /dev/null +++ b/packages/dsh-plugin-browserskill/scripts/build-skill-content.mjs @@ -0,0 +1,44 @@ +// Assemble the plugin's skill content: a dsh-specific routing prelude, the +// canonical CLI skill body verbatim (repo-root skill/SKILL.md — the same source +// crates/bsk-cli's build.rs mirrors), then a dsh routing reminder postlude. The +// original's YAML frontmatter is stripped: name/description are supplied by the +// registration. +// Output: src/skill-content.generated.ts (gitignored, built before tsdown and +// before vitest) so the markdown ships inlined in the bundle and registration +// is a pure in-memory read — never a per-step disk access. +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const pkg = join(here, ".."); +const prelude = readFileSync(join(pkg, "skill", "prelude.md"), "utf8"); +const upstream = readFileSync(join(pkg, "..", "..", "skill", "SKILL.md"), "utf8"); +const postlude = readFileSync(join(pkg, "skill", "postlude.md"), "utf8"); + +function stripFrontmatter(markdown) { + if (!markdown.startsWith("---\n")) return markdown; + const end = markdown.indexOf("\n---\n", 4); + return end === -1 ? markdown : markdown.slice(end + 5).replace(/^\s*\n/, ""); +} + +const NAME = "browser-skill"; +const DESCRIPTION = + "Browser automation against the user's logged-in Chromium through this plugin's injected browser_* tools; never invoke bsk through bash or shell. " + + "tools (bsk engine): visit and read pages, fill forms, click through flows, scrape data, " + + "smoke-test UI changes. Use when a task needs a real browser; requires the bsk CLI and the " + + "browser-skill extension connected."; + +const content = `${prelude.trim()}\n\n${stripFrontmatter(upstream).trim()}\n\n${postlude.trim()}\n`; + +const banner = + "// GENERATED by scripts/build-skill-content.mjs — do not edit.\n" + + "// Sources: skill/prelude.md + ../../../skill/SKILL.md + skill/postlude.md.\n"; + +writeFileSync( + join(pkg, "src", "skill-content.generated.ts"), + `${banner}export const BSK_SKILL_NAME = ${JSON.stringify(NAME)};\n` + + `export const BSK_SKILL_DESCRIPTION = ${JSON.stringify(DESCRIPTION)};\n` + + `export const BSK_SKILL_MARKDOWN = ${JSON.stringify(content)};\n`, +); +console.log(`skill-content.generated.ts written (${content.length} chars)`); diff --git a/packages/dsh-plugin-browserskill/skill/postlude.md b/packages/dsh-plugin-browserskill/skill/postlude.md new file mode 100644 index 0000000..0d4d16c --- /dev/null +++ b/packages/dsh-plugin-browserskill/skill/postlude.md @@ -0,0 +1,10 @@ +--- + +## DSH routing reminder + +The CLI-oriented reference above describes behavior, not the execution mechanism in +DeepSeek Harness. For every browser operation, call the injected `browser_*` model +tool. Do not translate these examples into `bash` commands and do not execute `bsk` +directly; the plugin invokes `bsk` internally. In particular, always begin with +`browser_session_start`, pass its returned session id to subsequent `browser_*` +tools, and finish with `browser_session_stop`. diff --git a/packages/dsh-plugin-browserskill/skill/prelude.md b/packages/dsh-plugin-browserskill/skill/prelude.md new file mode 100644 index 0000000..5aa3a3f --- /dev/null +++ b/packages/dsh-plugin-browserskill/skill/prelude.md @@ -0,0 +1,71 @@ +# browser-skill (dsh edition) + +## DSH tool-routing requirement (highest priority) + +This skill is running inside DeepSeek Harness, where the plugin has injected +structured `browser_*` model tools. **Always call those injected `browser_*` tools +for browser work. Never use `bash`, `shell`, `exec`, or another tool to invoke the +`bsk` CLI directly.** The CLI command names shown below are only conceptual +mappings for understanding the tools; they are not instructions to run commands. +If a `browser_*` tool is available, using the CLI directly is incorrect because it +bypasses plugin session ownership, observation UI, tool cards, and cleanup. + +Use the injected tool whose name matches the operation, for example: +`browser_session_start`, `browser_navigate`, `browser_observe`, `browser_snapshot`, +`browser_click`, `browser_fill`, `browser_press`, `browser_screenshot`, and +`browser_session_stop`. Pass arguments as the tool schema specifies (not CLI flags). + +Drive the user's **real Chromium browser** (logins, cookies, Agent Window isolation) +through this plugin's `browser_*` tools. Each injected tool is implemented by the +plugin using the `bsk` engine internally — the model must not invoke that engine +itself. The workflow, judgment rules, and safety constraints remain identical to +the canonical CLI skill. + +> **Tool availability**: invoking this skill (which you just did) makes the plugin's +> `browser_*` tools available for the rest of the session — they are injected on +> successful skill load. Until then they are deliberately hidden, so do not attempt +> a browser action before loading this skill. + +## Tool ↔ CLI map + +The tools below are thin, structured wrappers over the bsk CLI verbs of the same +name; `session` parameters take the 4-letter session id (no `--session` flag +spelling here): + +| dsh tool | bsk equivalent | +|---|---| +| `browser_session_start` / `browser_session_stop` / `browser_session_list` | `bsk session start/stop/list` | +| `browser_navigate` | `bsk navigate` | +| `browser_snapshot` / `browser_observe` | `bsk snapshot` / `bsk observe` | +| `browser_click` / `browser_fill` / `browser_press` | `bsk click` / `bsk fill` / `bsk press` | +| `browser_screenshot` | `bsk screenshot` | +| `browser_emulate` | `bsk emulate` | + +(`bsk console` / `bsk network` have no tool counterpart in this plugin yet.) + +Plugin-specific semantics that override or narrow the CLI rules below: + +- **Owned sessions only**: this plugin sees and acts on just the sessions IT + started via `browser_session_start`. `browser_session_list` shows only those + (with the current marker); passing a session id created by another program + (or person) sharing the bsk daemon is refused outright — never retry those + with the same id. +- **Current session**: omitted `session` parameters resolve to the most + recently started or used OWNED session. With several sessions in flight, + pass `session` explicitly to avoid surprises. +- **Screenshots**: when the model route accepts images, `browser_screenshot` + returns the PNG inline as an image attachment; otherwise it returns the + saved file path. +- **`browser_emulate mobile`**: requires `width`+`height` — the bsk daemon + refuses `--mobile` without viewport dimensions. +- **Live observation**: in the dsh Web UI, every owned session is watched by + the floating overlay (thumbnails, action, interrupt) — observational side + traffic is free and never steals your command turn; just keep acting. + +The canonical CLI skill body follows verbatim (source of truth: `skill/SKILL.md` +in the BrowserSkill repo, mirrored by build into `crates/bsk-cli/skill/SKILL.md`). +Read `bsk --session ` as "the `browser_` tool with +`session: `"; everything else — when to use, the stop-when-done rules, the +interaction loop, refs, sandbox rules — applies unchanged. + +--- diff --git a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.module.css b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.module.css new file mode 100644 index 0000000..c640261 --- /dev/null +++ b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.module.css @@ -0,0 +1,370 @@ +/* Observation overlay skin, BrowserSkill family: colors/radius ride the BSK + design tokens (--card/--border/--primary/--destructive/--ring/--radius, + defined on the .bsk-obs scope root by bsk-tokens.nomodule.css — values + mirrored from packages/ui/src/styles/tailwind.css). Layout-only here; + component chrome (buttons etc.) comes from @browser-skill/ui plus the + scope-prefixed utility sheet. The shell overlay layer is click-through, so + the card and capsule opt back into pointer events themselves. */ + +.card { + position: fixed; + z-index: 40; + display: flex; + flex-direction: column; + pointer-events: auto; + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: 0 8px 28px rgb(0 0 0 / 18%); + overflow: hidden; + color: var(--card-foreground); +} + +.body { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + font-size: 12px; + background: var(--card); + color: var(--card-foreground); +} + +.header { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + cursor: move; + user-select: none; + touch-action: none; + border-bottom: 1px solid var(--border); +} + +.status-text { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; +} + +.icon-button { + flex: none; + display: inline-flex; + align-items: center; + border: none; + background: none; + padding: 2px; + border-radius: calc(var(--radius) - 6px); + font: inherit; + color: var(--muted-foreground); + cursor: pointer; +} + +.icon-button:hover { + color: var(--foreground); + background: var(--accent); +} + +.stage { + position: relative; + flex: 1; + min-height: 0; + display: flex; + align-items: center; + justify-content: center; + background: var(--background); + overflow: hidden; +} + +/* Fade only on first mount. The is reused across frames (src swap, + no React key) so this animation must not replay on every breath. */ +.thumb { + max-width: 100%; + max-height: 100%; + object-fit: contain; + animation: bsk-obs-fade-in 240ms ease-out; +} +@keyframes bsk-obs-fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +.placeholder { + color: var(--muted-foreground); + font-size: 12px; +} + +.badge { + position: absolute; + top: 6px; + right: 6px; + width: 16px; + height: 16px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + color: oklch(0.7 0.19 60); + border: 1px solid oklch(0.7 0.19 60); + background: var(--card); +} + +.actions { + position: relative; + z-index: 3; + display: flex; + align-items: center; + justify-content: space-between; + gap: 4px; + + /* A little inset so icons are not flush with the resize corners. */ + padding: 4px 8px; + min-height: 32px; + border-top: 1px solid var(--border); +} + +.tool-wrap { + position: relative; + display: inline-flex; +} + +.tool-button { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: calc(var(--radius) - 4px); + background: none; + color: var(--muted-foreground); + cursor: pointer; +} + +.tool-button:disabled { + opacity: 0.4; + cursor: default; +} + +.tool-button:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; +} + +.tool-danger { + color: var(--destructive); +} + +/* Hover the wrap (not just :hover on the button) so the tooltip target and + the left-corner resize overlay still light the icon up. */ +.tool-wrap:hover .tool-button:not(:disabled) { + color: var(--foreground); + background: var(--accent); +} + +.tool-wrap:hover .tool-danger:not(:disabled) { + color: var(--destructive); + background: color-mix(in oklch, var(--destructive) 18%, var(--card)); +} + +.tool-wrap:hover .tool-button:disabled { + background: var(--accent); +} + +.hint { + position: absolute; + bottom: calc(100% + 4px); + left: 0; + width: max-content; + max-width: 220px; + padding: 4px 6px; + border-radius: calc(var(--radius) - 4px); + background: var(--card); + border: 1px solid var(--border); + color: var(--muted-foreground); + font-size: 11px; + line-height: 1.35; + box-shadow: 0 4px 14px rgb(0 0 0 / 16%); + z-index: 41; +} + +.hint[data-align="end"] { + left: auto; + right: 0; +} + +/* Invisible hit targets — no grip glyph; all four corners resize. */ +.resize-handle { + position: absolute; + width: 16px; + height: 16px; + touch-action: none; + z-index: 2; +} + +.resize-handle[data-corner="nw"] { + top: 0; + left: 0; + cursor: nwse-resize; +} + +.resize-handle[data-corner="ne"] { + top: 0; + right: 0; + cursor: nesw-resize; +} + +.resize-handle[data-corner="sw"] { + bottom: 0; + left: 0; + cursor: nesw-resize; +} + +.resize-handle[data-corner="se"] { + bottom: 0; + right: 0; + cursor: nwse-resize; +} + +.capsule { + position: fixed; + right: 16px; + top: 64px; + z-index: 40; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + pointer-events: auto; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--card); + box-shadow: 0 4px 14px rgb(0 0 0 / 16%); + font-size: 12px; + color: var(--muted-foreground); + cursor: pointer; +} + +.capsule:hover { + background: var(--accent); +} + +.strip { + display: flex; + gap: 6px; + padding: 4px 8px; + overflow-x: auto; + border-top: 1px solid var(--border); +} + +.strip-item { + position: relative; + flex: none; + border-radius: calc(var(--radius) - 6px); + border: 1px solid var(--border); +} + +.strip-item[data-focused] { + border-color: var(--primary); +} + +.strip-item[data-state="error"] { + border-color: var(--destructive); +} + +.strip-item[data-state="dead"] { + opacity: 0.45; +} + +.strip-main { + display: flex; + align-items: center; + gap: 4px; + padding: 3px 6px; + border: none; + border-radius: calc(var(--radius) - 6px); + background: none; + font: inherit; + font-size: 11px; + color: var(--muted-foreground); + cursor: pointer; +} + +.strip-main:hover { + background: var(--accent); +} + +.strip-thumb { + width: 28px; + height: 20px; + overflow: hidden; + border-radius: 3px; + background: var(--background); + display: inline-flex; +} + +.strip-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.strip-thumb-empty { + width: 100%; + height: 100%; +} + +.strip-id { + max-width: 64px; + overflow: hidden; + text-overflow: ellipsis; +} + +.pin-badge { + color: var(--primary); +} + +.strip-interrupt { + position: absolute; + top: -7px; + right: -7px; + width: 16px; + height: 16px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--destructive); + background: var(--card); + color: var(--destructive); + padding: 0; + cursor: pointer; +} + +.strip-interrupt:disabled { + opacity: 0.4; + cursor: default; +} + +.icon-button:focus-visible, +.capsule:focus-visible, +.strip-main:focus-visible, +.strip-interrupt:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; +} +@media (prefers-reduced-motion: reduce) { + .thumb { + animation: none; + } +} diff --git a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx new file mode 100644 index 0000000..d500aeb --- /dev/null +++ b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx @@ -0,0 +1,612 @@ +// Observation overlay: the default in-app carrier for live session watching. +// Expanded = draggable/resizable floating card (top-right, out of the +// composer's way); collapsed = a status capsule; "pop out" upgrades the same +// content to a native Document PiP window (user gesture required by the +// browser). Multi-session renders a meeting-style strip under the focus view. +// Visuals follow the BrowserSkill product family: @browser-skill/ui components +// and oklch tokens on a .bsk-obs scope root, so the card reads as BSK's own +// surface without leaking styles into (or inheriting themes from) the shell. + +import { cn } from "@browser-skill/ui"; +import { + RiArrowDownSLine, + RiErrorWarningLine, + RiPictureInPicture2Line, + RiPushpinFill, + RiStopCircleLine, +} from "@remixicon/react"; + +// remixicon's component types target @types/react 19 while the dsh shell +// runs react 18 — a compile-time-only recast keeps the 18 typecheck happy. +type IconComponent = (props: { size?: number | string; className?: string }) => ReactNode; +const asIcon = (component: unknown): IconComponent => component as IconComponent; +const IconStop = asIcon(RiStopCircleLine); +const IconPip = asIcon(RiPictureInPicture2Line); +const IconDown = asIcon(RiArrowDownSLine); +const IconWarn = asIcon(RiErrorWarningLine); +const IconPin = asIcon(RiPushpinFill); + +import { + type ReactNode, + type PointerEvent as ReactPointerEvent, + useCallback, + useEffect, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import { createPortal } from "react-dom"; +import type { SessionObservation } from "../observation"; +import css from "./ObservationOverlay.module.css"; +import type { ObservationClientStore } from "./observation-store"; + +/** Minimal Document PiP surface (TS lib.dom lacks it). */ +interface DocumentPip { + requestWindow(options?: { width?: number; height?: number }): Promise; +} + +interface Point { + x: number; + y: number; +} + +interface Size { + w: number; + h: number; +} + +const DEFAULT_SIZE: Size = { w: 320, h: 240 }; +const MIN_SIZE: Size = { w: 240, h: 180 }; +const EDGE_MARGIN = 16; +/** Default dock: top-right, just under the shell's top bar (no spacing tokens exist in dsh 0.1). */ +const TOP_OFFSET = 64; + +function pipApi(): DocumentPip | undefined { + if (typeof window === "undefined") return undefined; + return (window as unknown as { documentPictureInPicture?: DocumentPip }).documentPictureInPicture; +} + +function clampSize(size: Size, viewport: Size): Size { + const maxW = viewport.w * 0.8; + const maxH = viewport.h * 0.8; + return { + w: Math.min(Math.max(size.w, MIN_SIZE.w), maxW), + h: Math.min(Math.max(size.h, MIN_SIZE.h), maxH), + }; +} + +function clampPos(pos: Point, size: Size, viewport: Size): Point { + return { + x: Math.min(Math.max(pos.x, 0), Math.max(0, viewport.w - size.w)), + y: Math.min(Math.max(pos.y, 0), Math.max(0, viewport.h - size.h)), + }; +} + +export type ResizeCorner = "nw" | "ne" | "sw" | "se"; + +/** Grow/shrink from one corner, keeping the opposite corner planted. */ +export function applyResize( + base: Point & Size, + corner: ResizeCorner, + dx: number, + dy: number, + viewport: Size, +): { pos: Point; size: Size } { + const nextW = corner === "ne" || corner === "se" ? base.w + dx : base.w - dx; + const nextH = corner === "sw" || corner === "se" ? base.h + dy : base.h - dy; + const size = clampSize({ w: nextW, h: nextH }, viewport); + const x = corner === "nw" || corner === "sw" ? base.x + base.w - size.w : base.x; + const y = corner === "nw" || corner === "ne" ? base.y + base.h - size.h : base.y; + return { pos: clampPos({ x, y }, size, viewport), size }; +} + +const CORNER_LABEL: Record = { + nw: "top left", + ne: "top right", + sw: "bottom left", + se: "bottom right", +}; + +function formatElapsed(sinceMs: number, nowMs: number): string { + const total = Math.max(0, Math.floor((nowMs - sinceMs) / 1000)); + const mm = String(Math.floor(total / 60)).padStart(2, "0"); + const ss = String(total % 60).padStart(2, "0"); + return `${mm}:${ss}`; +} + +/** + * Auto-follow focus: the most recently touched session, but never yank focus + * to a session whose latest action failed (errors flag the strip item, they + * do not steal the stage) or one already reported dead. + */ +export function focusOf(sessions: readonly SessionObservation[]): SessionObservation | undefined { + if (sessions.length === 0) return undefined; + const byRecency = [...sessions].sort((a, b) => b.since - a.since); + const healthy = byRecency.find((s) => s.lastError === undefined && s.dead !== true); + return healthy ?? byRecency[0]; +} + +export function statusOf(obs: SessionObservation): "active" | "idle" | "error" { + if (obs.lastError !== undefined && obs.action === "idle") return "error"; + return obs.action === "idle" ? "idle" : "active"; +} + +/** Compact toolbar icon: no label, hover bubble for the name / semantics. */ +function IconAction(props: { + label: string; + hint: string; + disabled?: boolean; + danger?: boolean; + align?: "start" | "end"; + onClick: () => void; + children: ReactNode; +}) { + const [open, setOpen] = useState(false); + return ( + setOpen(true)} + onPointerLeave={() => setOpen(false)} + > + + {open ? ( + + {props.hint} + + ) : null} + + ); +} + +/** Flat status dot, specced after the BSK popup's ConnectionStatusIndicator. */ +function StatusDot({ state }: { state: "active" | "idle" | "error" | "dead" }) { + const color = + state === "active" + ? "bg-emerald-500" + : state === "error" + ? "bg-red-500" + : state === "dead" + ? "bg-amber-500" + : "bg-muted-foreground/40"; + return ( + + ); +} + +/** One strip item: mini frame, id, status dot, hover interrupt, pin toggle. */ +function StripItem(props: { + store: ObservationClientStore; + obs: SessionObservation; + pinned: boolean; + focused: boolean; + onTogglePin: (sessionId: string) => void; +}) { + const { store, obs, pinned, focused, onTogglePin } = props; + const [hover, setHover] = useState(false); + const thumbId = obs.thumbnailAttachmentId; + useEffect(() => { + store.ensureThumbnail(thumbId); + }, [store, thumbId]); + const thumb = store.getSnapshot().displayFrames[obs.sessionId]; + const state = obs.dead === true ? "dead" : statusOf(obs); + return ( +
setHover(true)} + onPointerLeave={() => setHover(false)} + > + + {hover && !pinned && obs.dead !== true ? ( + + ) : null} +
+ ); +} + +/** The floating card / PiP shared content. */ +function OverlayBody(props: { + store: ObservationClientStore; + focus: SessionObservation | undefined; + sessions: readonly SessionObservation[]; + available: boolean; + pinnedId: string | null; + onTogglePin: (sessionId: string) => void; + now: number; + onPopOut?: (() => void) | undefined; + onCollapse?: (() => void) | undefined; + inPip: boolean; + onHeaderPointerDown?: (event: ReactPointerEvent) => void; +}) { + const { + store, + focus, + sessions, + available, + pinnedId, + onTogglePin, + now, + onPopOut, + onCollapse, + inPip, + onHeaderPointerDown, + } = props; + const [interrupting, setInterrupting] = useState(false); + + const thumbId = focus?.thumbnailAttachmentId; + useEffect(() => { + store.ensureThumbnail(thumbId); + }, [store, thumbId]); + // Paint the last good frame while the next attachment decodes — swapping + // to a placeholder (and remounting with a fade) is what made the + // card flash on every breath / action-end capture. + const thumb = + focus !== undefined ? store.getSnapshot().displayFrames[focus.sessionId] : undefined; + const displayUrl = thumb?.url; + + const canInterrupt = + available && + !interrupting && + focus !== undefined && + focus.action !== "idle" && + focus.dead !== true; + const onInterrupt = (): void => { + if (!canInterrupt || focus === undefined) return; + setInterrupting(true); + void store.interrupt(focus.sessionId).finally(() => setInterrupting(false)); + }; + + const statusText = !available + ? "browser unavailable" + : focus === undefined + ? "no session" + : `${focus.sessionId} · ${focus.action === "idle" ? "idle" : focus.action} · ${formatElapsed(focus.since, now)}`; + const state = !available ? "error" : focus !== undefined ? statusOf(focus) : "idle"; + + return ( +
+
+ + {statusText} + {onCollapse !== undefined ? ( + + ) : null} +
+
+ {displayUrl !== undefined ? ( + {`session + ) : ( +
+ {!available + ? "last frame kept" + : thumb?.status === "error" + ? "frame unavailable" + : "waiting for page"} +
+ )} + {thumb?.status === "error" ? ( + + + + ) : null} +
+ {sessions.length >= 2 ? ( +
+ {sessions.map((obs) => ( + + ))} +
+ ) : null} +
+ + + + {onPopOut !== undefined ? ( + + + + ) : null} +
+
+ ); +} + +/** Clone the host document's style/link nodes into a PiP window. */ +function cloneStylesInto(pipWindow: Window): void { + for (const node of document.querySelectorAll('link[rel="stylesheet"], style')) { + pipWindow.document.head.appendChild(node.cloneNode(true)); + } +} + +export function ObservationOverlay({ store }: { store: ObservationClientStore }) { + const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot); + useEffect(() => { + store.start(); + return () => store.stop(); + }, [store]); + + const [collapsed, setCollapsed] = useState(false); + const [pos, setPos] = useState(null); + const [size, setSize] = useState(DEFAULT_SIZE); + const [pipWindow, setPipWindow] = useState(null); + const [pinnedId, setPinnedId] = useState(null); + const dragRef = useRef<{ + kind: "move" | "resize"; + corner?: ResizeCorner; + startX: number; + startY: number; + base: Point & Size; + } | null>(null); + + // Elapsed-time ticker: 1s while anything is active. + const anyActive = snapshot.sessions.some((s) => s.action !== "idle"); + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (!anyActive) return; + const timer = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(timer); + }, [anyActive]); + + // Focus: the pinned session wins while it still exists; otherwise auto-follow. + const pinned = + pinnedId !== null ? snapshot.sessions.find((s) => s.sessionId === pinnedId) : undefined; + const focus = pinned ?? focusOf(snapshot.sessions); + const onTogglePin = useCallback((sessionId: string) => { + setPinnedId((current) => (current === sessionId ? null : sessionId)); + }, []); + + const viewport = (): Size => ({ w: window.innerWidth, h: window.innerHeight }); + + const onPointerMove = useCallback((event: PointerEvent) => { + const drag = dragRef.current; + if (drag === null) return; + const dx = event.clientX - drag.startX; + const dy = event.clientY - drag.startY; + if (drag.kind === "move") { + setPos( + clampPos( + { x: drag.base.x + dx, y: drag.base.y + dy }, + { w: drag.base.w, h: drag.base.h }, + viewport(), + ), + ); + } else if (drag.corner !== undefined) { + const next = applyResize(drag.base, drag.corner, dx, dy, viewport()); + setPos(next.pos); + setSize(next.size); + } + }, []); + const onPointerUp = useCallback(() => { + dragRef.current = null; + document.removeEventListener("pointermove", onPointerMove); + document.removeEventListener("pointerup", onPointerUp); + }, [onPointerMove]); + + const cardOrigin = (): Point & Size => { + const vp = viewport(); + return { + x: pos?.x ?? vp.w - size.w - EDGE_MARGIN, + y: pos?.y ?? TOP_OFFSET, + w: size.w, + h: size.h, + }; + }; + + const beginMove = (event: ReactPointerEvent) => { + event.preventDefault(); + const rect = ( + event.currentTarget.closest("[data-obs-card]") as HTMLElement | null + )?.getBoundingClientRect(); + const origin = cardOrigin(); + const base = { + x: rect?.left ?? origin.x, + y: rect?.top ?? origin.y, + w: rect !== undefined && rect.width > 0 ? rect.width : origin.w, + h: rect !== undefined && rect.height > 0 ? rect.height : origin.h, + }; + dragRef.current = { kind: "move", startX: event.clientX, startY: event.clientY, base }; + setPos({ x: base.x, y: base.y }); + document.addEventListener("pointermove", onPointerMove); + document.addEventListener("pointerup", onPointerUp); + }; + + const beginResize = (corner: ResizeCorner) => (event: ReactPointerEvent) => { + event.preventDefault(); + event.stopPropagation(); + const rect = ( + event.currentTarget.closest("[data-obs-card]") as HTMLElement | null + )?.getBoundingClientRect(); + const origin = cardOrigin(); + const base = { + x: rect?.left ?? origin.x, + y: rect?.top ?? origin.y, + w: rect !== undefined && rect.width > 0 ? rect.width : origin.w, + h: rect !== undefined && rect.height > 0 ? rect.height : origin.h, + }; + dragRef.current = { + kind: "resize", + corner, + startX: event.clientX, + startY: event.clientY, + base, + }; + setPos({ x: base.x, y: base.y }); + document.addEventListener("pointermove", onPointerMove); + document.addEventListener("pointerup", onPointerUp); + }; + + const popOut = async (): Promise => { + const pip = pipApi(); + if (pip === undefined) return; + try { + const win = await pip.requestWindow({ width: size.w, height: size.h }); + cloneStylesInto(win); + win.addEventListener("pagehide", () => setPipWindow(null)); + setPipWindow(win); + } catch { + // requestWindow rejects without a user gesture (or when the window was + // closed mid-request): stay on the in-page card, no state change. + } + }; + + // Hidden while no owned session exists (and no PiP is up). + if (snapshot.sessions.length === 0 && pipWindow === null) return null; + + const body = ( + void popOut() : undefined} + onCollapse={pipWindow === null ? () => setCollapsed(true) : undefined} + onHeaderPointerDown={pipWindow === null ? beginMove : undefined} + /> + ); + + if (pipWindow !== null) { + return createPortal(body, pipWindow.document.body); + } + + if (collapsed) { + const state = focus !== undefined ? statusOf(focus) : "idle"; + return ( + + ); + } + + const style: React.CSSProperties = + pos !== null + ? { left: pos.x, top: pos.y, width: size.w, height: size.h } + : { right: EDGE_MARGIN, top: TOP_OFFSET, width: size.w, height: size.h }; + return ( +
+ {body} + {(["nw", "ne", "sw", "se"] as const).map((corner) => ( +
{ + const step = 16; + const dx = + event.key === "ArrowRight" ? step : event.key === "ArrowLeft" ? -step : 0; + const dy = event.key === "ArrowDown" ? step : event.key === "ArrowUp" ? -step : 0; + if (dx === 0 && dy === 0) return; + event.preventDefault(); + const next = applyResize(cardOrigin(), "se", dx, dy, viewport()); + setPos(next.pos); + setSize(next.size); + } + : undefined + } + /> + ))} +
+ ); +} diff --git a/packages/dsh-plugin-browserskill/src/client/ScreenshotToolView.module.css b/packages/dsh-plugin-browserskill/src/client/ScreenshotToolView.module.css new file mode 100644 index 0000000..87047b5 --- /dev/null +++ b/packages/dsh-plugin-browserskill/src/client/ScreenshotToolView.module.css @@ -0,0 +1,57 @@ +/* browser_screenshot toolview: disclosure row over a terminal block plus the + screenshot image. Semantic tokens only (--dsw-alias-*), no literal colors. */ + +.card { + display: flex; + flex-direction: column; +} + +.summary { + min-width: 0; + margin-left: 6px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--dsw-alias-label-secondary); +} + +.card[data-state="error"] .summary { + color: var(--dsw-alias-label-error); +} + +.body { + display: flex; + flex-direction: column; + gap: 8px; + padding: 4px 0 4px 22px; +} + +.image-wrap { + display: flex; +} + +.inspect-button { + align-self: flex-start; + border: none; + background: none; + padding: 0; + font: inherit; + font-size: 12px; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} + +.inspect-button:hover { + color: var(--dsw-alias-label-secondary); +} + +.inspect-button:focus-visible { + outline: 2px solid var(--dsw-alias-border-focus); + outline-offset: 2px; + border-radius: 2px; +} +@media (prefers-reduced-motion: reduce) { + .inspect-button { + transition: none; + } +} diff --git a/packages/dsh-plugin-browserskill/src/client/ScreenshotToolView.tsx b/packages/dsh-plugin-browserskill/src/client/ScreenshotToolView.tsx new file mode 100644 index 0000000..1107aec --- /dev/null +++ b/packages/dsh-plugin-browserskill/src/client/ScreenshotToolView.tsx @@ -0,0 +1,161 @@ +// browser_screenshot keyed toolview: the terminal card's command line and +// output stay exactly as the host renders them, and a result carrying an +// image block additionally renders the screenshot itself (thumbnail with the +// shared click-to-open lightbox). Pure function of the frozen call/result +// slice — live and replay paths render identically. + +import type { ImageAttachmentRef } from "@deepseek-ai/dsh-attachment"; +import { + type ImageLoader, + MessageImage, + type MessageImageLabels, +} from "@deepseek-ai/dsh-client-ui-attachment"; +import { + DisclosureRow, + StateDot, + type StateDotState, + TerminalBlock, + type TerminalBlockLabels, +} from "@deepseek-ai/dsh-client-ui-primitives"; +import type { ToolCallViewProps } from "@deepseek-ai/dsh-client-ui-tool/client"; +import { useState } from "react"; +import css from "./ScreenshotToolView.module.css"; + +/** Resolved loader bound to the owning session at registration time. */ +export type ScreenshotImageLoader = (attachment: ImageAttachmentRef) => Promise; + +export type ScreenshotToolViewProps = ToolCallViewProps & { + loadImage: ScreenshotImageLoader; +}; + +type ViewState = "running" | "ok" | "error"; + +interface ViewModel { + readonly state: ViewState; + readonly command: string; + readonly output: string | null; + readonly image: ImageAttachmentRef | null; + readonly summary: string; +} + +const TERMINAL_LABELS: Partial = { + running: "Running", + failed: "Failed", + done: "Done", + copy: "Copy", + copied: "Copied", + noOutput: "No output", + collapseAria: "Collapse output", + collapse: "Collapse", + expandAria: (hidden) => `Expand the remaining ${hidden} output lines`, + expand: (hidden) => `… ${hidden} more lines`, +}; + +const IMAGE_LABELS: MessageImageLabels = { + image: "screenshot", + open: "Open the original screenshot", + openNamed: (label) => `Open screenshot ${label}`, + loading: "Loading…", + loadFailed: "Load failed — retry", + lightbox: { dialog: "Screenshot preview", close: "Close preview" }, +}; + +function firstLine(text: string): string { + const newline = text.indexOf("\n"); + return newline === -1 ? text : text.slice(0, newline); +} + +/** Rebuild the command line from the logged arguments (mirrors the host presenter). */ +function commandOf(argsRaw: string, callId: string): string { + try { + const args = JSON.parse(argsRaw) as { session?: unknown; ref?: unknown }; + const parts = ["bsk", "screenshot", "--session"]; + parts.push( + typeof args.session === "string" && args.session !== "" ? args.session : "(current)", + ); + if (typeof args.ref === "string" && args.ref !== "") parts.push("--ref", args.ref); + return parts.join(" "); + } catch { + // Streaming can expose a truncated JSON prefix; it is still the best label. + return argsRaw === "" ? `bsk screenshot (${callId})` : `bsk screenshot ${firstLine(argsRaw)}`; + } +} + +/** Derive the display model from the frozen block only. */ +export function viewModelOf(block: ToolCallViewProps["block"]): ViewModel { + const settled = "kind" in block; + const argsRaw = (settled ? block.call?.argsRaw : block.argsRaw) ?? ""; + const command = commandOf(argsRaw, block.callId); + if (!settled) { + return { state: "running", command, output: null, image: null, summary: command }; + } + const textBlock = block.content.find((item) => item.type === "text"); + const imageBlock = block.content.find((item) => item.type === "image"); + const output = textBlock !== undefined && textBlock.type === "text" ? textBlock.text : null; + const image = + imageBlock !== undefined && imageBlock.type === "image" ? imageBlock.attachment : null; + const state: ViewState = block.isError ? "error" : "ok"; + const summary = output !== null ? firstLine(output) : command; + return { state, command, output, image, summary }; +} + +function dotState(state: ViewState): StateDotState { + switch (state) { + case "running": + return "ongoing"; + case "error": + return "error"; + default: + return "done"; + } +} + +/** + * Render one browser_screenshot call: a disclosure row over a terminal block + * (command + output) plus the screenshot image when the result carries one. + */ +export function ScreenshotToolView({ block, cwd, inspect, loadImage }: ScreenshotToolViewProps) { + const model = viewModelOf(block); + const [expanded, setExpanded] = useState(false); + const expandable = model.state === "running" || model.output !== null || model.image !== null; + const open = expanded && expandable; + return ( +
+ } + title="Screenshot" + open={open} + expandable={expandable} + onToggle={() => setExpanded((value) => !value)} + expandOnRowClick + previewChevron + collapsedContent={{model.summary}} + > +
+ + {model.image !== null ? ( +
+ +
+ ) : null} + {inspect !== undefined ? ( + + ) : null} +
+
+
+ ); +} diff --git a/packages/dsh-plugin-browserskill/src/client/bsk-tokens.nomodule.css b/packages/dsh-plugin-browserskill/src/client/bsk-tokens.nomodule.css new file mode 100644 index 0000000..f557c79 --- /dev/null +++ b/packages/dsh-plugin-browserskill/src/client/bsk-tokens.nomodule.css @@ -0,0 +1,49 @@ +/* + * BrowserSkill UI design tokens, scoped to the observation overlay root. + * VALUES MIRROR packages/ui/src/styles/tailwind.css (:root and .dark) — keep + * in sync with that file; it is the canonical source. Scoped here (instead of + * :root) so the dsh host theme can neither leak in nor be polluted by us. + * Dark follows prefers-color-scheme, same as the extension popup. + */ +.bsk-obs { + color-scheme: light dark; + --background: oklch(0.99 0.005 50); + --foreground: oklch(0.14 0.03 50); + --card: oklch(0.995 0.003 50 / 0.82); + --card-foreground: oklch(0.14 0.03 50); + --primary: oklch(0.62 0.22 45); + --primary-foreground: oklch(0.99 0.01 50); + --secondary: oklch(0.96 0.04 65); + --secondary-foreground: oklch(0.25 0.08 65); + --muted: oklch(0.96 0.01 50); + --muted-foreground: oklch(0.55 0.04 50); + --accent: oklch(0.96 0.04 65); + --accent-foreground: oklch(0.25 0.08 65); + --destructive: oklch(0.6 0.25 25); + --destructive-foreground: oklch(0.99 0.01 50); + --border: oklch(0.9 0.025 60); + --input: oklch(0.9 0.025 60); + --ring: oklch(0.62 0.22 45); + --radius: 0.75rem; +} +@media (prefers-color-scheme: dark) { + .bsk-obs { + --background: oklch(0.2 0.02 50); + --foreground: oklch(0.98 0.01 50); + --card: oklch(0.24 0.02 50 / 0.82); + --card-foreground: oklch(0.98 0.01 50); + --primary: oklch(0.7 0.18 45); + --primary-foreground: oklch(0.14 0.03 50); + --secondary: oklch(0.32 0.03 60); + --secondary-foreground: oklch(0.98 0.01 50); + --muted: oklch(0.3 0.02 50); + --muted-foreground: oklch(0.75 0.03 50); + --accent: oklch(0.32 0.03 60); + --accent-foreground: oklch(0.98 0.01 50); + --destructive: oklch(0.45 0.2 25); + --destructive-foreground: oklch(0.98 0.01 50); + --border: oklch(0.35 0.02 60); + --input: oklch(0.35 0.02 60); + --ring: oklch(0.7 0.18 45); + } +} diff --git a/packages/dsh-plugin-browserskill/src/client/index.ts b/packages/dsh-plugin-browserskill/src/client/index.ts new file mode 100644 index 0000000..ad22171 --- /dev/null +++ b/packages/dsh-plugin-browserskill/src/client/index.ts @@ -0,0 +1,84 @@ +/** + * dsh-plugin-browserskill browser half: the `browser_screenshot` keyed + * toolview plus the observation overlay (live thumbnails + interrupt) floating + * over the shell via the `shell.overlay` seat. + */ + +import type { ImageAttachmentRef } from "@deepseek-ai/dsh-attachment"; +import type { ClientContext, ISessions, SessionId } from "@deepseek-ai/dsh-client-runtime/client"; +// Type-only: pulls the 'shell.overlay' SlotMap merge into scope. +import type {} from "@deepseek-ai/dsh-client-ui-layout/client"; +import type { ToolCallViewProps } from "@deepseek-ai/dsh-client-ui-tool/client"; +import { createElement } from "react"; +// Scope-prefixed BSK design tokens and utility sheet (injected verbatim as +//