Files
BrowserSkill/packages/dsh-plugin-browserskill/src/index.ts
T
BB-fat ce11325de2 feat(dsh-plugin-browserskill): reveal tool schemas only after skill invocation
Progressive disclosure, final stage: with lazyTools on (default), the eleven
browser_* tool schemas stay out of the system prompt — the browser-skill
catalog entry is the sole advertisement. One successful skill invocation
(model tool call via tools/result, or a /browser-skill user gesture via the
session append feed) registers the suite for the process lifetime, idempotent
on repeats; session resume is covered by scanning durable events for a past
tool/call+tool/result pair (or skill-invocation message) on session entry and
at apply time. lazyTools: false keeps the legacy always-on registration.

Investigation (recorded in the ticket): tools/result carries normalized
name/arguments with an isError discriminant; dsh 0.1 has no official global
visibility switch (ctx.tools.restrict is agent-scoped and throws from a plain
context), making conditional registration the intended pattern; per-step
assembly picks the new suite up on the next step, and visibility changes are
first-class inputs of tool-skill's catalog digest. registerTools now returns
the combined disposer via a prototype-preserving tracking overlay; the skill
prelude tells the model the browser_* tools unlock on skill load.

Tests: +8 (hidden-before/reveal/idempotent/gesture/history hit+miss/dispose/
scanner pairing/two-state apply wiring). 124/124 green.
2026-08-14 16:43:51 +00:00

156 lines
6.3 KiB
TypeScript

/**
* dsh-plugin-browserskill — a DeepSeek Harness bundle that registers
* BrowserSkill (`bsk`) browser automation as model-visible tools.
*
* Each tool spawns `bsk <cmd> --json`, parses the structured output, and
* returns a canonical JSON value. The plugin tracks the sessions it starts so
* one agent conversation can drive several browsers at once; unloading the
* plugin stops every tracked session and kills any in-flight bsk children.
*
* @module dsh-plugin-browserskill
*/
import type { Context } from "@deepseek-ai/cordis";
import Schema from "@deepseek-ai/schemastery";
import { armLazyTools } from "./lazy-tools";
import { ObservationService } from "./observation";
import { registerObservationRoutes } from "./observation-http";
import { KeyedExecutor } from "./queue";
import { type BskRunner, createBskRunner } from "./runner";
import { SessionRegistry } from "./sessions";
import { registerBskSkill } from "./skill";
import { type PluginConfig, registerTools } from "./tools";
export const name = "dsh-plugin-browserskill";
export const inject = ["tools"];
/** Runtime configuration schema (validated and defaulted by Cordis). */
export const Config = Schema.object({
bskPath: Schema.string()
.default("bsk")
.description("Path to the bsk CLI binary (defaults to resolving `bsk` from PATH)."),
defaultTimeoutMs: Schema.number()
.default(120_000)
.description("Default per-command timeout in milliseconds."),
maxSessions: Schema.number()
.default(5)
.description("Maximum number of concurrent browser sessions started through this plugin."),
observationEnabled: Schema.boolean()
.default(true)
.description("Track per-session observation state (action/url/thumbnail) for the PiP overlay."),
thumbnailIntervalMs: Schema.number()
.default(1500)
.description("Thumbnail refresh cadence for active sessions (milliseconds)."),
idleIntervalMs: Schema.number()
.default(8000)
.description("Thumbnail refresh cadence for idle sessions; also the recent-activity window."),
lazyTools: Schema.boolean()
.default(true)
.description(
"Reveal the browser_* tools only after the browser-skill skill is invoked (default true); " +
"false registers the full suite at load.",
),
});
export type Config = PluginConfig;
/** Test seams: swap the process runner (unit tests never spawn a real bsk). */
export interface ApplyOptions {
runnerFactory?: (bskPath: string) => BskRunner;
}
export function apply(
ctx: Context,
config: Partial<PluginConfig> = {},
options: ApplyOptions = {},
): void {
const resolved: PluginConfig = {
bskPath: config.bskPath ?? "bsk",
defaultTimeoutMs: config.defaultTimeoutMs ?? 120_000,
maxSessions: config.maxSessions ?? 5,
observationEnabled: config.observationEnabled ?? true,
thumbnailIntervalMs: config.thumbnailIntervalMs ?? 1500,
idleIntervalMs: config.idleIntervalMs ?? 8000,
lazyTools: config.lazyTools ?? true,
};
const runner = options.runnerFactory?.(resolved.bskPath) ?? createBskRunner(resolved.bskPath);
const registry = new SessionRegistry(resolved.maxSessions);
const queue = new KeyedExecutor();
const observation = new ObservationService({
ctx,
runner,
registry,
queue,
options: {
enabled: resolved.observationEnabled,
thumbnailIntervalMs: resolved.thumbnailIntervalMs,
idleIntervalMs: resolved.idleIntervalMs,
},
});
// Progressive disclosure of the BSK agent skill (catalog entry resident,
// body on demand) through the official skill seam; silent no-op when the
// composition lacks it. With lazyTools on, the skill entry is initially the
// ONLY model-visible advertisement — the tool suite reveals itself on a
// successful skill invocation (or a session whose history already has one).
const unregisterSkill = registerBskSkill(ctx);
const removeSuite = resolved.lazyTools
? armLazyTools(ctx, () =>
registerTools({ ctx, runner, registry, config: resolved, observation, queue }),
)
: registerTools({ ctx, runner, registry, config: resolved, observation, queue });
// Route registration rides ctx.inject: the webServer service may be provided
// AFTER this plugin loads, and in headless compositions it never appears (the
// callback simply never runs, leaving the rest of the plugin unaffected).
let removeRoutes: () => void = () => {};
ctx.inject(["webServer"], (injected) => {
removeRoutes = registerObservationRoutes(injected, observation);
return () => removeRoutes();
});
// 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
// purpose — it answers without starting the daemon (`bsk status` would
// ensure-spawn one, an expensive side effect for a probe).
runner.run(["--version"], { timeoutMs: 10_000 }).then(
() => {},
(error: unknown) => {
const detail = error instanceof Error ? error.message : String(error);
console.warn(
`[${name}] bsk probe failed (${detail}); browser tools will report install guidance until the bsk CLI is available`,
);
},
);
// Unload cleanup: kill in-flight children, then stop every session this
// plugin OWNS (created via browser_session_start). Referenced or unknown
// sessions belonging to other programs on the shared daemon are never
// touched; per-stop failures (already stopped externally, daemon restart)
// are swallowed so one stale handle cannot abort the rest.
ctx.effect(() => {
return () => {
removeSuite();
unregisterSkill();
removeRoutes();
observation.dispose();
runner.killAll();
const stops = registry
.ownedIds()
.map((sessionId) =>
runner.run(["session", "stop", sessionId], { timeoutMs: 15_000 }).catch(() => {}),
);
return Promise.all(stops).then(() => {});
};
});
}
export type { ObservationEvent, ObservationOptions, SessionObservation } from "./observation";
export { ObservationService } from "./observation";
export { registerObservationRoutes } from "./observation-http";
export { KeyedExecutor } from "./queue";
export type { BskRunner, BskRunResult, SpawnImpl } from "./runner";
export { BskError, createBskRunner } from "./runner";
export { SessionRegistry } from "./sessions";
export type { PluginConfig, ToolDeps } from "./tools";
export { registerTools } from "./tools";