mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
210b21b637 | ||
|
|
1a70d2200e | ||
|
|
554861d6b1 | ||
|
|
f060a7c074 | ||
|
|
60b62d5420 | ||
|
|
70b851a3e8 | ||
|
|
da3534d066 | ||
|
|
a004291848 | ||
|
|
a4b6a61d29 | ||
|
|
cad0b5cbc9 |
Generated
+2
-2
@@ -134,7 +134,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bsk"
|
name = "bsk"
|
||||||
version = "0.1.10"
|
version = "0.1.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bsk-protocol",
|
"bsk-protocol",
|
||||||
@@ -168,7 +168,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bsk-protocol"
|
name = "bsk-protocol"
|
||||||
version = "0.1.10"
|
version = "0.1.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"schemars",
|
"schemars",
|
||||||
"semver",
|
"semver",
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ members = [
|
|||||||
default-members = ["crates/bsk-cli"]
|
default-members = ["crates/bsk-cli"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.10"
|
version = "0.1.11"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.85"
|
rust-version = "1.85"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@browser-skill/extension",
|
"name": "@browser-skill/extension",
|
||||||
"version": "0.1.6",
|
"version": "0.1.7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -20,22 +20,43 @@ extension keep owning the actual browser control — this package is a thin, wel
|
|||||||
| `browser_snapshot` | `bsk snapshot` | Indented aria-tree snapshot with `@eN` refs for interaction tools. |
|
| `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_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_click` | `bsk click` | Click a snapshot ref or CSS selector (button / click-count options). |
|
||||||
|
| `browser_hover` | `bsk hover` | Hover a ref or selector and allow hover-triggered UI to settle. |
|
||||||
| `browser_fill` | `bsk fill` | Fill an input / textarea / contenteditable (clears first by default). |
|
| `browser_fill` | `bsk fill` | Fill an input / textarea / contenteditable (clears first by default). |
|
||||||
|
| `browser_select` | `bsk select` | Select one or more option values on a `<select>`. |
|
||||||
| `browser_press` | `bsk press` | Dispatch a key or combo, optionally focusing a target first. |
|
| `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_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. |
|
| `browser_emulate` | `bsk emulate` | Apply or clear mobile device emulation on the active tab. |
|
||||||
|
| `browser_tab_list` | `bsk tab list` | List user and/or Agent Window tabs visible to an owned session. |
|
||||||
|
| `browser_tab_create` | `bsk tab create` | Create a foreground or background tab in the Agent Window. |
|
||||||
|
| `browser_tab_select` | `bsk tab select` | Focus an Agent Window tab. |
|
||||||
|
| `browser_tab_close` | `bsk tab close` | Close an Agent Window tab. |
|
||||||
|
| `browser_tab_borrow` | `bsk tab borrow` | Move a user tab into the Agent Window for controlled interaction. |
|
||||||
|
| `browser_tab_return` | `bsk tab return` | Return a borrowed tab to its original user window. |
|
||||||
|
| `browser_navigate_back` / `browser_navigate_forward` | `bsk navigate-back` / `bsk navigate-forward` | Traverse tab history and wait for a lifecycle phase. |
|
||||||
|
| `browser_reload` | `bsk reload` | Reload the active tab, optionally bypassing cache. |
|
||||||
|
| `browser_wait_for_navigation` | `bsk wait-for-navigation` | Wait explicitly for a page lifecycle event. |
|
||||||
|
| `browser_request_help` | `bsk request-help` | Pause for login, captcha, OTP, confirmation, or another human browser step. |
|
||||||
|
| `browser_get_html` | `bsk get-html` | Read raw document or ref-scoped HTML after semantic observation is insufficient. |
|
||||||
|
| `browser_console` | `bsk console` | Read buffered console/log/exception entries without evaluating JavaScript. |
|
||||||
|
| `browser_network` | `bsk network` | Read buffered network response/failure metadata. |
|
||||||
|
| `browser_window_resize` | `bsk window resize` | Resize the owned session's Agent Window. |
|
||||||
|
|
||||||
|
`bsk evaluate` and `bsk record` are intentionally not exposed by this plugin: arbitrary page
|
||||||
|
evaluation is a higher-risk capability, while recording is long-running and needs a dedicated
|
||||||
|
DeepSeek Harness lifecycle before it can be added safely.
|
||||||
|
|
||||||
## Agent skill (progressive disclosure)
|
## Agent skill (progressive disclosure)
|
||||||
|
|
||||||
Beyond the tools, the plugin publishes the **`browser-skill` agent skill** through the harness's
|
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
|
official skill seam (`ctx.skills.register`): the catalog entry (name + routing description) is
|
||||||
resident in `<available_skills>`, and the body is loaded only when the model invokes the `skill`
|
resident in `<available_skills>`, 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
|
tool. Its single source is the DSH-specific `skill/SKILL.md`, which documents only structured
|
||||||
truth: a dsh-specific prelude (`skill/prelude.md` — tool↔CLI map, owned-session semantics,
|
`browser_*` calls and their plugin semantics. The repository-root CLI skill is intentionally not
|
||||||
plugin-only overrides) followed verbatim by the canonical CLI skill (`skill/SKILL.md` at the
|
concatenated: its command examples belong to a different execution interface and would bypass
|
||||||
repo root — the same file `crates/bsk-cli/build.rs` mirrors for the CLI package; workflows,
|
the plugin's ownership, live observation UI, cancellation, and cleanup path if followed directly.
|
||||||
stop-when-done rules, refs usage, sandbox rules). Registration and every pre-step catalog
|
The build rejects internal CLI-name leakage, command-line code blocks, unknown browser tools, and
|
||||||
snapshot are pure in-memory reads (no disk/process/daemon); compositions without the skill
|
missing supported browser tools before embedding the Markdown. Registration and every pre-step
|
||||||
|
catalog snapshot are pure in-memory reads (no disk/process/daemon); compositions without the skill
|
||||||
seam degrade silently.
|
seam degrade silently.
|
||||||
|
|
||||||
## Multi-session model
|
## Multi-session model
|
||||||
@@ -88,7 +109,7 @@ All fields are optional and validated through the plugin's Schemastery `Config`:
|
|||||||
# lazyTools: true # reveal browser_* tools only after the skill is invoked
|
# lazyTools: true # reveal browser_* tools only after the skill is invoked
|
||||||
```
|
```
|
||||||
|
|
||||||
- **`lazyTools` (default `true`)** — the final progressive-disclosure stage: the eleven
|
- **`lazyTools` (default `true`)** — the final progressive-disclosure stage: the 28
|
||||||
`browser_*` tool schemas stay OUT of the system prompt (zero schema tokens) until the
|
`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
|
`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
|
advertisement. One successful invocation (model tool call, or a `/browser-skill` user
|
||||||
@@ -152,7 +173,7 @@ shell's theme cannot bleed back in.
|
|||||||
card). Screenshots additionally attach the image itself when the host mounts an attachment store
|
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.
|
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
|
- **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
|
`lib/client.cjs`, 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
|
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
|
image block, resolves the durable attachment through the client session's authorized
|
||||||
`readAttachment` RPC and renders it with the shared `MessageImage` thumbnail/lightbox atoms.
|
`readAttachment` RPC and renders it with the shared `MessageImage` thumbnail/lightbox atoms.
|
||||||
@@ -183,8 +204,8 @@ as `@wxg-prc-cpg/browser-skill-dsh-plugin`. The Cordis plugin id stays
|
|||||||
Trigger it by pushing a tag that matches `package.json`'s `version`:
|
Trigger it by pushing a tag that matches `package.json`'s `version`:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git tag dsh-plugin-v0.1.0
|
git tag dsh-plugin-v0.1.2
|
||||||
git push origin dsh-plugin-v0.1.0
|
git push origin dsh-plugin-v0.1.2
|
||||||
```
|
```
|
||||||
|
|
||||||
Or run the workflow from the Actions tab (`workflow_dispatch`). The job reads the
|
Or run the workflow from the Actions tab (`workflow_dispatch`). The job reads the
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@wxg-prc-cpg/browser-skill-dsh-plugin",
|
"name": "@wxg-prc-cpg/browser-skill-dsh-plugin",
|
||||||
"description": "DeepSeek Harness tool plugin that exposes BrowserSkill (bsk) browser automation to the model",
|
"description": "DeepSeek Harness tool plugin that exposes BrowserSkill browser automation (browser_* tools) to the model",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
@@ -107,6 +107,8 @@
|
|||||||
"@deepseek-ai/dsh-client-ui-primitives": "^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-client-ui-tool": "^0.1.0-rc.6",
|
||||||
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
|
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
|
||||||
|
"@deepseek-ai/dsh-scope": "^0.1.0-rc.6",
|
||||||
|
"@deepseek-ai/dsh-skill": "^0.1.0-rc.6",
|
||||||
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
|
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
|
||||||
"@deepseek-ai/schemastery": "^3.18.1",
|
"@deepseek-ai/schemastery": "^3.18.1",
|
||||||
"@tailwindcss/cli": "^4.3.3",
|
"@tailwindcss/cli": "^4.3.3",
|
||||||
|
|||||||
@@ -1,20 +1,15 @@
|
|||||||
// Assemble the plugin's skill content: a dsh-specific routing prelude, the
|
// Embed the DeepSeek Harness-specific browser skill at build time. The model-facing
|
||||||
// canonical CLI skill body verbatim (repo-root skill/SKILL.md — the same source
|
// skill deliberately documents only injected browser_* tools; the repository's CLI
|
||||||
// crates/bsk-cli's build.rs mirrors), then a dsh routing reminder postlude. The
|
// skill is a separate interface and must never be concatenated here.
|
||||||
// original's YAML frontmatter is stripped: name/description are supplied by the
|
|
||||||
// registration.
|
|
||||||
// Output: src/skill-content.generated.ts (gitignored, built before tsdown and
|
// Output: src/skill-content.generated.ts (gitignored, built before tsdown and
|
||||||
// before vitest) so the markdown ships inlined in the bundle and registration
|
// before vitest), so registration is a pure in-memory read.
|
||||||
// is a pure in-memory read — never a per-step disk access.
|
|
||||||
import { readFileSync, writeFileSync } from "node:fs";
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const here = dirname(fileURLToPath(import.meta.url));
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
const pkg = join(here, "..");
|
const pkg = join(here, "..");
|
||||||
const prelude = readFileSync(join(pkg, "skill", "prelude.md"), "utf8");
|
const source = readFileSync(join(pkg, "skill", "SKILL.md"), "utf8");
|
||||||
const upstream = readFileSync(join(pkg, "..", "..", "skill", "SKILL.md"), "utf8");
|
|
||||||
const postlude = readFileSync(join(pkg, "skill", "postlude.md"), "utf8");
|
|
||||||
|
|
||||||
function stripFrontmatter(markdown) {
|
function stripFrontmatter(markdown) {
|
||||||
if (!markdown.startsWith("---\n")) return markdown;
|
if (!markdown.startsWith("---\n")) return markdown;
|
||||||
@@ -22,18 +17,69 @@ function stripFrontmatter(markdown) {
|
|||||||
return end === -1 ? markdown : markdown.slice(end + 5).replace(/^\s*\n/, "");
|
return end === -1 ? markdown : markdown.slice(end + 5).replace(/^\s*\n/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SUPPORTED_BROWSER_TOOLS = [
|
||||||
|
"browser_session_start",
|
||||||
|
"browser_session_stop",
|
||||||
|
"browser_session_list",
|
||||||
|
"browser_navigate",
|
||||||
|
"browser_navigate_back",
|
||||||
|
"browser_navigate_forward",
|
||||||
|
"browser_reload",
|
||||||
|
"browser_wait_for_navigation",
|
||||||
|
"browser_snapshot",
|
||||||
|
"browser_observe",
|
||||||
|
"browser_get_html",
|
||||||
|
"browser_screenshot",
|
||||||
|
"browser_console",
|
||||||
|
"browser_network",
|
||||||
|
"browser_click",
|
||||||
|
"browser_hover",
|
||||||
|
"browser_fill",
|
||||||
|
"browser_select",
|
||||||
|
"browser_press",
|
||||||
|
"browser_tab_list",
|
||||||
|
"browser_tab_create",
|
||||||
|
"browser_tab_select",
|
||||||
|
"browser_tab_close",
|
||||||
|
"browser_tab_borrow",
|
||||||
|
"browser_tab_return",
|
||||||
|
"browser_request_help",
|
||||||
|
"browser_emulate",
|
||||||
|
"browser_window_resize",
|
||||||
|
];
|
||||||
|
|
||||||
|
function validateModelFacingSkill(markdown) {
|
||||||
|
if (/\bbsk\b/i.test(markdown)) {
|
||||||
|
throw new Error("DSH skill must not expose the internal CLI name");
|
||||||
|
}
|
||||||
|
if (/```(?:bash|sh|shell)\b/i.test(markdown)) {
|
||||||
|
throw new Error("DSH skill must not contain command-line code blocks");
|
||||||
|
}
|
||||||
|
|
||||||
|
const mentioned = new Set(markdown.match(/\bbrowser_[a-z][a-z_]*\b/g) ?? []);
|
||||||
|
const supported = new Set(SUPPORTED_BROWSER_TOOLS);
|
||||||
|
const unknown = [...mentioned].filter((name) => !supported.has(name));
|
||||||
|
const missing = SUPPORTED_BROWSER_TOOLS.filter((name) => !mentioned.has(name));
|
||||||
|
if (unknown.length > 0) {
|
||||||
|
throw new Error(`DSH skill mentions unsupported browser tools: ${unknown.join(", ")}`);
|
||||||
|
}
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new Error(`DSH skill omits supported browser tools: ${missing.join(", ")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const NAME = "browser-skill";
|
const NAME = "browser-skill";
|
||||||
const DESCRIPTION =
|
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. " +
|
"Browser automation against the user's logged-in Chromium through this plugin's injected " +
|
||||||
"tools (bsk engine): visit and read pages, fill forms, click through flows, scrape data, " +
|
"browser_* tools. Use to visit and read pages, fill forms, click through flows, inspect tabs, " +
|
||||||
"smoke-test UI changes. Use when a task needs a real browser; requires the bsk CLI and the " +
|
"debug page activity, or smoke-test UI changes in a managed Agent Window.";
|
||||||
"browser-skill extension connected.";
|
|
||||||
|
|
||||||
const content = `${prelude.trim()}\n\n${stripFrontmatter(upstream).trim()}\n\n${postlude.trim()}\n`;
|
const content = `${stripFrontmatter(source).trim()}\n`;
|
||||||
|
validateModelFacingSkill(content);
|
||||||
|
|
||||||
const banner =
|
const banner =
|
||||||
"// GENERATED by scripts/build-skill-content.mjs — do not edit.\n" +
|
"// GENERATED by scripts/build-skill-content.mjs — do not edit.\n" +
|
||||||
"// Sources: skill/prelude.md + ../../../skill/SKILL.md + skill/postlude.md.\n";
|
"// Source: skill/SKILL.md (DeepSeek Harness browser_* tool instructions).\n";
|
||||||
|
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
join(pkg, "src", "skill-content.generated.ts"),
|
join(pkg, "src", "skill-content.generated.ts"),
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
---
|
||||||
|
name: browser-skill
|
||||||
|
description: |
|
||||||
|
Use the injected browser_* tools to automate the user's logged-in Chromium
|
||||||
|
browser in a managed Agent Window.
|
||||||
|
---
|
||||||
|
|
||||||
|
# browser-skill for DeepSeek Harness
|
||||||
|
|
||||||
|
Drive the user's real Chromium browser through the structured `browser_*` tools provided by this
|
||||||
|
plugin. The browser keeps the user's existing logins and cookies while isolating automation in an
|
||||||
|
Agent Window. Use the tool schemas exactly as exposed by DeepSeek Harness; arguments are structured
|
||||||
|
fields, not command-line flags.
|
||||||
|
|
||||||
|
## Tool availability
|
||||||
|
|
||||||
|
Loading this skill reveals the browser tools for the rest of the conversation. If the tools were
|
||||||
|
hidden before this skill was loaded, start browser work only after the skill invocation succeeds.
|
||||||
|
|
||||||
|
All browser operations must use the injected tools directly. Do not translate the examples in this
|
||||||
|
document into terminal commands or invoke another process to control the browser. Direct tool calls
|
||||||
|
preserve session ownership, the live observation window, tool cards, cancellation, and cleanup.
|
||||||
|
|
||||||
|
## When to use
|
||||||
|
|
||||||
|
- Visit and read pages that require the user's existing browser session.
|
||||||
|
- Fill forms, select options, click through bounded flows, or smoke-test a web UI.
|
||||||
|
- Inspect tabs, page structure, screenshots, console messages, or network metadata.
|
||||||
|
- Work with a user tab after explicitly borrowing it into the Agent Window.
|
||||||
|
|
||||||
|
Do not use browser automation for tasks that need only local files, a purpose-built API, or an
|
||||||
|
explanation. Never extract credentials, cookies, tokens, or other secrets. Do not keep control of a
|
||||||
|
personal tab longer than the immediate task requires.
|
||||||
|
|
||||||
|
## Mandatory workflow
|
||||||
|
|
||||||
|
Every browser task follows this lifecycle:
|
||||||
|
|
||||||
|
1. Call `browser_session_start` and retain its returned `sessionId`.
|
||||||
|
2. Use that session for every browser operation. Pass `session` explicitly when more than one
|
||||||
|
session exists.
|
||||||
|
3. Call `browser_session_stop` when the bounded goal is reached or an error ends the task. Treat
|
||||||
|
cleanup like a `finally` path unless the user explicitly asks to keep the session open.
|
||||||
|
|
||||||
|
Conceptual tool-call sequence:
|
||||||
|
|
||||||
|
```text
|
||||||
|
browser_session_start({ url: "https://example.com" })
|
||||||
|
browser_observe({ session: sessionId })
|
||||||
|
browser_click({ session: sessionId, target: "@e4" })
|
||||||
|
browser_observe({ session: sessionId })
|
||||||
|
browser_session_stop({ session: sessionId })
|
||||||
|
```
|
||||||
|
|
||||||
|
The plugin owns only sessions created by `browser_session_start`. `browser_session_list` reports
|
||||||
|
only those owned sessions. Never guess or reuse a session id from another program. When `session`
|
||||||
|
is omitted, most tools use the most recently started or used owned session; explicit ids are safer
|
||||||
|
for multi-session work.
|
||||||
|
|
||||||
|
## Stop when the goal is met
|
||||||
|
|
||||||
|
Browser work is a bounded task, not open-ended browsing.
|
||||||
|
|
||||||
|
1. Define one observable success condition from the user's request.
|
||||||
|
2. Take the shortest path: observe, act, then perform at most one relevant check.
|
||||||
|
3. Stop immediately when success is visible. Do not refresh, navigate, click, or perform extra
|
||||||
|
verification after success.
|
||||||
|
4. If a human-only step appears, use `browser_request_help`. If an action fails twice without
|
||||||
|
progress, pause instead of brute-forcing.
|
||||||
|
5. When uncertain, make at most one fresh observation. If the state is still ambiguous, ask the
|
||||||
|
user rather than continuing to click.
|
||||||
|
|
||||||
|
## Core interaction loop
|
||||||
|
|
||||||
|
Use `browser_navigate` to open a destination in the active Agent Window tab, then inspect the page
|
||||||
|
before interacting:
|
||||||
|
|
||||||
|
1. `browser_observe` is the primary semantic view. It returns roles, states, text, and `@eN` refs,
|
||||||
|
including hints about conditional hover or focus surfaces.
|
||||||
|
2. `browser_snapshot` is the static accessibility-tree fallback when the semantic view is
|
||||||
|
insufficient or a stricter tree is useful.
|
||||||
|
3. Prefer a fresh `@eN` ref over a raw CSS selector for `browser_click`, `browser_hover`,
|
||||||
|
`browser_fill`, `browser_select`, and targeted `browser_press` calls.
|
||||||
|
4. Observe again after navigation or a meaningful DOM change before using another ref.
|
||||||
|
|
||||||
|
Refs invalidate after navigation and may also become stale after large DOM changes. Never carry a
|
||||||
|
ref to a new page. If an observation marks a control as hover-triggered, call `browser_hover` on the
|
||||||
|
trigger, observe again, and then act on the newly visible ref.
|
||||||
|
|
||||||
|
Use interaction tools according to their schemas:
|
||||||
|
|
||||||
|
- `browser_click`: click a ref or selector; optionally choose mouse button and click count.
|
||||||
|
- `browser_hover`: move to a ref or selector and allow hover UI to settle; then observe again.
|
||||||
|
- `browser_fill`: clear and fill a text-capable element; set `noClear` only when appending is
|
||||||
|
intentional.
|
||||||
|
- `browser_select`: pass `values` as an array, including for a single option.
|
||||||
|
- `browser_press`: send a key or combo, optionally focusing `target` first.
|
||||||
|
|
||||||
|
Actions may trigger a navigation after the action itself returns. Use
|
||||||
|
`browser_wait_for_navigation` only when that lifecycle wait is actually needed, then observe the
|
||||||
|
new page.
|
||||||
|
|
||||||
|
## Observation and page reading priority
|
||||||
|
|
||||||
|
Escalate page reading only as needed:
|
||||||
|
|
||||||
|
1. `browser_observe` for normal semantic understanding and interaction refs.
|
||||||
|
2. `browser_snapshot` for a stricter static accessibility tree.
|
||||||
|
3. `browser_get_html` for hidden DOM, metadata, or exact markup that the semantic views cannot
|
||||||
|
provide. A `ref` must come from a fresh observation; keep `maxBytes` bounded.
|
||||||
|
4. `browser_screenshot` for layout, styling, canvas, image content, or another genuinely visual
|
||||||
|
question. Pass a fresh `ref` to crop to one element, or omit it for the visible tab.
|
||||||
|
|
||||||
|
Do not start with raw HTML or a screenshot merely to discover ordinary controls. Screenshots may
|
||||||
|
return an inline image or a saved path depending on the deployment.
|
||||||
|
|
||||||
|
## Tabs and the Agent Window boundary
|
||||||
|
|
||||||
|
Write actions normally affect only Agent Window tabs.
|
||||||
|
|
||||||
|
- `browser_tab_list` lists `agent`, `user`, or `all` tabs visible to the owned session. Use
|
||||||
|
`scope: "user"` to locate a user tab before borrowing it.
|
||||||
|
- `browser_tab_create` creates an Agent Window tab. It is active by default; set `active: false`
|
||||||
|
for a background tab.
|
||||||
|
- `browser_tab_select` focuses an Agent Window tab.
|
||||||
|
- `browser_tab_close` closes an Agent Window tab.
|
||||||
|
- `browser_tab_borrow` moves a chosen user tab into the Agent Window before any write action.
|
||||||
|
- `browser_tab_return` returns a borrowed tab to its original window and position. Return it as
|
||||||
|
soon as the immediate task is finished; stopping the session is only the fallback cleanup.
|
||||||
|
|
||||||
|
Use only a `tabId` returned by `browser_tab_list` or `browser_tab_create`. Do not assume a tab id
|
||||||
|
from page position or title.
|
||||||
|
|
||||||
|
## Navigation and lifecycle
|
||||||
|
|
||||||
|
- `browser_navigate` opens a URL and can wait for a requested lifecycle phase.
|
||||||
|
- `browser_navigate_back` and `browser_navigate_forward` move one history entry.
|
||||||
|
- `browser_reload` reloads the current tab; use `hard: true` only when bypassing cache is necessary.
|
||||||
|
- `browser_wait_for_navigation` waits for a lifecycle event after an action that did not already
|
||||||
|
wait. Avoid speculative waits when no navigation is expected.
|
||||||
|
|
||||||
|
After navigation, history traversal, or reload, discard old refs and call `browser_observe` or
|
||||||
|
`browser_snapshot` again.
|
||||||
|
|
||||||
|
## Ask the user for browser help
|
||||||
|
|
||||||
|
Call `browser_request_help` for login, captcha, OTP, payment confirmation, consent, or another step
|
||||||
|
that requires the user. Give a clear `prompt`; when the prompt refers to a concrete control, pass
|
||||||
|
its fresh ref or selector in `targets` so the overlay can highlight it. Use `title` only when it
|
||||||
|
adds clarity.
|
||||||
|
|
||||||
|
Optional `completionCriteria` can finish the pause automatically when the page has an explicit,
|
||||||
|
stable success signal. It supports `any` or `all` conditions using `urlContains`, `urlMatches`,
|
||||||
|
`selectorExists`, `selectorMissing`, `textExists`, or `textMissing`, plus `stableForMs`.
|
||||||
|
|
||||||
|
```text
|
||||||
|
browser_request_help({
|
||||||
|
session: sessionId,
|
||||||
|
title: "Sign in required",
|
||||||
|
prompt: "Complete sign-in, then return control after the account page appears.",
|
||||||
|
targets: ["@e7"],
|
||||||
|
timeoutMs: 300000,
|
||||||
|
completionCriteria: {
|
||||||
|
any: [{ urlContains: "/account" }, { selectorExists: "[data-testid='account-menu']" }],
|
||||||
|
stableForMs: 1000
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Interpret outcomes deliberately:
|
||||||
|
|
||||||
|
- `continued` or `completed`: the user returned control or the explicit detector matched. Observe
|
||||||
|
again before reasoning about the new state or using refs.
|
||||||
|
- `cancelled`: stop the requested flow.
|
||||||
|
- `timed_out`: report the timeout; do not repeatedly reopen the prompt.
|
||||||
|
- `disabled`: unattended mode does not allow help; proceed only if safe or stop gracefully.
|
||||||
|
- `navigated`: legacy outcome; do not treat navigation alone as proof of success.
|
||||||
|
|
||||||
|
## Debugging without page scripting
|
||||||
|
|
||||||
|
Use the read-only debugging tools only when relevant to the user's task:
|
||||||
|
|
||||||
|
- `browser_console` reads buffered console messages, browser logs, and JavaScript exceptions.
|
||||||
|
Set `includeStack: true` when stack frames are useful.
|
||||||
|
- `browser_network` reads buffered response and failure metadata. It does not return request or
|
||||||
|
response headers or bodies and does not modify traffic.
|
||||||
|
|
||||||
|
Both tools accept `tabId`, `since`, `limit`, and `maxTextChars`. Their result returns `nextSince`;
|
||||||
|
pass that value as the next `since` cursor instead of repeatedly requesting the same entries.
|
||||||
|
|
||||||
|
Arbitrary page script evaluation and interaction recording are intentionally unsupported in this
|
||||||
|
DeepSeek Harness plugin. Do not invent tool names or route around this limitation. If the task
|
||||||
|
strictly requires either capability, explain that it is not available in this phase.
|
||||||
|
|
||||||
|
## Window and device controls
|
||||||
|
|
||||||
|
- `browser_window_resize` changes the Agent Window's outer `width` and `height`; each dimension
|
||||||
|
must be from 100 through 7680 CSS pixels.
|
||||||
|
- `browser_emulate` applies a built-in device preset or explicit viewport settings to the active
|
||||||
|
tab. Explicit `width` and `height` must be supplied together. `mobile: true` also requires both
|
||||||
|
dimensions. Use `off: true` alone to clear emulation. Emulation is per-tab and new tabs do not
|
||||||
|
inherit it.
|
||||||
|
|
||||||
|
## Tool reference
|
||||||
|
|
||||||
|
### Sessions
|
||||||
|
|
||||||
|
| Tool | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `browser_session_start` | Start an owned Agent Window session, optionally with URL, size, browser instance, focus behavior, or device preset. |
|
||||||
|
| `browser_session_stop` | Stop an owned session and close its Agent Window. |
|
||||||
|
| `browser_session_list` | List owned sessions and identify the current one. |
|
||||||
|
|
||||||
|
### Navigation and reading
|
||||||
|
|
||||||
|
| Tool | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `browser_navigate` | Navigate the active tab to a URL. |
|
||||||
|
| `browser_navigate_back` | Go back one history entry. |
|
||||||
|
| `browser_navigate_forward` | Go forward one history entry. |
|
||||||
|
| `browser_reload` | Reload the active tab, optionally bypassing cache. |
|
||||||
|
| `browser_wait_for_navigation` | Wait for an expected page lifecycle event. |
|
||||||
|
| `browser_observe` | Read the primary semantic page representation with refs. |
|
||||||
|
| `browser_snapshot` | Read the static accessibility-tree fallback with refs. |
|
||||||
|
| `browser_get_html` | Read raw document or ref-scoped HTML as a last resort. |
|
||||||
|
| `browser_screenshot` | Capture the visible tab or a ref-cropped element. |
|
||||||
|
|
||||||
|
### Interaction
|
||||||
|
|
||||||
|
| Tool | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `browser_click` | Click a ref or CSS selector. |
|
||||||
|
| `browser_hover` | Hover a ref or selector to reveal conditional UI. |
|
||||||
|
| `browser_fill` | Fill a text-capable element. |
|
||||||
|
| `browser_select` | Select one or more option values. |
|
||||||
|
| `browser_press` | Send a keyboard key or combo. |
|
||||||
|
|
||||||
|
### Tabs
|
||||||
|
|
||||||
|
| Tool | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `browser_tab_list` | List visible user and Agent Window tabs. |
|
||||||
|
| `browser_tab_create` | Create an Agent Window tab. |
|
||||||
|
| `browser_tab_select` | Focus an Agent Window tab. |
|
||||||
|
| `browser_tab_close` | Close an Agent Window tab. |
|
||||||
|
| `browser_tab_borrow` | Move a user tab into the Agent Window. |
|
||||||
|
| `browser_tab_return` | Return a borrowed tab to its original window. |
|
||||||
|
|
||||||
|
### Assistance, diagnostics, and display
|
||||||
|
|
||||||
|
| Tool | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `browser_request_help` | Pause for an explicit user browser step. |
|
||||||
|
| `browser_console` | Read buffered console and exception entries. |
|
||||||
|
| `browser_network` | Read buffered response and failure metadata. |
|
||||||
|
| `browser_window_resize` | Resize the Agent Window. |
|
||||||
|
| `browser_emulate` | Apply or clear per-tab device emulation. |
|
||||||
|
|
||||||
|
## Error handling and final checklist
|
||||||
|
|
||||||
|
On a stale ref, observe again and retry once with a fresh ref. On a closed or unknown tab, list
|
||||||
|
tabs instead of guessing. On an unknown session, list owned sessions or start a new one; never try
|
||||||
|
foreign ids. On timeout, decide whether one longer, purposeful wait is justified. Avoid repeated
|
||||||
|
actions with unchanged state.
|
||||||
|
|
||||||
|
Before finishing a browser task, verify that:
|
||||||
|
|
||||||
|
- The user's observable goal is met, or the blocker is clearly reported.
|
||||||
|
- Any borrowed tab has been returned when practical.
|
||||||
|
- The owned session has been stopped unless the user explicitly asked to keep it open.
|
||||||
|
- No further page action is taken after success.
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
---
|
|
||||||
|
|
||||||
## 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`.
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
# 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 <verb> --session <id>` as "the `browser_<verb>` tool with
|
|
||||||
`session: <id>`"; everything else — when to use, the stop-when-done rules, the
|
|
||||||
interaction loop, refs, sandbox rules — applies unchanged.
|
|
||||||
|
|
||||||
---
|
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Archive-triggered bsk session cleanup. DSH conversation archival writes
|
||||||
|
* the workspace registry's durable global state, which the storage domain
|
||||||
|
* broadcasts as `domain/changed` ({domain: 'workspace', table: ''}) with
|
||||||
|
* the new `archivedSessionIds`. The watcher diffs that set and stops every
|
||||||
|
* bsk session owned by a freshly archived conversation — archived sessions
|
||||||
|
* are hidden from every surface, so their Agent Windows would otherwise
|
||||||
|
* linger with no way back to them.
|
||||||
|
*
|
||||||
|
* Ownership lineage: browser_session_start records the calling agent's
|
||||||
|
* session id PLUS every ancestor along `header.parentSession` (a subagent's
|
||||||
|
* browsers are reaped when any ancestor is archived, the root conversation
|
||||||
|
* included). Lineage is resolved through the host session store; an
|
||||||
|
* unloaded ancestor simply ends the walk.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Context } from "@deepseek-ai/cordis";
|
||||||
|
import type { ObservationService } from "./observation";
|
||||||
|
import type { SessionRegistry } from "./sessions";
|
||||||
|
|
||||||
|
/** The wire shape of a `domain/changed` frame (see dsh-storage-domain). */
|
||||||
|
interface DomainChange {
|
||||||
|
domain?: string;
|
||||||
|
table?: string;
|
||||||
|
value?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The workspace registry's durable global singleton slice we read. */
|
||||||
|
interface WorkspaceGlobal {
|
||||||
|
archivedSessionIds?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Structural host session store face: only the lineage read is needed. */
|
||||||
|
interface SessionStoreLike {
|
||||||
|
get(id: string): { header: { parentSession?: string } } | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cap on lineage walks — defensive against a malformed parent chain. */
|
||||||
|
const MAX_LINEAGE_DEPTH = 16;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The DSH session ids that own a tool call's browser sessions: the calling
|
||||||
|
* agent's own session plus every ancestor along the seed lineage. Empty
|
||||||
|
* when the call carried no agent identity (those sessions outlive any
|
||||||
|
* archive cleanup by design — nothing can name their owner).
|
||||||
|
*/
|
||||||
|
export function ownerSessionIds(ctx: Context, agentId: string | undefined): string[] {
|
||||||
|
if (agentId === undefined) return [];
|
||||||
|
const store = ctx.get("sessions") as SessionStoreLike | undefined;
|
||||||
|
const ids: string[] = [];
|
||||||
|
let current: string | undefined = agentId;
|
||||||
|
for (let depth = 0; current !== undefined && depth < MAX_LINEAGE_DEPTH; depth += 1) {
|
||||||
|
if (ids.includes(current)) break; // a cycle in stored headers must not loop
|
||||||
|
ids.push(current);
|
||||||
|
current = store?.get(current)?.header.parentSession;
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watch conversation archival and stop the bsk sessions it opened. Returns
|
||||||
|
* the disposer (plugin unload). In compositions without the workspace
|
||||||
|
* domain (headless), the event simply never fires — and a context without
|
||||||
|
* the events mixin degrades to a no-op like the other optional seams.
|
||||||
|
*/
|
||||||
|
export function armArchiveCleanup(
|
||||||
|
ctx: Context,
|
||||||
|
registry: SessionRegistry,
|
||||||
|
observation: ObservationService,
|
||||||
|
): () => void {
|
||||||
|
// 'domain/changed' lives outside the vendored Events type map, so the
|
||||||
|
// listener goes through a structural view of the events mixin.
|
||||||
|
const on = (
|
||||||
|
ctx as { on?: (event: string, listener: (change: DomainChange) => void) => () => void }
|
||||||
|
).on;
|
||||||
|
if (typeof on !== "function") return () => {};
|
||||||
|
|
||||||
|
/** Archived ids already accounted for; lazily seeded from the registry. */
|
||||||
|
let seen: Set<string> | undefined;
|
||||||
|
const initialize = (): Set<string> => {
|
||||||
|
if (seen === undefined) {
|
||||||
|
const registryService = ctx.get("workspaceRegistry") as
|
||||||
|
| { archivedSessionIds?: readonly string[] }
|
||||||
|
| undefined;
|
||||||
|
seen = new Set(registryService?.archivedSessionIds ?? []);
|
||||||
|
}
|
||||||
|
return seen;
|
||||||
|
};
|
||||||
|
|
||||||
|
return on.call(ctx, "domain/changed", (change: DomainChange) => {
|
||||||
|
if (change?.domain !== "workspace" || change?.table !== "") return;
|
||||||
|
const archived = (change.value as WorkspaceGlobal | undefined)?.archivedSessionIds;
|
||||||
|
if (!Array.isArray(archived)) return;
|
||||||
|
const previous = initialize();
|
||||||
|
const fresh = archived.filter(
|
||||||
|
(id): id is string => typeof id === "string" && !previous.has(id),
|
||||||
|
);
|
||||||
|
seen = new Set(archived.filter((id): id is string => typeof id === "string"));
|
||||||
|
for (const dshSessionId of fresh) {
|
||||||
|
for (const sessionId of registry.ownedByDsh(dshSessionId)) {
|
||||||
|
// stopSession owns the full teardown (kill in-flight tools, queue
|
||||||
|
// the daemon stop, drop registry + observation entries); a failure
|
||||||
|
// just leaves the session for idle timeout or unload cleanup.
|
||||||
|
void observation.stopSession(sessionId).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -30,17 +30,51 @@
|
|||||||
color: var(--card-foreground);
|
color: var(--card-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sidebar carrier: fill the better-sidebar tab area and let the body flex
|
||||||
|
(the tab content container owns the outer sizing). */
|
||||||
|
.sidebar-tab {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tab > * {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The BSK product mark in the sidebar tab strip (the source artwork already
|
||||||
|
carries rounded corners — just soften the square to match). */
|
||||||
|
.brand-icon {
|
||||||
|
display: block;
|
||||||
|
border-radius: 22%;
|
||||||
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
cursor: move;
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Only the floating card drags by its header — the sidebar tab does not. */
|
||||||
|
.header[data-draggable] {
|
||||||
|
cursor: move;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* In the sidebar the status row is chrome, not a card edge: drop its bottom
|
||||||
|
border. A fixed-height bordered row can never track the shell header's
|
||||||
|
hairline across the panel boundary (its height varies per window), and
|
||||||
|
the BSK warm border tint clashes with the sidebar's neutral hairlines —
|
||||||
|
borderless, the stage's own background provides the separation. */
|
||||||
|
.sidebar-tab .header {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
.status-text {
|
.status-text {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -131,6 +165,12 @@
|
|||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.actions-group {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.tool-wrap {
|
.tool-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -165,8 +205,14 @@
|
|||||||
color: var(--destructive);
|
color: var(--destructive);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hover the wrap (not just :hover on the button) so the tooltip target and
|
/* Stop session: danger-tinted like interrupt. */
|
||||||
the left-corner resize overlay still light the icon up. */
|
.tool-stop {
|
||||||
|
color: var(--destructive);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover the wrap (not just :hover on the button) so the tooltip target
|
||||||
|
lights the icon up too. The corner resize handles sit above this bar, so
|
||||||
|
the outer sliver of the corner-most buttons starts a resize instead. */
|
||||||
.tool-wrap:hover .tool-button:not(:disabled) {
|
.tool-wrap:hover .tool-button:not(:disabled) {
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
@@ -181,6 +227,15 @@
|
|||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The armed (confirm) state of the stop button: solid destructive fill so
|
||||||
|
the second click reads as final. Written at the wrap-hover specificity so
|
||||||
|
the hover rules above never wash it out. */
|
||||||
|
.tool-button.tool-stop-armed,
|
||||||
|
.tool-wrap:hover .tool-button.tool-stop-armed {
|
||||||
|
color: var(--destructive-foreground);
|
||||||
|
background: var(--destructive);
|
||||||
|
}
|
||||||
|
|
||||||
.hint {
|
.hint {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: calc(100% + 4px);
|
bottom: calc(100% + 4px);
|
||||||
@@ -203,13 +258,16 @@
|
|||||||
right: 0;
|
right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Invisible hit targets — no grip glyph; all four corners resize. */
|
/* Invisible hit targets — no grip glyph; all four corners resize. Must sit
|
||||||
|
above the bottom .actions bar (z-index 3): it spans the full card width
|
||||||
|
and would otherwise swallow every pointer event aimed at the sw/se
|
||||||
|
corners. The top corners already win because .header is not positioned. */
|
||||||
.resize-handle {
|
.resize-handle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
z-index: 2;
|
z-index: 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resize-handle[data-corner="nw"] {
|
.resize-handle[data-corner="nw"] {
|
||||||
|
|||||||
@@ -3,13 +3,18 @@
|
|||||||
// composer's way); collapsed = a status capsule; "pop out" upgrades the same
|
// composer's way); collapsed = a status capsule; "pop out" upgrades the same
|
||||||
// content to a native Document PiP window (user gesture required by the
|
// content to a native Document PiP window (user gesture required by the
|
||||||
// browser). Multi-session renders a meeting-style strip under the focus view.
|
// browser). Multi-session renders a meeting-style strip under the focus view.
|
||||||
// Visuals follow the BrowserSkill product family: @browser-skill/ui components
|
// When the dsh-better-sidebar plugin is installed the tracking view moves
|
||||||
// and oklch tokens on a .bsk-obs scope root, so the card reads as BSK's own
|
// into a sidebar tab instead (see observation-sidebar.tsx) and this floating
|
||||||
// surface without leaking styles into (or inheriting themes from) the shell.
|
// card hides itself via the sidebar-mode flag. 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 { cn } from "@browser-skill/ui";
|
||||||
import {
|
import {
|
||||||
RiArrowDownSLine,
|
RiArrowDownSLine,
|
||||||
|
RiCheckLine,
|
||||||
|
RiCloseCircleLine,
|
||||||
RiErrorWarningLine,
|
RiErrorWarningLine,
|
||||||
RiPictureInPicture2Line,
|
RiPictureInPicture2Line,
|
||||||
RiPushpinFill,
|
RiPushpinFill,
|
||||||
@@ -25,6 +30,8 @@ const IconPip = asIcon(RiPictureInPicture2Line);
|
|||||||
const IconDown = asIcon(RiArrowDownSLine);
|
const IconDown = asIcon(RiArrowDownSLine);
|
||||||
const IconWarn = asIcon(RiErrorWarningLine);
|
const IconWarn = asIcon(RiErrorWarningLine);
|
||||||
const IconPin = asIcon(RiPushpinFill);
|
const IconPin = asIcon(RiPushpinFill);
|
||||||
|
const IconCloseSession = asIcon(RiCloseCircleLine);
|
||||||
|
const IconCheck = asIcon(RiCheckLine);
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
@@ -39,11 +46,12 @@ import { createPortal } from "react-dom";
|
|||||||
import type { SessionObservation } from "../observation";
|
import type { SessionObservation } from "../observation";
|
||||||
import css from "./ObservationOverlay.module.css";
|
import css from "./ObservationOverlay.module.css";
|
||||||
import type { ObservationClientStore } from "./observation-store";
|
import type { ObservationClientStore } from "./observation-store";
|
||||||
|
import { focusOf, statusOf, useObservationView, usePip } from "./observation-view";
|
||||||
|
import { getSidebarMode, subscribeSidebarMode } from "./sidebar-mode";
|
||||||
|
|
||||||
/** Minimal Document PiP surface (TS lib.dom lacks it). */
|
// The pure view helpers live in observation-view (shared with the sidebar
|
||||||
interface DocumentPip {
|
// tab); re-export so existing imports of this module keep working.
|
||||||
requestWindow(options?: { width?: number; height?: number }): Promise<Window>;
|
export { focusOf, statusOf };
|
||||||
}
|
|
||||||
|
|
||||||
interface Point {
|
interface Point {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -61,11 +69,6 @@ const EDGE_MARGIN = 16;
|
|||||||
/** Default dock: top-right, just under the shell's top bar (no spacing tokens exist in dsh 0.1). */
|
/** Default dock: top-right, just under the shell's top bar (no spacing tokens exist in dsh 0.1). */
|
||||||
const TOP_OFFSET = 64;
|
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 {
|
function clampSize(size: Size, viewport: Size): Size {
|
||||||
const maxW = viewport.w * 0.8;
|
const maxW = viewport.w * 0.8;
|
||||||
const maxH = viewport.h * 0.8;
|
const maxH = viewport.h * 0.8;
|
||||||
@@ -114,23 +117,6 @@ function formatElapsed(sinceMs: number, nowMs: number): string {
|
|||||||
return `${mm}:${ss}`;
|
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. */
|
/** Compact toolbar icon: no label, hover bubble for the name / semantics. */
|
||||||
function IconAction(props: {
|
function IconAction(props: {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -166,6 +152,83 @@ function IconAction(props: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How long the stop button stays armed before the confirm click expires. */
|
||||||
|
const STOP_ARM_MS = 3000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop-session button with a lightweight two-click confirm: the first click
|
||||||
|
* arms the button (solid red, check icon, short expiry), the second executes
|
||||||
|
* the stop. No dialog, no layout shift — and a stray single click can never
|
||||||
|
* close an Agent Window.
|
||||||
|
*/
|
||||||
|
function StopSessionAction(props: {
|
||||||
|
sessionId: string | undefined;
|
||||||
|
onStop: (sessionId: string) => Promise<boolean>;
|
||||||
|
}) {
|
||||||
|
const { sessionId, onStop } = props;
|
||||||
|
const [hover, setHover] = useState(false);
|
||||||
|
const [armed, setArmed] = useState(false);
|
||||||
|
const [stopping, setStopping] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!armed) return;
|
||||||
|
const timer = setTimeout(() => setArmed(false), STOP_ARM_MS);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [armed]);
|
||||||
|
|
||||||
|
// The focused session can vanish mid-arm (stopped elsewhere): disarm.
|
||||||
|
useEffect(() => {
|
||||||
|
if (sessionId === undefined) setArmed(false);
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
const disabled = sessionId === undefined || stopping;
|
||||||
|
const label = stopping
|
||||||
|
? "Stopping session…"
|
||||||
|
: armed
|
||||||
|
? `Confirm stop session ${sessionId ?? ""}`
|
||||||
|
: `Stop session ${sessionId ?? ""}`;
|
||||||
|
const hint = stopping
|
||||||
|
? "Closing the Agent Window…"
|
||||||
|
: armed
|
||||||
|
? `Click again to stop session ${sessionId ?? ""} and close its Agent Window.`
|
||||||
|
: `Stop session ${sessionId ?? ""} and close its Agent Window.`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={css["tool-wrap"]}
|
||||||
|
onPointerEnter={() => setHover(true)}
|
||||||
|
onPointerLeave={() => setHover(false)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(css["tool-button"], css["tool-stop"], armed && css["tool-stop-armed"])}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={label}
|
||||||
|
aria-pressed={armed}
|
||||||
|
data-armed={armed || undefined}
|
||||||
|
onClick={() => {
|
||||||
|
if (sessionId === undefined || stopping) return;
|
||||||
|
if (!armed) {
|
||||||
|
setArmed(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setArmed(false);
|
||||||
|
setStopping(true);
|
||||||
|
const target = sessionId;
|
||||||
|
void onStop(target).finally(() => setStopping(false));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{armed ? <IconCheck size={16} /> : <IconCloseSession size={16} />}
|
||||||
|
</button>
|
||||||
|
{hover ? (
|
||||||
|
<span className={css.hint} role="tooltip">
|
||||||
|
{hint}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Flat status dot, specced after the BSK popup's ConnectionStatusIndicator. */
|
/** Flat status dot, specced after the BSK popup's ConnectionStatusIndicator. */
|
||||||
function StatusDot({ state }: { state: "active" | "idle" | "error" | "dead" }) {
|
function StatusDot({ state }: { state: "active" | "idle" | "error" | "dead" }) {
|
||||||
const color =
|
const color =
|
||||||
@@ -246,8 +309,8 @@ function StripItem(props: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The floating card / PiP shared content. */
|
/** The floating card / PiP / sidebar-tab shared content. */
|
||||||
function OverlayBody(props: {
|
export function OverlayBody(props: {
|
||||||
store: ObservationClientStore;
|
store: ObservationClientStore;
|
||||||
focus: SessionObservation | undefined;
|
focus: SessionObservation | undefined;
|
||||||
sessions: readonly SessionObservation[];
|
sessions: readonly SessionObservation[];
|
||||||
@@ -310,6 +373,7 @@ function OverlayBody(props: {
|
|||||||
<div
|
<div
|
||||||
className={css.header}
|
className={css.header}
|
||||||
data-testid="obs-header"
|
data-testid="obs-header"
|
||||||
|
data-draggable={onHeaderPointerDown !== undefined || undefined}
|
||||||
onPointerDown={onHeaderPointerDown}
|
onPointerDown={onHeaderPointerDown}
|
||||||
role="presentation"
|
role="presentation"
|
||||||
>
|
>
|
||||||
@@ -363,15 +427,21 @@ function OverlayBody(props: {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className={css.actions}>
|
<div className={css.actions}>
|
||||||
<IconAction
|
<div className={css["actions-group"]}>
|
||||||
label={interrupting ? "Interrupting…" : "Interrupt the current browser action"}
|
<IconAction
|
||||||
hint="Stop the current browser action."
|
label={interrupting ? "Interrupting…" : "Interrupt the current browser action"}
|
||||||
disabled={!canInterrupt}
|
hint="Stop the current browser action."
|
||||||
danger
|
disabled={!canInterrupt}
|
||||||
onClick={onInterrupt}
|
danger
|
||||||
>
|
onClick={onInterrupt}
|
||||||
<IconStop size={16} />
|
>
|
||||||
</IconAction>
|
<IconStop size={16} />
|
||||||
|
</IconAction>
|
||||||
|
<StopSessionAction
|
||||||
|
sessionId={focus?.sessionId}
|
||||||
|
onStop={(sessionId) => store.stopSession(sessionId)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{onPopOut !== undefined ? (
|
{onPopOut !== undefined ? (
|
||||||
<IconAction
|
<IconAction
|
||||||
label="Pop out into a mini window"
|
label="Pop out into a mini window"
|
||||||
@@ -387,25 +457,17 @@ function OverlayBody(props: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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 }) {
|
export function ObservationOverlay({ store }: { store: ObservationClientStore }) {
|
||||||
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot);
|
const { snapshot, focus, pinnedId, onTogglePin, now } = useObservationView(store);
|
||||||
useEffect(() => {
|
// While the better-sidebar plugin carries the tracking view, this floating
|
||||||
store.start();
|
// card (and its capsule) stays out of the way; an already-open PiP window
|
||||||
return () => store.stop();
|
// keeps its portal until the user closes it.
|
||||||
}, [store]);
|
const sidebarMode = useSyncExternalStore(subscribeSidebarMode, getSidebarMode);
|
||||||
|
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const [pos, setPos] = useState<Point | null>(null);
|
const [pos, setPos] = useState<Point | null>(null);
|
||||||
const [size, setSize] = useState<Size>(DEFAULT_SIZE);
|
const [size, setSize] = useState<Size>(DEFAULT_SIZE);
|
||||||
const [pipWindow, setPipWindow] = useState<Window | null>(null);
|
const { pipWindow, pipSupported, popOut } = usePip();
|
||||||
const [pinnedId, setPinnedId] = useState<string | null>(null);
|
|
||||||
const dragRef = useRef<{
|
const dragRef = useRef<{
|
||||||
kind: "move" | "resize";
|
kind: "move" | "resize";
|
||||||
corner?: ResizeCorner;
|
corner?: ResizeCorner;
|
||||||
@@ -414,23 +476,6 @@ export function ObservationOverlay({ store }: { store: ObservationClientStore })
|
|||||||
base: Point & Size;
|
base: Point & Size;
|
||||||
} | null>(null);
|
} | 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 viewport = (): Size => ({ w: window.innerWidth, h: window.innerHeight });
|
||||||
|
|
||||||
const onPointerMove = useCallback((event: PointerEvent) => {
|
const onPointerMove = useCallback((event: PointerEvent) => {
|
||||||
@@ -511,34 +556,21 @@ export function ObservationOverlay({ store }: { store: ObservationClientStore })
|
|||||||
document.addEventListener("pointerup", onPointerUp);
|
document.addEventListener("pointerup", onPointerUp);
|
||||||
};
|
};
|
||||||
|
|
||||||
const popOut = async (): Promise<void> => {
|
|
||||||
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 = (
|
const body = (
|
||||||
<OverlayBody
|
<OverlayBody
|
||||||
store={store}
|
store={store}
|
||||||
focus={focus}
|
focus={focus}
|
||||||
sessions={snapshot.sessions}
|
sessions={snapshot.sessions}
|
||||||
available={snapshot.available}
|
available={snapshot.available}
|
||||||
pinnedId={pinned !== undefined ? pinnedId : null}
|
pinnedId={pinnedId}
|
||||||
onTogglePin={onTogglePin}
|
onTogglePin={onTogglePin}
|
||||||
now={now}
|
now={now}
|
||||||
inPip={pipWindow !== null}
|
inPip={pipWindow !== null}
|
||||||
onPopOut={pipWindow === null && pipApi() !== undefined ? () => void popOut() : undefined}
|
onPopOut={
|
||||||
|
pipWindow === null && pipSupported
|
||||||
|
? () => popOut({ width: size.w, height: size.h })
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onCollapse={pipWindow === null ? () => setCollapsed(true) : undefined}
|
onCollapse={pipWindow === null ? () => setCollapsed(true) : undefined}
|
||||||
onHeaderPointerDown={pipWindow === null ? beginMove : undefined}
|
onHeaderPointerDown={pipWindow === null ? beginMove : undefined}
|
||||||
/>
|
/>
|
||||||
@@ -548,6 +580,12 @@ export function ObservationOverlay({ store }: { store: ObservationClientStore })
|
|||||||
return createPortal(body, pipWindow.document.body);
|
return createPortal(body, pipWindow.document.body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The sidebar tab is the carrier now — no floating card, no capsule.
|
||||||
|
if (sidebarMode) return null;
|
||||||
|
|
||||||
|
// Hidden while no owned session exists (and no PiP is up).
|
||||||
|
if (snapshot.sessions.length === 0) return null;
|
||||||
|
|
||||||
if (collapsed) {
|
if (collapsed) {
|
||||||
const state = focus !== undefined ? statusOf(focus) : "idle";
|
const state = focus !== undefined ? statusOf(focus) : "idle";
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* The BrowserSkill product mark (apps/extension/assets/logo.png, downscaled
|
||||||
|
* to 32px and inlined): the sidebar tab icon, so the tracking view reads as
|
||||||
|
* BSK's own surface next to better-sidebar's built-in "browser" tab.
|
||||||
|
* Regenerate with: resize the source to 32x32 PNG and replace the payload.
|
||||||
|
*/
|
||||||
|
export const BSK_LOGO_URL =
|
||||||
|
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAJGklEQVR42m2X248c2V3HP79zTlV19/TcPOOZWY+vsdmQFdngTQJswgs3hQ15iCJslAey+8xDAIHgH+CBNxIpj0gkEXnAKyKQWITEJlKWXSssEMfJRmuMHa/W9njHMz3Tl+nprsv5/XiomlugpdN16lR1n9/1e75fAbAbeLlO3Pz2ly4vjO7+sRbF58oiP49FEREOPwaIYQjS3IvUy0fP66kAZoYJIM7SpPVY0uy14cyzXzn9u9+4c+MG/vp1otiNa16uvxr7X/+1L3bK3lcTyU9P80ilR/uKCGYHOxxd6s2kmdaGIVYb1qyY1e8HD63UUblstwirf9J95fW/sRvXvAD0v/6Z6/O68Xf5/pS8kigiTsTkuCfIwfzAxQN37chjaAyt594JIXE4gbKMhog6Nd/qthi7lVe6L3/nG7Jz4/fPJ9u3b4U4WSwiKog/EcsTLh+PSuO3GXYs/D44QnBgyniQs7OV0+8bOxPPM6c9l660VEyF0Nmrlp9/IYT+/S/PUJ4a5C46EW8HnjVhBKjXaHJfz9Xq95xzhMThHZR5xXBrwtPNnP6eRzvzhJWzrF1d5UI759//9iYra5lrt1ycDeXsaOf+n4Y4HX8+r9SciasLS/6P12LHoyF47wipYKpM9gqePpyyvVMxjBn+1DILH17l0rkZlhYcLetT3n/A5FGfTtvTCh4ibjpVI81/J0il62V1rNStLp6j4jowxwiJh2iMevtsbU3pjYQim6Nz9hJLH13i8mrGbJgSxjuUvQfEjT2GOw4Zddh0BakPtFPH/jRKqaCSrwetKi9mKIIYhyFuOg6TurCcF7ae5ty5t48/vcTiz3+Ei+szLC06WnGA7TykfNCnmObk5nAtj+53yDcTZteMwZOChbkMVa3/14DCJBBBFBxyopndUUBqwwxu/XjAJ778Bc4ul+R3fgyj9yg/GDMpFZxHgoe0hQ9QDRzFI48zqHzJeKhcvNKiLAyiQ1BwgsOcqNZ9Xo96rgaVgmqdiBACH740y/1/+j4b7w1x80tUkwLM4VptJAQMcN6o+p7iYQIqhBQmsUIrR7cd0NI12ODAwJnWTpvZoRGqdRrS1CECeR7Z2NzHRBjeecgbX/kut39UUlx5ATe3gFUlAM5Tb/4oHCJl0oadUcFsK8E5sFh7ZaqgEFBr3KyhxAySxDMplDv3BuztlmhhZC6QqONDrXkW28Kbf/1d7A9+i6vPr7N/e5vQDZQ7jvxxUqdPwFRwHaP3sGR1boZYGabWwHdd7MHMMBUMh5mRJp6tnZx33tllJbS53J1jZj6BKpKPpkhZ8e5mj3Nf/FV+8ZOnmNz6Pr6TUvUcxeOAc0cN6xwUoWIyUhZXMqqq6bKmrQ0hWBQzdXX+nGOwV3LnJ30+trjE8mxGUUVG/Qn7owkt7/hpb5fwmat84qXz5P95EwmOqh8oNkKTSkFqICTJYDgpCOppZYGyjIfgdoApQRScCtEgDZ5b93dxuWd3OuWD8T5pqbSLyEyWcHdrh+S3r/Kpz15i/z/eQhIh9hOKjQRTyBYgFhAndSpDW+gNC+ZbGR6jjM3BVsNsXQNqhqpiOPIicuXcLFvdKT7xtNXTG+X0Bopt9Wi9dJUXX7rI5O23kNQRB4F8I0CEzgr0/B7t2YB/2iJOQDrK8HHJ+dkZqqI+JfUAYRXMGc6pM1MBBa2Ubttz5ews51c6XFjr8OJHT5N0HPIbz/Ppz11h/+03IXVUg4T8ccAidFaNp7LHzdd2uXdvj866gQq5L8nHMD+TUJUG0aAyJBqidZQcUfFqeK2vlEo1rYh5iUXl6eMB5eoKv37tOUb/9q+4LBD7gXKj3ry7Ak/iHrdeH/ArC7P07ykPekMWzkFvkNMmkHkgagNoDciogYKzaGg0YgSNYLEJj0ndfiGj6o/Z3i5or5+j2BTKJwlawcwKbMQ9fvK9Ib+QtgitQMg8d384wVYLdocls1mCad1+Fg1TDgeV4UxFTB2ow44NjQ5rwcwZ+MjqmLe/dZOBLOHHCeUUZteED+Ie73xvyHNpmyx1TERozXieW1/mO/+yzWhTWVtsU5aCqKvRT4+GmRDEHGJ6yPPMBHNGeqbCdwqs02X9l14g/a8H3PzWm/zyp1ZZXgn8tDfgnbeGPN/qIEDSyhh7w2OcPZWRsESWCq3EoVFPksZDMuUIps7qLjDUQBxkZ0tcu8KSGdIXPo3OLbOSdfj4YMIPbg6ZXxux9wRmWimFKe0k0J7v8H5vQHchpSoiC536bKhKEDlCJzM7JLJqhqtxoEYoUcEFw/Yduh8Jl38OP7eAlBOm29ssziV87JlluqMOL15ZZWVlhu0yMrfYxQQmZUU3TbCmnog1mRE1xOp9xOoaEwVRI9QnUGOECLoPNlcRlucJ6xeIgx3K/3kX23pINWnRdnBxtYNWkbkkMJ5JSLKEwaSgRFlsJWh1mNBDfsEBzZGaYIiBIgRtImCmmArhlBFOR1Q9xZ3b6OOHWFnifEq1U/84TpXgHL4hUh7hv3t91pY6eCeUpTYs2o6RisYEO6B5hpoSRCWaanNAGP5UVZ+r+YB4dxcbZ4QVx/Q9wSYg/uDYNvKyoorwgyfbtDuBC6c65HmFa5Jsh56f3NgO4iGeYC68n8Ty2WkphAUVv2hoLuggoXgUcJkRh0rVF7wHi3Vw1YxuSMh1zMpcmw8tdynzCmmCL1JjzQG3lpNU37xAdNnjYC79xywUfzbJNboEX214iqeeuN/IrkKw6BAvNUiJ1a2K0HKOT144jQBFoTWRayrc9LhWa1TTUTNqJ038wCf/LO//xZfWWw9u/zCt8uWpSiTixZ3UeJzQPkerdkyq1RryZwVN87CJBALR0ExULMtG4/VnPy4AG3/4m19YGG/9fT7NmZpEhzg5lCNHOo//774u7UYp2WEt1XrSftYMTcF32ynbrfmXz3ztjW86u3bNn/nq69/eaZ/6PUlbm/PB+YSapUozMG2uB0M5fI7WSKoKasjBu6qHczEjwWQ2iJc03XmaLb5y5mtvfNOuNeLUruHlVeK7f/7yxbXBu39U5sXnq7I8L2pH6rwRgCZyUpeKnZSOB5K2EbT1l5hPk0c+zV7bXXzmry7/5T/cvXENf/1V4v8CSYIrURx3kHgAAAAASUVORK5CYII=";
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* dsh-plugin-browserskill browser half: the `browser_screenshot` keyed
|
* dsh-plugin-browserskill browser half: the `browser_screenshot` keyed
|
||||||
* toolview plus the observation overlay (live thumbnails + interrupt) floating
|
* toolview plus the observation overlay (live thumbnails + interrupt). The
|
||||||
* over the shell via the `shell.overlay` seat.
|
* default carrier is a floating card on the `shell.overlay` seat; when the
|
||||||
|
* dsh-better-sidebar plugin provides its `betterSidebar` service, the view
|
||||||
|
* moves into a sidebar tab instead (see observation-sidebar.tsx).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ImageAttachmentRef } from "@deepseek-ai/dsh-attachment";
|
import type { ImageAttachmentRef } from "@deepseek-ai/dsh-attachment";
|
||||||
@@ -15,6 +17,7 @@ import { createElement } from "react";
|
|||||||
import "./bsk-tokens.nomodule.css";
|
import "./bsk-tokens.nomodule.css";
|
||||||
import "./bsk-ui.nomodule.css";
|
import "./bsk-ui.nomodule.css";
|
||||||
import { ObservationOverlay } from "./ObservationOverlay";
|
import { ObservationOverlay } from "./ObservationOverlay";
|
||||||
|
import { type BetterSidebarLike, registerObservationSidebar } from "./observation-sidebar";
|
||||||
import { type EventSourceLike, ObservationClientStore } from "./observation-store";
|
import { type EventSourceLike, ObservationClientStore } from "./observation-store";
|
||||||
import { ScreenshotToolView } from "./ScreenshotToolView";
|
import { ScreenshotToolView } from "./ScreenshotToolView";
|
||||||
|
|
||||||
@@ -81,4 +84,14 @@ export function apply(ctx: ClientContext): void {
|
|||||||
createElement(ObservationOverlay, { store }),
|
createElement(ObservationOverlay, { store }),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
// Optional carrier upgrade: when the dsh-better-sidebar plugin is installed,
|
||||||
|
// its service moves the tracking view into a sidebar tab (the floating
|
||||||
|
// overlay hides itself through the sidebar-mode flag). In profiles without
|
||||||
|
// the sidebar plugin this fiber never runs and nothing changes.
|
||||||
|
ctx.inject(["betterSidebar"], (injected) =>
|
||||||
|
registerObservationSidebar(
|
||||||
|
(injected as unknown as { betterSidebar: BetterSidebarLike }).betterSidebar,
|
||||||
|
store,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
// better-sidebar carrier for the observation view. When the
|
||||||
|
// dsh-better-sidebar plugin is installed its client publishes a
|
||||||
|
// `betterSidebar` cordis service; the client entry then runs the
|
||||||
|
// registration below and the tracking view moves from the floating card
|
||||||
|
// into a single-instance sidebar tab (Document PiP pop-out stays available
|
||||||
|
// from inside the tab). Detection is purely service-based — profiles
|
||||||
|
// without the sidebar plugin never start this fiber and keep the floating
|
||||||
|
// overlay.
|
||||||
|
|
||||||
|
import { createElement, type ReactNode } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { BSK_LOGO_URL } from "./brand-icon";
|
||||||
|
import { OverlayBody } from "./ObservationOverlay";
|
||||||
|
import css from "./ObservationOverlay.module.css";
|
||||||
|
import type { ObservationClientStore } from "./observation-store";
|
||||||
|
import { useObservationView, usePip, visibleToScope } from "./observation-view";
|
||||||
|
import { setSidebarMode } from "./sidebar-mode";
|
||||||
|
|
||||||
|
/** The tab title — "Browser Skill", distinct from the sidebar's built-in "browser" tab. */
|
||||||
|
const TAB_TITLE = "Browser Skill";
|
||||||
|
|
||||||
|
/** Tab strip icon: the BrowserSkill product mark at the requested size. */
|
||||||
|
function TabIcon({ size }: { size: number }) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={BSK_LOGO_URL}
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
alt=""
|
||||||
|
aria-hidden
|
||||||
|
className={css["brand-icon"]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structural mirrors of dsh-better-sidebar's client service surface (only
|
||||||
|
* the slices this integration touches — see the upstream
|
||||||
|
* lib/types/client/service.d.ts). Declared locally so the plugin carries no
|
||||||
|
* dependency on the sidebar package; the service contract has been stable
|
||||||
|
* since v0.4.0 and newer capabilities arrive behind its `features` list.
|
||||||
|
*/
|
||||||
|
export interface SidebarTabLike {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SidebarLeafLike {
|
||||||
|
kind: "leaf";
|
||||||
|
id: string;
|
||||||
|
tabs: SidebarTabLike[];
|
||||||
|
active: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SidebarSplitLike {
|
||||||
|
kind: "split";
|
||||||
|
id: string;
|
||||||
|
dir: "row" | "col";
|
||||||
|
sizes: number[];
|
||||||
|
children: SidebarNodeLike[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SidebarNodeLike = SidebarLeafLike | SidebarSplitLike;
|
||||||
|
|
||||||
|
export interface SidebarStateLike {
|
||||||
|
splits: SidebarNodeLike;
|
||||||
|
bottomSplits: SidebarNodeLike;
|
||||||
|
/** Whether the right panel is expanded (the merged drawer on narrow screens). */
|
||||||
|
panelOpen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SidebarSnapshotLike {
|
||||||
|
sessionId?: string;
|
||||||
|
state?: SidebarStateLike;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props every tab component receives (the slices we read). */
|
||||||
|
export interface TabComponentPropsLike {
|
||||||
|
/** The conversation this sidebar instance belongs to. */
|
||||||
|
scope: { sessionId: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TabDescriptorLike {
|
||||||
|
id: string;
|
||||||
|
title: string | (() => string);
|
||||||
|
icon?: ReactNode | ((size: number) => ReactNode);
|
||||||
|
order?: number;
|
||||||
|
/** Single-instance: opening focuses the existing tab instead of duplicating. */
|
||||||
|
single?: boolean;
|
||||||
|
/** Small pill on the tab strip; null/undefined hides it. */
|
||||||
|
badge?: (
|
||||||
|
ctx: unknown,
|
||||||
|
scope: { sessionId: string },
|
||||||
|
state: unknown,
|
||||||
|
) => string | number | null | undefined;
|
||||||
|
component: (props: TabComponentPropsLike) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BetterSidebarLike {
|
||||||
|
registerTab(descriptor: TabDescriptorLike): () => void;
|
||||||
|
openTab(seed: {
|
||||||
|
type: string;
|
||||||
|
title?: string;
|
||||||
|
/** Content seed (lands on tab.path); content opens expand the panel. */
|
||||||
|
path?: string;
|
||||||
|
}): void;
|
||||||
|
getSnapshot(): SidebarSnapshotLike;
|
||||||
|
/** Sidebar state feed (v0.12+): session switches, state and prefs changes. */
|
||||||
|
subscribeState?: (listener: () => void) => () => void;
|
||||||
|
isTabEnabled(id: string): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The registered tab type id (also the SidebarTab.type value). */
|
||||||
|
export const OBSERVATION_TAB_TYPE = "browserskill:observation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inert content seed carried on auto-opened tabs: its mere presence makes
|
||||||
|
* the sidebar treat the open as a content open (expanding the hosting panel
|
||||||
|
* so the tracking view lands in sight). Never read by our component.
|
||||||
|
*/
|
||||||
|
export const OBSERVATION_TAB_PATH = "browser-skill:observation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The observation tab body: the same OverlayBody the floating card renders,
|
||||||
|
* minus the card chrome (no drag header, no collapse — the sidebar tab bar
|
||||||
|
* owns those), plus the PiP pop-out upgrade. The view is scoped to the
|
||||||
|
* sidebar's conversation: only browser sessions started by it (or its
|
||||||
|
* descendants) show here — the floating card keeps the global view.
|
||||||
|
*/
|
||||||
|
export function ObservationSidebarTab({
|
||||||
|
store,
|
||||||
|
scopeId,
|
||||||
|
}: {
|
||||||
|
store: ObservationClientStore;
|
||||||
|
scopeId: string;
|
||||||
|
}) {
|
||||||
|
const { snapshot, focus, pinnedId, onTogglePin, now } = useObservationView(store, scopeId);
|
||||||
|
const { pipWindow, pipSupported, popOut } = usePip();
|
||||||
|
|
||||||
|
const body = (
|
||||||
|
<OverlayBody
|
||||||
|
store={store}
|
||||||
|
focus={focus}
|
||||||
|
sessions={snapshot.sessions}
|
||||||
|
available={snapshot.available}
|
||||||
|
pinnedId={pinnedId}
|
||||||
|
onTogglePin={onTogglePin}
|
||||||
|
now={now}
|
||||||
|
inPip={pipWindow !== null}
|
||||||
|
onPopOut={pipWindow === null && pipSupported ? () => popOut() : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pipWindow !== null) {
|
||||||
|
return createPortal(body, pipWindow.document.body);
|
||||||
|
}
|
||||||
|
return <div className={css["sidebar-tab"]}>{body}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function* leafNodes(node: SidebarNodeLike): Generator<SidebarLeafLike> {
|
||||||
|
if (node.kind === "leaf") {
|
||||||
|
yield node;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const child of node.children) yield* leafNodes(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a tab of our type is already open in either sidebar workbench. */
|
||||||
|
export function observationTabOpen(state: SidebarStateLike | undefined): boolean {
|
||||||
|
if (state === undefined) return false;
|
||||||
|
for (const root of [state.splits, state.bottomSplits]) {
|
||||||
|
for (const leaf of leafNodes(root)) {
|
||||||
|
if (leaf.tabs.some((tab) => tab.type === OBSERVATION_TAB_TYPE)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the observation sidebar tab and flip the carrier flag. Returns
|
||||||
|
* the disposer the cordis fiber invokes when the sidebar service goes away
|
||||||
|
* (plugin unload/HMR): the floating overlay then resumes as the carrier.
|
||||||
|
*/
|
||||||
|
export function registerObservationSidebar(
|
||||||
|
service: BetterSidebarLike,
|
||||||
|
store: ObservationClientStore,
|
||||||
|
): () => void {
|
||||||
|
setSidebarMode(true);
|
||||||
|
// Hold the feed for the whole sidebar lifetime so the auto-open watcher
|
||||||
|
// sees new sessions even while the tab itself is closed.
|
||||||
|
store.acquire();
|
||||||
|
const disposeTab = service.registerTab({
|
||||||
|
id: OBSERVATION_TAB_TYPE,
|
||||||
|
title: TAB_TITLE,
|
||||||
|
icon: (size: number) => createElement(TabIcon, { size }),
|
||||||
|
single: true,
|
||||||
|
// The badge counts only sessions visible to the tab strip's own
|
||||||
|
// conversation (global sessions from other conversations stay hidden,
|
||||||
|
// mirroring the tab body's scoped view).
|
||||||
|
badge: (_ctx, scope) => {
|
||||||
|
const count = store
|
||||||
|
.getSnapshot()
|
||||||
|
.sessions.filter((s) => visibleToScope(s, scope.sessionId)).length;
|
||||||
|
return count > 0 ? count : null;
|
||||||
|
},
|
||||||
|
component: (props) =>
|
||||||
|
createElement(ObservationSidebarTab, { store, scopeId: props.scope.sessionId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-open the tab when a browser session VISIBLE TO the active
|
||||||
|
// conversation appears (and right away when one is already live). The
|
||||||
|
// evaluation runs on observation publishes AND on sidebar state changes:
|
||||||
|
// the latter covers the sidebar store's async init (its state is
|
||||||
|
// undefined for the first beats after page load) and conversation
|
||||||
|
// switches. The open carries a content seed (`path`): only content opens
|
||||||
|
// land in sight — the sidebar expands the hosting panel for them. While
|
||||||
|
// the panel is OPEN an existing tab is never re-focused — the user may
|
||||||
|
// be reading another page on purpose; while it is collapsed a NEW
|
||||||
|
// session (0→N) nudges the tab back into sight, mirroring how the
|
||||||
|
// floating card used to reappear.
|
||||||
|
const activeVisibleCount = (): number => {
|
||||||
|
const activeId = service.getSnapshot().sessionId;
|
||||||
|
if (activeId === undefined) return 0;
|
||||||
|
return store.getSnapshot().sessions.filter((s) => visibleToScope(s, activeId)).length;
|
||||||
|
};
|
||||||
|
let previousVisible = activeVisibleCount();
|
||||||
|
const evaluate = (): void => {
|
||||||
|
const count = activeVisibleCount();
|
||||||
|
const { state, sessionId } = service.getSnapshot();
|
||||||
|
if (
|
||||||
|
state !== undefined &&
|
||||||
|
sessionId !== undefined &&
|
||||||
|
service.isTabEnabled(OBSERVATION_TAB_TYPE)
|
||||||
|
) {
|
||||||
|
const open = observationTabOpen(state);
|
||||||
|
if (count > 0 && !open) {
|
||||||
|
service.openTab({ type: OBSERVATION_TAB_TYPE, path: OBSERVATION_TAB_PATH });
|
||||||
|
} else if (previousVisible === 0 && count > 0 && open && state.panelOpen === false) {
|
||||||
|
service.openTab({ type: OBSERVATION_TAB_TYPE, path: OBSERVATION_TAB_PATH });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
previousVisible = count;
|
||||||
|
};
|
||||||
|
evaluate();
|
||||||
|
const unsubscribe = store.subscribe(evaluate);
|
||||||
|
const unsubscribeState = service.subscribeState?.(evaluate);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
unsubscribeState?.();
|
||||||
|
disposeTab();
|
||||||
|
store.release();
|
||||||
|
setSidebarMode(false);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -53,6 +53,7 @@ export interface OverlaySnapshot {
|
|||||||
const STATE_URL = "/bsk-observation/state";
|
const STATE_URL = "/bsk-observation/state";
|
||||||
const EVENTS_URL = "/bsk-observation/events";
|
const EVENTS_URL = "/bsk-observation/events";
|
||||||
const INTERRUPT_URL = "/bsk-observation/interrupt";
|
const INTERRUPT_URL = "/bsk-observation/interrupt";
|
||||||
|
const STOP_URL = "/bsk-observation/stop";
|
||||||
|
|
||||||
function revoke(url: string | undefined): void {
|
function revoke(url: string | undefined): void {
|
||||||
if (url !== undefined && typeof URL.revokeObjectURL === "function") {
|
if (url !== undefined && typeof URL.revokeObjectURL === "function") {
|
||||||
@@ -78,6 +79,8 @@ export class ObservationClientStore {
|
|||||||
};
|
};
|
||||||
private available = true;
|
private available = true;
|
||||||
private started = false;
|
private started = false;
|
||||||
|
/** Refcount of mounted consumers (overlay card, sidebar tab, sidebar fiber). */
|
||||||
|
private consumers = 0;
|
||||||
|
|
||||||
constructor(private readonly deps: ObservationClientDeps) {}
|
constructor(private readonly deps: ObservationClientDeps) {}
|
||||||
|
|
||||||
@@ -189,6 +192,23 @@ export class ObservationClientStore {
|
|||||||
this.publish();
|
this.publish();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hold the feed for one consumer's lifetime: the stream starts with the
|
||||||
|
* first holder and stops with the last release. Several carriers can share
|
||||||
|
* the store (the floating card, the better-sidebar tab, and the sidebar
|
||||||
|
* integration fiber) without one unmount killing the others' updates.
|
||||||
|
*/
|
||||||
|
acquire(): void {
|
||||||
|
this.consumers += 1;
|
||||||
|
if (this.consumers === 1) this.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
release(): void {
|
||||||
|
if (this.consumers === 0) return;
|
||||||
|
this.consumers -= 1;
|
||||||
|
if (this.consumers === 0) this.stop();
|
||||||
|
}
|
||||||
|
|
||||||
/** Forget one session's tracked + held frames, revoking their blob URLs. */
|
/** Forget one session's tracked + held frames, revoking their blob URLs. */
|
||||||
private dropThumb(sessionId: string): void {
|
private dropThumb(sessionId: string): void {
|
||||||
const attachmentId = this.thumbBySession.get(sessionId);
|
const attachmentId = this.thumbBySession.get(sessionId);
|
||||||
@@ -292,4 +312,23 @@ export class ObservationClientStore {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop one session and close its Agent Window. The session's removal
|
||||||
|
* arrives through the SSE remove event — no local state is touched here.
|
||||||
|
*/
|
||||||
|
async stopSession(sessionId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const res = await this.deps.fetchFn(STOP_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ sessionId }),
|
||||||
|
});
|
||||||
|
if (!res.ok) return false;
|
||||||
|
const body = (await res.json()) as { stopped?: boolean };
|
||||||
|
return body.stopped === true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* Shared view logic for the observation carriers (the floating overlay card
|
||||||
|
* and the better-sidebar tab): the store-backed view model (snapshot, focus
|
||||||
|
* pinning, elapsed ticker) and the Document PiP pop-out. Extracted from
|
||||||
|
* ObservationOverlay so both carriers run the same focus/interrupt behavior
|
||||||
|
* without duplicating hooks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||||
|
import type { SessionObservation } from "../observation";
|
||||||
|
import type { ObservationClientStore, OverlaySnapshot } from "./observation-store";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal Document PiP surface (TS lib.dom lacks it). */
|
||||||
|
export interface DocumentPip {
|
||||||
|
requestWindow(options?: { width?: number; height?: number }): Promise<Window>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pipApi(): DocumentPip | undefined {
|
||||||
|
if (typeof window === "undefined") return undefined;
|
||||||
|
return (window as unknown as { documentPictureInPicture?: DocumentPip }).documentPictureInPicture;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clone the host document's style/link nodes into a PiP window. */
|
||||||
|
export function cloneStylesInto(pipWindow: Window): void {
|
||||||
|
for (const node of document.querySelectorAll('link[rel="stylesheet"], style')) {
|
||||||
|
pipWindow.document.head.appendChild(node.cloneNode(true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObservationView {
|
||||||
|
readonly snapshot: OverlaySnapshot;
|
||||||
|
/** Pinned session wins while it still exists; otherwise auto-follow. */
|
||||||
|
readonly focus: SessionObservation | undefined;
|
||||||
|
readonly pinnedId: string | null;
|
||||||
|
readonly onTogglePin: (sessionId: string) => void;
|
||||||
|
/** 1s ticker while anything is active (drives the elapsed readout). */
|
||||||
|
readonly now: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether one session is visible on a surface scoped to one DSH
|
||||||
|
* conversation: sessions started by that conversation or its descendants
|
||||||
|
* (the lineage ancestors ride along on the entry). Untracked sessions (no
|
||||||
|
* owner recorded) are hidden from scoped surfaces but stay in global views.
|
||||||
|
*/
|
||||||
|
export function visibleToScope(obs: SessionObservation, scopeId: string): boolean {
|
||||||
|
return obs.dshSessionIds?.includes(scopeId) === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The store-backed observation view model. Holds the feed for the component
|
||||||
|
* lifetime (refcounted — overlapping carriers never kill each other's
|
||||||
|
* stream). `scopeId` narrows the view to one DSH conversation's sessions
|
||||||
|
* (the better-sidebar tab); undefined keeps the global view (floating
|
||||||
|
* card, PiP).
|
||||||
|
*/
|
||||||
|
export function useObservationView(
|
||||||
|
store: ObservationClientStore,
|
||||||
|
scopeId?: string,
|
||||||
|
): ObservationView {
|
||||||
|
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot);
|
||||||
|
useEffect(() => {
|
||||||
|
store.acquire();
|
||||||
|
return () => store.release();
|
||||||
|
}, [store]);
|
||||||
|
|
||||||
|
const scopedSnapshot: OverlaySnapshot =
|
||||||
|
scopeId === undefined
|
||||||
|
? snapshot
|
||||||
|
: {
|
||||||
|
...snapshot,
|
||||||
|
sessions: snapshot.sessions.filter((s) => visibleToScope(s, scopeId)),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [pinnedId, setPinnedId] = useState<string | null>(null);
|
||||||
|
const pinned =
|
||||||
|
pinnedId !== null ? scopedSnapshot.sessions.find((s) => s.sessionId === pinnedId) : undefined;
|
||||||
|
const focus = pinned ?? focusOf(scopedSnapshot.sessions);
|
||||||
|
const onTogglePin = useCallback((sessionId: string) => {
|
||||||
|
setPinnedId((current) => (current === sessionId ? null : sessionId));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const anyActive = scopedSnapshot.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]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
snapshot: scopedSnapshot,
|
||||||
|
focus,
|
||||||
|
pinnedId: pinned !== undefined ? pinnedId : null,
|
||||||
|
onTogglePin,
|
||||||
|
now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PipHandle {
|
||||||
|
/** The open PiP window, or null while the content renders in its carrier. */
|
||||||
|
readonly pipWindow: Window | null;
|
||||||
|
/** Whether the browser exposes Document PiP (Chrome-only today). */
|
||||||
|
readonly pipSupported: boolean;
|
||||||
|
/**
|
||||||
|
* Upgrade the content into a native PiP window. Rejects silently without a
|
||||||
|
* user gesture (or when closed mid-request): the carrier keeps the content.
|
||||||
|
*/
|
||||||
|
readonly popOut: (size?: { width: number; height: number }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePip(): PipHandle {
|
||||||
|
const [pipWindow, setPipWindow] = useState<Window | null>(null);
|
||||||
|
const popOut = useCallback((size?: { width: number; height: number }): void => {
|
||||||
|
const pip = pipApi();
|
||||||
|
if (pip === undefined) return;
|
||||||
|
void pip
|
||||||
|
.requestWindow(size)
|
||||||
|
.then((win) => {
|
||||||
|
cloneStylesInto(win);
|
||||||
|
win.addEventListener("pagehide", () => setPipWindow(null));
|
||||||
|
setPipWindow(win);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
// Unmounting the carrier (tab close, conversation switch, plugin HMR) does
|
||||||
|
// not destroy the PiP browsing context: close it ourselves or it survives
|
||||||
|
// as a blank window. close() on an already-closed window is a no-op.
|
||||||
|
useEffect(() => () => pipWindow?.close(), [pipWindow]);
|
||||||
|
return { pipWindow, pipSupported: pipApi() !== undefined, popOut };
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Carrier switch for the observation view: while the better-sidebar
|
||||||
|
* integration fiber is alive (the dsh-better-sidebar plugin provides its
|
||||||
|
* `betterSidebar` service), the tracking view lives in a sidebar tab and the
|
||||||
|
* floating overlay card/capsule hides itself. The flag is a tiny external
|
||||||
|
* store so the overlay can read it through useSyncExternalStore and flip
|
||||||
|
* without a remount when the sidebar plugin (un)loads.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let active = false;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
export function getSidebarMode(): boolean {
|
||||||
|
return active;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeSidebarMode(listener: () => void): () => void {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSidebarMode(next: boolean): void {
|
||||||
|
if (active === next) return;
|
||||||
|
active = next;
|
||||||
|
for (const listener of [...listeners]) listener();
|
||||||
|
}
|
||||||
@@ -12,13 +12,14 @@
|
|||||||
|
|
||||||
import type { Context } from "@deepseek-ai/cordis";
|
import type { Context } from "@deepseek-ai/cordis";
|
||||||
import Schema from "@deepseek-ai/schemastery";
|
import Schema from "@deepseek-ai/schemastery";
|
||||||
|
import { armArchiveCleanup } from "./archive-cleanup";
|
||||||
import { armLazyTools } from "./lazy-tools";
|
import { armLazyTools } from "./lazy-tools";
|
||||||
import { ObservationService } from "./observation";
|
import { ObservationService } from "./observation";
|
||||||
import { registerObservationRoutes } from "./observation-http";
|
import { registerObservationRoutes } from "./observation-http";
|
||||||
import { KeyedExecutor } from "./queue";
|
import { KeyedExecutor } from "./queue";
|
||||||
import { type BskRunner, createBskRunner } from "./runner";
|
import { type BskRunner, createBskRunner } from "./runner";
|
||||||
import { SessionRegistry } from "./sessions";
|
import { SessionRegistry } from "./sessions";
|
||||||
import { registerBskSkill } from "./skill";
|
import { armAgentScopedBskSkill, registerBskSkill } from "./skill";
|
||||||
import { type PluginConfig, registerTools } from "./tools";
|
import { type PluginConfig, registerTools } from "./tools";
|
||||||
|
|
||||||
export const name = "dsh-plugin-browserskill";
|
export const name = "dsh-plugin-browserskill";
|
||||||
@@ -94,6 +95,11 @@ export function apply(
|
|||||||
// ONLY model-visible advertisement — the tool suite reveals itself on a
|
// ONLY model-visible advertisement — the tool suite reveals itself on a
|
||||||
// successful skill invocation (or a session whose history already has one).
|
// successful skill invocation (or a session whose history already has one).
|
||||||
const unregisterSkill = registerBskSkill(ctx);
|
const unregisterSkill = registerBskSkill(ctx);
|
||||||
|
// A same-name CLI skill left in ~/.agents is discovered in the nearer preset
|
||||||
|
// layer and shadows the global registration above. Re-register through every
|
||||||
|
// exact agent context at startup so DSH always loads the browser_* protocol
|
||||||
|
// instructions; the shared CLI skill remains untouched for other agents.
|
||||||
|
const disarmAgentSkill = armAgentScopedBskSkill(ctx);
|
||||||
const removeSuite = resolved.lazyTools
|
const removeSuite = resolved.lazyTools
|
||||||
? armLazyTools(ctx, () =>
|
? armLazyTools(ctx, () =>
|
||||||
registerTools({ ctx, runner, registry, config: resolved, observation, queue }),
|
registerTools({ ctx, runner, registry, config: resolved, observation, queue }),
|
||||||
@@ -107,6 +113,10 @@ export function apply(
|
|||||||
removeRoutes = registerObservationRoutes(injected, observation);
|
removeRoutes = registerObservationRoutes(injected, observation);
|
||||||
return () => removeRoutes();
|
return () => removeRoutes();
|
||||||
});
|
});
|
||||||
|
// Reap a conversation's browsers when the conversation itself is archived:
|
||||||
|
// archived sessions are hidden from every surface, so their Agent Windows
|
||||||
|
// would otherwise linger unreachable until idle timeout or unload.
|
||||||
|
const disarmArchiveCleanup = armArchiveCleanup(ctx, registry, observation);
|
||||||
|
|
||||||
// Non-blocking install probe: warn early when bsk is missing instead of
|
// Non-blocking install probe: warn early when bsk is missing instead of
|
||||||
// failing the first tool call with a bare spawn error. Uses --version on
|
// failing the first tool call with a bare spawn error. Uses --version on
|
||||||
@@ -130,20 +140,20 @@ export function apply(
|
|||||||
ctx.effect(() => {
|
ctx.effect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
removeSuite();
|
removeSuite();
|
||||||
|
disarmAgentSkill();
|
||||||
unregisterSkill();
|
unregisterSkill();
|
||||||
removeRoutes();
|
removeRoutes();
|
||||||
observation.dispose();
|
disarmArchiveCleanup();
|
||||||
runner.killAll();
|
runner.killAll();
|
||||||
const stops = registry
|
const stops = registry
|
||||||
.ownedIds()
|
.ownedIds()
|
||||||
.map((sessionId) =>
|
.map((sessionId) => observation.stopSession(sessionId).catch(() => false));
|
||||||
runner.run(["session", "stop", sessionId], { timeoutMs: 15_000 }).catch(() => {}),
|
return Promise.all(stops).then(() => observation.dispose());
|
||||||
);
|
|
||||||
return Promise.all(stops).then(() => {});
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { armArchiveCleanup, ownerSessionIds } from "./archive-cleanup";
|
||||||
export type { ObservationEvent, ObservationOptions, SessionObservation } from "./observation";
|
export type { ObservationEvent, ObservationOptions, SessionObservation } from "./observation";
|
||||||
export { ObservationService } from "./observation";
|
export { ObservationService } from "./observation";
|
||||||
export { registerObservationRoutes } from "./observation-http";
|
export { registerObservationRoutes } from "./observation-http";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Lazy tool-schema injection ("progressive disclosure", final stage): with
|
* Lazy tool-schema injection ("progressive disclosure", final stage): with
|
||||||
* `lazyTools` on (the default), the 11 browser_* tool schemas stay OUT of the
|
* `lazyTools` on (the default), the 28 browser_* tool schemas stay OUT of the
|
||||||
* system prompt until the `browser-skill` skill has actually been invoked —
|
* system prompt until the `browser-skill` skill has actually been invoked —
|
||||||
* the skill catalog entry is the only advertisement. One successful
|
* the skill catalog entry is the only advertisement. One successful
|
||||||
* invocation (model tool call, or a user's `/browser-skill` gesture) reveals
|
* invocation (model tool call, or a user's `/browser-skill` gesture) reveals
|
||||||
@@ -96,7 +96,7 @@ export function hasSuccessfulSkillInvocation(events: readonly SessionEventLike[]
|
|||||||
/**
|
/**
|
||||||
* Arm the lazy reveal. Returns a disposer tearing down listeners and — when
|
* Arm the lazy reveal. Returns a disposer tearing down listeners and — when
|
||||||
* the reveal already happened — the tool suite itself.
|
* the reveal already happened — the tool suite itself.
|
||||||
* @param registerSuite - registers the 11 browser_* tools, returns their disposer.
|
* @param registerSuite - registers the 28 browser_* tools, returns their disposer.
|
||||||
*/
|
*/
|
||||||
export function armLazyTools(ctx: Context, registerSuite: () => () => void): () => void {
|
export function armLazyTools(ctx: Context, registerSuite: () => () => void): () => void {
|
||||||
let suiteDisposer: (() => void) | undefined;
|
let suiteDisposer: (() => void) | undefined;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
* GET /bsk-observation/state → { sessions, available }
|
* GET /bsk-observation/state → { sessions, available }
|
||||||
* GET /bsk-observation/events → SSE stream of ObservationEvent
|
* GET /bsk-observation/events → SSE stream of ObservationEvent
|
||||||
* POST /bsk-observation/interrupt → body {sessionId?} → {interrupted: boolean}
|
* POST /bsk-observation/interrupt → body {sessionId?} → {interrupted: boolean}
|
||||||
|
* POST /bsk-observation/stop → body {sessionId} → {stopped: boolean}
|
||||||
* GET /bsk-observation/thumbnail/<attachmentId> → image bytes
|
* GET /bsk-observation/thumbnail/<attachmentId> → image bytes
|
||||||
*
|
*
|
||||||
* Routes exist only when a webServer service is mounted (web composition);
|
* Routes exist only when a webServer service is mounted (web composition);
|
||||||
@@ -166,6 +167,42 @@ export function registerObservationRoutes(
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
webServer.register({
|
||||||
|
kind: "exact",
|
||||||
|
path: `${ROUTE_BASE}/stop`,
|
||||||
|
handler: (req, res) => {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
sendJson(res, 405, { error: "method not allowed" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (fenceRejected(req, res)) return;
|
||||||
|
let body = "";
|
||||||
|
req.on("data", (chunk: Buffer | string) => {
|
||||||
|
body += chunk;
|
||||||
|
});
|
||||||
|
req.on("end", () => {
|
||||||
|
let sessionId: string | undefined;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(body || "{}") as { sessionId?: unknown };
|
||||||
|
if (typeof parsed.sessionId === "string" && parsed.sessionId !== "") {
|
||||||
|
sessionId = parsed.sessionId;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
sendJson(res, 400, { error: "invalid JSON body" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A destructive call always names its target (never "current").
|
||||||
|
if (sessionId === undefined) {
|
||||||
|
sendJson(res, 400, { error: "sessionId required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void observation.stopSession(sessionId).then(
|
||||||
|
(stopped) => sendJson(res, 200, { stopped }),
|
||||||
|
() => sendJson(res, 500, { error: "stop failed" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
webServer.register({
|
webServer.register({
|
||||||
kind: "prefix",
|
kind: "prefix",
|
||||||
path: `${ROUTE_BASE}/thumbnail`,
|
path: `${ROUTE_BASE}/thumbnail`,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { tmpdir } from "node:os";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import type { Context } from "@deepseek-ai/cordis";
|
import type { Context } from "@deepseek-ai/cordis";
|
||||||
import type { KeyedExecutor } from "./queue";
|
import type { KeyedExecutor } from "./queue";
|
||||||
import type { BskRunner } from "./runner";
|
import { BskError, type BskRunner, parseBskJson, runWithSessionBusyRetry } from "./runner";
|
||||||
import type { SessionRegistry } from "./sessions";
|
import type { SessionRegistry } from "./sessions";
|
||||||
|
|
||||||
/** One owned session's live observation record (wire-stable shape). */
|
/** One owned session's live observation record (wire-stable shape). */
|
||||||
@@ -39,6 +39,13 @@ export interface SessionObservation {
|
|||||||
* greys it out until it is stopped/removed. No more frames are requested.
|
* greys it out until it is stopped/removed. No more frames are requested.
|
||||||
*/
|
*/
|
||||||
dead?: boolean;
|
dead?: boolean;
|
||||||
|
/**
|
||||||
|
* The DSH conversations this session belongs to (the starting agent's
|
||||||
|
* session plus its seed-lineage ancestors), recorded at start. Scoped
|
||||||
|
* surfaces (the better-sidebar tab) filter by it; absent means untracked
|
||||||
|
* ownership — visible only in the global (unscoped) view.
|
||||||
|
*/
|
||||||
|
dshSessionIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Incremental event carried to subscribers (SSE on the wire). */
|
/** Incremental event carried to subscribers (SSE on the wire). */
|
||||||
@@ -83,6 +90,10 @@ export class ObservationService {
|
|||||||
private readonly listeners = new Set<(event: ObservationEvent) => void>();
|
private readonly listeners = new Set<(event: ObservationEvent) => void>();
|
||||||
private readonly captureTimers = new Map<string, unknown>();
|
private readonly captureTimers = new Map<string, unknown>();
|
||||||
private readonly captureInFlight = new Set<string>();
|
private readonly captureInFlight = new Set<string>();
|
||||||
|
/** Captures intentionally cancelled to make way for foreground work. */
|
||||||
|
private readonly capturePreempted = new Set<string>();
|
||||||
|
/** Number of queued/running model-facing calls that currently outrank thumbnails. */
|
||||||
|
private readonly foregroundDepth = new Map<string, number>();
|
||||||
private readonly captureFailures = new Map<string, number>();
|
private readonly captureFailures = new Map<string, number>();
|
||||||
private readonly lastActivity = new Map<string, number>();
|
private readonly lastActivity = new Map<string, number>();
|
||||||
/** Ephemeral frame id → PNG bytes. Only the live ring members are present. */
|
/** Ephemeral frame id → PNG bytes. Only the live ring members are present. */
|
||||||
@@ -153,11 +164,13 @@ export class ObservationService {
|
|||||||
/** Register a fresh owned session (called from browser_session_start). */
|
/** Register a fresh owned session (called from browser_session_start). */
|
||||||
addSession(sessionId: string, url?: string): void {
|
addSession(sessionId: string, url?: string): void {
|
||||||
if (!this.deps.options.enabled || this.disposed) return;
|
if (!this.deps.options.enabled || this.disposed) return;
|
||||||
|
const dshSessionIds = this.deps.registry.dshOwnersOf(sessionId);
|
||||||
this.put({
|
this.put({
|
||||||
sessionId,
|
sessionId,
|
||||||
...(url !== undefined ? { url } : {}),
|
...(url !== undefined ? { url } : {}),
|
||||||
action: "idle",
|
action: "idle",
|
||||||
since: this.scheduler.now(),
|
since: this.scheduler.now(),
|
||||||
|
...(dshSessionIds.length > 0 ? { dshSessionIds } : {}),
|
||||||
});
|
});
|
||||||
this.lastActivity.set(sessionId, this.scheduler.now());
|
this.lastActivity.set(sessionId, this.scheduler.now());
|
||||||
this.scheduleCapture(sessionId, 0);
|
this.scheduleCapture(sessionId, 0);
|
||||||
@@ -166,6 +179,8 @@ export class ObservationService {
|
|||||||
/** Drop a session (called from browser_session_stop). */
|
/** Drop a session (called from browser_session_stop). */
|
||||||
removeSession(sessionId: string): void {
|
removeSession(sessionId: string): void {
|
||||||
this.cancelCapture(sessionId);
|
this.cancelCapture(sessionId);
|
||||||
|
this.foregroundDepth.delete(sessionId);
|
||||||
|
this.capturePreempted.delete(sessionId);
|
||||||
this.captureFailures.delete(sessionId);
|
this.captureFailures.delete(sessionId);
|
||||||
this.lastActivity.delete(sessionId);
|
this.lastActivity.delete(sessionId);
|
||||||
this.dropSessionFrames(sessionId);
|
this.dropSessionFrames(sessionId);
|
||||||
@@ -177,6 +192,37 @@ export class ObservationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give model-facing work priority over the best-effort thumbnail lane.
|
||||||
|
* The first lease cancels a pending timer and gracefully interrupts an
|
||||||
|
* active bsk screenshot; the last release schedules one fresh frame.
|
||||||
|
*/
|
||||||
|
acquireForeground(sessionId: string): () => void {
|
||||||
|
if (!this.deps.options.enabled || this.disposed || !this.observations.has(sessionId)) {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
const depth = this.foregroundDepth.get(sessionId) ?? 0;
|
||||||
|
this.foregroundDepth.set(sessionId, depth + 1);
|
||||||
|
if (depth === 0) {
|
||||||
|
this.cancelCapture(sessionId);
|
||||||
|
if (this.deps.runner.killFor(`observation:${sessionId}`) > 0) {
|
||||||
|
this.capturePreempted.add(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let released = false;
|
||||||
|
return () => {
|
||||||
|
if (released) return;
|
||||||
|
released = true;
|
||||||
|
const remaining = (this.foregroundDepth.get(sessionId) ?? 1) - 1;
|
||||||
|
if (remaining > 0) {
|
||||||
|
this.foregroundDepth.set(sessionId, remaining);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.foregroundDepth.delete(sessionId);
|
||||||
|
if (!this.disposed && this.observations.has(sessionId)) this.scheduleCapture(sessionId, 0);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Mark an action starting on a session (tool entry instrumentation). */
|
/** Mark an action starting on a session (tool entry instrumentation). */
|
||||||
beginAction(sessionId: string, action: string): void {
|
beginAction(sessionId: string, action: string): void {
|
||||||
if (!this.deps.options.enabled || this.disposed) return;
|
if (!this.deps.options.enabled || this.disposed) return;
|
||||||
@@ -204,7 +250,7 @@ export class ObservationService {
|
|||||||
if (error !== undefined) next.lastError = error;
|
if (error !== undefined) next.lastError = error;
|
||||||
else delete next.lastError;
|
else delete next.lastError;
|
||||||
this.put(next);
|
this.put(next);
|
||||||
this.scheduleCapture(sessionId, 0);
|
if (!this.foregroundDepth.has(sessionId)) this.scheduleCapture(sessionId, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Record the settled page URL (navigate success / start with url). */
|
/** Record the settled page URL (navigate success / start with url). */
|
||||||
@@ -228,6 +274,55 @@ export class ObservationService {
|
|||||||
return this.deps.runner.killFor(target) > 0;
|
return this.deps.runner.killFor(target) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop one owned session and close its Agent Window (the overlay's stop
|
||||||
|
* button — same end state as `browser_session_stop`). Never waits behind a
|
||||||
|
* hung in-flight command: tool children are killed first so the session's
|
||||||
|
* keyed queue drains immediately, and no further captures queue up. A
|
||||||
|
* session the daemon already forgot stops idempotently — the goal state
|
||||||
|
* (entry gone) is identical.
|
||||||
|
* @returns false only for a foreign session; bsk failures reject so callers
|
||||||
|
* can preserve the structured error instead of silently leaving a ghost.
|
||||||
|
*/
|
||||||
|
async stopSession(sessionId: string, signal?: AbortSignal): Promise<boolean> {
|
||||||
|
if (!this.deps.registry.isOwned(sessionId)) return false;
|
||||||
|
const releaseForeground = this.acquireForeground(sessionId);
|
||||||
|
let actionError: string | undefined;
|
||||||
|
this.beginAction(sessionId, "stopping");
|
||||||
|
try {
|
||||||
|
this.deps.runner.killFor(sessionId);
|
||||||
|
const result = await this.deps.queue.run(
|
||||||
|
sessionId,
|
||||||
|
() =>
|
||||||
|
runWithSessionBusyRetry(
|
||||||
|
() =>
|
||||||
|
this.deps.runner.run(["session", "stop", sessionId], {
|
||||||
|
signal,
|
||||||
|
timeoutMs: 30_000,
|
||||||
|
tag: sessionId,
|
||||||
|
}),
|
||||||
|
signal,
|
||||||
|
),
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
if (result.aborted) throw abortError();
|
||||||
|
try {
|
||||||
|
parseBskJson(result, "session stop");
|
||||||
|
} catch (error) {
|
||||||
|
if (!isSessionNotFoundError(error)) throw error;
|
||||||
|
}
|
||||||
|
this.deps.registry.remove(sessionId);
|
||||||
|
this.removeSession(sessionId);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
actionError = error instanceof Error ? error.message.split("\n")[0] : String(error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
this.endAction(sessionId, actionError);
|
||||||
|
releaseForeground();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read one captured thumbnail from the in-process ring. Powers the plugin's
|
* Read one captured thumbnail from the in-process ring. Powers the plugin's
|
||||||
* own HTTP thumbnail route — frames are plugin-owned runtime data, never
|
* own HTTP thumbnail route — frames are plugin-owned runtime data, never
|
||||||
@@ -248,6 +343,8 @@ export class ObservationService {
|
|||||||
for (const sessionId of [...this.captureTimers.keys()]) this.cancelCapture(sessionId);
|
for (const sessionId of [...this.captureTimers.keys()]) this.cancelCapture(sessionId);
|
||||||
this.captureTimers.clear();
|
this.captureTimers.clear();
|
||||||
this.captureInFlight.clear();
|
this.captureInFlight.clear();
|
||||||
|
this.capturePreempted.clear();
|
||||||
|
this.foregroundDepth.clear();
|
||||||
this.captureFailures.clear();
|
this.captureFailures.clear();
|
||||||
this.lastActivity.clear();
|
this.lastActivity.clear();
|
||||||
this.frames.clear();
|
this.frames.clear();
|
||||||
@@ -274,6 +371,7 @@ export class ObservationService {
|
|||||||
/** Schedule the next capture for a session; `delayMs` 0 means "as soon as the event loop allows". */
|
/** Schedule the next capture for a session; `delayMs` 0 means "as soon as the event loop allows". */
|
||||||
private scheduleCapture(sessionId: string, delayMs?: number): void {
|
private scheduleCapture(sessionId: string, delayMs?: number): void {
|
||||||
if (!this.deps.options.enabled || this.disposed) return;
|
if (!this.deps.options.enabled || this.disposed) return;
|
||||||
|
if (this.foregroundDepth.has(sessionId)) return;
|
||||||
const entry = this.observations.get(sessionId);
|
const entry = this.observations.get(sessionId);
|
||||||
if (entry === undefined || entry.dead === true) return;
|
if (entry === undefined || entry.dead === true) return;
|
||||||
this.cancelCapture(sessionId);
|
this.cancelCapture(sessionId);
|
||||||
@@ -310,12 +408,15 @@ export class ObservationService {
|
|||||||
const outPath = join(tmpdir(), `bsk-obs-${this.scratchNamespace}-${sessionKey}.png`);
|
const outPath = join(tmpdir(), `bsk-obs-${this.scratchNamespace}-${sessionKey}.png`);
|
||||||
let writtenPath = outPath;
|
let writtenPath = outPath;
|
||||||
try {
|
try {
|
||||||
const result = await this.deps.queue.run(sessionId, () =>
|
const result = await this.deps.queue.run(sessionId, () => {
|
||||||
this.deps.runner.run(["screenshot", "--session", sessionId, "--out", outPath], {
|
// A foreground lease may have arrived while this capture was queued.
|
||||||
|
if (this.foregroundDepth.has(sessionId)) return Promise.resolve(undefined);
|
||||||
|
return this.deps.runner.run(["screenshot", "--session", sessionId, "--out", outPath], {
|
||||||
timeoutMs: 15_000,
|
timeoutMs: 15_000,
|
||||||
tag: `observation:${sessionId}`,
|
tag: `observation:${sessionId}`,
|
||||||
}),
|
});
|
||||||
);
|
});
|
||||||
|
if (result === undefined) return;
|
||||||
if (result.code !== 0) {
|
if (result.code !== 0) {
|
||||||
let code: string | undefined;
|
let code: string | undefined;
|
||||||
try {
|
try {
|
||||||
@@ -323,8 +424,12 @@ export class ObservationService {
|
|||||||
} catch {
|
} catch {
|
||||||
code = undefined;
|
code = undefined;
|
||||||
}
|
}
|
||||||
if (code === "session_not_found") {
|
if (isSessionNotFoundCode(code)) {
|
||||||
this.markDead(sessionId);
|
// Observation entries are owned sessions. If the daemon has already
|
||||||
|
// forgotten one (external close / restart), reconcile both stores so
|
||||||
|
// the PiP cannot retain a ghost strip or keep retrying screenshots.
|
||||||
|
this.deps.registry.remove(sessionId);
|
||||||
|
this.removeSession(sessionId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw new Error(`screenshot exited ${result.code}`);
|
throw new Error(`screenshot exited ${result.code}`);
|
||||||
@@ -339,12 +444,17 @@ export class ObservationService {
|
|||||||
this.setAvailable(true);
|
this.setAvailable(true);
|
||||||
this.publishFrame(sessionId, entry, data);
|
this.publishFrame(sessionId, entry, data);
|
||||||
} catch {
|
} catch {
|
||||||
// Silent by design: keep the previous frame, count toward backoff.
|
// Foreground preemption is healthy scheduling, not browser/daemon
|
||||||
this.captureFailures.set(sessionId, (this.captureFailures.get(sessionId) ?? 0) + 1);
|
// unavailability; do not let normal tool traffic trip capture backoff.
|
||||||
this.globalFailures += 1;
|
if (!this.capturePreempted.delete(sessionId)) {
|
||||||
if (this.globalFailures >= FAILURE_BACKOFF_THRESHOLD) this.setAvailable(false);
|
// Silent by design: keep the previous frame, count toward backoff.
|
||||||
|
this.captureFailures.set(sessionId, (this.captureFailures.get(sessionId) ?? 0) + 1);
|
||||||
|
this.globalFailures += 1;
|
||||||
|
if (this.globalFailures >= FAILURE_BACKOFF_THRESHOLD) this.setAvailable(false);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await unlink(writtenPath).catch(() => {});
|
await unlink(writtenPath).catch(() => {});
|
||||||
|
this.capturePreempted.delete(sessionId);
|
||||||
this.captureInFlight.delete(sessionId);
|
this.captureInFlight.delete(sessionId);
|
||||||
if (!this.disposed && this.observations.has(sessionId)) this.scheduleCapture(sessionId);
|
if (!this.disposed && this.observations.has(sessionId)) this.scheduleCapture(sessionId);
|
||||||
}
|
}
|
||||||
@@ -375,14 +485,20 @@ export class ObservationService {
|
|||||||
this.hashes.delete(sessionId);
|
this.hashes.delete(sessionId);
|
||||||
this.seqs.delete(sessionId);
|
this.seqs.delete(sessionId);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Mark a session dead: grey it out, stop asking for frames, keep the entry for the strip. */
|
function isSessionNotFoundCode(code: string | undefined): boolean {
|
||||||
private markDead(sessionId: string): void {
|
return code === "not_found" || code === "session_not_found";
|
||||||
const entry = this.observations.get(sessionId);
|
}
|
||||||
if (entry === undefined || entry.dead === true) return;
|
|
||||||
this.cancelCapture(sessionId);
|
function isSessionNotFoundError(error: unknown): boolean {
|
||||||
this.put({ ...entry, dead: true, action: "idle" });
|
return error instanceof BskError && isSessionNotFoundCode(error.code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function abortError(): Error {
|
||||||
|
const error = new Error("tool call aborted");
|
||||||
|
error.name = "AbortError";
|
||||||
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Map a bsk command label onto its observation action verb. */
|
/** Map a bsk command label onto its observation action verb. */
|
||||||
@@ -400,14 +516,48 @@ export function actionForLabel(label: string): string {
|
|||||||
return "observing";
|
return "observing";
|
||||||
case "click":
|
case "click":
|
||||||
return "clicking";
|
return "clicking";
|
||||||
|
case "hover":
|
||||||
|
return "hovering";
|
||||||
case "fill":
|
case "fill":
|
||||||
return "filling";
|
return "filling";
|
||||||
|
case "select":
|
||||||
|
return "selecting";
|
||||||
case "press":
|
case "press":
|
||||||
return "pressing";
|
return "pressing";
|
||||||
case "screenshot":
|
case "screenshot":
|
||||||
return "capturing";
|
return "capturing";
|
||||||
case "emulate":
|
case "emulate":
|
||||||
return "emulating";
|
return "emulating";
|
||||||
|
case "tab list":
|
||||||
|
return "listing tabs";
|
||||||
|
case "tab create":
|
||||||
|
return "creating tab";
|
||||||
|
case "tab close":
|
||||||
|
return "closing tab";
|
||||||
|
case "tab select":
|
||||||
|
return "selecting tab";
|
||||||
|
case "tab borrow":
|
||||||
|
return "borrowing tab";
|
||||||
|
case "tab return":
|
||||||
|
return "returning tab";
|
||||||
|
case "navigate-back":
|
||||||
|
return "navigating back";
|
||||||
|
case "navigate-forward":
|
||||||
|
return "navigating forward";
|
||||||
|
case "reload":
|
||||||
|
return "reloading";
|
||||||
|
case "wait-for-navigation":
|
||||||
|
return "waiting for navigation";
|
||||||
|
case "request-help":
|
||||||
|
return "waiting for user";
|
||||||
|
case "get-html":
|
||||||
|
return "reading HTML";
|
||||||
|
case "console":
|
||||||
|
return "reading console";
|
||||||
|
case "network":
|
||||||
|
return "reading network";
|
||||||
|
case "window resize":
|
||||||
|
return "resizing window";
|
||||||
default:
|
default:
|
||||||
return label;
|
return label;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { ToolDefinition, ToolResult, ToolRunContext } from "@deepseek-ai/dsh-tools";
|
||||||
|
import type { ToolDeps } from "./tools";
|
||||||
|
|
||||||
|
export type ToolRegistrar = (definition: ToolDefinition) => () => void;
|
||||||
|
|
||||||
|
type TerminalPresentation = { card: "terminal"; output: string; exitCode: number } | undefined;
|
||||||
|
|
||||||
|
/** Existing tool runtime seams reused by the phase-one capability modules. */
|
||||||
|
export interface PhaseOneRuntime {
|
||||||
|
run(
|
||||||
|
exec: ToolRunContext,
|
||||||
|
args: string[],
|
||||||
|
label: string,
|
||||||
|
observeSession?: string,
|
||||||
|
runnerTimeoutMs?: number,
|
||||||
|
): Promise<unknown>;
|
||||||
|
commandLine(args: string[]): string;
|
||||||
|
presentTerminalResult: (_args: never, result: ToolResult) => TerminalPresentation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireNonEmpty(value: string, name: string): void {
|
||||||
|
if (value.trim().length === 0) throw new Error(`${name} must be a non-empty string`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requirePositive(value: number | undefined, name: string): void {
|
||||||
|
if (value !== undefined && value <= 0) throw new Error(`${name} must be greater than zero`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CLI positional target detection is shared by hover/select/request-help. */
|
||||||
|
export function isSnapshotRef(target: string): boolean {
|
||||||
|
return /^@?e\d+$/.test(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give the child enough time to honour a command-level timeout plus IPC
|
||||||
|
* settlement slack, without shortening the plugin's configured default.
|
||||||
|
*/
|
||||||
|
export function runnerTimeout(deps: ToolDeps, commandTimeoutMs: number | undefined): number {
|
||||||
|
if (commandTimeoutMs === undefined) return deps.config.defaultTimeoutMs;
|
||||||
|
return Math.max(deps.config.defaultTimeoutMs, commandTimeoutMs + 15_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendTarget(args: string[], target: string): void {
|
||||||
|
args.push(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendTabId(args: string[], tabId: number | undefined): void {
|
||||||
|
if (tabId !== undefined) args.push("--tab-id", String(tabId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendWaitOptions(
|
||||||
|
args: string[],
|
||||||
|
waitUntil: string | undefined,
|
||||||
|
timeoutMs: number | undefined,
|
||||||
|
): void {
|
||||||
|
if (waitUntil !== undefined) args.push("--wait-until", waitUntil);
|
||||||
|
if (timeoutMs !== undefined) args.push("--timeout", `${timeoutMs}ms`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
||||||
|
import {
|
||||||
|
appendTabId,
|
||||||
|
appendTarget,
|
||||||
|
type PhaseOneRuntime,
|
||||||
|
requireNonEmpty,
|
||||||
|
requirePositive,
|
||||||
|
runnerTimeout,
|
||||||
|
type ToolRegistrar,
|
||||||
|
} from "./phase-one-runtime";
|
||||||
|
import { SESSION_PARAM, TAB_ID_PARAM, TIMEOUT_MS_PARAM } from "./tool-params";
|
||||||
|
import type { ToolDeps } from "./tools";
|
||||||
|
|
||||||
|
const MODIFIERS = ["alt", "ctrl", "meta", "shift"] as const;
|
||||||
|
|
||||||
|
/** Add hover and select without bypassing session ownership or observation. */
|
||||||
|
export function registerPhaseOneInteractionTools(
|
||||||
|
deps: ToolDeps,
|
||||||
|
register: ToolRegistrar,
|
||||||
|
runtime: PhaseOneRuntime,
|
||||||
|
): void {
|
||||||
|
const { registry } = deps;
|
||||||
|
|
||||||
|
register(
|
||||||
|
defineTool({
|
||||||
|
name: "browser_hover",
|
||||||
|
description:
|
||||||
|
"Move the mouse over a snapshot ref or CSS selector to reveal hover-triggered UI. " +
|
||||||
|
"Run browser_observe or browser_snapshot again afterwards to discover newly visible refs.",
|
||||||
|
parameters: {
|
||||||
|
target: {
|
||||||
|
type: "string",
|
||||||
|
required: true,
|
||||||
|
description: "Snapshot ref (@e3 / e3) or CSS selector of the element to hover.",
|
||||||
|
},
|
||||||
|
session: SESSION_PARAM,
|
||||||
|
tabId: TAB_ID_PARAM,
|
||||||
|
modifiers: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string", enum: MODIFIERS },
|
||||||
|
description: "Keyboard modifiers held during the mouse move.",
|
||||||
|
},
|
||||||
|
settleMs: {
|
||||||
|
type: "integer",
|
||||||
|
description: "Milliseconds to wait for hover-triggered UI to settle (default: 200).",
|
||||||
|
},
|
||||||
|
timeoutMs: TIMEOUT_MS_PARAM,
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
session: { type: "string", required: true },
|
||||||
|
tabId: { type: "integer", required: true },
|
||||||
|
x: { type: "number", required: true },
|
||||||
|
y: { type: "number", required: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
render: (_args, value) => [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `[session ${value.session}] hovered at (${value.x}, ${value.y}) on tab ${value.tabId}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(args, exec) {
|
||||||
|
requireNonEmpty(args.target, "target");
|
||||||
|
requirePositive(args.timeoutMs, "timeoutMs");
|
||||||
|
requirePositive(args.settleMs, "settleMs");
|
||||||
|
const sessionId = registry.resolve(args.session, "browser_hover");
|
||||||
|
const cmdArgs = ["hover", "--session", sessionId];
|
||||||
|
appendTabId(cmdArgs, args.tabId);
|
||||||
|
if (args.modifiers !== undefined && args.modifiers.length > 0) {
|
||||||
|
cmdArgs.push("--modifiers", args.modifiers.join(","));
|
||||||
|
}
|
||||||
|
if (args.settleMs !== undefined) cmdArgs.push("--settle", `${args.settleMs}ms`);
|
||||||
|
if (args.timeoutMs !== undefined) cmdArgs.push("--timeout", `${args.timeoutMs}ms`);
|
||||||
|
appendTarget(cmdArgs, args.target);
|
||||||
|
const reply = (await runtime.run(
|
||||||
|
exec,
|
||||||
|
cmdArgs,
|
||||||
|
"hover",
|
||||||
|
sessionId,
|
||||||
|
runnerTimeout(deps, args.timeoutMs),
|
||||||
|
)) as { tab_id: number; x: number; y: number };
|
||||||
|
return { session: sessionId, tabId: reply.tab_id, x: reply.x, y: reply.y };
|
||||||
|
},
|
||||||
|
presentCall: (args) => ({
|
||||||
|
card: "terminal",
|
||||||
|
title: runtime.commandLine([
|
||||||
|
"hover",
|
||||||
|
args.target,
|
||||||
|
"--session",
|
||||||
|
args.session ?? "(current)",
|
||||||
|
]),
|
||||||
|
description: "Hover an element",
|
||||||
|
}),
|
||||||
|
presentResult: runtime.presentTerminalResult,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
register(
|
||||||
|
defineTool({
|
||||||
|
name: "browser_select",
|
||||||
|
description:
|
||||||
|
"Set a select element's option values. Pass one value for a normal select or multiple " +
|
||||||
|
"values for a multi-select; values replace the current selection.",
|
||||||
|
parameters: {
|
||||||
|
target: {
|
||||||
|
type: "string",
|
||||||
|
required: true,
|
||||||
|
description: "Snapshot ref (@e3 / e3) or CSS selector of the select element.",
|
||||||
|
},
|
||||||
|
values: {
|
||||||
|
type: "array",
|
||||||
|
required: true,
|
||||||
|
items: { type: "string" },
|
||||||
|
description: "Option value attributes to select; at least one value is required.",
|
||||||
|
},
|
||||||
|
session: SESSION_PARAM,
|
||||||
|
tabId: TAB_ID_PARAM,
|
||||||
|
timeoutMs: TIMEOUT_MS_PARAM,
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
session: { type: "string", required: true },
|
||||||
|
tabId: { type: "integer", required: true },
|
||||||
|
multiple: { type: "boolean", required: true },
|
||||||
|
selectedValues: {
|
||||||
|
type: "array",
|
||||||
|
required: true,
|
||||||
|
items: { type: "string" },
|
||||||
|
},
|
||||||
|
selectedLabels: {
|
||||||
|
type: "array",
|
||||||
|
required: true,
|
||||||
|
items: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
render: (_args, value) => [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text:
|
||||||
|
`[session ${value.session}] selected ` +
|
||||||
|
`${value.selectedValues.join(", ") || "(none)"} on tab ${value.tabId}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(args, exec) {
|
||||||
|
requireNonEmpty(args.target, "target");
|
||||||
|
requirePositive(args.timeoutMs, "timeoutMs");
|
||||||
|
if (args.values.length === 0) throw new Error("values must contain at least one option");
|
||||||
|
const sessionId = registry.resolve(args.session, "browser_select");
|
||||||
|
const cmdArgs = ["select", "--session", sessionId];
|
||||||
|
appendTabId(cmdArgs, args.tabId);
|
||||||
|
for (const value of args.values) cmdArgs.push("--value", value);
|
||||||
|
if (args.timeoutMs !== undefined) cmdArgs.push("--timeout", `${args.timeoutMs}ms`);
|
||||||
|
appendTarget(cmdArgs, args.target);
|
||||||
|
const reply = (await runtime.run(
|
||||||
|
exec,
|
||||||
|
cmdArgs,
|
||||||
|
"select",
|
||||||
|
sessionId,
|
||||||
|
runnerTimeout(deps, args.timeoutMs),
|
||||||
|
)) as {
|
||||||
|
tab_id: number;
|
||||||
|
multiple: boolean;
|
||||||
|
selected_values: string[];
|
||||||
|
selected_labels: string[];
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
session: sessionId,
|
||||||
|
tabId: reply.tab_id,
|
||||||
|
multiple: reply.multiple,
|
||||||
|
selectedValues: reply.selected_values,
|
||||||
|
selectedLabels: reply.selected_labels,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
presentCall: (args) => ({
|
||||||
|
card: "terminal",
|
||||||
|
title: runtime.commandLine([
|
||||||
|
"select",
|
||||||
|
args.target,
|
||||||
|
"--session",
|
||||||
|
args.session ?? "(current)",
|
||||||
|
]),
|
||||||
|
description: `Select ${args.values.length} option${args.values.length === 1 ? "" : "s"}`,
|
||||||
|
}),
|
||||||
|
presentResult: runtime.presentTerminalResult,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
||||||
|
import {
|
||||||
|
appendTabId,
|
||||||
|
appendWaitOptions,
|
||||||
|
type PhaseOneRuntime,
|
||||||
|
requirePositive,
|
||||||
|
runnerTimeout,
|
||||||
|
type ToolRegistrar,
|
||||||
|
} from "./phase-one-runtime";
|
||||||
|
import { SESSION_PARAM, TAB_ID_PARAM, TIMEOUT_MS_PARAM, WAIT_UNTIL_PARAM } from "./tool-params";
|
||||||
|
import type { ToolDeps } from "./tools";
|
||||||
|
|
||||||
|
interface RawHistoryResult {
|
||||||
|
tab_id: number;
|
||||||
|
previous_url?: string;
|
||||||
|
final_url?: string;
|
||||||
|
reached: string;
|
||||||
|
error_text?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HISTORY_OUTPUT = {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
session: { type: "string", required: true },
|
||||||
|
tabId: { type: "integer", required: true },
|
||||||
|
previousUrl: { type: "string" },
|
||||||
|
finalUrl: { type: "string" },
|
||||||
|
reached: { type: "string", required: true },
|
||||||
|
errorText: { type: "string" },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function mapHistory(sessionId: string, reply: RawHistoryResult) {
|
||||||
|
return {
|
||||||
|
session: sessionId,
|
||||||
|
tabId: reply.tab_id,
|
||||||
|
...(reply.previous_url !== undefined ? { previousUrl: reply.previous_url } : {}),
|
||||||
|
...(reply.final_url !== undefined ? { finalUrl: reply.final_url } : {}),
|
||||||
|
reached: reply.reached,
|
||||||
|
...(reply.error_text !== undefined ? { errorText: reply.error_text } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add browser history, reload, and explicit lifecycle waiting. */
|
||||||
|
export function registerPhaseOneNavigationTools(
|
||||||
|
deps: ToolDeps,
|
||||||
|
register: ToolRegistrar,
|
||||||
|
runtime: PhaseOneRuntime,
|
||||||
|
): void {
|
||||||
|
const { registry } = deps;
|
||||||
|
|
||||||
|
const registerHistory = (direction: "back" | "forward") => {
|
||||||
|
const toolName = `browser_navigate_${direction}` as const;
|
||||||
|
const command = `navigate-${direction}`;
|
||||||
|
register(
|
||||||
|
defineTool({
|
||||||
|
name: toolName,
|
||||||
|
description: `Navigate the active Agent Window tab ${direction} by one history entry.`,
|
||||||
|
parameters: {
|
||||||
|
session: SESSION_PARAM,
|
||||||
|
tabId: TAB_ID_PARAM,
|
||||||
|
waitUntil: WAIT_UNTIL_PARAM,
|
||||||
|
timeoutMs: TIMEOUT_MS_PARAM,
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
schema: HISTORY_OUTPUT,
|
||||||
|
render: (_args, value) => [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text:
|
||||||
|
`[session ${value.session}] navigated ${direction} on tab ${value.tabId}` +
|
||||||
|
(value.finalUrl !== undefined ? ` to ${value.finalUrl}` : "") +
|
||||||
|
` (reached: ${value.reached})` +
|
||||||
|
(value.errorText !== undefined ? ` — ${value.errorText}` : ""),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(args, exec) {
|
||||||
|
requirePositive(args.timeoutMs, "timeoutMs");
|
||||||
|
const sessionId = registry.resolve(args.session, toolName);
|
||||||
|
const cmdArgs = [command, "--session", sessionId];
|
||||||
|
appendTabId(cmdArgs, args.tabId);
|
||||||
|
appendWaitOptions(cmdArgs, args.waitUntil, args.timeoutMs);
|
||||||
|
const reply = (await runtime.run(
|
||||||
|
exec,
|
||||||
|
cmdArgs,
|
||||||
|
command,
|
||||||
|
sessionId,
|
||||||
|
runnerTimeout(deps, args.timeoutMs),
|
||||||
|
)) as RawHistoryResult;
|
||||||
|
if (reply.final_url !== undefined) deps.observation.setUrl(sessionId, reply.final_url);
|
||||||
|
return mapHistory(sessionId, reply);
|
||||||
|
},
|
||||||
|
presentCall: (args) => ({
|
||||||
|
card: "terminal",
|
||||||
|
title: runtime.commandLine([command, "--session", args.session ?? "(current)"]),
|
||||||
|
description: `Navigate ${direction}`,
|
||||||
|
}),
|
||||||
|
presentResult: runtime.presentTerminalResult,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
registerHistory("back");
|
||||||
|
registerHistory("forward");
|
||||||
|
|
||||||
|
register(
|
||||||
|
defineTool({
|
||||||
|
name: "browser_reload",
|
||||||
|
description:
|
||||||
|
"Reload the active Agent Window tab and wait for a lifecycle phase. Set hard=true to " +
|
||||||
|
"bypass the HTTP cache.",
|
||||||
|
parameters: {
|
||||||
|
session: SESSION_PARAM,
|
||||||
|
tabId: TAB_ID_PARAM,
|
||||||
|
waitUntil: WAIT_UNTIL_PARAM,
|
||||||
|
timeoutMs: TIMEOUT_MS_PARAM,
|
||||||
|
hard: { type: "boolean", description: "Bypass the HTTP cache while reloading." },
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
schema: HISTORY_OUTPUT,
|
||||||
|
render: (_args, value) => [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text:
|
||||||
|
`[session ${value.session}] reloaded tab ${value.tabId}` +
|
||||||
|
(value.finalUrl !== undefined ? ` at ${value.finalUrl}` : "") +
|
||||||
|
` (reached: ${value.reached})` +
|
||||||
|
(value.errorText !== undefined ? ` — ${value.errorText}` : ""),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(args, exec) {
|
||||||
|
requirePositive(args.timeoutMs, "timeoutMs");
|
||||||
|
const sessionId = registry.resolve(args.session, "browser_reload");
|
||||||
|
const cmdArgs = ["reload", "--session", sessionId];
|
||||||
|
appendTabId(cmdArgs, args.tabId);
|
||||||
|
appendWaitOptions(cmdArgs, args.waitUntil, args.timeoutMs);
|
||||||
|
if (args.hard === true) cmdArgs.push("--hard");
|
||||||
|
const reply = (await runtime.run(
|
||||||
|
exec,
|
||||||
|
cmdArgs,
|
||||||
|
"reload",
|
||||||
|
sessionId,
|
||||||
|
runnerTimeout(deps, args.timeoutMs),
|
||||||
|
)) as RawHistoryResult;
|
||||||
|
if (reply.final_url !== undefined) deps.observation.setUrl(sessionId, reply.final_url);
|
||||||
|
return mapHistory(sessionId, reply);
|
||||||
|
},
|
||||||
|
presentCall: (args) => ({
|
||||||
|
card: "terminal",
|
||||||
|
title: runtime.commandLine([
|
||||||
|
"reload",
|
||||||
|
"--session",
|
||||||
|
args.session ?? "(current)",
|
||||||
|
...(args.hard === true ? ["--hard"] : []),
|
||||||
|
]),
|
||||||
|
description: "Reload the active tab",
|
||||||
|
}),
|
||||||
|
presentResult: runtime.presentTerminalResult,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
register(
|
||||||
|
defineTool({
|
||||||
|
name: "browser_wait_for_navigation",
|
||||||
|
description:
|
||||||
|
"Wait for a page lifecycle event on the active Agent Window tab. Use after an action " +
|
||||||
|
"that may navigate when the action result itself does not wait for navigation.",
|
||||||
|
parameters: {
|
||||||
|
session: SESSION_PARAM,
|
||||||
|
tabId: TAB_ID_PARAM,
|
||||||
|
waitUntil: WAIT_UNTIL_PARAM,
|
||||||
|
timeoutMs: TIMEOUT_MS_PARAM,
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
session: { type: "string", required: true },
|
||||||
|
tabId: { type: "integer", required: true },
|
||||||
|
reached: { type: "string", required: true },
|
||||||
|
errorText: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
render: (_args, value) => [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text:
|
||||||
|
`[session ${value.session}] navigation wait on tab ${value.tabId}: ${value.reached}` +
|
||||||
|
(value.errorText !== undefined ? ` — ${value.errorText}` : ""),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(args, exec) {
|
||||||
|
requirePositive(args.timeoutMs, "timeoutMs");
|
||||||
|
const sessionId = registry.resolve(args.session, "browser_wait_for_navigation");
|
||||||
|
const timeoutMs = args.timeoutMs ?? 30_000;
|
||||||
|
const cmdArgs = ["wait-for-navigation", "--session", sessionId];
|
||||||
|
appendTabId(cmdArgs, args.tabId);
|
||||||
|
if (args.waitUntil !== undefined) cmdArgs.push("--wait-until", args.waitUntil);
|
||||||
|
cmdArgs.push("--timeout", `${timeoutMs}ms`);
|
||||||
|
const reply = (await runtime.run(
|
||||||
|
exec,
|
||||||
|
cmdArgs,
|
||||||
|
"wait-for-navigation",
|
||||||
|
sessionId,
|
||||||
|
runnerTimeout(deps, timeoutMs),
|
||||||
|
)) as { tab_id: number; reached: string; error_text?: string };
|
||||||
|
return {
|
||||||
|
session: sessionId,
|
||||||
|
tabId: reply.tab_id,
|
||||||
|
reached: reply.reached,
|
||||||
|
...(reply.error_text !== undefined ? { errorText: reply.error_text } : {}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
presentCall: (args) => ({
|
||||||
|
card: "terminal",
|
||||||
|
title: runtime.commandLine([
|
||||||
|
"wait-for-navigation",
|
||||||
|
"--session",
|
||||||
|
args.session ?? "(current)",
|
||||||
|
]),
|
||||||
|
description: "Wait for page navigation",
|
||||||
|
}),
|
||||||
|
presentResult: runtime.presentTerminalResult,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,672 @@
|
|||||||
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
||||||
|
import {
|
||||||
|
appendTabId,
|
||||||
|
isSnapshotRef,
|
||||||
|
type PhaseOneRuntime,
|
||||||
|
requireNonEmpty,
|
||||||
|
requirePositive,
|
||||||
|
runnerTimeout,
|
||||||
|
type ToolRegistrar,
|
||||||
|
} from "./phase-one-runtime";
|
||||||
|
import { SESSION_PARAM, TAB_ID_PARAM, TIMEOUT_MS_PARAM } from "./tool-params";
|
||||||
|
import type { ToolDeps } from "./tools";
|
||||||
|
|
||||||
|
interface HelpConditionInput {
|
||||||
|
urlContains?: string;
|
||||||
|
urlMatches?: string;
|
||||||
|
selectorExists?: string;
|
||||||
|
selectorMissing?: string;
|
||||||
|
textExists?: string;
|
||||||
|
textMissing?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompletionCriteriaInput {
|
||||||
|
any?: HelpConditionInput[];
|
||||||
|
all?: HelpConditionInput[];
|
||||||
|
stableForMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawConsoleFrame {
|
||||||
|
function_name?: string;
|
||||||
|
url?: string;
|
||||||
|
line?: number;
|
||||||
|
column?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawConsoleEntry {
|
||||||
|
sequence: number;
|
||||||
|
kind: string;
|
||||||
|
level: string;
|
||||||
|
text: string;
|
||||||
|
url?: string;
|
||||||
|
line?: number;
|
||||||
|
column?: number;
|
||||||
|
timestamp?: number;
|
||||||
|
stack_trace?: RawConsoleFrame[];
|
||||||
|
truncated?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawNetworkEntry {
|
||||||
|
sequence: number;
|
||||||
|
kind: "response" | "failure";
|
||||||
|
method?: string;
|
||||||
|
url?: string;
|
||||||
|
status?: number;
|
||||||
|
status_text?: string;
|
||||||
|
mime_type?: string;
|
||||||
|
resource_type?: string;
|
||||||
|
error_text?: string;
|
||||||
|
timestamp?: number;
|
||||||
|
truncated?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HELP_CONDITION_SCHEMA = {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
urlContains: { type: "string" },
|
||||||
|
urlMatches: { type: "string" },
|
||||||
|
selectorExists: { type: "string" },
|
||||||
|
selectorMissing: { type: "string" },
|
||||||
|
textExists: { type: "string" },
|
||||||
|
textMissing: { type: "string" },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const DEBUG_PARAMETERS = {
|
||||||
|
session: SESSION_PARAM,
|
||||||
|
tabId: TAB_ID_PARAM,
|
||||||
|
since: {
|
||||||
|
type: "integer",
|
||||||
|
description: "Return entries with a sequence strictly greater than this cursor.",
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
type: "integer",
|
||||||
|
description: "Maximum entries to return (default: 50, daemon cap: 200).",
|
||||||
|
},
|
||||||
|
maxTextChars: {
|
||||||
|
type: "integer",
|
||||||
|
description: "Maximum characters per entry text/URL (default: 1000, daemon cap: 4096).",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function mapCondition(condition: HelpConditionInput) {
|
||||||
|
return {
|
||||||
|
...(condition.urlContains !== undefined ? { url_contains: condition.urlContains } : {}),
|
||||||
|
...(condition.urlMatches !== undefined ? { url_matches: condition.urlMatches } : {}),
|
||||||
|
...(condition.selectorExists !== undefined
|
||||||
|
? { selector_exists: condition.selectorExists }
|
||||||
|
: {}),
|
||||||
|
...(condition.selectorMissing !== undefined
|
||||||
|
? { selector_missing: condition.selectorMissing }
|
||||||
|
: {}),
|
||||||
|
...(condition.textExists !== undefined ? { text_exists: condition.textExists } : {}),
|
||||||
|
...(condition.textMissing !== undefined ? { text_missing: condition.textMissing } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCompletionCriteria(criteria: CompletionCriteriaInput) {
|
||||||
|
return {
|
||||||
|
...(criteria.any !== undefined ? { any: criteria.any.map(mapCondition) } : {}),
|
||||||
|
...(criteria.all !== undefined ? { all: criteria.all.map(mapCondition) } : {}),
|
||||||
|
...(criteria.stableForMs !== undefined ? { stable_for_ms: criteria.stableForMs } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateDebugArgs(args: { since?: number; limit?: number; maxTextChars?: number }): void {
|
||||||
|
if (args.since !== undefined && args.since < 0) {
|
||||||
|
throw new Error("since must be zero or greater");
|
||||||
|
}
|
||||||
|
requirePositive(args.limit, "limit");
|
||||||
|
requirePositive(args.maxTextChars, "maxTextChars");
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendDebugOptions(
|
||||||
|
args: string[],
|
||||||
|
options: { tabId?: number; since?: number; limit?: number; maxTextChars?: number },
|
||||||
|
): void {
|
||||||
|
appendTabId(args, options.tabId);
|
||||||
|
if (options.since !== undefined) args.push("--since", String(options.since));
|
||||||
|
if (options.limit !== undefined) args.push("--limit", String(options.limit));
|
||||||
|
if (options.maxTextChars !== undefined) {
|
||||||
|
args.push("--max-text-chars", String(options.maxTextChars));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human help, raw HTML, diagnostics, and Agent Window sizing. */
|
||||||
|
export function registerPhaseOneSupportTools(
|
||||||
|
deps: ToolDeps,
|
||||||
|
register: ToolRegistrar,
|
||||||
|
runtime: PhaseOneRuntime,
|
||||||
|
): void {
|
||||||
|
const { registry } = deps;
|
||||||
|
|
||||||
|
register(
|
||||||
|
defineTool({
|
||||||
|
name: "browser_request_help",
|
||||||
|
description:
|
||||||
|
"Pause browser automation and ask the user to complete an in-page step such as login, " +
|
||||||
|
"captcha, OTP, or confirmation. After continued/completed, observe again before using refs.",
|
||||||
|
parameters: {
|
||||||
|
prompt: {
|
||||||
|
type: "string",
|
||||||
|
required: true,
|
||||||
|
description: "Clear instructions shown to the user in the browser help overlay.",
|
||||||
|
},
|
||||||
|
session: SESSION_PARAM,
|
||||||
|
tabId: TAB_ID_PARAM,
|
||||||
|
title: { type: "string", description: "Optional title for the help overlay." },
|
||||||
|
targets: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
description: "Snapshot refs or CSS selectors to scroll to and highlight for the user.",
|
||||||
|
},
|
||||||
|
timeoutMs: {
|
||||||
|
...TIMEOUT_MS_PARAM,
|
||||||
|
description: "Maximum wait for the user in milliseconds (default: 300000).",
|
||||||
|
},
|
||||||
|
completionCriteria: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
description: "Optional explicit success detector for automatic completion.",
|
||||||
|
properties: {
|
||||||
|
any: { type: "array", items: HELP_CONDITION_SCHEMA },
|
||||||
|
all: { type: "array", items: HELP_CONDITION_SCHEMA },
|
||||||
|
stableForMs: {
|
||||||
|
type: "integer",
|
||||||
|
description: "How long criteria must remain true before completing.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
session: { type: "string", required: true },
|
||||||
|
tabId: { type: "integer", required: true },
|
||||||
|
outcome: {
|
||||||
|
type: "string",
|
||||||
|
required: true,
|
||||||
|
enum: ["continued", "cancelled", "timed_out", "completed", "navigated", "disabled"],
|
||||||
|
},
|
||||||
|
completedBy: { type: "string" },
|
||||||
|
note: { type: "string" },
|
||||||
|
resolvedTargets: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
matched: { type: "boolean", required: true },
|
||||||
|
ref: { type: "string" },
|
||||||
|
selector: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
render: (_args, value) => [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text:
|
||||||
|
`[session ${value.session}] user help outcome on tab ${value.tabId}: ${value.outcome}` +
|
||||||
|
(value.note !== undefined ? ` — ${value.note}` : "") +
|
||||||
|
(value.resolvedTargets !== undefined
|
||||||
|
? `\ntargets: ${value.resolvedTargets
|
||||||
|
.map(
|
||||||
|
(target) =>
|
||||||
|
`${target.ref ?? target.selector ?? "(unknown)"}=${target.matched ? "matched" : "not found"}`,
|
||||||
|
)
|
||||||
|
.join(", ")}`
|
||||||
|
: ""),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(args, exec) {
|
||||||
|
requireNonEmpty(args.prompt, "prompt");
|
||||||
|
if (args.title !== undefined) requireNonEmpty(args.title, "title");
|
||||||
|
requirePositive(args.timeoutMs, "timeoutMs");
|
||||||
|
for (const target of args.targets ?? []) requireNonEmpty(target, "target");
|
||||||
|
const criteria = args.completionCriteria as CompletionCriteriaInput | undefined;
|
||||||
|
if (criteria?.stableForMs !== undefined && criteria.stableForMs < 0) {
|
||||||
|
throw new Error("completionCriteria.stableForMs must be zero or greater");
|
||||||
|
}
|
||||||
|
const sessionId = registry.resolve(args.session, "browser_request_help");
|
||||||
|
const timeoutMs = args.timeoutMs ?? 300_000;
|
||||||
|
const cmdArgs = [
|
||||||
|
"request-help",
|
||||||
|
"--session",
|
||||||
|
sessionId,
|
||||||
|
"--prompt",
|
||||||
|
args.prompt,
|
||||||
|
"--timeout",
|
||||||
|
`${timeoutMs}ms`,
|
||||||
|
];
|
||||||
|
appendTabId(cmdArgs, args.tabId);
|
||||||
|
if (args.title !== undefined) cmdArgs.push("--title", args.title);
|
||||||
|
for (const target of args.targets ?? []) cmdArgs.push("--target", target);
|
||||||
|
if (criteria !== undefined) {
|
||||||
|
cmdArgs.push("--completion-criteria", JSON.stringify(mapCompletionCriteria(criteria)));
|
||||||
|
}
|
||||||
|
const reply = (await runtime.run(
|
||||||
|
exec,
|
||||||
|
cmdArgs,
|
||||||
|
"request-help",
|
||||||
|
sessionId,
|
||||||
|
runnerTimeout(deps, timeoutMs),
|
||||||
|
)) as {
|
||||||
|
outcome: "continued" | "cancelled" | "timed_out" | "completed" | "navigated" | "disabled";
|
||||||
|
completed_by?: string;
|
||||||
|
note?: string;
|
||||||
|
tab_id: number;
|
||||||
|
resolved_targets?: { matched: boolean; ref?: string; selector?: string }[];
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
session: sessionId,
|
||||||
|
tabId: reply.tab_id,
|
||||||
|
outcome: reply.outcome,
|
||||||
|
...(reply.completed_by !== undefined ? { completedBy: reply.completed_by } : {}),
|
||||||
|
...(reply.note !== undefined ? { note: reply.note } : {}),
|
||||||
|
...(reply.resolved_targets !== undefined
|
||||||
|
? { resolvedTargets: reply.resolved_targets }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
presentCall: (args) => ({
|
||||||
|
card: "terminal",
|
||||||
|
title: runtime.commandLine(["request-help", "--session", args.session ?? "(current)"]),
|
||||||
|
description: "Ask the user for browser help",
|
||||||
|
}),
|
||||||
|
presentResult: runtime.presentTerminalResult,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
register(
|
||||||
|
defineTool({
|
||||||
|
name: "browser_get_html",
|
||||||
|
description:
|
||||||
|
"Read raw DOM HTML from the active Agent Window tab, optionally scoped to a fresh " +
|
||||||
|
"snapshot ref. Use only when browser_observe/browser_snapshot cannot answer the question.",
|
||||||
|
parameters: {
|
||||||
|
session: SESSION_PARAM,
|
||||||
|
tabId: TAB_ID_PARAM,
|
||||||
|
ref: { type: "string", description: "Fresh snapshot ref that scopes the HTML subtree." },
|
||||||
|
maxBytes: {
|
||||||
|
type: "integer",
|
||||||
|
description: "Maximum returned HTML bytes before truncation (default: 524288).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
session: { type: "string", required: true },
|
||||||
|
tabId: { type: "integer", required: true },
|
||||||
|
html: { type: "string", required: true },
|
||||||
|
truncated: { type: "boolean", required: true },
|
||||||
|
byteSize: { type: "integer", required: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
render: (_args, value) => [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text:
|
||||||
|
value.html +
|
||||||
|
(value.truncated ? `\n(HTML truncated; original size ${value.byteSize} bytes)` : ""),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
isConcurrencySafe: () => true,
|
||||||
|
async execute(args, exec) {
|
||||||
|
if (args.ref !== undefined) {
|
||||||
|
requireNonEmpty(args.ref, "ref");
|
||||||
|
if (!isSnapshotRef(args.ref)) throw new Error("ref must be a snapshot ref such as @e3");
|
||||||
|
}
|
||||||
|
requirePositive(args.maxBytes, "maxBytes");
|
||||||
|
const sessionId = registry.resolve(args.session, "browser_get_html");
|
||||||
|
const cmdArgs = ["get-html", "--session", sessionId];
|
||||||
|
appendTabId(cmdArgs, args.tabId);
|
||||||
|
if (args.ref !== undefined) cmdArgs.push("--ref", args.ref);
|
||||||
|
if (args.maxBytes !== undefined) cmdArgs.push("--max-bytes", String(args.maxBytes));
|
||||||
|
const reply = (await runtime.run(exec, cmdArgs, "get-html", sessionId)) as {
|
||||||
|
tab_id: number;
|
||||||
|
html: string;
|
||||||
|
truncated?: boolean;
|
||||||
|
byte_size: number;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
session: sessionId,
|
||||||
|
tabId: reply.tab_id,
|
||||||
|
html: reply.html,
|
||||||
|
truncated: reply.truncated ?? false,
|
||||||
|
byteSize: reply.byte_size,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
presentCall: (args) => ({
|
||||||
|
card: "terminal",
|
||||||
|
title: runtime.commandLine([
|
||||||
|
"get-html",
|
||||||
|
"--session",
|
||||||
|
args.session ?? "(current)",
|
||||||
|
...(args.ref !== undefined ? ["--ref", args.ref] : []),
|
||||||
|
]),
|
||||||
|
description: "Read raw page HTML",
|
||||||
|
}),
|
||||||
|
presentResult: runtime.presentTerminalResult,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
register(
|
||||||
|
defineTool({
|
||||||
|
name: "browser_console",
|
||||||
|
description:
|
||||||
|
"Read buffered console messages, browser log entries, and JavaScript exceptions from a tab. " +
|
||||||
|
"This is read-only and does not evaluate JavaScript.",
|
||||||
|
parameters: {
|
||||||
|
...DEBUG_PARAMETERS,
|
||||||
|
includeStack: {
|
||||||
|
type: "boolean",
|
||||||
|
description: "Include structured stack frames in returned console entries.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
session: { type: "string", required: true },
|
||||||
|
tabId: { type: "integer", required: true },
|
||||||
|
entries: {
|
||||||
|
type: "array",
|
||||||
|
required: true,
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
sequence: { type: "integer", required: true },
|
||||||
|
kind: { type: "string", required: true },
|
||||||
|
level: { type: "string", required: true },
|
||||||
|
text: { type: "string", required: true },
|
||||||
|
url: { type: "string" },
|
||||||
|
line: { type: "integer" },
|
||||||
|
column: { type: "integer" },
|
||||||
|
timestamp: { type: "number" },
|
||||||
|
stackTrace: {
|
||||||
|
type: "array",
|
||||||
|
required: true,
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
functionName: { type: "string" },
|
||||||
|
url: { type: "string" },
|
||||||
|
line: { type: "integer" },
|
||||||
|
column: { type: "integer" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
truncated: { type: "boolean", required: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
nextSince: { type: "integer", required: true },
|
||||||
|
truncated: { type: "boolean", required: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
render: (_args, value) => [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text:
|
||||||
|
value.entries.length === 0
|
||||||
|
? "(no console messages captured)"
|
||||||
|
: value.entries
|
||||||
|
.map((entry) => {
|
||||||
|
const location =
|
||||||
|
entry.url === undefined
|
||||||
|
? ""
|
||||||
|
: ` ${entry.url}${entry.line !== undefined ? `:${entry.line}` : ""}${entry.column !== undefined ? `:${entry.column}` : ""}`;
|
||||||
|
const stack = entry.stackTrace
|
||||||
|
.map(
|
||||||
|
(frame) =>
|
||||||
|
`\n at ${frame.functionName ?? "<anonymous>"} ${frame.url ?? ""}` +
|
||||||
|
`${frame.line !== undefined ? `:${frame.line}` : ""}` +
|
||||||
|
`${frame.column !== undefined ? `:${frame.column}` : ""}`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
return `#${entry.sequence} ${entry.level} ${entry.kind}${location} ${entry.text}${stack}`;
|
||||||
|
})
|
||||||
|
.join("\n") +
|
||||||
|
(value.truncated
|
||||||
|
? `\n(output truncated; continue with since=${value.nextSince})`
|
||||||
|
: ""),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
isConcurrencySafe: () => true,
|
||||||
|
async execute(args, exec) {
|
||||||
|
validateDebugArgs(args);
|
||||||
|
const sessionId = registry.resolve(args.session, "browser_console");
|
||||||
|
const cmdArgs = ["console", "--session", sessionId];
|
||||||
|
appendDebugOptions(cmdArgs, args);
|
||||||
|
if (args.includeStack === true) cmdArgs.push("--include-stack");
|
||||||
|
const reply = (await runtime.run(exec, cmdArgs, "console", sessionId)) as {
|
||||||
|
tab_id: number;
|
||||||
|
entries?: RawConsoleEntry[];
|
||||||
|
next_since: number;
|
||||||
|
truncated?: boolean;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
session: sessionId,
|
||||||
|
tabId: reply.tab_id,
|
||||||
|
entries: (reply.entries ?? []).map((entry) => ({
|
||||||
|
sequence: entry.sequence,
|
||||||
|
kind: entry.kind,
|
||||||
|
level: entry.level,
|
||||||
|
text: entry.text,
|
||||||
|
...(entry.url !== undefined ? { url: entry.url } : {}),
|
||||||
|
...(entry.line !== undefined ? { line: entry.line } : {}),
|
||||||
|
...(entry.column !== undefined ? { column: entry.column } : {}),
|
||||||
|
...(entry.timestamp !== undefined ? { timestamp: entry.timestamp } : {}),
|
||||||
|
stackTrace: (entry.stack_trace ?? []).map((frame) => ({
|
||||||
|
...(frame.function_name !== undefined ? { functionName: frame.function_name } : {}),
|
||||||
|
...(frame.url !== undefined ? { url: frame.url } : {}),
|
||||||
|
...(frame.line !== undefined ? { line: frame.line } : {}),
|
||||||
|
...(frame.column !== undefined ? { column: frame.column } : {}),
|
||||||
|
})),
|
||||||
|
truncated: entry.truncated ?? false,
|
||||||
|
})),
|
||||||
|
nextSince: reply.next_since,
|
||||||
|
truncated: reply.truncated ?? false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
presentCall: (args) => ({
|
||||||
|
card: "terminal",
|
||||||
|
title: runtime.commandLine(["console", "--session", args.session ?? "(current)"]),
|
||||||
|
description: "Read browser console messages",
|
||||||
|
}),
|
||||||
|
presentResult: runtime.presentTerminalResult,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
register(
|
||||||
|
defineTool({
|
||||||
|
name: "browser_network",
|
||||||
|
description:
|
||||||
|
"Read buffered network responses and failures from a tab. Returns metadata only; request " +
|
||||||
|
"and response headers and bodies are not captured.",
|
||||||
|
parameters: DEBUG_PARAMETERS,
|
||||||
|
output: {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
session: { type: "string", required: true },
|
||||||
|
tabId: { type: "integer", required: true },
|
||||||
|
entries: {
|
||||||
|
type: "array",
|
||||||
|
required: true,
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
sequence: { type: "integer", required: true },
|
||||||
|
kind: { type: "string", required: true, enum: ["response", "failure"] },
|
||||||
|
method: { type: "string" },
|
||||||
|
url: { type: "string" },
|
||||||
|
status: { type: "integer" },
|
||||||
|
statusText: { type: "string" },
|
||||||
|
mimeType: { type: "string" },
|
||||||
|
resourceType: { type: "string" },
|
||||||
|
errorText: { type: "string" },
|
||||||
|
timestamp: { type: "number" },
|
||||||
|
truncated: { type: "boolean", required: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
nextSince: { type: "integer", required: true },
|
||||||
|
truncated: { type: "boolean", required: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
render: (_args, value) => [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text:
|
||||||
|
value.entries.length === 0
|
||||||
|
? "(no network activity captured)"
|
||||||
|
: value.entries
|
||||||
|
.map((entry) =>
|
||||||
|
entry.kind === "failure"
|
||||||
|
? `#${entry.sequence} FAILED ${entry.method ?? "?"} ${entry.url ?? "(unknown)"} — ${entry.errorText ?? "failed"}`
|
||||||
|
: `#${entry.sequence} ${entry.status ?? "?"} ${entry.method ?? "?"} ${entry.url ?? "(unknown)"}`,
|
||||||
|
)
|
||||||
|
.join("\n") +
|
||||||
|
(value.truncated
|
||||||
|
? `\n(output truncated; continue with since=${value.nextSince})`
|
||||||
|
: ""),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
isConcurrencySafe: () => true,
|
||||||
|
async execute(args, exec) {
|
||||||
|
validateDebugArgs(args);
|
||||||
|
const sessionId = registry.resolve(args.session, "browser_network");
|
||||||
|
const cmdArgs = ["network", "--session", sessionId];
|
||||||
|
appendDebugOptions(cmdArgs, args);
|
||||||
|
const reply = (await runtime.run(exec, cmdArgs, "network", sessionId)) as {
|
||||||
|
tab_id: number;
|
||||||
|
entries?: RawNetworkEntry[];
|
||||||
|
next_since: number;
|
||||||
|
truncated?: boolean;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
session: sessionId,
|
||||||
|
tabId: reply.tab_id,
|
||||||
|
entries: (reply.entries ?? []).map((entry) => ({
|
||||||
|
sequence: entry.sequence,
|
||||||
|
kind: entry.kind,
|
||||||
|
...(entry.method !== undefined ? { method: entry.method } : {}),
|
||||||
|
...(entry.url !== undefined ? { url: entry.url } : {}),
|
||||||
|
...(entry.status !== undefined ? { status: entry.status } : {}),
|
||||||
|
...(entry.status_text !== undefined ? { statusText: entry.status_text } : {}),
|
||||||
|
...(entry.mime_type !== undefined ? { mimeType: entry.mime_type } : {}),
|
||||||
|
...(entry.resource_type !== undefined ? { resourceType: entry.resource_type } : {}),
|
||||||
|
...(entry.error_text !== undefined ? { errorText: entry.error_text } : {}),
|
||||||
|
...(entry.timestamp !== undefined ? { timestamp: entry.timestamp } : {}),
|
||||||
|
truncated: entry.truncated ?? false,
|
||||||
|
})),
|
||||||
|
nextSince: reply.next_since,
|
||||||
|
truncated: reply.truncated ?? false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
presentCall: (args) => ({
|
||||||
|
card: "terminal",
|
||||||
|
title: runtime.commandLine(["network", "--session", args.session ?? "(current)"]),
|
||||||
|
description: "Read browser network activity",
|
||||||
|
}),
|
||||||
|
presentResult: runtime.presentTerminalResult,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
register(
|
||||||
|
defineTool({
|
||||||
|
name: "browser_window_resize",
|
||||||
|
description: "Resize the owned session's Agent Window using outer CSS-pixel dimensions.",
|
||||||
|
parameters: {
|
||||||
|
width: {
|
||||||
|
type: "integer",
|
||||||
|
required: true,
|
||||||
|
description: "Agent Window outer width in CSS pixels (100..=7680).",
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
type: "integer",
|
||||||
|
required: true,
|
||||||
|
description: "Agent Window outer height in CSS pixels (100..=7680).",
|
||||||
|
},
|
||||||
|
session: SESSION_PARAM,
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
session: { type: "string", required: true },
|
||||||
|
windowId: { type: "integer", required: true },
|
||||||
|
width: { type: "integer", required: true },
|
||||||
|
height: { type: "integer", required: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
render: (_args, value) => [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `[session ${value.session}] resized window ${value.windowId} to ${value.width}x${value.height}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(args, exec) {
|
||||||
|
if (args.width < 100 || args.width > 7680 || args.height < 100 || args.height > 7680) {
|
||||||
|
throw new Error("width and height must each be in the range 100..=7680");
|
||||||
|
}
|
||||||
|
const sessionId = registry.resolve(args.session, "browser_window_resize");
|
||||||
|
const reply = (await runtime.run(
|
||||||
|
exec,
|
||||||
|
[
|
||||||
|
"window",
|
||||||
|
"resize",
|
||||||
|
"--session",
|
||||||
|
sessionId,
|
||||||
|
"--width",
|
||||||
|
String(args.width),
|
||||||
|
"--height",
|
||||||
|
String(args.height),
|
||||||
|
],
|
||||||
|
"window resize",
|
||||||
|
sessionId,
|
||||||
|
)) as { window_id: number; width: number; height: number };
|
||||||
|
return {
|
||||||
|
session: sessionId,
|
||||||
|
windowId: reply.window_id,
|
||||||
|
width: reply.width,
|
||||||
|
height: reply.height,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
presentCall: (args) => ({
|
||||||
|
card: "terminal",
|
||||||
|
title: runtime.commandLine([
|
||||||
|
"window",
|
||||||
|
"resize",
|
||||||
|
"--session",
|
||||||
|
args.session ?? "(current)",
|
||||||
|
"--width",
|
||||||
|
String(args.width),
|
||||||
|
"--height",
|
||||||
|
String(args.height),
|
||||||
|
]),
|
||||||
|
description: "Resize the Agent Window",
|
||||||
|
}),
|
||||||
|
presentResult: runtime.presentTerminalResult,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
||||||
|
import type { PhaseOneRuntime, ToolRegistrar } from "./phase-one-runtime";
|
||||||
|
import { requireNonEmpty } from "./phase-one-runtime";
|
||||||
|
import { SESSION_PARAM } from "./tool-params";
|
||||||
|
import type { ToolDeps } from "./tools";
|
||||||
|
|
||||||
|
const TAB_ID_REQUIRED = {
|
||||||
|
type: "integer",
|
||||||
|
required: true,
|
||||||
|
description: "Chrome tab id returned by browser_tab_list or browser_tab_create.",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
interface RawTabInfo {
|
||||||
|
tab_id: number;
|
||||||
|
title?: string;
|
||||||
|
url?: string;
|
||||||
|
window_id?: number;
|
||||||
|
active?: boolean;
|
||||||
|
scope?: "user" | "agent";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add Agent Window tab management while preserving the owned-session boundary. */
|
||||||
|
export function registerPhaseOneTabTools(
|
||||||
|
deps: ToolDeps,
|
||||||
|
register: ToolRegistrar,
|
||||||
|
runtime: PhaseOneRuntime,
|
||||||
|
): void {
|
||||||
|
const { registry } = deps;
|
||||||
|
|
||||||
|
register(
|
||||||
|
defineTool({
|
||||||
|
name: "browser_tab_list",
|
||||||
|
description:
|
||||||
|
"List tabs visible to an owned browser session. Other sessions' Agent Windows remain " +
|
||||||
|
"hidden; use scope=user to find a user tab before borrowing it.",
|
||||||
|
parameters: {
|
||||||
|
session: SESSION_PARAM,
|
||||||
|
scope: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["user", "agent", "all"],
|
||||||
|
description: "Which tabs to list (default: all).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
session: { type: "string", required: true },
|
||||||
|
tabs: {
|
||||||
|
type: "array",
|
||||||
|
required: true,
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
tabId: { type: "integer", required: true },
|
||||||
|
title: { type: "string" },
|
||||||
|
url: { type: "string" },
|
||||||
|
windowId: { type: "integer" },
|
||||||
|
active: { type: "boolean" },
|
||||||
|
scope: { type: "string", enum: ["user", "agent"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
render: (_args, value) => [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text:
|
||||||
|
value.tabs.length === 0
|
||||||
|
? `[session ${value.session}] no matching tabs`
|
||||||
|
: value.tabs
|
||||||
|
.map(
|
||||||
|
(tab) =>
|
||||||
|
`${tab.tabId} [${tab.scope ?? "unknown"}]${tab.active === true ? " [active]" : ""} ` +
|
||||||
|
`${tab.title ?? "(untitled)"} — ${tab.url ?? "(unknown URL)"}`,
|
||||||
|
)
|
||||||
|
.join("\n"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
isConcurrencySafe: () => true,
|
||||||
|
async execute(args, exec) {
|
||||||
|
const sessionId = registry.resolve(args.session, "browser_tab_list");
|
||||||
|
const cmdArgs = ["tab", "list", "--session", sessionId];
|
||||||
|
if (args.scope !== undefined) cmdArgs.push("--scope", args.scope);
|
||||||
|
const reply = (await runtime.run(exec, cmdArgs, "tab list", sessionId)) as {
|
||||||
|
tabs: RawTabInfo[];
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
session: sessionId,
|
||||||
|
tabs: reply.tabs.map((tab) => ({
|
||||||
|
tabId: tab.tab_id,
|
||||||
|
...(tab.title !== undefined ? { title: tab.title } : {}),
|
||||||
|
...(tab.url !== undefined ? { url: tab.url } : {}),
|
||||||
|
...(tab.window_id !== undefined ? { windowId: tab.window_id } : {}),
|
||||||
|
...(tab.active !== undefined ? { active: tab.active } : {}),
|
||||||
|
...(tab.scope !== undefined ? { scope: tab.scope } : {}),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
presentCall: (args) => ({
|
||||||
|
card: "terminal",
|
||||||
|
title: runtime.commandLine([
|
||||||
|
"tab",
|
||||||
|
"list",
|
||||||
|
"--session",
|
||||||
|
args.session ?? "(current)",
|
||||||
|
...(args.scope !== undefined ? ["--scope", args.scope] : []),
|
||||||
|
]),
|
||||||
|
description: "List browser tabs",
|
||||||
|
}),
|
||||||
|
presentResult: runtime.presentTerminalResult,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
register(
|
||||||
|
defineTool({
|
||||||
|
name: "browser_tab_create",
|
||||||
|
description:
|
||||||
|
"Create a tab inside the owned session's Agent Window. The tab is focused by default; " +
|
||||||
|
"set active=false to open it in the background.",
|
||||||
|
parameters: {
|
||||||
|
session: SESSION_PARAM,
|
||||||
|
url: { type: "string", description: "Initial URL (default: chrome://newtab/)." },
|
||||||
|
active: { type: "boolean", description: "Whether to focus the new tab (default: true)." },
|
||||||
|
index: { type: "integer", description: "Insertion index in the Agent Window tab strip." },
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
session: { type: "string", required: true },
|
||||||
|
tabId: { type: "integer", required: true },
|
||||||
|
windowId: { type: "integer", required: true },
|
||||||
|
url: { type: "string", required: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
render: (_args, value) => [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `[session ${value.session}] created tab ${value.tabId} in window ${value.windowId} (${value.url || "pending URL"})`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(args, exec) {
|
||||||
|
if (args.url !== undefined) requireNonEmpty(args.url, "url");
|
||||||
|
const sessionId = registry.resolve(args.session, "browser_tab_create");
|
||||||
|
const cmdArgs = ["tab", "create", "--session", sessionId];
|
||||||
|
if (args.url !== undefined) cmdArgs.push("--url", args.url);
|
||||||
|
if (args.active === false) cmdArgs.push("--no-active");
|
||||||
|
if (args.index !== undefined) cmdArgs.push("--index", String(args.index));
|
||||||
|
const reply = (await runtime.run(exec, cmdArgs, "tab create", sessionId)) as {
|
||||||
|
tab_id: number;
|
||||||
|
window_id: number;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
if (args.active !== false && reply.url.length > 0) {
|
||||||
|
deps.observation.setUrl(sessionId, reply.url);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
session: sessionId,
|
||||||
|
tabId: reply.tab_id,
|
||||||
|
windowId: reply.window_id,
|
||||||
|
url: reply.url,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
presentCall: (args) => ({
|
||||||
|
card: "terminal",
|
||||||
|
title: runtime.commandLine([
|
||||||
|
"tab",
|
||||||
|
"create",
|
||||||
|
"--session",
|
||||||
|
args.session ?? "(current)",
|
||||||
|
...(args.url !== undefined ? ["--url", args.url] : []),
|
||||||
|
]),
|
||||||
|
description: "Create an Agent Window tab",
|
||||||
|
}),
|
||||||
|
presentResult: runtime.presentTerminalResult,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
register(
|
||||||
|
defineTool({
|
||||||
|
name: "browser_tab_close",
|
||||||
|
description: "Close a tab in the owned session's Agent Window.",
|
||||||
|
parameters: { tabId: TAB_ID_REQUIRED, session: SESSION_PARAM },
|
||||||
|
output: {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
session: { type: "string", required: true },
|
||||||
|
tabId: { type: "integer", required: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
render: (_args, value) => [
|
||||||
|
{ type: "text", text: `[session ${value.session}] closed tab ${value.tabId}` },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(args, exec) {
|
||||||
|
const sessionId = registry.resolve(args.session, "browser_tab_close");
|
||||||
|
const reply = (await runtime.run(
|
||||||
|
exec,
|
||||||
|
["tab", "close", String(args.tabId), "--session", sessionId],
|
||||||
|
"tab close",
|
||||||
|
sessionId,
|
||||||
|
)) as { tab_id: number };
|
||||||
|
return { session: sessionId, tabId: reply.tab_id };
|
||||||
|
},
|
||||||
|
presentCall: (args) => ({
|
||||||
|
card: "terminal",
|
||||||
|
title: runtime.commandLine([
|
||||||
|
"tab",
|
||||||
|
"close",
|
||||||
|
String(args.tabId),
|
||||||
|
"--session",
|
||||||
|
args.session ?? "(current)",
|
||||||
|
]),
|
||||||
|
description: "Close an Agent Window tab",
|
||||||
|
}),
|
||||||
|
presentResult: runtime.presentTerminalResult,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
register(
|
||||||
|
defineTool({
|
||||||
|
name: "browser_tab_select",
|
||||||
|
description: "Focus a tab in the owned session's Agent Window.",
|
||||||
|
parameters: { tabId: TAB_ID_REQUIRED, session: SESSION_PARAM },
|
||||||
|
output: {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
session: { type: "string", required: true },
|
||||||
|
tabId: { type: "integer", required: true },
|
||||||
|
windowId: { type: "integer", required: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
render: (_args, value) => [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `[session ${value.session}] selected tab ${value.tabId} in window ${value.windowId}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(args, exec) {
|
||||||
|
const sessionId = registry.resolve(args.session, "browser_tab_select");
|
||||||
|
const reply = (await runtime.run(
|
||||||
|
exec,
|
||||||
|
["tab", "select", String(args.tabId), "--session", sessionId],
|
||||||
|
"tab select",
|
||||||
|
sessionId,
|
||||||
|
)) as { tab_id: number; window_id: number };
|
||||||
|
return { session: sessionId, tabId: reply.tab_id, windowId: reply.window_id };
|
||||||
|
},
|
||||||
|
presentCall: (args) => ({
|
||||||
|
card: "terminal",
|
||||||
|
title: runtime.commandLine([
|
||||||
|
"tab",
|
||||||
|
"select",
|
||||||
|
String(args.tabId),
|
||||||
|
"--session",
|
||||||
|
args.session ?? "(current)",
|
||||||
|
]),
|
||||||
|
description: "Select an Agent Window tab",
|
||||||
|
}),
|
||||||
|
presentResult: runtime.presentTerminalResult,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
register(
|
||||||
|
defineTool({
|
||||||
|
name: "browser_tab_borrow",
|
||||||
|
description:
|
||||||
|
"Move a user-window tab into the owned session's Agent Window for controlled interaction. " +
|
||||||
|
"Return it with browser_tab_return when finished; stopping the session also auto-returns it.",
|
||||||
|
parameters: { tabId: TAB_ID_REQUIRED, session: SESSION_PARAM },
|
||||||
|
output: {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
session: { type: "string", required: true },
|
||||||
|
tabId: { type: "integer", required: true },
|
||||||
|
originalWindowId: { type: "integer", required: true },
|
||||||
|
originalIndex: { type: "integer", required: true },
|
||||||
|
agentWindowId: { type: "integer", required: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
render: (_args, value) => [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text:
|
||||||
|
`[session ${value.session}] borrowed tab ${value.tabId} from window ` +
|
||||||
|
`${value.originalWindowId} into Agent Window ${value.agentWindowId}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(args, exec) {
|
||||||
|
const sessionId = registry.resolve(args.session, "browser_tab_borrow");
|
||||||
|
const reply = (await runtime.run(
|
||||||
|
exec,
|
||||||
|
["tab", "borrow", String(args.tabId), "--session", sessionId],
|
||||||
|
"tab borrow",
|
||||||
|
sessionId,
|
||||||
|
)) as {
|
||||||
|
tab_id: number;
|
||||||
|
original_window_id: number;
|
||||||
|
original_index: number;
|
||||||
|
agent_window_id: number;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
session: sessionId,
|
||||||
|
tabId: reply.tab_id,
|
||||||
|
originalWindowId: reply.original_window_id,
|
||||||
|
originalIndex: reply.original_index,
|
||||||
|
agentWindowId: reply.agent_window_id,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
presentCall: (args) => ({
|
||||||
|
card: "terminal",
|
||||||
|
title: runtime.commandLine([
|
||||||
|
"tab",
|
||||||
|
"borrow",
|
||||||
|
String(args.tabId),
|
||||||
|
"--session",
|
||||||
|
args.session ?? "(current)",
|
||||||
|
]),
|
||||||
|
description: "Borrow a user tab",
|
||||||
|
}),
|
||||||
|
presentResult: runtime.presentTerminalResult,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
register(
|
||||||
|
defineTool({
|
||||||
|
name: "browser_tab_return",
|
||||||
|
description: "Return a borrowed tab to its original user window and tab-strip position.",
|
||||||
|
parameters: { tabId: TAB_ID_REQUIRED, session: SESSION_PARAM },
|
||||||
|
output: {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
session: { type: "string", required: true },
|
||||||
|
tabId: { type: "integer", required: true },
|
||||||
|
returnedToWindowId: { type: "integer", required: true },
|
||||||
|
returnedToIndex: { type: "integer", required: true },
|
||||||
|
fallback: { type: "boolean", required: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
render: (_args, value) => [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text:
|
||||||
|
`[session ${value.session}] returned tab ${value.tabId} to window ` +
|
||||||
|
`${value.returnedToWindowId} at index ${value.returnedToIndex}` +
|
||||||
|
(value.fallback ? " (original window unavailable; used fallback)" : ""),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(args, exec) {
|
||||||
|
const sessionId = registry.resolve(args.session, "browser_tab_return");
|
||||||
|
const reply = (await runtime.run(
|
||||||
|
exec,
|
||||||
|
["tab", "return", String(args.tabId), "--session", sessionId],
|
||||||
|
"tab return",
|
||||||
|
sessionId,
|
||||||
|
)) as {
|
||||||
|
tab_id: number;
|
||||||
|
returned_to_window_id: number;
|
||||||
|
returned_to_index: number;
|
||||||
|
fallback?: boolean;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
session: sessionId,
|
||||||
|
tabId: reply.tab_id,
|
||||||
|
returnedToWindowId: reply.returned_to_window_id,
|
||||||
|
returnedToIndex: reply.returned_to_index,
|
||||||
|
fallback: reply.fallback ?? false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
presentCall: (args) => ({
|
||||||
|
card: "terminal",
|
||||||
|
title: runtime.commandLine([
|
||||||
|
"tab",
|
||||||
|
"return",
|
||||||
|
String(args.tabId),
|
||||||
|
"--session",
|
||||||
|
args.session ?? "(current)",
|
||||||
|
]),
|
||||||
|
description: "Return a borrowed tab",
|
||||||
|
}),
|
||||||
|
presentResult: runtime.presentTerminalResult,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { PhaseOneRuntime, ToolRegistrar } from "./phase-one-runtime";
|
||||||
|
import { registerPhaseOneInteractionTools } from "./phase-one-tools-interaction";
|
||||||
|
import { registerPhaseOneNavigationTools } from "./phase-one-tools-navigation";
|
||||||
|
import { registerPhaseOneSupportTools } from "./phase-one-tools-support";
|
||||||
|
import { registerPhaseOneTabTools } from "./phase-one-tools-tabs";
|
||||||
|
import type { ToolDeps } from "./tools";
|
||||||
|
|
||||||
|
/** Register the first DSH capability-parity tranche on the existing runtime. */
|
||||||
|
export function registerPhaseOneTools(
|
||||||
|
deps: ToolDeps,
|
||||||
|
register: ToolRegistrar,
|
||||||
|
runtime: PhaseOneRuntime,
|
||||||
|
): void {
|
||||||
|
registerPhaseOneInteractionTools(deps, register, runtime);
|
||||||
|
registerPhaseOneTabTools(deps, register, runtime);
|
||||||
|
registerPhaseOneNavigationTools(deps, register, runtime);
|
||||||
|
registerPhaseOneSupportTools(deps, register, runtime);
|
||||||
|
}
|
||||||
@@ -64,14 +64,17 @@ export interface BskRunner {
|
|||||||
killFor(tag: string): number;
|
killFor(tag: string): number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const KILL_GRACE_MS = 2000;
|
// Business RPCs translate SIGINT into the daemon's cancel(rpc_id) protocol.
|
||||||
|
// Give that bounded reconciliation path time to settle before the hard kill.
|
||||||
|
const KILL_GRACE_MS = 3000;
|
||||||
|
const SESSION_BUSY_RETRY_DELAY_MS = 100;
|
||||||
|
|
||||||
export function createBskRunner(bskPath: string, spawnImpl: SpawnImpl = spawn): BskRunner {
|
export function createBskRunner(bskPath: string, spawnImpl: SpawnImpl = spawn): BskRunner {
|
||||||
const live = new Map<ChildProcess, string | undefined>();
|
const live = new Map<ChildProcess, string | undefined>();
|
||||||
|
|
||||||
function killChild(child: ChildProcess): void {
|
function killChild(child: ChildProcess): void {
|
||||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||||
child.kill("SIGTERM");
|
child.kill("SIGINT");
|
||||||
const force = setTimeout(() => {
|
const force = setTimeout(() => {
|
||||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||||
}, KILL_GRACE_MS);
|
}, KILL_GRACE_MS);
|
||||||
@@ -163,6 +166,60 @@ export function isCommandNotFound(error: unknown): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True only for the daemon's transient per-session reconciliation window. */
|
||||||
|
export function isSessionBusyResult(result: BskRunResult): boolean {
|
||||||
|
if (result.code === 0) return false;
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(result.stdout) as BskErrorBody;
|
||||||
|
const reason =
|
||||||
|
typeof body.data === "object" && body.data !== null && "reason" in body.data
|
||||||
|
? (body.data as { reason?: unknown }).reason
|
||||||
|
: undefined;
|
||||||
|
return reason === "session_busy";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry exactly once after the tiny daemon-settlement race that can follow a
|
||||||
|
* graceful SIGINT cancellation. Other errors and persistent busy states stay
|
||||||
|
* visible to callers.
|
||||||
|
*/
|
||||||
|
export async function runWithSessionBusyRetry(
|
||||||
|
run: () => Promise<BskRunResult>,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<BskRunResult> {
|
||||||
|
const first = await run();
|
||||||
|
if (!isSessionBusyResult(first)) return first;
|
||||||
|
await abortableDelay(SESSION_BUSY_RETRY_DELAY_MS, signal);
|
||||||
|
return run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function abortableDelay(ms: number, signal?: AbortSignal): Promise<void> {
|
||||||
|
if (signal?.aborted) return Promise.reject(abortError());
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(done, ms);
|
||||||
|
timer.unref();
|
||||||
|
const onAbort = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
signal?.removeEventListener("abort", onAbort);
|
||||||
|
reject(abortError());
|
||||||
|
};
|
||||||
|
function done() {
|
||||||
|
signal?.removeEventListener("abort", onAbort);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
signal?.addEventListener("abort", onAbort, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function abortError(): Error {
|
||||||
|
const error = new Error("tool call aborted");
|
||||||
|
error.name = "AbortError";
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
/** Install guidance shown when the bsk CLI cannot be spawned. */
|
/** Install guidance shown when the bsk CLI cannot be spawned. */
|
||||||
export function bskInstallMessage(bskPath: string): string {
|
export function bskInstallMessage(bskPath: string): string {
|
||||||
return (
|
return (
|
||||||
@@ -182,7 +239,7 @@ export function parseBskJson(result: BskRunResult, commandLabel: string): unknow
|
|||||||
throw new BskError(`bsk ${commandLabel} timed out`, { timedOut: true });
|
throw new BskError(`bsk ${commandLabel} timed out`, { timedOut: true });
|
||||||
}
|
}
|
||||||
const body = result.stdout.trim();
|
const body = result.stdout.trim();
|
||||||
// Killed by our own interrupt (SIGTERM from killFor), not by the abort path:
|
// Killed by our own interrupt (SIGINT from killFor), not by the abort path:
|
||||||
// say so instead of doubling the generic label into the message.
|
// say so instead of doubling the generic label into the message.
|
||||||
if (result.code === null && !result.aborted && !result.timedOut) {
|
if (result.code === null && !result.aborted && !result.timedOut) {
|
||||||
throw new BskError(`bsk ${commandLabel} was interrupted (process killed)`);
|
throw new BskError(`bsk ${commandLabel} was interrupted (process killed)`);
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ export interface TrackedSession {
|
|||||||
export class SessionRegistry {
|
export class SessionRegistry {
|
||||||
private readonly sessions = new Map<string, TrackedSession>();
|
private readonly sessions = new Map<string, TrackedSession>();
|
||||||
private currentId: string | undefined;
|
private currentId: string | undefined;
|
||||||
|
/**
|
||||||
|
* bsk session id → the DSH session ids that may clean it up: the agent
|
||||||
|
* session that started it plus every ancestor along the seed lineage, so
|
||||||
|
* archiving a conversation at ANY level of the chain reaps its browsers
|
||||||
|
* (see archive-cleanup.ts).
|
||||||
|
*/
|
||||||
|
private readonly dshOwners = new Map<string, ReadonlySet<string>>();
|
||||||
/**
|
/**
|
||||||
* Slots reserved by in-flight starts. reserveStart/completeStart/abandonStart
|
* Slots reserved by in-flight starts. reserveStart/completeStart/abandonStart
|
||||||
* run synchronously around the async spawn, so concurrent starts can never
|
* run synchronously around the async spawn, so concurrent starts can never
|
||||||
@@ -72,12 +79,37 @@ export class SessionRegistry {
|
|||||||
/** Forget a session; falls back to the most recent remaining one. */
|
/** Forget a session; falls back to the most recent remaining one. */
|
||||||
remove(sessionId: string): void {
|
remove(sessionId: string): void {
|
||||||
this.sessions.delete(sessionId);
|
this.sessions.delete(sessionId);
|
||||||
|
this.dshOwners.delete(sessionId);
|
||||||
if (this.currentId === sessionId) {
|
if (this.currentId === sessionId) {
|
||||||
const rest = [...this.sessions.values()];
|
const rest = [...this.sessions.values()];
|
||||||
this.currentId = rest.length > 0 ? rest[rest.length - 1].sessionId : undefined;
|
this.currentId = rest.length > 0 ? rest[rest.length - 1].sessionId : undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record which DSH conversation(s) a freshly started bsk session belongs
|
||||||
|
* to (the starting agent's session plus its ancestors). No-op without ids
|
||||||
|
* — e.g. a start whose caller carried no agent identity.
|
||||||
|
*/
|
||||||
|
trackOwner(sessionId: string, dshSessionIds: readonly string[]): void {
|
||||||
|
if (dshSessionIds.length === 0 || !this.sessions.has(sessionId)) return;
|
||||||
|
this.dshOwners.set(sessionId, new Set(dshSessionIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** bsk session ids owned by the given DSH conversation (or its descendants). */
|
||||||
|
ownedByDsh(dshSessionId: string): string[] {
|
||||||
|
const owned: string[] = [];
|
||||||
|
for (const [sessionId, owners] of this.dshOwners) {
|
||||||
|
if (owners.has(dshSessionId)) owned.push(sessionId);
|
||||||
|
}
|
||||||
|
return owned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The DSH conversation ids owning one bsk session (empty when untracked). */
|
||||||
|
dshOwnersOf(sessionId: string): string[] {
|
||||||
|
return [...(this.dshOwners.get(sessionId) ?? [])];
|
||||||
|
}
|
||||||
|
|
||||||
/** Mark an owned session as most recently used (recency order refresh). */
|
/** Mark an owned session as most recently used (recency order refresh). */
|
||||||
private touch(sessionId: string): void {
|
private touch(sessionId: string): void {
|
||||||
const existing = this.sessions.get(sessionId);
|
const existing = this.sessions.get(sessionId);
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
* Progressive disclosure of the BrowserSkill agent skill through the harness's
|
* Progressive disclosure of the BrowserSkill agent skill through the harness's
|
||||||
* official skill seam (`ctx.skills`). The catalog entry (name + description)
|
* official skill seam (`ctx.skills`). The catalog entry (name + description)
|
||||||
* is resident; the body is loaded only when the model invokes the `skill`
|
* is resident; the body is loaded only when the model invokes the `skill`
|
||||||
* tool. The markdown is build-time assembled into a static module (dsh
|
* tool. The dedicated DeepSeek Harness markdown is embedded into a static
|
||||||
* prelude + the canonical CLI skill body verbatim), so registration and every
|
* module at build time, so registration and every pre-step catalog snapshot
|
||||||
* pre-step catalog snapshot are pure in-memory reads — no disk, no process,
|
* are pure in-memory reads — no disk, no process, no daemon. The CLI-oriented
|
||||||
* no daemon.
|
* repository skill is intentionally a separate interface.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Context } from "@deepseek-ai/cordis";
|
import type { Context } from "@deepseek-ai/cordis";
|
||||||
|
import type { Agent } from "@deepseek-ai/dsh-agent";
|
||||||
import {
|
import {
|
||||||
BSK_SKILL_DESCRIPTION,
|
BSK_SKILL_DESCRIPTION,
|
||||||
BSK_SKILL_MARKDOWN,
|
BSK_SKILL_MARKDOWN,
|
||||||
@@ -25,8 +26,13 @@ interface SkillsLike {
|
|||||||
}): () => void;
|
}): () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Structural view of the live-agent registry (absent in bare compositions). */
|
||||||
|
interface AgentsLike {
|
||||||
|
list(): Agent[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Publish the BSK skill as an embedded runtime skill. Returns the unregistration
|
* Publish the browser skill as an embedded runtime skill. Returns the unregistration
|
||||||
* disposer. A composition without the `skills` service (or with dsh-tool-skill
|
* disposer. A composition without the `skills` service (or with dsh-tool-skill
|
||||||
* retired) leaves the rest of the plugin unaffected — the no-op is silent.
|
* retired) leaves the rest of the plugin unaffected — the no-op is silent.
|
||||||
*/
|
*/
|
||||||
@@ -43,3 +49,54 @@ export function registerBskSkill(ctx: Context): () => void {
|
|||||||
source: "bundled",
|
source: "bundled",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install the DSH-specific browser skill into every exact agent scope.
|
||||||
|
*
|
||||||
|
* DSH merges skill layers nearest-first (`agent -> preset -> global`). A legacy
|
||||||
|
* CLI skill installed at `~/.agents/skills/browser-skill` is discovered by the
|
||||||
|
* preset filesystem provider and therefore shadows a plugin-level registration.
|
||||||
|
* Registering the embedded skill through `agent.ctx` makes the DSH protocol
|
||||||
|
* contract authoritative for that agent without touching the shared CLI skill.
|
||||||
|
*
|
||||||
|
* New agents are handled at `agent/session-start`, the first supported startup
|
||||||
|
* injection point and still before the first prompt assembly. Existing agents
|
||||||
|
* are registered immediately so plugin reloads take effect without recreating
|
||||||
|
* the conversation. Returns a disposer for all plugin-owned registrations.
|
||||||
|
*/
|
||||||
|
export function armAgentScopedBskSkill(ctx: Context): () => void {
|
||||||
|
const registrations = new Map<Agent, () => void>();
|
||||||
|
let active = true;
|
||||||
|
|
||||||
|
const registerForAgent = (agent: Agent): void => {
|
||||||
|
if (!active || registrations.has(agent)) return;
|
||||||
|
registrations.set(agent, registerBskSkill(agent.ctx));
|
||||||
|
};
|
||||||
|
|
||||||
|
let stopSessionStart = () => {};
|
||||||
|
let stopDisposed = () => {};
|
||||||
|
if (typeof ctx.on === "function") {
|
||||||
|
stopSessionStart = ctx.on("agent/session-start", ({ agent }) => {
|
||||||
|
registerForAgent(agent);
|
||||||
|
});
|
||||||
|
stopDisposed = ctx.on("agent/disposed", ({ agent }) => {
|
||||||
|
// Agent-scoped effects have already unwound at this lifecycle edge. Drop
|
||||||
|
// the retained disposer without invoking it against a disposed context.
|
||||||
|
registrations.delete(agent);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const agents = ctx.get("agents") as AgentsLike | undefined | null;
|
||||||
|
if (agents != null && typeof agents.list === "function") {
|
||||||
|
for (const agent of agents.list()) registerForAgent(agent);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (!active) return;
|
||||||
|
active = false;
|
||||||
|
stopDisposed();
|
||||||
|
stopSessionStart();
|
||||||
|
for (const unregister of [...registrations.values()].reverse()) unregister();
|
||||||
|
registrations.clear();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/** Shared model-facing parameter schemas for browser tools. */
|
||||||
|
|
||||||
|
export const SESSION_PARAM = {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"bsk session id to act on; must be one created by this plugin's browser_session_start. " +
|
||||||
|
"Omit to use the current session (the one most recently started or used).",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const TAB_ID_PARAM = {
|
||||||
|
type: "integer",
|
||||||
|
description: "Target tab id. Omit to use the Agent Window's active tab.",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const WAIT_UNTIL_PARAM = {
|
||||||
|
type: "string",
|
||||||
|
enum: ["load", "domcontentloaded", "networkidle", "commit"],
|
||||||
|
description: "Page lifecycle phase to wait for (default: load).",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const TIMEOUT_MS_PARAM = {
|
||||||
|
type: "integer",
|
||||||
|
description: "Command timeout in milliseconds; must be greater than zero.",
|
||||||
|
} as const;
|
||||||
@@ -17,11 +17,20 @@ import {
|
|||||||
type ToolResult,
|
type ToolResult,
|
||||||
type ToolRunContext,
|
type ToolRunContext,
|
||||||
} from "@deepseek-ai/dsh-tools";
|
} from "@deepseek-ai/dsh-tools";
|
||||||
|
import { ownerSessionIds } from "./archive-cleanup";
|
||||||
import { trySaveScreenshot } from "./image";
|
import { trySaveScreenshot } from "./image";
|
||||||
import { actionForLabel, type ObservationService } from "./observation";
|
import { actionForLabel, type ObservationService } from "./observation";
|
||||||
|
import { registerPhaseOneTools } from "./phase-one-tools";
|
||||||
import type { KeyedExecutor } from "./queue";
|
import type { KeyedExecutor } from "./queue";
|
||||||
import { type BskRunner, bskInstallMessage, isCommandNotFound, parseBskJson } from "./runner";
|
import {
|
||||||
|
type BskRunner,
|
||||||
|
bskInstallMessage,
|
||||||
|
isCommandNotFound,
|
||||||
|
parseBskJson,
|
||||||
|
runWithSessionBusyRetry,
|
||||||
|
} from "./runner";
|
||||||
import type { SessionRegistry } from "./sessions";
|
import type { SessionRegistry } from "./sessions";
|
||||||
|
import { SESSION_PARAM } from "./tool-params";
|
||||||
|
|
||||||
/** Plugin configuration resolved from the Schemastery schema in index.ts. */
|
/** Plugin configuration resolved from the Schemastery schema in index.ts. */
|
||||||
export interface PluginConfig {
|
export interface PluginConfig {
|
||||||
@@ -61,13 +70,6 @@ const DEVICE_PRESETS = [
|
|||||||
"galaxy-tab-s8",
|
"galaxy-tab-s8",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const SESSION_PARAM = {
|
|
||||||
type: "string",
|
|
||||||
description:
|
|
||||||
"bsk session id to act on; must be one created by this plugin's browser_session_start. " +
|
|
||||||
"Omit to use the current session (the one most recently started or used).",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run one bsk command and return its parsed JSON payload, or throw.
|
* Run one bsk command and return its parsed JSON payload, or throw.
|
||||||
* `observeSession` marks the run as model-facing work on that session: it is
|
* `observeSession` marks the run as model-facing work on that session: it is
|
||||||
@@ -81,7 +83,10 @@ async function runBsk(
|
|||||||
args: string[],
|
args: string[],
|
||||||
label: string,
|
label: string,
|
||||||
observeSession?: string,
|
observeSession?: string,
|
||||||
|
runnerTimeoutMs?: number,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
|
const releaseForeground =
|
||||||
|
observeSession !== undefined ? deps.observation.acquireForeground(observeSession) : undefined;
|
||||||
// beginAction must fire only when the task actually starts inside the
|
// beginAction must fire only when the task actually starts inside the
|
||||||
// per-session queue — marking it while still queued would overwrite the
|
// per-session queue — marking it while still queued would overwrite the
|
||||||
// in-flight action's label (and flash it idle on a queued abort).
|
// in-flight action's label (and flash it idle on a queued abort).
|
||||||
@@ -95,11 +100,15 @@ async function runBsk(
|
|||||||
began = true;
|
began = true;
|
||||||
deps.observation.beginAction(observeSession, actionForLabel(label));
|
deps.observation.beginAction(observeSession, actionForLabel(label));
|
||||||
}
|
}
|
||||||
return deps.runner.run(args, {
|
return runWithSessionBusyRetry(
|
||||||
signal: exec.signal,
|
() =>
|
||||||
timeoutMs: deps.config.defaultTimeoutMs,
|
deps.runner.run(args, {
|
||||||
...(observeSession !== undefined ? { tag: observeSession } : {}),
|
signal: exec.signal,
|
||||||
});
|
timeoutMs: runnerTimeoutMs ?? deps.config.defaultTimeoutMs,
|
||||||
|
...(observeSession !== undefined ? { tag: observeSession } : {}),
|
||||||
|
}),
|
||||||
|
exec.signal,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
result =
|
result =
|
||||||
observeSession !== undefined
|
observeSession !== undefined
|
||||||
@@ -124,6 +133,7 @@ async function runBsk(
|
|||||||
if (observeSession !== undefined && began) {
|
if (observeSession !== undefined && began) {
|
||||||
deps.observation.endAction(observeSession, actionError);
|
deps.observation.endAction(observeSession, actionError);
|
||||||
}
|
}
|
||||||
|
releaseForeground?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,6 +255,9 @@ export function registerTools(deps: ToolDeps): () => void {
|
|||||||
browserInstanceId: reply.browser_instance_id,
|
browserInstanceId: reply.browser_instance_id,
|
||||||
startedAtMs: Date.now(),
|
startedAtMs: Date.now(),
|
||||||
});
|
});
|
||||||
|
// Ownership for archive cleanup: the calling conversation and its
|
||||||
|
// ancestors reap this session when any of them is archived.
|
||||||
|
registry.trackOwner(reply.session_id, ownerSessionIds(deps.ctx, exec.agent?.id));
|
||||||
deps.observation.addSession(reply.session_id, args.url);
|
deps.observation.addSession(reply.session_id, args.url);
|
||||||
try {
|
try {
|
||||||
if (args.device !== undefined) {
|
if (args.device !== undefined) {
|
||||||
@@ -267,22 +280,15 @@ export function registerTools(deps: ToolDeps): () => void {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// A half-initialized session must not leak: stop it before surfacing.
|
// A half-initialized session must not leak: stop it before surfacing.
|
||||||
// The stop funnels through the session's queue (an in-flight command
|
// Reuse the same capture-preempting, idempotent path as explicit and
|
||||||
// — e.g. an observation capture — would otherwise make the daemon
|
// overlay stops; local ownership is dropped even when daemon cleanup
|
||||||
// refuse it), with one retry for good measure.
|
// itself fails, because this start never becomes usable to the model.
|
||||||
for (let attempt = 0; attempt < 2; attempt++) {
|
try {
|
||||||
try {
|
await deps.observation.stopSession(reply.session_id);
|
||||||
const stopped = await deps.queue.run(reply.session_id, () =>
|
} catch {
|
||||||
deps.runner.run(["session", "stop", reply.session_id], { timeoutMs: 30_000 }),
|
registry.remove(reply.session_id);
|
||||||
);
|
deps.observation.removeSession(reply.session_id);
|
||||||
if (stopped.code === 0) break;
|
|
||||||
} catch {
|
|
||||||
// retry once, then give up quietly (registry/observation are
|
|
||||||
// cleaned below either way; the daemon reaps orphans).
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
registry.remove(reply.session_id);
|
|
||||||
deps.observation.removeSession(reply.session_id);
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -326,9 +332,15 @@ export function registerTools(deps: ToolDeps): () => void {
|
|||||||
},
|
},
|
||||||
async execute(args, exec) {
|
async execute(args, exec) {
|
||||||
const sessionId = registry.resolveForStop(args.session);
|
const sessionId = registry.resolveForStop(args.session);
|
||||||
await runBsk(deps, exec, ["session", "stop", sessionId], "session stop", sessionId);
|
try {
|
||||||
registry.remove(sessionId);
|
const stopped = await deps.observation.stopSession(sessionId, exec.signal);
|
||||||
deps.observation.removeSession(sessionId);
|
if (!stopped) throw new Error(`browser session ${sessionId} is not owned by this plugin`);
|
||||||
|
} catch (error) {
|
||||||
|
if (isCommandNotFound(error)) {
|
||||||
|
throw new Error(bskInstallMessage(deps.config.bskPath));
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
return { stopped: sessionId };
|
return { stopped: sessionId };
|
||||||
},
|
},
|
||||||
presentCall: (args) => ({
|
presentCall: (args) => ({
|
||||||
@@ -955,6 +967,12 @@ export function registerTools(deps: ToolDeps): () => void {
|
|||||||
presentResult: presentTerminalResult,
|
presentResult: presentTerminalResult,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
registerPhaseOneTools(deps, register, {
|
||||||
|
run: (exec, args, label, observeSession, runnerTimeoutMs) =>
|
||||||
|
runBsk(deps, exec, args, label, observeSession, runnerTimeoutMs),
|
||||||
|
commandLine: (args) => cmdline(deps, args),
|
||||||
|
presentTerminalResult,
|
||||||
|
});
|
||||||
return () => {
|
return () => {
|
||||||
for (const dispose of disposers.splice(0)) dispose();
|
for (const dispose of disposers.splice(0)) dispose();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
// Archive-triggered cleanup: lineage resolution for browser-session
|
||||||
|
// ownership, the registry's owner index, and the domain/changed watcher
|
||||||
|
// that reaps a freshly archived conversation's bsk sessions.
|
||||||
|
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { armArchiveCleanup, ownerSessionIds } from "../src/archive-cleanup";
|
||||||
|
import type { ObservationService } from "../src/observation";
|
||||||
|
import { SessionRegistry } from "../src/sessions";
|
||||||
|
|
||||||
|
/** A ctx stub carrying a session store with the given lineage headers. */
|
||||||
|
function ctxWithSessions(headers: Record<string, { parentSession?: string }>) {
|
||||||
|
return {
|
||||||
|
get: (key: string) =>
|
||||||
|
key === "sessions"
|
||||||
|
? {
|
||||||
|
get: (id: string) => (headers[id] === undefined ? undefined : { header: headers[id] }),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
} as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ownerSessionIds", () => {
|
||||||
|
it("walks the seed lineage to the root; empty without an agent identity", () => {
|
||||||
|
expect(ownerSessionIds(ctxWithSessions({}), undefined)).toEqual([]);
|
||||||
|
const ctx = ctxWithSessions({
|
||||||
|
child: { parentSession: "parent" },
|
||||||
|
parent: { parentSession: "root" },
|
||||||
|
root: {},
|
||||||
|
});
|
||||||
|
expect(ownerSessionIds(ctx, "child")).toEqual(["child", "parent", "root"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops the walk at an unloaded ancestor", () => {
|
||||||
|
const ctx = ctxWithSessions({ child: { parentSession: "gone" } });
|
||||||
|
expect(ownerSessionIds(ctx, "child")).toEqual(["child", "gone"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never loops on a malformed parent cycle", () => {
|
||||||
|
const ctx = ctxWithSessions({
|
||||||
|
a: { parentSession: "b" },
|
||||||
|
b: { parentSession: "a" },
|
||||||
|
});
|
||||||
|
expect(ownerSessionIds(ctx, "a")).toEqual(["a", "b"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SessionRegistry owner tracking", () => {
|
||||||
|
function start(registry: SessionRegistry, sessionId: string): void {
|
||||||
|
registry.reserveStart();
|
||||||
|
registry.completeStart({ sessionId, startedAtMs: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
it("indexes owners and forgets them on remove; ignores empty/unknown ownership", () => {
|
||||||
|
const registry = new SessionRegistry(5);
|
||||||
|
start(registry, "bsk1");
|
||||||
|
start(registry, "bsk2");
|
||||||
|
registry.trackOwner("bsk1", ["conv-a", "root"]);
|
||||||
|
registry.trackOwner("bsk2", ["conv-b"]);
|
||||||
|
expect(registry.ownedByDsh("root")).toEqual(["bsk1"]);
|
||||||
|
expect(registry.ownedByDsh("conv-a")).toEqual(["bsk1"]);
|
||||||
|
expect(registry.ownedByDsh("conv-b")).toEqual(["bsk2"]);
|
||||||
|
expect(registry.ownedByDsh("nobody")).toEqual([]);
|
||||||
|
registry.remove("bsk1");
|
||||||
|
expect(registry.ownedByDsh("root")).toEqual([]);
|
||||||
|
// Empty owner lists and unknown session ids record nothing.
|
||||||
|
registry.trackOwner("bsk2", []);
|
||||||
|
registry.trackOwner("ghost", ["conv-c"]);
|
||||||
|
expect(registry.ownedByDsh("conv-c")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("armArchiveCleanup", () => {
|
||||||
|
function harness(opts: { archived?: string[] } = {}) {
|
||||||
|
const registry = new SessionRegistry(5);
|
||||||
|
const observation = { stopSession: vi.fn(async () => true) };
|
||||||
|
const listeners = new Set<(change: unknown) => void>();
|
||||||
|
const ctx = {
|
||||||
|
get: (key: string) =>
|
||||||
|
key === "workspaceRegistry" ? { archivedSessionIds: opts.archived ?? [] } : undefined,
|
||||||
|
on: (event: string, listener: (change: unknown) => void) => {
|
||||||
|
if (event === "domain/changed") listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const emit = (change: unknown) => {
|
||||||
|
for (const listener of [...listeners]) listener(change);
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
registry,
|
||||||
|
observation: observation as unknown as ObservationService,
|
||||||
|
stopSession: observation.stopSession,
|
||||||
|
emit,
|
||||||
|
arm: () =>
|
||||||
|
armArchiveCleanup(ctx as never, registry, observation as unknown as ObservationService),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOwned(registry: SessionRegistry, bskId: string, owners: string[]): void {
|
||||||
|
registry.reserveStart();
|
||||||
|
registry.completeStart({ sessionId: bskId, startedAtMs: 1 });
|
||||||
|
registry.trackOwner(bskId, owners);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("stops every bsk session owned by a freshly archived conversation, until disarmed", () => {
|
||||||
|
const h = harness();
|
||||||
|
startOwned(h.registry, "bsk1", ["conv-a", "root"]);
|
||||||
|
startOwned(h.registry, "bsk2", ["conv-b"]);
|
||||||
|
const disarm = h.arm();
|
||||||
|
h.emit({
|
||||||
|
domain: "workspace",
|
||||||
|
table: "",
|
||||||
|
value: { archivedSessionIds: ["root"] },
|
||||||
|
});
|
||||||
|
expect(h.stopSession).toHaveBeenCalledTimes(1);
|
||||||
|
expect(h.stopSession).toHaveBeenCalledWith("bsk1");
|
||||||
|
// After the disposer runs the watcher is silent again.
|
||||||
|
disarm();
|
||||||
|
h.emit({
|
||||||
|
domain: "workspace",
|
||||||
|
table: "",
|
||||||
|
value: { archivedSessionIds: ["root", "conv-b"] },
|
||||||
|
});
|
||||||
|
expect(h.stopSession).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores pre-archived ids, foreign domains, and malformed frames", () => {
|
||||||
|
const h = harness({ archived: ["old-conv"] });
|
||||||
|
startOwned(h.registry, "bsk1", ["old-conv"]);
|
||||||
|
startOwned(h.registry, "bsk2", ["conv-a"]);
|
||||||
|
h.arm();
|
||||||
|
// Seeded from the registry: the pre-archived id must not retro-fire.
|
||||||
|
h.emit({ domain: "workspace", table: "", value: { archivedSessionIds: ["old-conv"] } });
|
||||||
|
h.emit({ domain: "settings", table: "", value: { archivedSessionIds: ["conv-a"] } });
|
||||||
|
h.emit({ domain: "workspace", table: "rows", value: { archivedSessionIds: ["conv-a"] } });
|
||||||
|
h.emit({ domain: "workspace", table: "", value: {} });
|
||||||
|
expect(h.stopSession).not.toHaveBeenCalled();
|
||||||
|
// A genuinely new archive still fires.
|
||||||
|
h.emit({
|
||||||
|
domain: "workspace",
|
||||||
|
table: "",
|
||||||
|
value: { archivedSessionIds: ["old-conv", "conv-a"] },
|
||||||
|
});
|
||||||
|
expect(h.stopSession).toHaveBeenCalledWith("bsk2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a re-archived session as fresh again after unarchive", () => {
|
||||||
|
const h = harness();
|
||||||
|
startOwned(h.registry, "bsk1", ["conv-a"]);
|
||||||
|
h.arm();
|
||||||
|
h.emit({ domain: "workspace", table: "", value: { archivedSessionIds: ["conv-a"] } });
|
||||||
|
expect(h.stopSession).toHaveBeenCalledTimes(1);
|
||||||
|
// Unarchive, then re-archive: the second archival cleans up again.
|
||||||
|
h.emit({ domain: "workspace", table: "", value: { archivedSessionIds: [] } });
|
||||||
|
h.emit({ domain: "workspace", table: "", value: { archivedSessionIds: ["conv-a"] } });
|
||||||
|
expect(h.stopSession).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
// upgrade/fallback (mocked documentPictureInPicture). NOTE: use RTL's waitFor
|
// upgrade/fallback (mocked documentPictureInPicture). NOTE: use RTL's waitFor
|
||||||
// (act-flushing), not vi.waitFor, when asserting store-driven UI updates.
|
// (act-flushing), not vi.waitFor, when asserting store-driven UI updates.
|
||||||
|
|
||||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { applyResize, ObservationOverlay } from "../../src/client/ObservationOverlay";
|
import { applyResize, ObservationOverlay } from "../../src/client/ObservationOverlay";
|
||||||
import { type EventSourceLike, ObservationClientStore } from "../../src/client/observation-store";
|
import { type EventSourceLike, ObservationClientStore } from "../../src/client/observation-store";
|
||||||
@@ -157,6 +157,45 @@ describe("ObservationOverlay", () => {
|
|||||||
await screen.findByText(/s1 · clicking/);
|
await screen.findByText(/s1 · clicking/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("stop session: the first click only arms the confirm, the second posts", async () => {
|
||||||
|
const h = makeHarness([{ sessionId: "s1", action: "idle", since: Date.now() }]);
|
||||||
|
render(<ObservationOverlay store={h.store} />);
|
||||||
|
await screen.findByText(/s1 · idle/);
|
||||||
|
const stop = screen.getByRole("button", { name: "Stop session s1" });
|
||||||
|
fireEvent.click(stop);
|
||||||
|
// Armed: a plain click must never close an Agent Window by itself.
|
||||||
|
expect(h.fetches.some((f) => f.url === "/bsk-observation/stop")).toBe(false);
|
||||||
|
const confirm = await screen.findByRole("button", { name: "Confirm stop session s1" });
|
||||||
|
expect(confirm.getAttribute("aria-pressed")).toBe("true");
|
||||||
|
fireEvent.click(confirm);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
h.fetches.some(
|
||||||
|
(f) => f.url === "/bsk-observation/stop" && f.init?.body === '{"sessionId":"s1"}',
|
||||||
|
),
|
||||||
|
).toBe(true),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disarms the stop confirm after the expiry window", async () => {
|
||||||
|
const h = makeHarness([{ sessionId: "s1", action: "idle", since: Date.now() }]);
|
||||||
|
render(<ObservationOverlay store={h.store} />);
|
||||||
|
const stop = await screen.findByRole("button", { name: "Stop session s1" });
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
fireEvent.click(stop);
|
||||||
|
// Synchronous queries only: waitFor-style finds hang under fake timers.
|
||||||
|
expect(screen.getByRole("button", { name: "Confirm stop session s1" })).toBeTruthy();
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(3100);
|
||||||
|
});
|
||||||
|
expect(screen.getByRole("button", { name: "Stop session s1" })).toBeTruthy();
|
||||||
|
expect(h.fetches.some((f) => f.url === "/bsk-observation/stop")).toBe(false);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("resizes within min/max clamps", async () => {
|
it("resizes within min/max clamps", async () => {
|
||||||
const h = makeHarness([BUSY]);
|
const h = makeHarness([BUSY]);
|
||||||
render(<ObservationOverlay store={h.store} />);
|
render(<ObservationOverlay store={h.store} />);
|
||||||
@@ -222,6 +261,7 @@ describe("ObservationOverlay", () => {
|
|||||||
addEventListener: (name: string, fn: () => void) => {
|
addEventListener: (name: string, fn: () => void) => {
|
||||||
listeners.set(name, [...(listeners.get(name) ?? []), fn]);
|
listeners.set(name, [...(listeners.get(name) ?? []), fn]);
|
||||||
},
|
},
|
||||||
|
close: vi.fn(),
|
||||||
} as unknown as Window;
|
} as unknown as Window;
|
||||||
});
|
});
|
||||||
(window as unknown as Record<string, unknown>).documentPictureInPicture = { requestWindow };
|
(window as unknown as Record<string, unknown>).documentPictureInPicture = { requestWindow };
|
||||||
@@ -236,6 +276,28 @@ describe("ObservationOverlay", () => {
|
|||||||
await screen.findByTestId("obs-card");
|
await screen.findByTestId("obs-card");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("closes the PiP window when the carrier unmounts", async () => {
|
||||||
|
const h = makeHarness([BUSY]);
|
||||||
|
const pipDoc = document.implementation.createHTMLDocument("pip");
|
||||||
|
const close = vi.fn();
|
||||||
|
const requestWindow = vi.fn(async () => {
|
||||||
|
return {
|
||||||
|
document: pipDoc,
|
||||||
|
addEventListener: () => {},
|
||||||
|
close,
|
||||||
|
} as unknown as Window;
|
||||||
|
});
|
||||||
|
(window as unknown as Record<string, unknown>).documentPictureInPicture = { requestWindow };
|
||||||
|
const { unmount } = render(<ObservationOverlay store={h.store} />);
|
||||||
|
const popout = await screen.findByRole("button", { name: /Pop out/ });
|
||||||
|
fireEvent.click(popout);
|
||||||
|
await waitFor(() => expect(pipDoc.body.textContent).toContain("s1"));
|
||||||
|
// Unmounting (tab close, conversation switch, plugin HMR) closes the PiP
|
||||||
|
// instead of leaving a blank window behind.
|
||||||
|
unmount();
|
||||||
|
expect(close).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("renders the strip for two sessions and pins focus on click", async () => {
|
it("renders the strip for two sessions and pins focus on click", async () => {
|
||||||
const older: SessionObservation = {
|
const older: SessionObservation = {
|
||||||
sessionId: "s1",
|
sessionId: "s1",
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
// better-sidebar carrier: tab registration + auto-open, the sidebar-mode
|
||||||
|
// flag hiding the floating overlay, PiP pop-out from the tab, and the
|
||||||
|
// store's refcounted acquire/release across overlapping carriers.
|
||||||
|
|
||||||
|
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { ObservationOverlay } from "../../src/client/ObservationOverlay";
|
||||||
|
import {
|
||||||
|
type BetterSidebarLike,
|
||||||
|
OBSERVATION_TAB_PATH,
|
||||||
|
OBSERVATION_TAB_TYPE,
|
||||||
|
ObservationSidebarTab,
|
||||||
|
observationTabOpen,
|
||||||
|
registerObservationSidebar,
|
||||||
|
type SidebarNodeLike,
|
||||||
|
type SidebarStateLike,
|
||||||
|
type TabDescriptorLike,
|
||||||
|
} from "../../src/client/observation-sidebar";
|
||||||
|
import { type EventSourceLike, ObservationClientStore } from "../../src/client/observation-store";
|
||||||
|
import { getSidebarMode, setSidebarMode } from "../../src/client/sidebar-mode";
|
||||||
|
import type { SessionObservation } from "../../src/observation";
|
||||||
|
|
||||||
|
// Owned by the sidebar mock's active conversation ("conv-1") so the scoped
|
||||||
|
// visibility logic lets it through; FOREIGN belongs to another conversation.
|
||||||
|
const BUSY: SessionObservation = {
|
||||||
|
sessionId: "s1",
|
||||||
|
action: "clicking",
|
||||||
|
since: Date.now() - 7000,
|
||||||
|
dshSessionIds: ["conv-1"],
|
||||||
|
};
|
||||||
|
const FOREIGN: SessionObservation = {
|
||||||
|
sessionId: "s9",
|
||||||
|
action: "clicking",
|
||||||
|
since: Date.now() - 5000,
|
||||||
|
dshSessionIds: ["other-conv"],
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Harness {
|
||||||
|
store: ObservationClientStore;
|
||||||
|
es: () => EventSourceLike | undefined;
|
||||||
|
push: (sessions: SessionObservation[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeHarness(initial: SessionObservation[]): Harness {
|
||||||
|
let es: EventSourceLike | undefined;
|
||||||
|
let current = initial;
|
||||||
|
const store = new ObservationClientStore({
|
||||||
|
fetchFn: async (url: string) => {
|
||||||
|
if (url === "/bsk-observation/state") {
|
||||||
|
return { ok: true, json: async () => ({ sessions: current }) };
|
||||||
|
}
|
||||||
|
return { ok: true, json: async () => ({ interrupted: true }) };
|
||||||
|
},
|
||||||
|
eventSourceFactory: () => {
|
||||||
|
es = { onmessage: null, close: vi.fn() };
|
||||||
|
return es;
|
||||||
|
},
|
||||||
|
loadImage: async (id: string) => `blob:${id}`,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
es: () => es,
|
||||||
|
push: (sessions) => {
|
||||||
|
current = sessions;
|
||||||
|
for (const s of sessions) {
|
||||||
|
es?.onmessage?.({ data: JSON.stringify({ type: "upsert", session: s }) });
|
||||||
|
}
|
||||||
|
if (sessions.length === 0) es?.onmessage?.({ data: JSON.stringify({ type: "reset" }) });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function leafWith(type: string, panelOpen = true): SidebarStateLike {
|
||||||
|
return {
|
||||||
|
panelOpen,
|
||||||
|
splits: {
|
||||||
|
kind: "split",
|
||||||
|
id: "root",
|
||||||
|
dir: "row",
|
||||||
|
sizes: [1],
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
kind: "leaf",
|
||||||
|
id: "pane-1",
|
||||||
|
active: type,
|
||||||
|
tabs: [{ id: type, type, title: type }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
bottomSplits: { kind: "leaf", id: "pane-2", active: null, tabs: [] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SidebarMock {
|
||||||
|
service: BetterSidebarLike;
|
||||||
|
descriptor: () => TabDescriptorLike;
|
||||||
|
openTab: ReturnType<typeof vi.fn>;
|
||||||
|
disposeTab: ReturnType<typeof vi.fn>;
|
||||||
|
setState: (state: SidebarStateLike | undefined) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert a tab into the first leaf of the main tree (mirrors a real open). */
|
||||||
|
function withTabOpened(state: SidebarStateLike, type: string): SidebarStateLike {
|
||||||
|
let done = false;
|
||||||
|
const walk = (node: SidebarNodeLike): SidebarNodeLike => {
|
||||||
|
if (done) return node;
|
||||||
|
if (node.kind === "leaf") {
|
||||||
|
done = true;
|
||||||
|
return { ...node, active: type, tabs: [...node.tabs, { id: type, type, title: type }] };
|
||||||
|
}
|
||||||
|
return { ...node, children: node.children.map(walk) };
|
||||||
|
};
|
||||||
|
return { ...state, splits: walk(state.splits) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSidebar(
|
||||||
|
initialState: SidebarStateLike | undefined = leafWith("explorer"),
|
||||||
|
): SidebarMock {
|
||||||
|
let descriptor: TabDescriptorLike | undefined;
|
||||||
|
let state: SidebarStateLike | undefined = initialState;
|
||||||
|
const openTab = vi.fn((seed: { type: string }) => {
|
||||||
|
// Mirror the real feedback loop: a created tab lands in the state, so
|
||||||
|
// later evaluations see it open (single-instance dedupe re-focuses).
|
||||||
|
if (state !== undefined && !observationTabOpen(state)) {
|
||||||
|
state = withTabOpened(state, seed.type);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const disposeTab = vi.fn();
|
||||||
|
const service: BetterSidebarLike = {
|
||||||
|
registerTab: (next) => {
|
||||||
|
descriptor = next;
|
||||||
|
return disposeTab;
|
||||||
|
},
|
||||||
|
openTab,
|
||||||
|
getSnapshot: () => ({ sessionId: "conv-1", state }),
|
||||||
|
isTabEnabled: () => true,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
service,
|
||||||
|
descriptor: () => {
|
||||||
|
if (descriptor === undefined) throw new Error("no tab registered");
|
||||||
|
return descriptor;
|
||||||
|
},
|
||||||
|
openTab,
|
||||||
|
disposeTab,
|
||||||
|
setState: (next) => {
|
||||||
|
state = next;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
setSidebarMode(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("registerObservationSidebar", () => {
|
||||||
|
it("registers a single-instance tab and holds the feed for its lifetime", () => {
|
||||||
|
const h = makeHarness([]);
|
||||||
|
const sidebar = makeSidebar();
|
||||||
|
expect(getSidebarMode()).toBe(false);
|
||||||
|
const dispose = registerObservationSidebar(sidebar.service, h.store);
|
||||||
|
expect(getSidebarMode()).toBe(true);
|
||||||
|
// The fiber holds the observation feed itself, so the auto-open watcher
|
||||||
|
// sees new sessions even while the tab is closed.
|
||||||
|
expect(h.es()).toBeDefined();
|
||||||
|
const descriptor = sidebar.descriptor();
|
||||||
|
expect(descriptor.id).toBe(OBSERVATION_TAB_TYPE);
|
||||||
|
expect(descriptor.single).toBe(true);
|
||||||
|
expect(descriptor.title).toBe("Browser Skill");
|
||||||
|
dispose();
|
||||||
|
expect(getSidebarMode()).toBe(false);
|
||||||
|
expect(sidebar.disposeTab).toHaveBeenCalledTimes(1);
|
||||||
|
expect(h.es()?.close).toHaveBeenCalled?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the tab right away when a session is already live at activation", async () => {
|
||||||
|
const h = makeHarness([BUSY]);
|
||||||
|
const sidebar = makeSidebar();
|
||||||
|
registerObservationSidebar(sidebar.service, h.store);
|
||||||
|
// The initial state fetch lands asynchronously; the 0→N watcher then fires.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(sidebar.openTab).toHaveBeenCalledWith({
|
||||||
|
type: OBSERVATION_TAB_TYPE,
|
||||||
|
path: OBSERVATION_TAB_PATH,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-opens on the first session but never re-focuses an open tab", async () => {
|
||||||
|
const h = makeHarness([]);
|
||||||
|
const sidebar = makeSidebar();
|
||||||
|
registerObservationSidebar(sidebar.service, h.store);
|
||||||
|
expect(sidebar.openTab).not.toHaveBeenCalled();
|
||||||
|
// First session arrives: the tab lands in the sidebar.
|
||||||
|
h.push([BUSY]);
|
||||||
|
await waitFor(() => expect(sidebar.openTab).toHaveBeenCalledTimes(1));
|
||||||
|
// The sidebar now reports the tab open (persisted/restored): further
|
||||||
|
// session arrivals must not steal focus from whatever the user reads.
|
||||||
|
sidebar.setState(leafWith(OBSERVATION_TAB_TYPE));
|
||||||
|
h.push([]);
|
||||||
|
h.push([{ ...BUSY, sessionId: "s2" }]);
|
||||||
|
h.push([]);
|
||||||
|
h.push([{ ...BUSY, sessionId: "s3" }]);
|
||||||
|
await waitFor(() => expect(h.store.getSnapshot().sessions.length).toBe(1));
|
||||||
|
expect(sidebar.openTab).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not open while the sidebar has no active session state", () => {
|
||||||
|
const h = makeHarness([BUSY]);
|
||||||
|
const sidebar = makeSidebar(undefined);
|
||||||
|
registerObservationSidebar(sidebar.service, h.store);
|
||||||
|
expect(sidebar.openTab).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nudges an existing tab back into sight when the panel is collapsed", async () => {
|
||||||
|
const h = makeHarness([]);
|
||||||
|
// Tab already open (persisted), but the panel is collapsed: a new
|
||||||
|
// session should surface the tracking view again (focus + expand via a
|
||||||
|
// content open), like the floating card reappearing.
|
||||||
|
const sidebar = makeSidebar(leafWith(OBSERVATION_TAB_TYPE, false));
|
||||||
|
registerObservationSidebar(sidebar.service, h.store);
|
||||||
|
h.push([BUSY]);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(sidebar.openTab).toHaveBeenCalledWith({
|
||||||
|
type: OBSERVATION_TAB_TYPE,
|
||||||
|
path: OBSERVATION_TAB_PATH,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the visible session count as the tab badge", () => {
|
||||||
|
const h = makeHarness([]);
|
||||||
|
const sidebar = makeSidebar();
|
||||||
|
registerObservationSidebar(sidebar.service, h.store);
|
||||||
|
const badge = sidebar.descriptor().badge;
|
||||||
|
const scope = { sessionId: "conv-1" };
|
||||||
|
expect(badge?.(undefined, scope, undefined)).toBeNull();
|
||||||
|
h.store.acquire();
|
||||||
|
h.push([BUSY, FOREIGN]);
|
||||||
|
expect(badge?.(undefined, scope, undefined)).toBe(1);
|
||||||
|
expect(badge?.(undefined, { sessionId: "other-conv" }, undefined)).toBe(1);
|
||||||
|
expect(badge?.(undefined, { sessionId: "nobody" }, undefined)).toBeNull();
|
||||||
|
h.store.release();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not auto-open for sessions owned by other conversations", async () => {
|
||||||
|
const h = makeHarness([]);
|
||||||
|
const sidebar = makeSidebar();
|
||||||
|
registerObservationSidebar(sidebar.service, h.store);
|
||||||
|
h.push([FOREIGN]);
|
||||||
|
await waitFor(() => expect(h.store.getSnapshot().sessions.length).toBe(1));
|
||||||
|
expect(sidebar.openTab).not.toHaveBeenCalled();
|
||||||
|
// A session visible to the active conversation does open the tab.
|
||||||
|
h.push([BUSY]);
|
||||||
|
await waitFor(() => expect(sidebar.openTab).toHaveBeenCalledTimes(1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("observationTabOpen", () => {
|
||||||
|
it("finds the tab in either workbench tree", () => {
|
||||||
|
expect(observationTabOpen(undefined)).toBe(false);
|
||||||
|
expect(observationTabOpen(leafWith("explorer"))).toBe(false);
|
||||||
|
expect(observationTabOpen(leafWith(OBSERVATION_TAB_TYPE))).toBe(true);
|
||||||
|
const inBottom: SidebarStateLike = {
|
||||||
|
splits: { kind: "leaf", id: "p1", active: null, tabs: [] },
|
||||||
|
bottomSplits: {
|
||||||
|
kind: "leaf",
|
||||||
|
id: "p2",
|
||||||
|
active: OBSERVATION_TAB_TYPE,
|
||||||
|
tabs: [{ id: OBSERVATION_TAB_TYPE, type: OBSERVATION_TAB_TYPE, title: "Browser" }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(observationTabOpen(inBottom)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ObservationSidebarTab", () => {
|
||||||
|
it("renders the tracking view without card chrome (no collapse, no drag header)", async () => {
|
||||||
|
const h = makeHarness([BUSY]);
|
||||||
|
render(<ObservationSidebarTab store={h.store} scopeId="conv-1" />);
|
||||||
|
await screen.findByText(/s1 · clicking/);
|
||||||
|
expect(screen.queryByRole("button", { name: "Collapse" })).toBeNull();
|
||||||
|
expect(screen.getByTestId("obs-header").dataset.draggable).toBeUndefined();
|
||||||
|
// No resize handles — the sidebar owns the geometry.
|
||||||
|
expect(screen.queryByTestId("obs-resize-se")).toBeNull();
|
||||||
|
// Session controls ride along: interrupt + stop-with-confirm.
|
||||||
|
expect(screen.getByRole("button", { name: "Stop session s1" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scopes the view to the sidebar's conversation", async () => {
|
||||||
|
const h = makeHarness([BUSY, FOREIGN]);
|
||||||
|
render(<ObservationSidebarTab store={h.store} scopeId="conv-1" />);
|
||||||
|
// Focus and content belong to the owned session; the foreign one never
|
||||||
|
// reaches the stage, the status line, or the strip (which needs 2+).
|
||||||
|
await screen.findByText(/s1 · clicking/);
|
||||||
|
expect(screen.queryByText(/s9/)).toBeNull();
|
||||||
|
expect(screen.queryByTestId("obs-strip")).toBeNull();
|
||||||
|
// Scoping to the other conversation swaps what's visible.
|
||||||
|
render(<ObservationSidebarTab store={h.store} scopeId="other-conv" />);
|
||||||
|
await screen.findByText(/s9 · clicking/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("carrier switching", () => {
|
||||||
|
it("hides the floating overlay while sidebar mode is active", async () => {
|
||||||
|
const h = makeHarness([]);
|
||||||
|
const { container } = render(<ObservationOverlay store={h.store} />);
|
||||||
|
h.push([BUSY]);
|
||||||
|
await screen.findByTestId("obs-card");
|
||||||
|
setSidebarMode(true);
|
||||||
|
await waitFor(() => expect(container.firstChild).toBeNull());
|
||||||
|
setSidebarMode(false);
|
||||||
|
await screen.findByTestId("obs-card");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the feed alive across overlapping carriers (refcount)", async () => {
|
||||||
|
const h = makeHarness([]);
|
||||||
|
const overlay = render(<ObservationOverlay store={h.store} />);
|
||||||
|
await waitFor(() => expect(h.es()).toBeDefined());
|
||||||
|
// A second carrier mounts (the sidebar tab), then the overlay unmounts:
|
||||||
|
// the stream must survive for the remaining consumer.
|
||||||
|
const tab = render(<ObservationSidebarTab store={h.store} scopeId="conv-1" />);
|
||||||
|
overlay.unmount();
|
||||||
|
h.push([BUSY]);
|
||||||
|
await screen.findByText(/s1 · clicking/);
|
||||||
|
expect(h.es()?.close ?? vi.fn()).not.toHaveBeenCalled();
|
||||||
|
tab.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -137,6 +137,9 @@ describe("client plugin registration", () => {
|
|||||||
const sessions = { binding: () => undefined };
|
const sessions = { binding: () => undefined };
|
||||||
const ctx = {
|
const ctx = {
|
||||||
get: (key: string) => (key === "sessions" ? sessions : undefined),
|
get: (key: string) => (key === "sessions" ? sessions : undefined),
|
||||||
|
// cordis ctx.inject: the betterSidebar carrier upgrade stays dormant in
|
||||||
|
// this composition (the callback only runs once the service exists).
|
||||||
|
inject: (_deps: string[], _fn: (injected: unknown) => unknown) => {},
|
||||||
slots: {
|
slots: {
|
||||||
inject: (_name: string, fn: () => unknown) => fn(),
|
inject: (_name: string, fn: () => unknown) => fn(),
|
||||||
register: (slot: { name: string; key?: string; id?: string }, view: unknown) => {
|
register: (slot: { name: string; key?: string; id?: string }, view: unknown) => {
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ function fakeRunner(
|
|||||||
opts: {
|
opts: {
|
||||||
screenshotFails?: boolean;
|
screenshotFails?: boolean;
|
||||||
screenshotNotFound?: boolean;
|
screenshotNotFound?: boolean;
|
||||||
|
stopNotFound?: boolean;
|
||||||
|
stopFails?: boolean;
|
||||||
killCount?: number;
|
killCount?: number;
|
||||||
screenshotBytes?: () => Uint8Array;
|
screenshotBytes?: () => Uint8Array;
|
||||||
} = {},
|
} = {},
|
||||||
@@ -72,11 +74,26 @@ function fakeRunner(
|
|||||||
killed,
|
killed,
|
||||||
async run(args: string[], options: BskRunOptions = {}): Promise<BskRunResult> {
|
async run(args: string[], options: BskRunOptions = {}): Promise<BskRunResult> {
|
||||||
calls.push({ args, options });
|
calls.push({ args, options });
|
||||||
|
if (args[0] === "session" && args[1] === "stop") {
|
||||||
|
if (opts.stopNotFound) {
|
||||||
|
return {
|
||||||
|
code: 4,
|
||||||
|
stdout: JSON.stringify({ code: "not_found", message: "no such session" }),
|
||||||
|
stderr: "",
|
||||||
|
timedOut: false,
|
||||||
|
aborted: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (opts.stopFails) {
|
||||||
|
return { code: 1, stdout: "", stderr: "boom", timedOut: false, aborted: false };
|
||||||
|
}
|
||||||
|
return { code: 0, stdout: "{}", stderr: "", timedOut: false, aborted: false };
|
||||||
|
}
|
||||||
if (args[0] === "screenshot") {
|
if (args[0] === "screenshot") {
|
||||||
if (opts.screenshotNotFound) {
|
if (opts.screenshotNotFound) {
|
||||||
return {
|
return {
|
||||||
code: 4,
|
code: 4,
|
||||||
stdout: JSON.stringify({ code: "session_not_found", message: "no such session" }),
|
stdout: JSON.stringify({ code: "not_found", message: "no such session" }),
|
||||||
stderr: "",
|
stderr: "",
|
||||||
timedOut: false,
|
timedOut: false,
|
||||||
aborted: false,
|
aborted: false,
|
||||||
@@ -180,6 +197,23 @@ describe("state machine", () => {
|
|||||||
expect(events[events.length - 1].type).toBe("reset");
|
expect(events[events.length - 1].type).toBe("reset");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("stamps the registry-recorded DSH owners onto new entries", () => {
|
||||||
|
const registry = new SessionRegistry(5);
|
||||||
|
own(registry, "s1");
|
||||||
|
own(registry, "s2");
|
||||||
|
registry.trackOwner("s1", ["conv-a", "root"]);
|
||||||
|
const { service } = setup({ registry });
|
||||||
|
service.addSession("s1");
|
||||||
|
service.addSession("s2");
|
||||||
|
expect(service.getState()).toEqual([
|
||||||
|
{ sessionId: "s1", action: "idle", since: 1_000_000, dshSessionIds: ["conv-a", "root"] },
|
||||||
|
{ sessionId: "s2", action: "idle", since: 1_000_000 },
|
||||||
|
]);
|
||||||
|
// The owner stamp survives later upserts (action/url/frame churn).
|
||||||
|
service.beginAction("s1", "clicking");
|
||||||
|
expect(service.getState()[0].dshSessionIds).toEqual(["conv-a", "root"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("ignores instrumentation for unknown sessions (owned-only by construction)", () => {
|
it("ignores instrumentation for unknown sessions (owned-only by construction)", () => {
|
||||||
const { service } = setup({});
|
const { service } = setup({});
|
||||||
service.beginAction("foreign", "clicking");
|
service.beginAction("foreign", "clicking");
|
||||||
@@ -190,6 +224,57 @@ describe("state machine", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("thumbnail cadence", () => {
|
describe("thumbnail cadence", () => {
|
||||||
|
it("gives foreground work priority and resumes with one fresh capture", async () => {
|
||||||
|
const scheduler = fakeScheduler();
|
||||||
|
let finishCapture: ((result: BskRunResult) => void) | undefined;
|
||||||
|
const killed: string[] = [];
|
||||||
|
const runner: FakeRunner = {
|
||||||
|
calls: [],
|
||||||
|
killed,
|
||||||
|
run(args) {
|
||||||
|
if (args[0] !== "screenshot") {
|
||||||
|
return Promise.resolve({
|
||||||
|
code: 0,
|
||||||
|
stdout: "{}",
|
||||||
|
stderr: "",
|
||||||
|
timedOut: false,
|
||||||
|
aborted: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
finishCapture = resolve;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
killAll() {},
|
||||||
|
killFor(tag) {
|
||||||
|
killed.push(tag);
|
||||||
|
if (tag === "observation:s1" && finishCapture !== undefined) {
|
||||||
|
finishCapture({
|
||||||
|
code: 5,
|
||||||
|
stdout: JSON.stringify({ code: "cancelled", message: "cancelled" }),
|
||||||
|
stderr: "",
|
||||||
|
timedOut: false,
|
||||||
|
aborted: false,
|
||||||
|
});
|
||||||
|
finishCapture = undefined;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const { service } = setup({ runner, scheduler });
|
||||||
|
service.addSession("s1");
|
||||||
|
scheduler.runNext();
|
||||||
|
await waitFor(() => finishCapture !== undefined);
|
||||||
|
|
||||||
|
const release = service.acquireForeground("s1");
|
||||||
|
expect(killed).toEqual(["observation:s1"]);
|
||||||
|
await waitFor(() => scheduler.pending().length === 0);
|
||||||
|
release();
|
||||||
|
expect(service.isAvailable()).toBe(true);
|
||||||
|
expect(scheduler.pending()).toEqual([0]);
|
||||||
|
});
|
||||||
|
|
||||||
it("captures immediately on add and after action end, then fast-cadences while active", async () => {
|
it("captures immediately on add and after action end, then fast-cadences while active", async () => {
|
||||||
const scheduler = fakeScheduler();
|
const scheduler = fakeScheduler();
|
||||||
const runner = fakeRunner();
|
const runner = fakeRunner();
|
||||||
@@ -307,6 +392,57 @@ describe("interrupt routing", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("stopSession", () => {
|
||||||
|
it("stops an owned session: kills in-flight tools, runs session stop, removes the entry", async () => {
|
||||||
|
const registry = new SessionRegistry(5);
|
||||||
|
own(registry, "s1");
|
||||||
|
const runner = fakeRunner();
|
||||||
|
const { service, events } = setup({ registry, runner });
|
||||||
|
service.addSession("s1");
|
||||||
|
expect(registry.isOwned("s1")).toBe(true);
|
||||||
|
|
||||||
|
await expect(service.stopSession("s1")).resolves.toBe(true);
|
||||||
|
expect(runner.killed).toEqual(["observation:s1", "s1"]);
|
||||||
|
const stop = runner.calls.find((c) => c.args[0] === "session" && c.args[1] === "stop");
|
||||||
|
expect(stop?.args).toEqual(["session", "stop", "s1"]);
|
||||||
|
expect(registry.isOwned("s1")).toBe(false);
|
||||||
|
expect(service.getState()).toEqual([]);
|
||||||
|
expect(events[events.length - 1]).toMatchObject({ type: "remove" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses foreign sessions without touching the runner", async () => {
|
||||||
|
const registry = new SessionRegistry(5);
|
||||||
|
own(registry, "s1");
|
||||||
|
const runner = fakeRunner();
|
||||||
|
const { service } = setup({ registry, runner });
|
||||||
|
await expect(service.stopSession("foreign")).resolves.toBe(false);
|
||||||
|
expect(runner.killed).toEqual([]);
|
||||||
|
expect(runner.calls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops idempotently when the daemon already forgot the session (dead entries)", async () => {
|
||||||
|
const registry = new SessionRegistry(5);
|
||||||
|
own(registry, "s1");
|
||||||
|
const runner = fakeRunner({ stopNotFound: true });
|
||||||
|
const { service, events } = setup({ registry, runner });
|
||||||
|
service.addSession("s1");
|
||||||
|
await expect(service.stopSession("s1")).resolves.toBe(true);
|
||||||
|
expect(registry.isOwned("s1")).toBe(false);
|
||||||
|
expect(events[events.length - 1]).toMatchObject({ type: "remove" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects and keeps the entry when the stop itself fails", async () => {
|
||||||
|
const registry = new SessionRegistry(5);
|
||||||
|
own(registry, "s1");
|
||||||
|
const runner = fakeRunner({ stopFails: true });
|
||||||
|
const { service } = setup({ registry, runner });
|
||||||
|
service.addSession("s1");
|
||||||
|
await expect(service.stopSession("s1")).rejects.toThrow(/boom/);
|
||||||
|
expect(registry.isOwned("s1")).toBe(true);
|
||||||
|
expect(service.getState().map((s) => s.sessionId)).toEqual(["s1"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("HTTP/SSE interface", () => {
|
describe("HTTP/SSE interface", () => {
|
||||||
interface RecordedRoute {
|
interface RecordedRoute {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -405,11 +541,45 @@ describe("HTTP/SSE interface", () => {
|
|||||||
await interruptRoute?.handler(postReq, interruptRes.res as never);
|
await interruptRoute?.handler(postReq, interruptRes.res as never);
|
||||||
expect(JSON.parse(interruptRes.res.body())).toEqual({ interrupted: true });
|
expect(JSON.parse(interruptRes.res.body())).toEqual({ interrupted: true });
|
||||||
|
|
||||||
|
// stop (POST {sessionId}): owned session stops and leaves the state
|
||||||
|
const stopRoute = routes.get("/bsk-observation/stop");
|
||||||
|
const stopRes = fakeRes();
|
||||||
|
await stopRoute?.handler(postReq, stopRes.res as never);
|
||||||
|
// The stop is async behind the response-less handler — poll the state.
|
||||||
|
await waitFor(() => service.getState().length === 0);
|
||||||
|
expect(JSON.parse(stopRes.res.body())).toEqual({ stopped: true });
|
||||||
|
|
||||||
dispose();
|
dispose();
|
||||||
expect(routes.size).toBe(0);
|
expect(routes.size).toBe(0);
|
||||||
service.dispose();
|
service.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("refuses a stop without a sessionId", async () => {
|
||||||
|
const registry = new SessionRegistry(5);
|
||||||
|
own(registry, "s1");
|
||||||
|
const { service } = setup({ registry });
|
||||||
|
const { routes, webServer } = routeHarness();
|
||||||
|
const ctx = { get: (key: string) => (key === "webServer" ? webServer : undefined) } as never;
|
||||||
|
const dispose = registerObservationRoutes(ctx, service);
|
||||||
|
|
||||||
|
const stopRoute = routes.get("/bsk-observation/stop");
|
||||||
|
const res = fakeRes();
|
||||||
|
const emptyReq = fakeReq({
|
||||||
|
method: "POST",
|
||||||
|
headers: { host: "127.0.0.1:3999", "content-type": "application/json" },
|
||||||
|
on: (event: string, fn: (chunk?: string) => void) => {
|
||||||
|
if (event === "data") fn("{}");
|
||||||
|
if (event === "end") fn();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await stopRoute?.handler(emptyReq, res.res as never);
|
||||||
|
expect(res.res.status).toBe(400);
|
||||||
|
expect(registry.isOwned("s1")).toBe(true);
|
||||||
|
|
||||||
|
dispose();
|
||||||
|
service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
it("fences off non-loopback, cross-site, and simple-request traffic", async () => {
|
it("fences off non-loopback, cross-site, and simple-request traffic", async () => {
|
||||||
const registry = new SessionRegistry(5);
|
const registry = new SessionRegistry(5);
|
||||||
own(registry, "s1");
|
own(registry, "s1");
|
||||||
@@ -471,7 +641,7 @@ describe("HTTP/SSE interface", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("availability and dead sessions", () => {
|
describe("availability and missing sessions", () => {
|
||||||
it("flips availability off after repeated global failures and back on success", async () => {
|
it("flips availability off after repeated global failures and back on success", async () => {
|
||||||
const scheduler = fakeScheduler();
|
const scheduler = fakeScheduler();
|
||||||
const runner = fakeRunner({ screenshotFails: true });
|
const runner = fakeRunner({ screenshotFails: true });
|
||||||
@@ -491,23 +661,18 @@ describe("availability and dead sessions", () => {
|
|||||||
service.dispose();
|
service.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks a session dead on session_not_found and stops asking for frames", async () => {
|
it("removes an owned ghost on not_found and stops asking for frames", async () => {
|
||||||
const scheduler = fakeScheduler();
|
const scheduler = fakeScheduler();
|
||||||
const runner = fakeRunner({ screenshotNotFound: true });
|
const runner = fakeRunner({ screenshotNotFound: true });
|
||||||
const { service, events } = setup({ runner, scheduler });
|
const registry = new SessionRegistry(5);
|
||||||
|
own(registry, "s1");
|
||||||
|
const { service, events } = setup({ runner, scheduler, registry });
|
||||||
service.addSession("s1");
|
service.addSession("s1");
|
||||||
scheduler.runNext();
|
scheduler.runNext();
|
||||||
await waitFor(() => events.some((e) => e.type === "upsert" && e.session?.dead === true));
|
await waitFor(() => events.some((e) => e.type === "remove"));
|
||||||
expect(service.getState()[0].dead).toBe(true);
|
|
||||||
expect(service.getState()[0].action).toBe("idle");
|
|
||||||
// No further captures are scheduled for a dead session.
|
|
||||||
expect(scheduler.pending()).toEqual([]);
|
|
||||||
// Instrumentation is dropped for dead sessions.
|
|
||||||
service.beginAction("s1", "clicking");
|
|
||||||
expect(service.getState()[0].action).toBe("idle");
|
|
||||||
// A dead session leaves cleanly.
|
|
||||||
service.removeSession("s1");
|
|
||||||
expect(service.getState()).toEqual([]);
|
expect(service.getState()).toEqual([]);
|
||||||
|
expect(registry.isOwned("s1")).toBe(false);
|
||||||
|
expect(scheduler.pending()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clears lastError on the next action and keeps it on failure", () => {
|
it("clears lastError on the next action and keeps it on failure", () => {
|
||||||
@@ -530,6 +695,9 @@ describe("actionForLabel", () => {
|
|||||||
expect(actionForLabel("navigate")).toBe("navigating");
|
expect(actionForLabel("navigate")).toBe("navigating");
|
||||||
expect(actionForLabel("screenshot")).toBe("capturing");
|
expect(actionForLabel("screenshot")).toBe("capturing");
|
||||||
expect(actionForLabel("session start")).toBe("starting");
|
expect(actionForLabel("session start")).toBe("starting");
|
||||||
|
expect(actionForLabel("tab borrow")).toBe("borrowing tab");
|
||||||
|
expect(actionForLabel("request-help")).toBe("waiting for user");
|
||||||
|
expect(actionForLabel("wait-for-navigation")).toBe("waiting for navigation");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import type { ChildProcess } from "node:child_process";
|
import type { ChildProcess } from "node:child_process";
|
||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { type BskRunResult, createBskRunner, isCommandNotFound, parseBskJson } from "../src/runner";
|
import {
|
||||||
|
type BskRunResult,
|
||||||
|
createBskRunner,
|
||||||
|
isCommandNotFound,
|
||||||
|
isSessionBusyResult,
|
||||||
|
parseBskJson,
|
||||||
|
runWithSessionBusyRetry,
|
||||||
|
} from "../src/runner";
|
||||||
|
|
||||||
/** Minimal fake ChildProcess driven by the test. */
|
/** Minimal fake ChildProcess driven by the test. */
|
||||||
class FakeChild extends EventEmitter {
|
class FakeChild extends EventEmitter {
|
||||||
@@ -63,7 +70,7 @@ describe("createBskRunner", () => {
|
|||||||
controller.abort();
|
controller.abort();
|
||||||
const result = await promise;
|
const result = await promise;
|
||||||
expect(result.aborted).toBe(true);
|
expect(result.aborted).toBe(true);
|
||||||
expect(child.killedWith).toContain("SIGTERM");
|
expect(child.killedWith).toContain("SIGINT");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("kills the child on timeout", async () => {
|
it("kills the child on timeout", async () => {
|
||||||
@@ -80,7 +87,7 @@ describe("createBskRunner", () => {
|
|||||||
const promise = runner.run(["session", "list"]);
|
const promise = runner.run(["session", "list"]);
|
||||||
runner.killAll();
|
runner.killAll();
|
||||||
const result = await promise;
|
const result = await promise;
|
||||||
expect(child.killedWith).toContain("SIGTERM");
|
expect(child.killedWith).toContain("SIGINT");
|
||||||
expect(result.code).toBeNull();
|
expect(result.code).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -140,3 +147,51 @@ describe("isCommandNotFound", () => {
|
|||||||
expect(isCommandNotFound(new Error("other"))).toBe(false);
|
expect(isCommandNotFound(new Error("other"))).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("session busy reconciliation", () => {
|
||||||
|
const busy: BskRunResult = {
|
||||||
|
code: 4,
|
||||||
|
stdout: JSON.stringify({
|
||||||
|
code: "timeout",
|
||||||
|
message: "session already has an unfinished command",
|
||||||
|
data: { reason: "session_busy" },
|
||||||
|
}),
|
||||||
|
stderr: "",
|
||||||
|
timedOut: false,
|
||||||
|
aborted: false,
|
||||||
|
};
|
||||||
|
const ok: BskRunResult = {
|
||||||
|
code: 0,
|
||||||
|
stdout: "{}",
|
||||||
|
stderr: "",
|
||||||
|
timedOut: false,
|
||||||
|
aborted: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("recognizes only the structured session_busy reason", () => {
|
||||||
|
expect(isSessionBusyResult(busy)).toBe(true);
|
||||||
|
expect(isSessionBusyResult({ ...busy, stdout: '{"code":"timeout"}' })).toBe(false);
|
||||||
|
expect(isSessionBusyResult(ok)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries one transient busy result and returns the settled response", async () => {
|
||||||
|
const replies = [busy, ok];
|
||||||
|
let calls = 0;
|
||||||
|
const result = await runWithSessionBusyRetry(async () => {
|
||||||
|
calls += 1;
|
||||||
|
return replies.shift() ?? ok;
|
||||||
|
});
|
||||||
|
expect(result).toBe(ok);
|
||||||
|
expect(calls).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not loop on a persistent busy result", async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const result = await runWithSessionBusyRetry(async () => {
|
||||||
|
calls += 1;
|
||||||
|
return busy;
|
||||||
|
});
|
||||||
|
expect(result).toBe(busy);
|
||||||
|
expect(calls).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
// BSK skill injection: registration wiring, catalog content, progressive-load
|
// Browser skill injection: registration wiring, catalog content, progressive-load
|
||||||
// weight, and silent degradation without the skill seam.
|
// weight, and silent degradation without the skill seam.
|
||||||
|
|
||||||
|
import { Context } from "@deepseek-ai/cordis";
|
||||||
|
import { createScope, scopeTarget } from "@deepseek-ai/dsh-scope";
|
||||||
|
import { SkillRegistry } from "@deepseek-ai/dsh-skill";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { registerBskSkill } from "../src/skill";
|
import { armAgentScopedBskSkill, registerBskSkill } from "../src/skill";
|
||||||
|
|
||||||
function fakeCtx(skills?: unknown) {
|
function fakeCtx(skills?: unknown) {
|
||||||
return { get: (key: string) => (key === "skills" ? skills : undefined) } as never;
|
return { get: (key: string) => (key === "skills" ? skills : undefined) } as never;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("registerBskSkill", () => {
|
describe("registerBskSkill", () => {
|
||||||
it("registers the catalog entry with the assembled body", () => {
|
it("registers the catalog entry with the DSH browser-tool body", () => {
|
||||||
const registrations: { skill: Record<string, unknown>; disposer: () => void }[] = [];
|
const registrations: { skill: Record<string, unknown>; disposer: () => void }[] = [];
|
||||||
const skills = {
|
const skills = {
|
||||||
register(skill: Record<string, unknown>) {
|
register(skill: Record<string, unknown>) {
|
||||||
@@ -26,23 +29,51 @@ describe("registerBskSkill", () => {
|
|||||||
expect(skill.name).toBe("browser-skill");
|
expect(skill.name).toBe("browser-skill");
|
||||||
expect(typeof skill.description).toBe("string");
|
expect(typeof skill.description).toBe("string");
|
||||||
expect(String(skill.description)).toContain("browser_*");
|
expect(String(skill.description)).toContain("browser_*");
|
||||||
// body = dsh prelude + canonical CLI skill body + dsh postlude
|
|
||||||
const content = String(skill.content);
|
const content = String(skill.content);
|
||||||
expect(content).toContain("browser_observe");
|
const mentionedTools = [...new Set(content.match(/\bbrowser_[a-z][a-z_]*\b/g) ?? [])].sort();
|
||||||
expect(String(skill.description)).toContain("never invoke bsk through bash or shell");
|
expect(mentionedTools).toEqual([
|
||||||
expect(content).toContain("Always call those injected `browser_*` tools");
|
"browser_click",
|
||||||
expect(content).toContain("Never use `bash`, `shell`, `exec`");
|
"browser_console",
|
||||||
expect(content).toContain("## DSH routing reminder");
|
"browser_emulate",
|
||||||
expect(content.trimEnd()).toMatch(
|
"browser_fill",
|
||||||
/always begin with[\s\S]*`browser_session_start`[\s\S]*`browser_session_stop`\.$/,
|
"browser_get_html",
|
||||||
);
|
"browser_hover",
|
||||||
expect(content).toContain("Owned sessions only");
|
"browser_navigate",
|
||||||
|
"browser_navigate_back",
|
||||||
|
"browser_navigate_forward",
|
||||||
|
"browser_network",
|
||||||
|
"browser_observe",
|
||||||
|
"browser_press",
|
||||||
|
"browser_reload",
|
||||||
|
"browser_request_help",
|
||||||
|
"browser_screenshot",
|
||||||
|
"browser_select",
|
||||||
|
"browser_session_list",
|
||||||
|
"browser_session_start",
|
||||||
|
"browser_session_stop",
|
||||||
|
"browser_snapshot",
|
||||||
|
"browser_tab_borrow",
|
||||||
|
"browser_tab_close",
|
||||||
|
"browser_tab_create",
|
||||||
|
"browser_tab_list",
|
||||||
|
"browser_tab_return",
|
||||||
|
"browser_tab_select",
|
||||||
|
"browser_wait_for_navigation",
|
||||||
|
"browser_window_resize",
|
||||||
|
]);
|
||||||
|
expect(String(skill.description)).not.toMatch(/\bbsk\b/i);
|
||||||
|
expect(content).not.toMatch(/\bbsk\b/i);
|
||||||
|
expect(content).not.toMatch(/```(?:bash|sh|shell)\b/i);
|
||||||
|
expect(content).not.toMatch(/--[a-z]/);
|
||||||
|
expect(content).toContain("All browser operations must use the injected tools directly");
|
||||||
expect(content).toContain("Mandatory workflow");
|
expect(content).toContain("Mandatory workflow");
|
||||||
expect(content).toContain("Refs invalidate after navigation");
|
expect(content).toContain("Refs invalidate after navigation");
|
||||||
|
expect(content).toContain("evaluation and interaction recording are intentionally unsupported");
|
||||||
expect(content.length).toBeGreaterThan(10_000);
|
expect(content.length).toBeGreaterThan(10_000);
|
||||||
expect(skill.source).toBe("bundled");
|
expect(skill.source).toBe("bundled");
|
||||||
// the original YAML frontmatter must not leak into the body
|
// Source frontmatter is registration metadata and must not leak into the body.
|
||||||
expect(content.includes("name: browser-skill\ndescription:")).toBe(false);
|
expect(content.startsWith("---")).toBe(false);
|
||||||
|
expect(content).not.toContain("name: browser-skill\ndescription:");
|
||||||
// disposer passthrough
|
// disposer passthrough
|
||||||
unregister();
|
unregister();
|
||||||
expect(skill.__disposed).toBe(true);
|
expect(skill.__disposed).toBe(true);
|
||||||
@@ -72,3 +103,88 @@ describe("registerBskSkill", () => {
|
|||||||
expect(() => disposer()).not.toThrow();
|
expect(() => disposer()).not.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("armAgentScopedBskSkill", () => {
|
||||||
|
it("overrides a nearer legacy CLI skill when the DSH agent starts", async () => {
|
||||||
|
const root = new Context();
|
||||||
|
const skillFiber = root.plugin(SkillRegistry);
|
||||||
|
await skillFiber;
|
||||||
|
|
||||||
|
// The plugin's original registration is global.
|
||||||
|
registerBskSkill(skillFiber.ctx);
|
||||||
|
|
||||||
|
// Model the standard preset's filesystem discovery of
|
||||||
|
// ~/.agents/skills/browser-skill. Its nearer scope wins before the fix.
|
||||||
|
const presetKey = { preset: "standard" };
|
||||||
|
const presetScope = createScope(skillFiber.ctx, presetKey);
|
||||||
|
presetScope.ctx.skills.register({
|
||||||
|
name: "browser-skill",
|
||||||
|
description: "Legacy BrowserSkill CLI instructions",
|
||||||
|
content: "Use bsk shell commands.",
|
||||||
|
source: "user-agents",
|
||||||
|
});
|
||||||
|
|
||||||
|
const agentKey = { agent: "test-agent" };
|
||||||
|
const agentScope = createScope(skillFiber.ctx, agentKey, { parent: presetKey });
|
||||||
|
const agent = {
|
||||||
|
id: "test-agent",
|
||||||
|
ctx: agentScope.ctx,
|
||||||
|
} as never;
|
||||||
|
const agents = { list: () => [] };
|
||||||
|
const pluginCtx = skillFiber.ctx.extend({ agents });
|
||||||
|
|
||||||
|
const before = await skillFiber.ctx.skills.get("browser-skill", { scope: agentKey });
|
||||||
|
expect(before?.content).toBe("Use bsk shell commands.");
|
||||||
|
expect(before?.source).toBe("user-agents");
|
||||||
|
|
||||||
|
const disarm = armAgentScopedBskSkill(pluginCtx);
|
||||||
|
pluginCtx.emit(scopeTarget(agent, agentKey), "agent/session-start", {
|
||||||
|
agent,
|
||||||
|
source: "startup",
|
||||||
|
});
|
||||||
|
|
||||||
|
const after = await skillFiber.ctx.skills.get("browser-skill", { scope: agentKey });
|
||||||
|
expect(after?.content).toContain("All browser operations must use the injected tools directly");
|
||||||
|
expect(after?.content).not.toMatch(/\bbsk\b/i);
|
||||||
|
expect(after?.source).toBe("bundled");
|
||||||
|
|
||||||
|
disarm();
|
||||||
|
await agentScope.dispose();
|
||||||
|
await presetScope.dispose();
|
||||||
|
await skillFiber.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("installs on existing agents for plugin reload and disposes registrations", () => {
|
||||||
|
const registered: Record<string, unknown>[] = [];
|
||||||
|
let disposed = 0;
|
||||||
|
const agent = {
|
||||||
|
ctx: fakeCtx({
|
||||||
|
register(skill: Record<string, unknown>) {
|
||||||
|
registered.push(skill);
|
||||||
|
return () => {
|
||||||
|
disposed += 1;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
} as never;
|
||||||
|
const listeners = new Map<string, (payload: { agent: never }) => void>();
|
||||||
|
const ctx = {
|
||||||
|
get: (key: string) => (key === "agents" ? { list: () => [agent] } : undefined),
|
||||||
|
on(event: string, listener: (payload: { agent: never }) => void) {
|
||||||
|
listeners.set(event, listener);
|
||||||
|
return () => listeners.delete(event);
|
||||||
|
},
|
||||||
|
} as never;
|
||||||
|
|
||||||
|
const disarm = armAgentScopedBskSkill(ctx);
|
||||||
|
expect(registered).toHaveLength(1);
|
||||||
|
|
||||||
|
// A later lifecycle notification for the same agent must not duplicate it.
|
||||||
|
listeners.get("agent/session-start")?.({ agent });
|
||||||
|
expect(registered).toHaveLength(1);
|
||||||
|
|
||||||
|
disarm();
|
||||||
|
expect(disposed).toBe(1);
|
||||||
|
expect(listeners.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -142,16 +142,33 @@ describe("tool registration", () => {
|
|||||||
const { tools } = setup({});
|
const { tools } = setup({});
|
||||||
expect([...tools.keys()].sort()).toEqual([
|
expect([...tools.keys()].sort()).toEqual([
|
||||||
"browser_click",
|
"browser_click",
|
||||||
|
"browser_console",
|
||||||
"browser_emulate",
|
"browser_emulate",
|
||||||
"browser_fill",
|
"browser_fill",
|
||||||
|
"browser_get_html",
|
||||||
|
"browser_hover",
|
||||||
"browser_navigate",
|
"browser_navigate",
|
||||||
|
"browser_navigate_back",
|
||||||
|
"browser_navigate_forward",
|
||||||
|
"browser_network",
|
||||||
"browser_observe",
|
"browser_observe",
|
||||||
"browser_press",
|
"browser_press",
|
||||||
|
"browser_reload",
|
||||||
|
"browser_request_help",
|
||||||
"browser_screenshot",
|
"browser_screenshot",
|
||||||
|
"browser_select",
|
||||||
"browser_session_list",
|
"browser_session_list",
|
||||||
"browser_session_start",
|
"browser_session_start",
|
||||||
"browser_session_stop",
|
"browser_session_stop",
|
||||||
"browser_snapshot",
|
"browser_snapshot",
|
||||||
|
"browser_tab_borrow",
|
||||||
|
"browser_tab_close",
|
||||||
|
"browser_tab_create",
|
||||||
|
"browser_tab_list",
|
||||||
|
"browser_tab_return",
|
||||||
|
"browser_tab_select",
|
||||||
|
"browser_wait_for_navigation",
|
||||||
|
"browser_window_resize",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -436,6 +453,336 @@ describe("interaction tools", () => {
|
|||||||
/mutually exclusive/,
|
/mutually exclusive/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("browser_hover and browser_select map their structured arguments", async () => {
|
||||||
|
const { tools, calls } = setup({
|
||||||
|
"session start": START_REPLY("s1"),
|
||||||
|
hover: { tab_id: 7, used_ref: "@e3", x: 12, y: 24 },
|
||||||
|
select: {
|
||||||
|
tab_id: 7,
|
||||||
|
used_ref: "@e4",
|
||||||
|
multiple: true,
|
||||||
|
selected_values: ["a", "b"],
|
||||||
|
selected_labels: ["Alpha", "Beta"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await startSession(tools);
|
||||||
|
const hovered = (await tools
|
||||||
|
.get("browser_hover")
|
||||||
|
?.execute(
|
||||||
|
{ target: "@e3", modifiers: ["shift"], settleMs: 250, timeoutMs: 2000 },
|
||||||
|
makeExec(),
|
||||||
|
)) as { x: number; y: number };
|
||||||
|
expect(hovered).toMatchObject({ x: 12, y: 24 });
|
||||||
|
expect(calls[1].args).toEqual([
|
||||||
|
"hover",
|
||||||
|
"--session",
|
||||||
|
"s1",
|
||||||
|
"--modifiers",
|
||||||
|
"shift",
|
||||||
|
"--settle",
|
||||||
|
"250ms",
|
||||||
|
"--timeout",
|
||||||
|
"2000ms",
|
||||||
|
"@e3",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const selected = (await tools
|
||||||
|
.get("browser_select")
|
||||||
|
?.execute({ target: "@e4", values: ["a", "b"], tabId: 7 }, makeExec())) as {
|
||||||
|
selectedValues: string[];
|
||||||
|
selectedLabels: string[];
|
||||||
|
};
|
||||||
|
expect(selected).toMatchObject({
|
||||||
|
selectedValues: ["a", "b"],
|
||||||
|
selectedLabels: ["Alpha", "Beta"],
|
||||||
|
});
|
||||||
|
expect(calls[2].args).toEqual([
|
||||||
|
"select",
|
||||||
|
"--session",
|
||||||
|
"s1",
|
||||||
|
"--tab-id",
|
||||||
|
"7",
|
||||||
|
"--value",
|
||||||
|
"a",
|
||||||
|
"--value",
|
||||||
|
"b",
|
||||||
|
"@e4",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("phase-one tab tools", () => {
|
||||||
|
it("maps list/create/close/select/borrow/return through the owned session", async () => {
|
||||||
|
const { tools, calls } = setup({
|
||||||
|
"session start": START_REPLY("s1"),
|
||||||
|
"tab list": {
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
tab_id: 7,
|
||||||
|
title: "Example",
|
||||||
|
url: "https://example.test",
|
||||||
|
window_id: 2,
|
||||||
|
active: true,
|
||||||
|
scope: "agent",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"tab create": { tab_id: 8, window_id: 2, url: "https://new.test" },
|
||||||
|
"tab close": { tab_id: 8 },
|
||||||
|
"tab select": { tab_id: 7, window_id: 2 },
|
||||||
|
"tab borrow": {
|
||||||
|
tab_id: 20,
|
||||||
|
original_window_id: 9,
|
||||||
|
original_index: 1,
|
||||||
|
agent_window_id: 2,
|
||||||
|
},
|
||||||
|
"tab return": {
|
||||||
|
tab_id: 20,
|
||||||
|
returned_to_window_id: 9,
|
||||||
|
returned_to_index: 1,
|
||||||
|
fallback: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await startSession(tools);
|
||||||
|
|
||||||
|
const listed = (await tools
|
||||||
|
.get("browser_tab_list")
|
||||||
|
?.execute({ scope: "agent" }, makeExec())) as { tabs: { tabId: number }[] };
|
||||||
|
expect(listed.tabs[0].tabId).toBe(7);
|
||||||
|
const created = (await tools
|
||||||
|
.get("browser_tab_create")
|
||||||
|
?.execute({ url: "https://new.test", active: false, index: 1 }, makeExec())) as {
|
||||||
|
tabId: number;
|
||||||
|
};
|
||||||
|
expect(created.tabId).toBe(8);
|
||||||
|
await tools.get("browser_tab_close")?.execute({ tabId: 8 }, makeExec());
|
||||||
|
await tools.get("browser_tab_select")?.execute({ tabId: 7 }, makeExec());
|
||||||
|
const borrowed = (await tools
|
||||||
|
.get("browser_tab_borrow")
|
||||||
|
?.execute({ tabId: 20 }, makeExec())) as { originalWindowId: number };
|
||||||
|
expect(borrowed.originalWindowId).toBe(9);
|
||||||
|
const returned = (await tools
|
||||||
|
.get("browser_tab_return")
|
||||||
|
?.execute({ tabId: 20 }, makeExec())) as { fallback: boolean };
|
||||||
|
expect(returned.fallback).toBe(false);
|
||||||
|
|
||||||
|
expect(calls.slice(1).map((call) => call.args)).toEqual([
|
||||||
|
["tab", "list", "--session", "s1", "--scope", "agent"],
|
||||||
|
[
|
||||||
|
"tab",
|
||||||
|
"create",
|
||||||
|
"--session",
|
||||||
|
"s1",
|
||||||
|
"--url",
|
||||||
|
"https://new.test",
|
||||||
|
"--no-active",
|
||||||
|
"--index",
|
||||||
|
"1",
|
||||||
|
],
|
||||||
|
["tab", "close", "8", "--session", "s1"],
|
||||||
|
["tab", "select", "7", "--session", "s1"],
|
||||||
|
["tab", "borrow", "20", "--session", "s1"],
|
||||||
|
["tab", "return", "20", "--session", "s1"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("phase-one navigation tools", () => {
|
||||||
|
it("maps back/forward/reload/wait and preserves timeout budgets", async () => {
|
||||||
|
const { tools, calls } = setup({
|
||||||
|
"session start": START_REPLY("s1"),
|
||||||
|
"navigate-back": {
|
||||||
|
tab_id: 7,
|
||||||
|
previous_url: "https://b.test",
|
||||||
|
final_url: "https://a.test",
|
||||||
|
reached: "load",
|
||||||
|
},
|
||||||
|
"navigate-forward": {
|
||||||
|
tab_id: 7,
|
||||||
|
previous_url: "https://a.test",
|
||||||
|
final_url: "https://b.test",
|
||||||
|
reached: "load",
|
||||||
|
},
|
||||||
|
reload: {
|
||||||
|
tab_id: 7,
|
||||||
|
previous_url: "https://b.test",
|
||||||
|
final_url: "https://b.test",
|
||||||
|
reached: "networkidle",
|
||||||
|
},
|
||||||
|
"wait-for-navigation": { tab_id: 7, reached: "timeout", error_text: "no navigation" },
|
||||||
|
});
|
||||||
|
await startSession(tools);
|
||||||
|
await tools
|
||||||
|
.get("browser_navigate_back")
|
||||||
|
?.execute({ tabId: 7, waitUntil: "commit", timeoutMs: 1000 }, makeExec());
|
||||||
|
await tools.get("browser_navigate_forward")?.execute({}, makeExec());
|
||||||
|
await tools
|
||||||
|
.get("browser_reload")
|
||||||
|
?.execute({ hard: true, waitUntil: "networkidle" }, makeExec());
|
||||||
|
const waited = (await tools
|
||||||
|
.get("browser_wait_for_navigation")
|
||||||
|
?.execute({ timeoutMs: 2500 }, makeExec())) as { reached: string; errorText: string };
|
||||||
|
expect(waited).toMatchObject({ reached: "timeout", errorText: "no navigation" });
|
||||||
|
expect(calls[1].args).toEqual([
|
||||||
|
"navigate-back",
|
||||||
|
"--session",
|
||||||
|
"s1",
|
||||||
|
"--tab-id",
|
||||||
|
"7",
|
||||||
|
"--wait-until",
|
||||||
|
"commit",
|
||||||
|
"--timeout",
|
||||||
|
"1000ms",
|
||||||
|
]);
|
||||||
|
expect(calls[2].args).toEqual(["navigate-forward", "--session", "s1"]);
|
||||||
|
expect(calls[3].args).toEqual([
|
||||||
|
"reload",
|
||||||
|
"--session",
|
||||||
|
"s1",
|
||||||
|
"--wait-until",
|
||||||
|
"networkidle",
|
||||||
|
"--hard",
|
||||||
|
]);
|
||||||
|
expect(calls[4].args).toEqual([
|
||||||
|
"wait-for-navigation",
|
||||||
|
"--session",
|
||||||
|
"s1",
|
||||||
|
"--timeout",
|
||||||
|
"2500ms",
|
||||||
|
]);
|
||||||
|
expect(calls[4].options.timeoutMs).toBe(120_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("phase-one support tools", () => {
|
||||||
|
it("maps request-help targets and structured completion criteria", async () => {
|
||||||
|
const { tools, calls } = setup({
|
||||||
|
"session start": START_REPLY("s1"),
|
||||||
|
"request-help": {
|
||||||
|
outcome: "completed",
|
||||||
|
completed_by: "system",
|
||||||
|
note: "signed in",
|
||||||
|
tab_id: 7,
|
||||||
|
resolved_targets: [{ matched: true, ref: "@e3" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await startSession(tools);
|
||||||
|
const value = (await tools.get("browser_request_help")?.execute(
|
||||||
|
{
|
||||||
|
prompt: "Please sign in",
|
||||||
|
title: "Login required",
|
||||||
|
targets: ["@e3", "#submit"],
|
||||||
|
timeoutMs: 300_000,
|
||||||
|
completionCriteria: {
|
||||||
|
any: [{ urlContains: "/dashboard" }, { selectorExists: "[data-user]" }],
|
||||||
|
stableForMs: 1000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
makeExec(),
|
||||||
|
)) as { outcome: string; note: string };
|
||||||
|
expect(value).toMatchObject({ outcome: "completed", note: "signed in" });
|
||||||
|
expect(calls[1].options.timeoutMs).toBe(315_000);
|
||||||
|
expect(calls[1].args.slice(0, 8)).toEqual([
|
||||||
|
"request-help",
|
||||||
|
"--session",
|
||||||
|
"s1",
|
||||||
|
"--prompt",
|
||||||
|
"Please sign in",
|
||||||
|
"--timeout",
|
||||||
|
"300000ms",
|
||||||
|
"--title",
|
||||||
|
]);
|
||||||
|
const criteriaIndex = calls[1].args.indexOf("--completion-criteria");
|
||||||
|
expect(JSON.parse(calls[1].args[criteriaIndex + 1])).toEqual({
|
||||||
|
any: [{ url_contains: "/dashboard" }, { selector_exists: "[data-user]" }],
|
||||||
|
stable_for_ms: 1000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps get-html, console, and network results without raw shell access", async () => {
|
||||||
|
const { tools, calls } = setup({
|
||||||
|
"session start": START_REPLY("s1"),
|
||||||
|
"get-html": { tab_id: 7, html: "<main>Hi</main>", truncated: false, byte_size: 15 },
|
||||||
|
console: {
|
||||||
|
tab_id: 7,
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
sequence: 3,
|
||||||
|
kind: "exception",
|
||||||
|
level: "error",
|
||||||
|
text: "boom",
|
||||||
|
stack_trace: [{ function_name: "run", url: "https://a.test/app.js", line: 4 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
next_since: 3,
|
||||||
|
truncated: false,
|
||||||
|
},
|
||||||
|
network: {
|
||||||
|
tab_id: 7,
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
sequence: 5,
|
||||||
|
kind: "response",
|
||||||
|
method: "GET",
|
||||||
|
url: "https://a.test/api",
|
||||||
|
status: 200,
|
||||||
|
mime_type: "application/json",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
next_since: 5,
|
||||||
|
truncated: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await startSession(tools);
|
||||||
|
const html = (await tools
|
||||||
|
.get("browser_get_html")
|
||||||
|
?.execute({ ref: "@e2", maxBytes: 2048 }, makeExec())) as { html: string };
|
||||||
|
expect(html.html).toBe("<main>Hi</main>");
|
||||||
|
const consoleValue = (await tools
|
||||||
|
.get("browser_console")
|
||||||
|
?.execute({ since: 1, limit: 10, includeStack: true }, makeExec())) as {
|
||||||
|
entries: { stackTrace: { functionName: string }[] }[];
|
||||||
|
};
|
||||||
|
expect(consoleValue.entries[0].stackTrace[0].functionName).toBe("run");
|
||||||
|
const networkValue = (await tools
|
||||||
|
.get("browser_network")
|
||||||
|
?.execute({ tabId: 7, maxTextChars: 512 }, makeExec())) as {
|
||||||
|
entries: { mimeType: string }[];
|
||||||
|
};
|
||||||
|
expect(networkValue.entries[0].mimeType).toBe("application/json");
|
||||||
|
expect(calls.slice(1).map((call) => call.args)).toEqual([
|
||||||
|
["get-html", "--session", "s1", "--ref", "@e2", "--max-bytes", "2048"],
|
||||||
|
["console", "--session", "s1", "--since", "1", "--limit", "10", "--include-stack"],
|
||||||
|
["network", "--session", "s1", "--tab-id", "7", "--max-text-chars", "512"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resizes only within the CLI-supported range", async () => {
|
||||||
|
const { tools, calls } = setup({
|
||||||
|
"session start": START_REPLY("s1"),
|
||||||
|
"window resize": { window_id: 2, width: 1280, height: 800 },
|
||||||
|
});
|
||||||
|
await startSession(tools);
|
||||||
|
const resize = tools.get("browser_window_resize");
|
||||||
|
const value = (await resize?.execute({ width: 1280, height: 800 }, makeExec())) as {
|
||||||
|
windowId: number;
|
||||||
|
};
|
||||||
|
expect(value.windowId).toBe(2);
|
||||||
|
expect(calls[1].args).toEqual([
|
||||||
|
"window",
|
||||||
|
"resize",
|
||||||
|
"--session",
|
||||||
|
"s1",
|
||||||
|
"--width",
|
||||||
|
"1280",
|
||||||
|
"--height",
|
||||||
|
"800",
|
||||||
|
]);
|
||||||
|
await expect(resize?.execute({ width: 99, height: 800 }, makeExec())).rejects.toThrow(
|
||||||
|
/100\.\.=7680/,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("browser_screenshot", () => {
|
describe("browser_screenshot", () => {
|
||||||
|
|||||||
Generated
+6
@@ -114,6 +114,12 @@ importers:
|
|||||||
'@deepseek-ai/dsh-llm':
|
'@deepseek-ai/dsh-llm':
|
||||||
specifier: ^0.1.0-rc.6
|
specifier: ^0.1.0-rc.6
|
||||||
version: 0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-attachment@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-brand@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-brand@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1))(@deepseek-ai/dsh-timeout@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))
|
version: 0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-attachment@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-brand@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-brand@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1))(@deepseek-ai/dsh-timeout@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))
|
||||||
|
'@deepseek-ai/dsh-scope':
|
||||||
|
specifier: ^0.1.0-rc.6
|
||||||
|
version: 0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1))
|
||||||
|
'@deepseek-ai/dsh-skill':
|
||||||
|
specifier: ^0.1.0-rc.6
|
||||||
|
version: 0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1))(@deepseek-ai/dsh-llm@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-attachment@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-brand@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-brand@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1))(@deepseek-ai/dsh-timeout@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1))))(@deepseek-ai/dsh-scope@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))
|
||||||
'@deepseek-ai/dsh-tools':
|
'@deepseek-ai/dsh-tools':
|
||||||
specifier: ^0.1.0-rc.6
|
specifier: ^0.1.0-rc.6
|
||||||
version: 0.1.0-rc.6(f8724372086ccc1457fc84e7becee2e0)
|
version: 0.1.0-rc.6(f8724372086ccc1457fc84e7becee2e0)
|
||||||
|
|||||||
Reference in New Issue
Block a user