feat(packages): add dsh-plugin-browserskill — DeepSeek Harness tools for bsk

Add a new monorepo package that registers BrowserSkill (bsk) browser
automation as model-visible tools in DeepSeek Harness (dsh):

- 11 tools mapping to bsk CLI --json commands: session start/stop/list,
  navigate, snapshot, observe, click, fill, press, screenshot, emulate
- multi-session support: optional session arg with a current-session
  pointer, session id echoed in every result, configurable concurrency
  cap (default 5), and dispose-time cleanup of plugin-started sessions
- cancellation: exec.signal aborts kill the underlying bsk child process
- terminal-style UI cards (command line as the call card, output as the
  result card); screenshots inline the PNG through the host attachment
  store when the model route accepts image input, else return the path
- config: bskPath / defaultTimeoutMs / maxSessions via Schemastery schema
- install guidance when the bsk binary is missing; bsk JSON error
  envelopes (code/message/hint) surfaced to the model
- unit tests mock the bsk runner (no real browser required); the root
  lint script now also runs the package's typecheck + tests so the
  existing CI frontend job covers the new package (a dedicated CI job
  diff is provided in the PR; the pushing token lacks workflow scope)
This commit is contained in:
BB-fat
2026-08-13 15:04:52 +00:00
parent 4549ed0892
commit 703fc64399
17 changed files with 3135 additions and 11 deletions
@@ -0,0 +1,92 @@
/**
* Tracks the bsk sessions started through this plugin and the "current"
* session pointer used when a tool call omits its optional `session` arg.
* The pointer moves to whatever session was most recently started, stopped,
* or operated on, so a model can run several browsers side by side without
* repeating the id on every call.
*/
export interface TrackedSession {
sessionId: string;
browserInstanceId?: string;
startedAtMs: number;
}
export class SessionRegistry {
private readonly sessions = new Map<string, TrackedSession>();
private currentId: string | undefined;
constructor(private readonly maxSessions: number) {}
/**
* Register a freshly started session and make it current.
* @throws when the configured concurrency cap is already reached.
*/
add(session: TrackedSession): void {
if (!this.sessions.has(session.sessionId) && this.sessions.size >= this.maxSessions) {
throw new Error(
`session limit reached (${this.maxSessions} concurrent sessions); ` +
"stop one with browser_session_stop before starting another",
);
}
this.sessions.set(session.sessionId, session);
this.currentId = session.sessionId;
}
/** Forget a stopped session; falls back to the most recent remaining one. */
remove(sessionId: string): void {
this.sessions.delete(sessionId);
if (this.currentId === sessionId) {
const rest = [...this.sessions.values()];
this.currentId = rest.length > 0 ? rest[rest.length - 1].sessionId : undefined;
}
}
/** Mark a session as most recently used. Unknown ids are adopted. */
touch(sessionId: string): void {
const existing = this.sessions.get(sessionId);
if (existing !== undefined) {
// Re-insert to refresh recency order.
this.sessions.delete(sessionId);
this.sessions.set(sessionId, existing);
} else {
this.sessions.set(sessionId, { sessionId, startedAtMs: Date.now() });
}
this.currentId = sessionId;
}
/** The current session id, if any. */
current(): string | undefined {
return this.currentId;
}
/** Tracked sessions in least- to most-recently-used order. */
list(): TrackedSession[] {
return [...this.sessions.values()];
}
size(): number {
return this.sessions.size;
}
/**
* Resolve the session a tool call acts on: an explicit `session` argument
* wins (and becomes current); otherwise fall back to the current session.
* @throws when neither is available.
*/
resolve(explicit: string | undefined, toolName: string): string {
if (explicit !== undefined && explicit.trim().length > 0) {
this.touch(explicit);
return explicit;
}
const current = this.current();
if (current === undefined) {
throw new Error(
`${toolName} needs a session but none is active — start one with browser_session_start ` +
"or pass an explicit `session` id",
);
}
this.touch(current);
return current;
}
}