Compare commits

..
Author SHA1 Message Date
drakezhang 388c1283bc fix: clarify invalid selectors and dedupe diagnostics 2026-09-01 21:03:22 +08:00
drakezhang b449be1544 chore: add session-loss diagnostics 2026-09-01 20:16:32 +08:00
29 changed files with 1684 additions and 302 deletions
-4
View File
@@ -7,10 +7,6 @@ dist/
.output/ .output/
.wxt/ .wxt/
.web-extension-id .web-extension-id
.pnpm-store/
# Git worktrees
.worktrees/
# Logs # Logs
*.log *.log
+61 -6
View File
@@ -1,6 +1,7 @@
import { i18n } from "@browser-skill/i18n"; import { i18n } from "@browser-skill/i18n";
import { ChromiumCdp } from "@/browser-driver/chromium-cdp"; import { ChromiumCdp } from "@/browser-driver/chromium-cdp";
import { ConnectionController } from "@/lib/connection-controller"; import { ConnectionController } from "@/lib/connection-controller";
import { DiagnosticLogger } from "@/lib/diagnostics";
import { startHeartbeat } from "@/lib/heartbeat"; import { startHeartbeat } from "@/lib/heartbeat";
import { import {
getConnectionEnabled, getConnectionEnabled,
@@ -46,9 +47,20 @@ import type { Transport } from "@/transport/transport";
import { WSTransport } from "@/transport/ws-transport"; import { WSTransport } from "@/transport/ws-transport";
export default defineBackground(() => { export default defineBackground(() => {
const controller = new ConnectionController(); const diagnostics = new DiagnosticLogger();
const transport = new WSTransport({ url: __BSK_DAEMON_WS_URL__ }); diagnostics.record("extension.worker.started", {
const sessions = new SessionManager(); diagnostic_build: "lumi-session-loss-v2",
extension_version: __BSK_EXT_VERSION__,
extension_runtime_id: chrome.runtime.id,
daemon_ws_url: __BSK_DAEMON_WS_URL__,
});
const controller = new ConnectionController(diagnostics.sink);
const transport = new WSTransport({
url: __BSK_DAEMON_WS_URL__,
diagnostics: diagnostics.sink,
});
diagnostics.bindTransport((frame) => transport.send(frame));
const sessions = new SessionManager({ diagnostics: diagnostics.sink });
const cdp = new ChromiumCdp(); const cdp = new ChromiumCdp();
const sessionsLive = attachSessionsLiveFlag({ manager: sessions }); const sessionsLive = attachSessionsLiveFlag({ manager: sessions });
let overlayGeneration = 0; let overlayGeneration = 0;
@@ -109,6 +121,9 @@ export default defineBackground(() => {
} }
function onOverlaySessionStateChanged(): void { function onOverlaySessionStateChanged(): void {
diagnostics.record("background.sessions.changed", {
live_session_ids: sessions.list().map((ctx) => ctx.sessionId),
});
void sessionsLive.syncFromManager(); void sessionsLive.syncFromManager();
const liveSessionIds = new Set(sessions.list().map((ctx) => ctx.sessionId)); const liveSessionIds = new Set(sessions.list().map((ctx) => ctx.sessionId));
for (const sessionId of controlModes.keys()) { for (const sessionId of controlModes.keys()) {
@@ -146,6 +161,7 @@ export default defineBackground(() => {
onSessionsChanged: () => { onSessionsChanged: () => {
void sessionsLive.syncFromManager(); void sessionsLive.syncFromManager();
}, },
diagnostics: diagnostics.sink,
}); });
const recordDeps = { const recordDeps = {
tabsApi: chrome.tabs, tabsApi: chrome.tabs,
@@ -193,6 +209,7 @@ export default defineBackground(() => {
title: i18n.t("helpRequest.notificationTitle", { ns: "extension" }), title: i18n.t("helpRequest.notificationTitle", { ns: "extension" }),
body: "", body: "",
}), }),
diagnostics: diagnostics.sink,
}); });
dispatcher.start(); dispatcher.start();
if (typeof chrome.notifications?.onClicked?.addListener === "function") { if (typeof chrome.notifications?.onClicked?.addListener === "function") {
@@ -225,6 +242,7 @@ export default defineBackground(() => {
transport, transport,
cdp, cdp,
onSessionsChanged: onOverlaySessionStateChanged, onSessionsChanged: onOverlaySessionStateChanged,
diagnostics: diagnostics.sink,
}); });
// MV3 service worker keepalive + reconnect supervisor (review M4/M5 // MV3 service worker keepalive + reconnect supervisor (review M4/M5
@@ -236,6 +254,7 @@ export default defineBackground(() => {
startKeepalive({ startKeepalive({
transport, transport,
shouldConnect: () => controller.isConnectionEnabled, shouldConnect: () => controller.isConnectionEnabled,
diagnostics: diagnostics.sink,
}); });
// Application-level heartbeat (Chrome 116+): while the post-handshake // Application-level heartbeat (Chrome 116+): while the post-handshake
@@ -249,6 +268,19 @@ export default defineBackground(() => {
controller.subscribe((snap) => controller.subscribe((snap) =>
cb(snap.state === "connected" || snap.state === "version_skew"), cb(snap.state === "connected" || snap.state === "version_skew"),
), ),
diagnostics: diagnostics.sink,
});
controller.subscribe((snapshot) => {
const ready = snapshot.state === "connected" || snapshot.state === "version_skew";
diagnostics.setTransportReady(ready);
diagnostics.record("background.controller.snapshot", {
state: snapshot.state,
connection_enabled: snapshot.connectionEnabled,
instance_id: snapshot.instanceId,
last_error: snapshot.lastError,
live_session_ids: sessions.list().map((ctx) => ctx.sessionId),
});
}); });
// Wake-driven reconnect (best effort). An MV3 service worker is killed // Wake-driven reconnect (best effort). An MV3 service worker is killed
@@ -257,7 +289,12 @@ export default defineBackground(() => {
// the worker and let us reconnect immediately instead of waiting for // the worker and let us reconnect immediately instead of waiting for
// the next 30s alarm tick. They only help when the daemon is actually // the next 30s alarm tick. They only help when the daemon is actually
// running; a cold daemon is (re)spawned by the next `bsk` command. // running; a cold daemon is (re)spawned by the next `bsk` command.
const reconnectIfNeeded = () => { const reconnectIfNeeded = (source: string) => {
diagnostics.record("background.wake_reconnect.requested", {
source,
controller_enabled: controller.isConnectionEnabled,
transport_state: transport.state,
});
if (!controller.isConnectionEnabled) return; if (!controller.isConnectionEnabled) return;
if (transport.state === "connected") return; if (transport.state === "connected") return;
void transport.connect().catch((err) => { void transport.connect().catch((err) => {
@@ -265,13 +302,14 @@ export default defineBackground(() => {
}); });
}; };
if (typeof chrome.runtime?.onStartup?.addListener === "function") { if (typeof chrome.runtime?.onStartup?.addListener === "function") {
chrome.runtime.onStartup.addListener(reconnectIfNeeded); chrome.runtime.onStartup.addListener(() => reconnectIfNeeded("runtime.onStartup"));
} }
if (typeof chrome.idle?.onStateChanged?.addListener === "function") { if (typeof chrome.idle?.onStateChanged?.addListener === "function") {
chrome.idle.onStateChanged.addListener((state) => { chrome.idle.onStateChanged.addListener((state) => {
diagnostics.record("chrome.idle.state_changed", { state });
// "active" fires when the user returns from idle/locked — the most // "active" fires when the user returns from idle/locked — the most
// reliable "machine just woke" signal we get. // reliable "machine just woke" signal we get.
if (state === "active") reconnectIfNeeded(); if (state === "active") reconnectIfNeeded("idle.active");
}); });
// Treat >60s of no input as idle so the active transition is timely. // Treat >60s of no input as idle so the active transition is timely.
chrome.idle.setDetectionInterval?.(60); chrome.idle.setDetectionInterval?.(60);
@@ -279,14 +317,21 @@ export default defineBackground(() => {
void (async () => { void (async () => {
const connectionEnabled = await getConnectionEnabled(); const connectionEnabled = await getConnectionEnabled();
diagnostics.record("background.controller.attach_started", { connectionEnabled });
await controller.attach(transport, detectBrowserMeta(), connectionEnabled, { await controller.attach(transport, detectBrowserMeta(), connectionEnabled, {
beforeDisconnect: async () => { beforeDisconnect: async () => {
diagnostics.record("background.before_disconnect_cleanup", {
live_session_ids: sessions.list().map((ctx) => ctx.sessionId),
});
const report = await cleanupAfterDisconnect(); const report = await cleanupAfterDisconnect();
if (report.failures.length > 0) { if (report.failures.length > 0) {
console.warn("[browser-skill] session cleanup before disconnect was incomplete", report); console.warn("[browser-skill] session cleanup before disconnect was incomplete", report);
} }
}, },
onDisconnected: async () => { onDisconnected: async () => {
diagnostics.record("background.after_disconnect_cleanup", {
live_session_ids: sessions.list().map((ctx) => ctx.sessionId),
});
const report = await cleanupAfterDisconnect(); const report = await cleanupAfterDisconnect();
if (report.failures.length > 0) { if (report.failures.length > 0) {
console.warn("[browser-skill] session cleanup after disconnect was incomplete", report); console.warn("[browser-skill] session cleanup after disconnect was incomplete", report);
@@ -294,6 +339,7 @@ export default defineBackground(() => {
}, },
}); });
})().catch((err) => { })().catch((err) => {
diagnostics.record("background.controller.attach_failed", { error: err });
console.error("[browser-skill] controller failed to attach", err); console.error("[browser-skill] controller failed to attach", err);
}); });
@@ -374,6 +420,15 @@ export default defineBackground(() => {
dbg.__bhDispatcher = dispatcher; dbg.__bhDispatcher = dispatcher;
} }
if (typeof chrome.runtime?.onSuspend?.addListener === "function") {
chrome.runtime.onSuspend.addListener(() => {
diagnostics.record("extension.worker.suspending", {
live_session_ids: sessions.list().map((ctx) => ctx.sessionId),
transport_state: transport.state,
});
});
}
console.info("[browser-skill] background worker initialised"); console.info("[browser-skill] background worker initialised");
}); });
@@ -0,0 +1,85 @@
import { describe, expect, it, vi } from "vitest";
import { DiagnosticLogger } from "../diagnostics";
class MemoryStorage {
value: Record<string, unknown> = {};
async get(key: string): Promise<Record<string, unknown>> {
return { [key]: this.value[key] };
}
async set(items: Record<string, unknown>): Promise<void> {
this.value = { ...this.value, ...items };
}
}
describe("DiagnosticLogger", () => {
it("persists evidence before transport readiness and flushes it after reconnect", async () => {
const storage = new MemoryStorage();
const send = vi.fn();
const logger = new DiagnosticLogger(storage);
logger.bindTransport(send);
logger.record("transport.socket.closed", { code: 1006 });
await logger.flush();
expect(send).not.toHaveBeenCalled();
logger.setTransportReady(true);
await logger.flush();
expect(send).toHaveBeenCalledOnce();
expect(send.mock.calls[0]?.[0]).toMatchObject({
event: "browser.diagnostic",
payload: {
schema_version: 1,
entries: [
{
event: "transport.socket.closed",
fields: { code: 1006 },
},
],
},
});
expect(storage.value.bsk_diagnostic_events_v1).toEqual([]);
});
it("keeps persisted evidence when the transport rejects the flush", async () => {
const storage = new MemoryStorage();
const logger = new DiagnosticLogger(storage);
logger.bindTransport(() => {
throw new Error("socket closed");
});
logger.record("session.stop.requested", { session_id: "abcd" });
logger.setTransportReady(true);
await logger.flush();
expect(storage.value.bsk_diagnostic_events_v1).toMatchObject([
{ event: "session.stop.requested", fields: { session_id: "abcd" } },
]);
});
it("does not resend an acknowledged event when send records a new event", async () => {
const storage = new MemoryStorage();
const batches: Array<{ payload?: { entries?: Array<{ event: string }> } }> = [];
const logger = new DiagnosticLogger(storage);
logger.record("dispatcher.rpc.received", { rpc_id: "one" });
await logger.flush();
logger.bindTransport((frame) => {
batches.push(frame as { payload?: { entries?: Array<{ event: string }> } });
if (batches.length === 1) {
logger.record("dispatcher.rpc.response_sent", { rpc_id: "one" });
}
});
logger.setTransportReady(true);
await logger.flush();
await logger.flush();
expect(batches.map((batch) => batch.payload?.entries?.map((entry) => entry.event))).toEqual([
["dispatcher.rpc.received"],
["dispatcher.rpc.response_sent"],
]);
expect(storage.value.bsk_diagnostic_events_v1).toEqual([]);
});
});
@@ -6,6 +6,7 @@ import {
} from "../transport/handshake"; } from "../transport/handshake";
import type { Transport } from "../transport/transport"; import type { Transport } from "../transport/transport";
import type { ConnectionState, HandshakeResult } from "../transport/types"; import type { ConnectionState, HandshakeResult } from "../transport/types";
import type { DiagnosticSink } from "./diagnostics";
import { getLabel, getOrCreateInstanceId } from "./instance-id"; import { getLabel, getOrCreateInstanceId } from "./instance-id";
import { compareProtocol, parseProtocolMajor } from "./semver"; import { compareProtocol, parseProtocolMajor } from "./semver";
@@ -51,6 +52,11 @@ export class ConnectionController {
private handshakeAbort: AbortController | null = null; private handshakeAbort: AbortController | null = null;
private handshakeRetryTimer: ReturnType<typeof setTimeout> | null = null; private handshakeRetryTimer: ReturnType<typeof setTimeout> | null = null;
private suppressDisconnectRecovery = false; private suppressDisconnectRecovery = false;
private readonly diagnostics?: DiagnosticSink;
constructor(diagnostics?: DiagnosticSink) {
this.diagnostics = diagnostics;
}
get isConnectionEnabled(): boolean { get isConnectionEnabled(): boolean {
return this.connectionEnabled; return this.connectionEnabled;
@@ -89,8 +95,20 @@ export class ConnectionController {
this.lifecycleHooks = lifecycleHooks; this.lifecycleHooks = lifecycleHooks;
this.instanceId = await getOrCreateInstanceId(); this.instanceId = await getOrCreateInstanceId();
this.label = await getLabel(); this.label = await getLabel();
this.diagnostics?.("controller.attach", {
instance_id: this.instanceId,
connection_enabled: connectionEnabled,
transport_state: transport.state,
});
transport.onConnectionStateChange((s) => { transport.onConnectionStateChange((s) => {
this.diagnostics?.("controller.transport_state", {
transport_state: s,
controller_state: this.currentState,
connection_generation: this.connectionGeneration,
suppress_disconnect_recovery: this.suppressDisconnectRecovery,
recovery_inflight: this.disconnectRecovery !== null,
});
if (s === "disconnected") { if (s === "disconnected") {
// An in-flight handshake belongs to the dead connection — cancel it so // An in-flight handshake belongs to the dead connection — cancel it so
// a late resolution can never clobber the next attempt. // a late resolution can never clobber the next attempt.
@@ -103,6 +121,9 @@ export class ConnectionController {
// handshake bounce); the caller owns the reconnect policy there. // handshake bounce); the caller owns the reconnect policy there.
this.suppressDisconnectRecovery = false; this.suppressDisconnectRecovery = false;
} else { } else {
this.diagnostics?.("controller.disconnect_recovery.requested", {
connection_generation: this.connectionGeneration,
});
void this.recoverFromDisconnect(); void this.recoverFromDisconnect();
} }
} }
@@ -161,6 +182,12 @@ export class ConnectionController {
const generation = ++this.connectionGeneration; const generation = ++this.connectionGeneration;
const abort = new AbortController(); const abort = new AbortController();
this.handshakeAbort = abort; this.handshakeAbort = abort;
this.diagnostics?.("controller.handshake.started", {
connection_generation: generation,
browser_name: browser.name,
browser_version: browser.version,
instance_id: this.instanceId,
});
void this.runHandshake(browser, generation, abort.signal); void this.runHandshake(browser, generation, abort.signal);
} }
@@ -197,11 +224,21 @@ export class ConnectionController {
} }
this.clearHandshakeRetry(); this.clearHandshakeRetry();
this.lastError = null; this.lastError = null;
this.diagnostics?.("controller.handshake.completed", {
connection_generation: generation,
verdict: verdict.kind,
daemon_version: outcome.result.version,
daemon_protocol: outcome.result.protocol_version,
});
this.setState(verdict.kind); this.setState(verdict.kind);
} catch (err) { } catch (err) {
if (generation !== this.connectionGeneration || signal.aborted || isAbortError(err)) return; if (generation !== this.connectionGeneration || signal.aborted || isAbortError(err)) return;
this.handshake = null; this.handshake = null;
this.lastError = err instanceof Error ? err.message : String(err); this.lastError = err instanceof Error ? err.message : String(err);
this.diagnostics?.("controller.handshake.failed", {
connection_generation: generation,
error: err,
});
// Bounce the half-open socket deliberately; the retry timer below owns // Bounce the half-open socket deliberately; the retry timer below owns
// the reconnect, so skip unexpected-loss recovery for this disconnect. // the reconnect, so skip unexpected-loss recovery for this disconnect.
this.suppressDisconnectRecovery = true; this.suppressDisconnectRecovery = true;
@@ -228,23 +265,57 @@ export class ConnectionController {
} }
private recoverFromDisconnect(): Promise<void> { private recoverFromDisconnect(): Promise<void> {
if (this.disconnectRecovery) return this.disconnectRecovery; if (this.disconnectRecovery) {
this.diagnostics?.("controller.disconnect_recovery.coalesced", {
connection_generation: this.connectionGeneration,
});
return this.disconnectRecovery;
}
const recovery = (async () => { const recovery = (async () => {
const recoveryGeneration = this.connectionGeneration;
this.diagnostics?.("controller.disconnect_recovery.started", {
recovery_generation: recoveryGeneration,
});
// WSTransport schedules its reconnect timer immediately after notifying // WSTransport schedules its reconnect timer immediately after notifying
// state listeners. Yield once, then disconnect explicitly so that timer // state listeners. Yield once, then disconnect explicitly so that timer
// is cancelled before local session teardown begins. // is cancelled before local session teardown begins.
await Promise.resolve(); await Promise.resolve();
if (!this.connectionEnabled || !this.transport) return; if (!this.connectionEnabled || !this.transport) {
this.diagnostics?.("controller.disconnect_recovery.skipped", {
recovery_generation: recoveryGeneration,
reason: "disabled_or_missing_transport",
});
return;
}
// Another path already reconnected while we yielded — nothing to do. // Another path already reconnected while we yielded — nothing to do.
if (this.currentState !== "disconnected") return; if (this.currentState !== "disconnected") {
this.diagnostics?.("controller.disconnect_recovery.skipped", {
recovery_generation: recoveryGeneration,
reason: "state_already_changed",
current_state: this.currentState,
});
return;
}
await this.transport.disconnect().catch(() => {}); await this.transport.disconnect().catch(() => {});
try { try {
this.diagnostics?.("controller.disconnect_cleanup.started", {
recovery_generation: recoveryGeneration,
current_generation: this.connectionGeneration,
});
await this.lifecycleHooks.onDisconnected?.(); await this.lifecycleHooks.onDisconnected?.();
this.diagnostics?.("controller.disconnect_cleanup.completed", {
recovery_generation: recoveryGeneration,
current_generation: this.connectionGeneration,
});
} catch (err) { } catch (err) {
console.warn("[browser-skill] session cleanup after disconnect failed", err); console.warn("[browser-skill] session cleanup after disconnect failed", err);
} }
if (!this.connectionEnabled) return; if (!this.connectionEnabled) return;
try { try {
this.diagnostics?.("controller.disconnect_reconnect.started", {
recovery_generation: recoveryGeneration,
current_generation: this.connectionGeneration,
});
await this.transport.connect(); await this.transport.connect();
} catch (err) { } catch (err) {
this.lastError = err instanceof Error ? err.message : String(err); this.lastError = err instanceof Error ? err.message : String(err);
@@ -252,22 +323,39 @@ export class ConnectionController {
} }
})(); })();
this.disconnectRecovery = recovery.finally(() => { this.disconnectRecovery = recovery.finally(() => {
this.diagnostics?.("controller.disconnect_recovery.completed", {
connection_generation: this.connectionGeneration,
current_state: this.currentState,
});
this.disconnectRecovery = null; this.disconnectRecovery = null;
}); });
return this.disconnectRecovery; return this.disconnectRecovery;
} }
private cancelHandshake(): void { private cancelHandshake(): void {
const previousGeneration = this.connectionGeneration;
this.connectionGeneration += 1; this.connectionGeneration += 1;
this.diagnostics?.("controller.handshake.cancelled", {
previous_generation: previousGeneration,
next_generation: this.connectionGeneration,
had_abort_controller: this.handshakeAbort !== null,
});
this.handshakeAbort?.abort(); this.handshakeAbort?.abort();
this.handshakeAbort = null; this.handshakeAbort = null;
} }
private scheduleHandshakeRetry(browser: { name: string; version: string }): void { private scheduleHandshakeRetry(browser: { name: string; version: string }): void {
if (this.handshakeRetryTimer || !this.connectionEnabled) return; if (this.handshakeRetryTimer || !this.connectionEnabled) return;
this.diagnostics?.("controller.handshake_retry.scheduled", {
delay_ms: HANDSHAKE_RETRY_DELAY_MS,
connection_generation: this.connectionGeneration,
});
this.handshakeRetryTimer = setTimeout(() => { this.handshakeRetryTimer = setTimeout(() => {
this.handshakeRetryTimer = null; this.handshakeRetryTimer = null;
if (!this.connectionEnabled || !this.transport) return; if (!this.connectionEnabled || !this.transport) return;
this.diagnostics?.("controller.handshake_retry.timer_fired", {
connection_generation: this.connectionGeneration,
});
void this.transport.connect().catch((err) => { void this.transport.connect().catch((err) => {
this.lastError = err instanceof Error ? err.message : String(err); this.lastError = err instanceof Error ? err.message : String(err);
this.setState("disconnected"); this.setState("disconnected");
+178
View File
@@ -0,0 +1,178 @@
import type { ProtocolFrame } from "@/transport/types";
const STORAGE_KEY = "bsk_diagnostic_events_v1";
const MAX_ENTRIES = 500;
export type DiagnosticFields = Record<string, unknown>;
export type DiagnosticSink = (event: string, fields?: DiagnosticFields) => void;
export interface DiagnosticEntry {
id: string;
sequence: number;
timestamp: string;
monotonic_ms: number;
worker_boot_id: string;
event: string;
fields: DiagnosticFields;
}
interface DiagnosticStorage {
get(key: string): Promise<Record<string, unknown>>;
set(items: Record<string, unknown>): Promise<void>;
}
function defaultStorage(): DiagnosticStorage | null {
if (typeof chrome === "undefined" || !chrome.storage?.local) return null;
return {
get: (key) => chrome.storage.local.get(key),
set: (items) => chrome.storage.local.set(items),
};
}
function makeBootId(): string {
try {
return crypto.randomUUID();
} catch {
return `worker-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
}
function normalise(value: unknown): unknown {
if (value instanceof Error) {
return { name: value.name, message: value.message, stack: value.stack };
}
if (typeof value === "bigint") return value.toString();
if (Array.isArray(value)) return value.map(normalise);
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, child]) => [
key,
normalise(child),
]),
);
}
return value;
}
/**
* Persistent diagnostics for the MV3 background worker.
*
* Events are first written to chrome.storage.local so a worker termination or
* dead WebSocket does not erase the evidence. Once the post-handshake link is
* ready, pending entries are forwarded to the daemon as one
* `browser.diagnostic` event and removed from the local queue.
*/
export class DiagnosticLogger {
readonly workerBootId: string;
readonly sink: DiagnosticSink;
private sequence = 0;
private storageQueue: Promise<void> = Promise.resolve();
private flushPromise: Promise<void> | null = null;
private flushRequested = false;
private send: ((frame: ProtocolFrame) => void) | null = null;
private transportReady = false;
private readonly storage: DiagnosticStorage | null;
constructor(storage: DiagnosticStorage | null = defaultStorage()) {
this.workerBootId = makeBootId();
this.storage = storage;
this.sink = (event, fields = {}) => this.record(event, fields);
}
bindTransport(send: (frame: ProtocolFrame) => void): void {
this.send = send;
}
setTransportReady(ready: boolean): void {
this.transportReady = ready;
if (ready) void this.flush();
}
record(event: string, fields: DiagnosticFields = {}): void {
const sequence = ++this.sequence;
const entry: DiagnosticEntry = {
id: `${this.workerBootId}:${sequence}`,
sequence,
timestamp: new Date().toISOString(),
monotonic_ms: Math.round(performance.now()),
worker_boot_id: this.workerBootId,
event,
fields: normalise(fields) as DiagnosticFields,
};
console.info("[bsk diagnostic]", entry);
this.storageQueue = this.storageQueue
.then(async () => {
if (!this.storage) return;
const stored = await this.storage.get(STORAGE_KEY);
const previous = Array.isArray(stored[STORAGE_KEY])
? (stored[STORAGE_KEY] as DiagnosticEntry[])
: [];
await this.storage.set({
[STORAGE_KEY]: [...previous, entry].slice(-MAX_ENTRIES),
});
})
.catch((error) => {
console.warn("[bsk diagnostic] failed to persist event", error);
});
if (this.transportReady) void this.flush();
}
flush(): Promise<void> {
if (this.flushPromise) {
this.flushRequested = true;
return this.flushPromise;
}
const running = (async () => {
await this.storageQueue;
if (!this.transportReady || !this.send || !this.storage) return;
const stored = await this.storage.get(STORAGE_KEY);
const entries = Array.isArray(stored[STORAGE_KEY])
? (stored[STORAGE_KEY] as DiagnosticEntry[])
: [];
if (entries.length === 0) return;
try {
this.send({
event: "browser.diagnostic",
payload: {
schema_version: 1,
worker_boot_id: this.workerBootId,
entries,
},
});
} catch (error) {
console.warn("[bsk diagnostic] daemon flush failed", error);
return;
}
const sentIds = new Set(entries.map((entry) => entry.id));
// Serialize acknowledgement behind every record write that may have
// started while send() was in progress. Without this, a concurrent
// record can read the pre-ack queue and write an already-sent entry
// back into storage, causing the next batch to duplicate it.
this.storageQueue = this.storageQueue
.then(async () => {
const current = await this.storage?.get(STORAGE_KEY);
const currentEntries = Array.isArray(current?.[STORAGE_KEY])
? (current[STORAGE_KEY] as DiagnosticEntry[])
: [];
await this.storage?.set({
[STORAGE_KEY]: currentEntries.filter((entry) => !sentIds.has(entry.id)),
});
})
.catch((error) => {
console.warn("[bsk diagnostic] failed to acknowledge event batch", error);
});
await this.storageQueue;
})().catch((error) => {
console.warn("[bsk diagnostic] flush failed", error);
});
this.flushPromise = running.finally(() => {
this.flushPromise = null;
if (this.flushRequested && this.transportReady) {
this.flushRequested = false;
void this.flush();
}
});
return this.flushPromise;
}
}
+6
View File
@@ -21,6 +21,7 @@
* after the handshake has landed. * after the handshake has landed.
*/ */
import type { DiagnosticSink } from "@/lib/diagnostics";
import type { ProtocolFrame } from "@/transport/types"; import type { ProtocolFrame } from "@/transport/types";
export const HEARTBEAT_EVENT = "system.heartbeat"; export const HEARTBEAT_EVENT = "system.heartbeat";
@@ -46,6 +47,7 @@ export interface HeartbeatOptions {
intervalMs?: number; intervalMs?: number;
/** Inject fake timers (tests). */ /** Inject fake timers (tests). */
timers?: HeartbeatTimers; timers?: HeartbeatTimers;
diagnostics?: DiagnosticSink;
} }
export interface HeartbeatHandle { export interface HeartbeatHandle {
@@ -74,7 +76,9 @@ export function startHeartbeat(options: HeartbeatOptions): HeartbeatHandle {
const beat = () => { const beat = () => {
try { try {
options.send({ event: HEARTBEAT_EVENT, payload: {} }); options.send({ event: HEARTBEAT_EVENT, payload: {} });
options.diagnostics?.("heartbeat.sent");
} catch (err) { } catch (err) {
options.diagnostics?.("heartbeat.send_failed", { error: err });
// The socket raced a close between the state change and this // The socket raced a close between the state change and this
// tick. The transport's own reconnect path recovers; swallow so // tick. The transport's own reconnect path recovers; swallow so
// the interval keeps running (it is cleared on the disconnect // the interval keeps running (it is cleared on the disconnect
@@ -87,12 +91,14 @@ export function startHeartbeat(options: HeartbeatOptions): HeartbeatHandle {
if (timer !== null) { if (timer !== null) {
timers.clearInterval(timer); timers.clearInterval(timer);
timer = null; timer = null;
options.diagnostics?.("heartbeat.timer.stopped");
} }
}; };
const start = () => { const start = () => {
if (timer !== null) return; if (timer !== null) return;
timer = timers.setInterval(beat, intervalMs); timer = timers.setInterval(beat, intervalMs);
options.diagnostics?.("heartbeat.timer.started", { interval_ms: intervalMs });
}; };
const unsubscribe = options.onActiveChange((active) => { const unsubscribe = options.onActiveChange((active) => {
+17 -2
View File
@@ -20,6 +20,7 @@
* SW idle threshold. * SW idle threshold.
*/ */
import type { DiagnosticSink } from "@/lib/diagnostics";
import type { Transport } from "@/transport/transport"; import type { Transport } from "@/transport/transport";
export const KEEPALIVE_ALARM_NAME = "bh-keepalive"; export const KEEPALIVE_ALARM_NAME = "bh-keepalive";
@@ -59,6 +60,7 @@ export interface KeepaliveOptions {
alarmName?: string; alarmName?: string;
/** Override the alarm period (tests). */ /** Override the alarm period (tests). */
periodMin?: number; periodMin?: number;
diagnostics?: DiagnosticSink;
} }
export interface KeepaliveHandle { export interface KeepaliveHandle {
@@ -79,11 +81,24 @@ export function startKeepalive(options: KeepaliveOptions): KeepaliveHandle {
const period = options.periodMin ?? KEEPALIVE_PERIOD_MIN; const period = options.periodMin ?? KEEPALIVE_PERIOD_MIN;
const tick = async () => { const tick = async () => {
if (options.shouldConnect && !options.shouldConnect()) return; if (options.shouldConnect && !options.shouldConnect()) {
if (options.transport.state === "connected") return; options.diagnostics?.("keepalive.tick.skipped", { reason: "connection_disabled" });
return;
}
if (options.transport.state === "connected") {
options.diagnostics?.("keepalive.tick.healthy", { transport_state: "connected" });
return;
}
options.diagnostics?.("keepalive.reconnect.started", {
transport_state: options.transport.state,
});
try { try {
await options.transport.connect(); await options.transport.connect();
options.diagnostics?.("keepalive.reconnect.completed", {
transport_state: options.transport.state,
});
} catch (err) { } catch (err) {
options.diagnostics?.("keepalive.reconnect.failed", { error: err });
console.debug("[bsk keepalive] connect attempt failed", err); console.debug("[bsk keepalive] connect attempt failed", err);
} }
}; };
@@ -1,3 +1,4 @@
import type { DiagnosticSink } from "@/lib/diagnostics";
import { handleSessionStop, type SessionStopDeps } from "@/tools/session"; import { handleSessionStop, type SessionStopDeps } from "@/tools/session";
import type { SessionManager } from "./manager"; import type { SessionManager } from "./manager";
@@ -15,6 +16,7 @@ export interface DisconnectCleanupOptions {
manager: SessionManager; manager: SessionManager;
sessionStopDeps?: SessionStopDeps; sessionStopDeps?: SessionStopDeps;
onSessionsChanged?: () => void; onSessionsChanged?: () => void;
diagnostics?: DiagnosticSink;
} }
/** /**
@@ -29,7 +31,15 @@ export function createDisconnectCleanup(options: DisconnectCleanupOptions) {
let inFlight: Promise<DisconnectCleanupReport> | null = null; let inFlight: Promise<DisconnectCleanupReport> | null = null;
return (): Promise<DisconnectCleanupReport> => { return (): Promise<DisconnectCleanupReport> => {
if (inFlight) return inFlight; if (inFlight) {
options.diagnostics?.("disconnect_cleanup.coalesced", {
live_session_ids: options.manager.list().map((ctx) => ctx.sessionId),
});
return inFlight;
}
options.diagnostics?.("disconnect_cleanup.scheduled", {
live_session_ids: options.manager.list().map((ctx) => ctx.sessionId),
});
inFlight = cleanupSessions(options).finally(() => { inFlight = cleanupSessions(options).finally(() => {
inFlight = null; inFlight = null;
}); });
@@ -42,8 +52,17 @@ async function cleanupSessions(
): Promise<DisconnectCleanupReport> { ): Promise<DisconnectCleanupReport> {
const stoppedSessionIds: string[] = []; const stoppedSessionIds: string[] = [];
const failures: DisconnectCleanupFailure[] = []; const failures: DisconnectCleanupFailure[] = [];
const capturedSessions = options.manager.list();
options.diagnostics?.("disconnect_cleanup.started", {
captured_session_ids: capturedSessions.map((ctx) => ctx.sessionId),
});
for (const ctx of options.manager.list()) { for (const ctx of capturedSessions) {
options.diagnostics?.("disconnect_cleanup.session_stop.started", {
session_id: ctx.sessionId,
agent_window_id: ctx.agentWindowId,
still_registered: options.manager.has(ctx.sessionId),
});
try { try {
const result = await handleSessionStop( const result = await handleSessionStop(
options.manager, options.manager,
@@ -62,14 +81,26 @@ async function cleanupSessions(
continue; continue;
} }
stoppedSessionIds.push(ctx.sessionId); stoppedSessionIds.push(ctx.sessionId);
options.diagnostics?.("disconnect_cleanup.session_stop.completed", {
session_id: ctx.sessionId,
});
} catch (err) { } catch (err) {
failures.push({ failures.push({
sessionId: ctx.sessionId, sessionId: ctx.sessionId,
message: err instanceof Error ? err.message : String(err), message: err instanceof Error ? err.message : String(err),
}); });
options.diagnostics?.("disconnect_cleanup.session_stop.failed", {
session_id: ctx.sessionId,
error: err,
});
} }
} }
options.onSessionsChanged?.(); options.onSessionsChanged?.();
options.diagnostics?.("disconnect_cleanup.completed", {
stopped_session_ids: stoppedSessionIds,
failures,
remaining_session_ids: options.manager.list().map((ctx) => ctx.sessionId),
});
return { stoppedSessionIds, failures }; return { stoppedSessionIds, failures };
} }
@@ -1,3 +1,4 @@
import type { DiagnosticSink } from "@/lib/diagnostics";
import type { Transport } from "@/transport/transport"; import type { Transport } from "@/transport/transport";
import type { EventFrame } from "@/transport/types"; import type { EventFrame } from "@/transport/types";
import type { SessionManager } from "./manager"; import type { SessionManager } from "./manager";
@@ -24,6 +25,7 @@ export interface SessionEventHandlerOptions {
* `chrome.storage.session` "sessions live" flag (review M4/M5 I3). * `chrome.storage.session` "sessions live" flag (review M4/M5 I3).
*/ */
onSessionsChanged?: () => void; onSessionsChanged?: () => void;
diagnostics?: DiagnosticSink;
} }
function chromeWindowEvents(): WindowRemovedListener { function chromeWindowEvents(): WindowRemovedListener {
@@ -50,6 +52,11 @@ export function attachSessionEventHandler(options: SessionEventHandlerOptions):
const onRemoved = (windowId: number): void => { const onRemoved = (windowId: number): void => {
const ctx = manager.findByWindowId(windowId); const ctx = manager.findByWindowId(windowId);
options.diagnostics?.("chrome.window.removed", {
window_id: windowId,
matched_session_id: ctx?.sessionId ?? null,
live_session_ids: manager.list().map((entry) => entry.sessionId),
});
if (!ctx) return; if (!ctx) return;
const returnFailures = Array.from(ctx.borrowedTabs.keys()).map((tabId) => ({ const returnFailures = Array.from(ctx.borrowedTabs.keys()).map((tabId) => ({
tab_id: tabId, tab_id: tabId,
@@ -81,7 +88,16 @@ export function attachSessionEventHandler(options: SessionEventHandlerOptions):
}; };
try { try {
transport.send(event); transport.send(event);
options.diagnostics?.("session.window_closed.sent", {
session_id: ctx.sessionId,
window_id: windowId,
});
} catch (err) { } catch (err) {
options.diagnostics?.("session.window_closed.send_failed", {
session_id: ctx.sessionId,
window_id: windowId,
error: err,
});
console.warn("[bh] could not push session.window_closed event", err); console.warn("[bh] could not push session.window_closed event", err);
} }
}) })
@@ -1,3 +1,4 @@
import type { DiagnosticSink } from "@/lib/diagnostics";
import { AGENT_WINDOW_HOME, type AgentWindowApi, chromeAgentWindowApi } from "./agent-window"; import { AGENT_WINDOW_HOME, type AgentWindowApi, chromeAgentWindowApi } from "./agent-window";
import { RefStore } from "./ref-store"; import { RefStore } from "./ref-store";
@@ -23,6 +24,7 @@ export interface BorrowReservation {
export interface SessionManagerOptions { export interface SessionManagerOptions {
agentWindow?: AgentWindowApi; agentWindow?: AgentWindowApi;
now?: () => number; now?: () => number;
diagnostics?: DiagnosticSink;
} }
/** Options for starting a session's Agent Window. */ /** Options for starting a session's Agent Window. */
@@ -83,10 +85,12 @@ export class SessionManager {
private readonly borrowReservations = new Map<number, string>(); private readonly borrowReservations = new Map<number, string>();
private readonly agentWindow: AgentWindowApi; private readonly agentWindow: AgentWindowApi;
private readonly now: () => number; private readonly now: () => number;
private readonly diagnostics?: DiagnosticSink;
constructor(options: SessionManagerOptions = {}) { constructor(options: SessionManagerOptions = {}) {
this.agentWindow = options.agentWindow ?? chromeAgentWindowApi; this.agentWindow = options.agentWindow ?? chromeAgentWindowApi;
this.now = options.now ?? Date.now; this.now = options.now ?? Date.now;
this.diagnostics = options.diagnostics;
} }
has(sessionId: string): boolean { has(sessionId: string): boolean {
@@ -169,6 +173,13 @@ export class SessionManager {
* daemon in the `tool.session_start` reply. * daemon in the `tool.session_start` reply.
*/ */
async start(sessionId: string, opts: SessionStartOptions = {}): Promise<SessionContext> { async start(sessionId: string, opts: SessionStartOptions = {}): Promise<SessionContext> {
this.diagnostics?.("session_manager.start.requested", {
session_id: sessionId,
existing_session_ids: this.list().map((ctx) => ctx.sessionId),
focused: opts.focused ?? null,
size: opts.size ?? null,
signal_aborted: opts.signal?.aborted ?? false,
});
if (this.sessions.has(sessionId)) { if (this.sessions.has(sessionId)) {
throw new Error(`[bh] session ${sessionId} already exists`); throw new Error(`[bh] session ${sessionId} already exists`);
} }
@@ -178,6 +189,10 @@ export class SessionManager {
try { try {
const { signal: _signal, ...createOptions } = opts; const { signal: _signal, ...createOptions } = opts;
windowId = await this.agentWindow.create(AGENT_WINDOW_HOME, createOptions); windowId = await this.agentWindow.create(AGENT_WINDOW_HOME, createOptions);
this.diagnostics?.("session_manager.window.created", {
session_id: sessionId,
agent_window_id: windowId,
});
throwIfSessionStartAborted(opts.signal); throwIfSessionStartAborted(opts.signal);
await this.agentWindow.ensureActiveTab(windowId, AGENT_WINDOW_HOME); await this.agentWindow.ensureActiveTab(windowId, AGENT_WINDOW_HOME);
throwIfSessionStartAborted(opts.signal); throwIfSessionStartAborted(opts.signal);
@@ -191,8 +206,18 @@ export class SessionManager {
}; };
this.sessions.set(sessionId, ctx); this.sessions.set(sessionId, ctx);
this.windowIndex.set(windowId, sessionId); this.windowIndex.set(windowId, sessionId);
this.diagnostics?.("session_manager.start.committed", {
session_id: sessionId,
agent_window_id: windowId,
live_session_ids: this.list().map((entry) => entry.sessionId),
});
return ctx; return ctx;
} catch (startupError) { } catch (startupError) {
this.diagnostics?.("session_manager.start.failed", {
session_id: sessionId,
agent_window_id: windowId,
error: startupError,
});
if (windowId !== null) { if (windowId !== null) {
try { try {
await this.agentWindow.remove(windowId); await this.agentWindow.remove(windowId);
@@ -216,12 +241,28 @@ export class SessionManager {
options: { dropOnly?: boolean } = {}, options: { dropOnly?: boolean } = {},
): Promise<SessionContext | null> { ): Promise<SessionContext | null> {
const ctx = this.sessions.get(sessionId); const ctx = this.sessions.get(sessionId);
this.diagnostics?.("session_manager.stop.requested", {
session_id: sessionId,
drop_only: options.dropOnly ?? false,
existed: ctx !== undefined,
live_session_ids: this.list().map((entry) => entry.sessionId),
});
if (!ctx) return null; if (!ctx) return null;
if (!options.dropOnly) { if (!options.dropOnly) {
await this.agentWindow.remove(ctx.agentWindowId); await this.agentWindow.remove(ctx.agentWindowId);
this.diagnostics?.("session_manager.window.removed", {
session_id: sessionId,
agent_window_id: ctx.agentWindowId,
});
} }
this.sessions.delete(sessionId); this.sessions.delete(sessionId);
this.windowIndex.delete(ctx.agentWindowId); this.windowIndex.delete(ctx.agentWindowId);
this.diagnostics?.("session_manager.stop.committed", {
session_id: sessionId,
agent_window_id: ctx.agentWindowId,
drop_only: options.dropOnly ?? false,
live_session_ids: this.list().map((entry) => entry.sessionId),
});
return ctx; return ctx;
} }
@@ -456,6 +456,30 @@ describe("handleClick", () => {
expect(res).toMatchObject({ code: "not_found", data: { reason: "selector_not_found" } }); expect(res).toMatchObject({ code: "not_found", data: { reason: "selector_not_found" } });
}); });
it("returns invalid_selector when Chrome rejects non-CSS selector syntax", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const fake = makeFakeCdp({
"DOM.getDocument": () => ({ root: { nodeId: 1 } }),
"DOM.querySelector": () => {
throw new Error('{"code":-32000,"message":"DOM Error while querying"}');
},
});
const res = await handleClick(
sm,
{ session_id: "aa11", selector: "text=下午 12~18点" },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
expect(res).toMatchObject({
code: "invalid_params",
message: "selector is not valid CSS: text=下午 12~18点",
data: {
reason: "invalid_selector",
selector: "text=下午 12~18点",
},
});
});
it("respects the AbortSignal", async () => { it("respects the AbortSignal", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11"); const ctx = await sm.start("aa11");
+67
View File
@@ -1,3 +1,4 @@
import type { DiagnosticSink } from "@/lib/diagnostics";
import { OVERLAY_AUTOMATION_BYPASS } from "@/lib/overlay-bridge"; import { OVERLAY_AUTOMATION_BYPASS } from "@/lib/overlay-bridge";
import type { SessionManager } from "@/session-manager/manager"; import type { SessionManager } from "@/session-manager/manager";
import type { Transport } from "@/transport/transport"; import type { Transport } from "@/transport/transport";
@@ -118,6 +119,7 @@ export interface DispatcherDeps {
approveBorrow?: BorrowConfirmationApprover; approveBorrow?: BorrowConfirmationApprover;
/** i18n notification copy for `tool.request_help` (resolved per-call). */ /** i18n notification copy for `tool.request_help` (resolved per-call). */
helpNotificationCopy?: () => { title: string; body: string }; helpNotificationCopy?: () => { title: string; body: string };
diagnostics?: DiagnosticSink;
} }
/** /**
@@ -145,6 +147,7 @@ export class ToolDispatcher {
private readonly onBrowserControlResumed?: (sessionId: string) => void; private readonly onBrowserControlResumed?: (sessionId: string) => void;
private readonly approveBorrow?: BorrowConfirmationApprover; private readonly approveBorrow?: BorrowConfirmationApprover;
private readonly helpNotificationCopy?: () => { title: string; body: string }; private readonly helpNotificationCopy?: () => { title: string; body: string };
private readonly diagnostics?: DiagnosticSink;
private subscription: { dispose(): void } | null = null; private subscription: { dispose(): void } | null = null;
private readonly hoverBypassTabs = new Map<number, string>(); private readonly hoverBypassTabs = new Map<number, string>();
private readonly hoverLatches = new Map<number, HoverLatch>(); private readonly hoverLatches = new Map<number, HoverLatch>();
@@ -165,6 +168,7 @@ export class ToolDispatcher {
this.onBrowserControlResumed = deps.onBrowserControlResumed; this.onBrowserControlResumed = deps.onBrowserControlResumed;
this.approveBorrow = deps.approveBorrow; this.approveBorrow = deps.approveBorrow;
this.helpNotificationCopy = deps.helpNotificationCopy; this.helpNotificationCopy = deps.helpNotificationCopy;
this.diagnostics = deps.diagnostics;
} }
start(): void { start(): void {
@@ -192,6 +196,16 @@ export class ToolDispatcher {
private async dispatch(msg: ProtocolFrame): Promise<void> { private async dispatch(msg: ProtocolFrame): Promise<void> {
if (!isRequestFrame(msg)) return; if (!isRequestFrame(msg)) return;
const req = msg as RequestFrame; const req = msg as RequestFrame;
const requestSessionId = (req.params as { session_id?: unknown } | undefined)?.session_id;
const startedAt = performance.now();
this.diagnostics?.("dispatcher.rpc.received", {
rpc_id: req.id,
method: req.method,
session_id: typeof requestSessionId === "string" ? requestSessionId : null,
transport_state: this.transport.state,
live_session_ids: this.sessions.list().map((ctx) => ctx.sessionId),
inflight_rpc_ids: Array.from(this.inflightAbortControllers.keys()),
});
// Cancel frames take a fast path: trip the matching controller // Cancel frames take a fast path: trip the matching controller
// (if any), reply with `{cancelled}` so the daemon can answer // (if any), reply with `{cancelled}` so the daemon can answer
@@ -200,6 +214,12 @@ export class ToolDispatcher {
const params = (req.params as { rpc_id?: string } | undefined) ?? {}; const params = (req.params as { rpc_id?: string } | undefined) ?? {};
const target = typeof params.rpc_id === "string" ? params.rpc_id : ""; const target = typeof params.rpc_id === "string" ? params.rpc_id : "";
const ac = target ? this.inflightAbortControllers.get(target) : undefined; const ac = target ? this.inflightAbortControllers.get(target) : undefined;
this.diagnostics?.("dispatcher.cancel.received", {
rpc_id: req.id,
target_rpc_id: target,
target_found: ac !== undefined,
inflight_rpc_ids: Array.from(this.inflightAbortControllers.keys()),
});
if (ac) { if (ac) {
try { try {
ac.abort(); ac.abort();
@@ -213,7 +233,17 @@ export class ToolDispatcher {
}; };
try { try {
this.transport.send(reply); this.transport.send(reply);
this.diagnostics?.("dispatcher.cancel.ack_sent", {
rpc_id: req.id,
target_rpc_id: target,
cancelled: ac !== undefined,
});
} catch (sendErr) { } catch (sendErr) {
this.diagnostics?.("dispatcher.cancel.ack_failed", {
rpc_id: req.id,
target_rpc_id: target,
error: sendErr,
});
console.warn("[bsk dispatcher] failed to ack cancel", sendErr); console.warn("[bsk dispatcher] failed to ack cancel", sendErr);
} }
return; return;
@@ -238,6 +268,13 @@ export class ToolDispatcher {
} }
} }
} catch (err) { } catch (err) {
this.diagnostics?.("dispatcher.rpc.invoke_failed", {
rpc_id: req.id,
method: req.method,
session_id: typeof requestSessionId === "string" ? requestSessionId : null,
abort_signal: ac.signal.aborted,
error: err,
});
if (isAbortLikeError(err)) { if (isAbortLikeError(err)) {
body = { body = {
id: req.id, id: req.id,
@@ -258,8 +295,27 @@ export class ToolDispatcher {
let sent = true; let sent = true;
try { try {
this.transport.send(body); this.transport.send(body);
this.diagnostics?.("dispatcher.rpc.response_sent", {
rpc_id: req.id,
method: req.method,
session_id: typeof requestSessionId === "string" ? requestSessionId : null,
response_kind: "error" in body ? "error" : "ok",
error_code: "error" in body ? body.error.code : null,
elapsed_ms: Math.round(performance.now() - startedAt),
transport_state: this.transport.state,
live_session_ids: this.sessions.list().map((ctx) => ctx.sessionId),
});
} catch (sendErr) { } catch (sendErr) {
sent = false; sent = false;
this.diagnostics?.("dispatcher.rpc.response_send_failed", {
rpc_id: req.id,
method: req.method,
session_id: typeof requestSessionId === "string" ? requestSessionId : null,
started_session_id: startedSession,
elapsed_ms: Math.round(performance.now() - startedAt),
transport_state: this.transport.state,
error: sendErr,
});
// Transport is dead by the time we want to reply. Drop the link // Transport is dead by the time we want to reply. Drop the link
// proactively so the alarm-driven keepalive reconnects sooner // proactively so the alarm-driven keepalive reconnects sooner
// and the daemon's pending RPC times out cleanly instead of // and the daemon's pending RPC times out cleanly instead of
@@ -278,12 +334,23 @@ export class ToolDispatcher {
try { try {
const ctx = await this.sessions.stop(startedSession); const ctx = await this.sessions.stop(startedSession);
if (ctx) { if (ctx) {
this.diagnostics?.("dispatcher.session_start.rolled_back", {
rpc_id: req.id,
session_id: startedSession,
agent_window_id: ctx.agentWindowId,
reason: "response_send_failed",
});
console.warn( console.warn(
"[bsk dispatcher] rolled back orphan session after send failure", "[bsk dispatcher] rolled back orphan session after send failure",
startedSession, startedSession,
); );
} }
} catch (rollbackErr) { } catch (rollbackErr) {
this.diagnostics?.("dispatcher.session_start.rollback_failed", {
rpc_id: req.id,
session_id: startedSession,
error: rollbackErr,
});
console.warn("[bsk dispatcher] session rollback after send failure failed", rollbackErr); console.warn("[bsk dispatcher] session rollback after send failure failed", rollbackErr);
} }
} }
+16
View File
@@ -106,6 +106,14 @@ function isAbortLikeError(err: unknown): boolean {
return err instanceof DOMException && err.name === "AbortError"; return err instanceof DOMException && err.name === "AbortError";
} }
function isInvalidCssSelectorError(err: unknown): boolean {
const message = err instanceof Error ? err.message : String(err);
return (
message.includes("DOM Error while querying") ||
/invalid selector|not a valid selector|querySelector.*failed/i.test(message)
);
}
async function wait(ms: number, signal?: AbortSignal): Promise<void> { async function wait(ms: number, signal?: AbortSignal): Promise<void> {
if (signal?.aborted) throw new DOMException("aborted", "AbortError"); if (signal?.aborted) throw new DOMException("aborted", "AbortError");
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
@@ -208,6 +216,14 @@ async function resolveBackendNode(
} }
return { backendNodeId, cdpTarget: { tabId: target.tabId }, usedSelector: params.selector }; return { backendNodeId, cdpTarget: { tabId: target.tabId }, usedSelector: params.selector };
} catch (err) { } catch (err) {
if (isInvalidCssSelectorError(err)) {
return rpcError(
"invalid_params",
"invalid_selector",
`selector is not valid CSS: ${params.selector}`,
{ selector: params.selector },
);
}
return { return {
code: "cdp_failed", code: "cdp_failed",
message: err instanceof Error ? err.message : String(err), message: err instanceof Error ? err.message : String(err),
+1
View File
@@ -24,6 +24,7 @@ export type RpcErrorReason =
| "agent_window_scope" | "agent_window_scope"
| "element_not_visible" | "element_not_visible"
| "ref_not_found" | "ref_not_found"
| "invalid_selector"
| "selector_not_found" | "selector_not_found"
| "target_not_fillable" | "target_not_fillable"
| "target_not_select" | "target_not_select"
+54 -1
View File
@@ -1,3 +1,4 @@
import type { DiagnosticSink } from "@/lib/diagnostics";
import type { ConnectionStateHandler, Disposable, FrameHandler, Transport } from "./transport"; import type { ConnectionStateHandler, Disposable, FrameHandler, Transport } from "./transport";
import type { ConnectionState, ProtocolFrame } from "./types"; import type { ConnectionState, ProtocolFrame } from "./types";
@@ -18,6 +19,7 @@ export interface WSTransportOptions {
* Inject a fake WebSocket constructor in tests; defaults to `globalThis.WebSocket`. * Inject a fake WebSocket constructor in tests; defaults to `globalThis.WebSocket`.
*/ */
webSocketFactory?: WebSocketFactory; webSocketFactory?: WebSocketFactory;
diagnostics?: DiagnosticSink;
} }
interface CloseLikeEvent { interface CloseLikeEvent {
@@ -44,6 +46,7 @@ export class WSTransport implements Transport {
private readonly factory: WebSocketFactory; private readonly factory: WebSocketFactory;
private readonly initialDelayMs: number; private readonly initialDelayMs: number;
private readonly maxDelayMs: number; private readonly maxDelayMs: number;
private readonly diagnostics?: DiagnosticSink;
private socket: WebSocket | null = null; private socket: WebSocket | null = null;
private currentState: ConnectionState = "disconnected"; private currentState: ConnectionState = "disconnected";
@@ -63,6 +66,7 @@ export class WSTransport implements Transport {
this.factory = options.webSocketFactory ?? ((url: string) => new WebSocket(url)); this.factory = options.webSocketFactory ?? ((url: string) => new WebSocket(url));
this.initialDelayMs = options.reconnect?.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS; this.initialDelayMs = options.reconnect?.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS;
this.maxDelayMs = options.reconnect?.maxDelayMs ?? DEFAULT_MAX_DELAY_MS; this.maxDelayMs = options.reconnect?.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
this.diagnostics = options.diagnostics;
} }
get state(): ConnectionState { get state(): ConnectionState {
@@ -70,6 +74,13 @@ export class WSTransport implements Transport {
} }
connect(): Promise<void> { connect(): Promise<void> {
this.diagnostics?.("transport.connect.requested", {
state: this.currentState,
socket_generation: this.socketGeneration,
has_socket: this.socket !== null,
has_connecting_promise: this.connectingPromise !== null,
has_reconnect_timer: this.reconnectTimer !== null,
});
if (this.currentState === "connected") return Promise.resolve(); if (this.currentState === "connected") return Promise.resolve();
this.explicitlyClosed = false; this.explicitlyClosed = false;
@@ -94,6 +105,11 @@ export class WSTransport implements Transport {
} }
async disconnect(): Promise<void> { async disconnect(): Promise<void> {
this.diagnostics?.("transport.disconnect.requested", {
state: this.currentState,
socket_generation: this.socketGeneration,
has_socket: this.socket !== null,
});
this.explicitlyClosed = true; this.explicitlyClosed = true;
if (this.reconnectTimer) { if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer); clearTimeout(this.reconnectTimer);
@@ -124,6 +140,12 @@ export class WSTransport implements Transport {
send(msg: ProtocolFrame): void { send(msg: ProtocolFrame): void {
if (!this.socket || this.socket.readyState !== 1 /* OPEN */) { if (!this.socket || this.socket.readyState !== 1 /* OPEN */) {
this.diagnostics?.("transport.send.rejected", {
state: this.currentState,
socket_generation: this.socketGeneration,
socket_ready_state: this.socket?.readyState ?? null,
frame_kind: "method" in msg ? msg.method : "event" in msg ? msg.event : "response",
});
throw new Error("[WSTransport] cannot send while not connected"); throw new Error("[WSTransport] cannot send while not connected");
} }
this.socket.send(JSON.stringify(msg)); this.socket.send(JSON.stringify(msg));
@@ -152,10 +174,16 @@ export class WSTransport implements Transport {
const generation = ++this.socketGeneration; const generation = ++this.socketGeneration;
const socket = this.factory(this.url); const socket = this.factory(this.url);
this.socket = socket; this.socket = socket;
this.diagnostics?.("transport.socket.created", {
socket_generation: generation,
reconnect_attempt: this.reconnectAttempt,
url: this.url,
});
socket.addEventListener("open", () => { socket.addEventListener("open", () => {
if (!this.isCurrentSocket(socket, generation)) return; if (!this.isCurrentSocket(socket, generation)) return;
this.reconnectAttempt = 0; this.reconnectAttempt = 0;
this.diagnostics?.("transport.socket.open", { socket_generation: generation });
this.setState("connected"); this.setState("connected");
const resolve = this.resolveConnect; const resolve = this.resolveConnect;
this.resolveConnect = null; this.resolveConnect = null;
@@ -174,6 +202,7 @@ export class WSTransport implements Transport {
}); });
socket.addEventListener("error", () => { socket.addEventListener("error", () => {
this.diagnostics?.("transport.socket.error", { socket_generation: generation });
// The 'close' handler always fires after 'error' in browsers, so we // The 'close' handler always fires after 'error' in browsers, so we
// just record the error and let close drive reconnect logic. // just record the error and let close drive reconnect logic.
}); });
@@ -197,7 +226,16 @@ export class WSTransport implements Transport {
} }
private handleClose(socket: WebSocket, generation: number, _ev: CloseLikeEvent): void { private handleClose(socket: WebSocket, generation: number, _ev: CloseLikeEvent): void {
if (!this.isCurrentSocket(socket, generation)) return; const isCurrent = this.isCurrentSocket(socket, generation);
this.diagnostics?.("transport.socket.close", {
socket_generation: generation,
current_socket_generation: this.socketGeneration,
is_current: isCurrent,
explicitly_closed: this.explicitlyClosed,
close_code: _ev.code ?? null,
close_reason: _ev.reason ?? "",
});
if (!isCurrent) return;
this.socket = null; this.socket = null;
if (this.explicitlyClosed) { if (this.explicitlyClosed) {
this.setState("disconnected"); this.setState("disconnected");
@@ -211,9 +249,18 @@ export class WSTransport implements Transport {
if (this.reconnectTimer) return; if (this.reconnectTimer) return;
const delay = Math.min(this.initialDelayMs * 2 ** this.reconnectAttempt, this.maxDelayMs); const delay = Math.min(this.initialDelayMs * 2 ** this.reconnectAttempt, this.maxDelayMs);
this.reconnectAttempt += 1; this.reconnectAttempt += 1;
this.diagnostics?.("transport.reconnect.scheduled", {
delay_ms: delay,
reconnect_attempt: this.reconnectAttempt,
socket_generation: this.socketGeneration,
});
this.reconnectTimer = setTimeout(() => { this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null; this.reconnectTimer = null;
if (this.explicitlyClosed) return; if (this.explicitlyClosed) return;
this.diagnostics?.("transport.reconnect.timer_fired", {
reconnect_attempt: this.reconnectAttempt,
socket_generation: this.socketGeneration,
});
void this.connect().catch((err) => { void this.connect().catch((err) => {
console.debug("[WSTransport] reconnect attempt failed", err); console.debug("[WSTransport] reconnect attempt failed", err);
}); });
@@ -226,7 +273,13 @@ export class WSTransport implements Transport {
private setState(next: ConnectionState): void { private setState(next: ConnectionState): void {
if (this.currentState === next) return; if (this.currentState === next) return;
const previous = this.currentState;
this.currentState = next; this.currentState = next;
this.diagnostics?.("transport.state.changed", {
previous,
next,
socket_generation: this.socketGeneration,
});
for (const h of this.stateHandlers) { for (const h of this.stateHandlers) {
try { try {
h(next); h(next);
+3 -2
View File
@@ -22,7 +22,8 @@ export default defineConfig({
outDir: "dist", outDir: "dist",
modules: ["@wxt-dev/module-react"], modules: ["@wxt-dev/module-react"],
manifest: { manifest: {
name: "BrowserSkill", name: "BrowserSkill (Lumi Diagnostics)",
version_name: `${EXTENSION_VERSION}-lumi-session-loss-v2`,
description: description:
"Let AI agents use your logged-in browser in a separate Agent Window—without interrupting your work. Powered by the bsk CLI.", "Let AI agents use your logged-in browser in a separate Agent Window—without interrupting your work. Powered by the bsk CLI.",
// Flat debugger sessions are required to address out-of-process iframes. // Flat debugger sessions are required to address out-of-process iframes.
@@ -45,7 +46,7 @@ export default defineConfig({
128: "icon/logo.png", 128: "icon/logo.png",
}, },
action: { action: {
default_title: "BrowserSkill", default_title: "BrowserSkill (Lumi Diagnostics)",
default_icon: { default_icon: {
16: "icon/logo.png", 16: "icon/logo.png",
32: "icon/logo.png", 32: "icon/logo.png",
+283 -98
View File
@@ -1,142 +1,327 @@
--- ---
name: browser-skill name: browser-skill
description: | description: |
Use when the user asks to automate their logged-in Chromium browser: visit Use when the user asks to perform browser automation tasks against their
and read pages, fill forms, scrape data, click through flows, regression-test logged-in browser: visit and read pages, fill forms, scrape data, click
a PR's UI, validate a deployed page, or operate a tab they identify. Requires through a flow, regression-test a PR's UI, validate a deployed page.
the bsk CLI and browser extension. Requires the bsk CLI installed and the browser-skill extension loaded.
--- ---
# browser-skill # browser-skill
Drive the user's real Chromium browser through `bsk`. Automation runs in an isolated **Agent Drive the user's **real Chromium browser** (with their logins and cookies) through the `bsk` CLI. The extension opens an isolated **Agent Window** for automation; the user's normal windows stay protected unless you explicitly borrow a tab.
Window** with the user's existing logins and cookies. User-window tabs remain protected unless they
are explicitly borrowed.
Do not use this skill for tasks with no browser, for extension installation, or when the user only ## When to use
wants instructions. Never extract credentials, cookies, tokens, or other secrets from pages.
## Required lifecycle - Open pages, read titles/text, scrape structured data from sites the user can already access
- Fill forms, click through multi-step flows, smoke-test a UI change
- Understand pages with `bsk snapshot` first; use `bsk get-html` or `bsk screenshot` only when the snapshot is insufficient
- Operate on a specific user tab they point you at (after `bsk tab borrow`)
Every browser task owns a bounded session: ## When NOT to use
```text - Tasks with **no browser** involved (files, APIs, databases only)
1. bsk session start # retain the printed 4-letter session id - Installing or configuring the extension (point the user to setup docs instead)
2. bsk ... --session <id> # pass it to every session-scoped command - **Credential harvesting** — never run `bsk evaluate` on banking, SSO, or password-manager pages to extract tokens, cookies, or secrets
3. bsk session stop <id> # always run on success and error paths - Long-lived control of a user's personal login window — borrow only for the immediate step, then `bsk tab return` or end the session
- Replacing the user's manual browsing when they only wanted an explanation
## Prerequisites
1. `bsk` on `PATH` (Rust CLI from browser-skill)
2. browser-skill **extension** loaded in Chromium and connected (popup shows green, or an upgrade reminder — still connected; continue)
3. Any `bsk` command auto-starts background services as needed; use `bsk doctor` if anything fails
## Mandatory workflow
Every automation task **must** follow this lifecycle. Do **not** rely on idle timeouts (default session idle is 5 minutes).
```
1. bsk session start → capture the 4-letter session id printed on stdout
2. … every tool command … → always pass --session <id>
3. bsk session stop <id> → REQUIRED when done (even on error paths)
``` ```
Do not rely on the idle timeout for cleanup. Stop the session as soon as the goal is met unless the Optional: `bsk session start --browser <instance-id-or-label>` when multiple browsers are connected (`bsk browsers` / error output lists them). Add `--no-focus` to open the Agent Window in the background without stealing focus from the user's current window.
user explicitly asks to keep it open. Stopping also returns borrowed tabs.
Any `bsk` command auto-starts the background services it needs; never manage the daemon by hand. Emergency cleanup: `bsk session stop --all` or the Agent Window overlay **Stop all**.
When multiple browsers are connected, use `bsk browsers` and start with
`bsk session start --browser <id-or-label>`. Add `--no-focus` to that same start command when the
Agent Window does not need to interrupt the user's current work; it is not a flag on other commands.
Run `bsk doctor` when startup or transport problems persist after one retry.
## Work toward one observable goal ## Stop when the goal is met
- Derive a concrete success condition from the user's request or a supplied trace. Every task is a **bounded goal**, not open-ended browsing. The goal may come from the user's request, a recorded `trace.json`, or both.
- Take the shortest purposeful path: observe, act, then make at most one observation to confirm an
ambiguous result.
- Once success is visible, do not click, refresh, navigate, switch tabs, or perform extra checks.
- If a human-only step appears or two attempts make no progress, request help instead of
brute-forcing.
With a trace, follow its semantic target information and values in order, but treat its refs as 1. **Define success first** — one concrete, observable condition derived from the user's words, `purpose`, or the last meaningful step in a trace (e.g. "form submitted", "item added to cart", "playback started").
record-local hints. Stop when its purpose or last meaningful effect is satisfied. A trace guides the 2. **Take the shortest path** — snapshot → act → at most one check. Do not wander, re-try unrelated actions, or stack exploratory steps.
task; it does not expand the user's goal or authorize additional actions. 3. **Stop as soon as success is reached** — run `bsk session stop <id>` immediately unless the user explicitly asked to keep the session open (e.g. "don't close yet", "keep browsing").
4. **No post-success work** — once the goal is met, do not click, refresh, navigate, re-search, switch tabs, or "double-check" that it worked. Further verification is a new task.
5. **When blocked, pause — do not brute-force** — if the page requires human input (login, captcha, OTP, payment confirmation) or an action fails twice with no progress, call `bsk request-help` instead of retrying blindly. See **Ask the human for help** below.
6. **When unsure** — at most one extra `bsk snapshot`. If success looks met, stop. If not, ask the user; do not keep clicking.
## Observe, act, observe **With a trace:** replay steps in order using `target` role/name/tag and raw `value`/`selection` fields. After the last step (or when its `effect.navigated_to` / success hint is satisfied), apply rules 3–4 immediately. The trace guides execution; it does not extend control beyond the goal.
Use this default loop: **Without a trace:** the user's request *is* the success condition. Satisfying it ends the task — same stop rules apply.
```text ## Core interaction loop
Write operations only affect tabs in the **Agent Window** (or tabs you **borrowed** into it).
```
bsk navigate <url> --session <id> bsk navigate <url> --session <id>
bsk observe --session <id> bsk observe --session <id> → primary semantic VOM view; reveals hover/focus surfaces
bsk click|hover|fill|select|press ... --session <id> bsk snapshot --session <id> → static aria tree fallback when VOM is insufficient
bsk observe --session <id> # after navigation or a meaningful DOM change bsk hover @e3 --session <id> → reveal hover-triggered menus before re-observing/clicking
bsk click @e4 --session <id> → or bsk fill, bsk select, bsk press
bsk observe --session <id> → again after navigation / DOM change
``` ```
Prefer fresh `@eN` refs over CSS selectors. Navigation invalidates refs; large DOM changes may also **Refs invalidate after navigation** — always re-snapshot before clicking, filling, or selecting on a new page.
make them stale. Observe again before the next interaction.
An observation marks a hover-only surface as `@e1 button "Products" [hover first: Shoes | Bags]`. Prefer `@eN` refs from the latest snapshot over raw CSS selectors. Use `--ref` / `--selector` when ambiguous (`bsk click --help`).
The listed items are labels, not usable refs: hover the trigger, observe again, then act on the
revealed item's own ref. Do not click the trigger itself unless the user wants the trigger's action.
Escalate page reading only as needed: When VOM renders `[hover first: …]` on an element, the listed items are not currently clickable refs. Run `bsk hover <that-ref> --session <id>`, then immediately run `bsk snapshot` or `bsk observe` again and click the newly visible menu item ref. Do not click the trigger itself unless the user explicitly wants the trigger action.
1. `bsk observe` for normal semantic understanding, text, controls, and refs. ## Observation priority
2. `bsk snapshot` when a stricter static accessibility tree is more useful.
3. `bsk get-html` for exact markup or hidden metadata that semantic views cannot provide.
4. `bsk screenshot` for layout, styling, canvas, images, or requested visual evidence.
Do not start with raw HTML or screenshots merely to discover ordinary controls. When interaction is Start with `bsk observe` to understand page structure, text, controls, element refs, and conditional hover/focus surfaces. Use `bsk snapshot` only when you need the stricter static accessibility tree or VOM is insufficient. Only escalate to raw HTML or screenshots when the latest observation cannot answer the question:
needed, obtain a fresh observation before acting on screenshot or HTML findings.
## Respect the Agent Window boundary 1. `bsk observe` — primary semantic VOM observation; may run bounded perception probes such as hover-surface discovery
2. `bsk snapshot` — strict static accessibility tree fallback
3. `bsk get-html` — when hidden DOM, metadata, or markup details are required
4. `bsk screenshot` — when visual layout, canvas/image content, or styling cannot be inferred from the observation. Use `--ref @eN` (from the latest snapshot/observe) to crop to one element; omit `--ref` for the full visible tab.
Normal page writes affect only Agent Window tabs. To operate a user tab, first list it with Do **not** call `bsk get-html` or `bsk screenshot` first just to inspect a page.
`bsk tab list --scope user --session <id>`, then `bsk tab borrow <tab-id>`. Return it immediately
after the relevant step with `bsk tab return <tab-id>`; never invent a tab id or keep a personal tab
borrowed across unrelated work.
## Ask the human when needed ## Sandbox rules
Use `bsk request-help` for login, captcha, OTP, payment confirmation, consent, or another step the | Rule | Detail |
user must complete. Give a precise prompt and pass fresh `--target` refs/selectors when concrete |------|--------|
controls can be highlighted. Use completion criteria only when the page has a clear stable success | Agent Window | `bsk tab create`, `bsk navigate`, `bsk click`, etc. work on agent tabs by default |
signal. | User tabs | Read-only until borrowed: `bsk tab list --session <id> --scope user` then `bsk tab borrow <tab-id> --session <id>` |
| Return borrowed tabs | Call `bsk tab return <tab-id> --session <id>` when finished; unreturned tabs are **auto-returned** on `bsk session stop` |
| Writes off-agent | Commands that mutate the page fail if the tab is not in the Agent Window — borrow or create a tab first |
The result `outcome` is one of `continued`, `completed`, `cancelled`, `timed_out`, or `disabled` ## Global flags
(`navigated` is deprecated — never treat navigation as a completion signal). Resume only after
`continued` or `completed`. Treat `cancelled` as rejection, and `timed_out` or `disabled` as a
blocker rather than a reason to retry. After control returns, run a fresh `bsk observe` before
reasoning about the page or using refs.
## Command inventory | Flag | Purpose |
|------|---------|
| `--json` | Machine-readable JSON on stdout (errors too) |
| `--quiet` | Suppress informational stderr |
| `-v` / `-vv` | More verbose logging |
This list of names is complete. Never invent a command outside it; read Auto-update is **on by default** — the background daemon upgrades `bsk` itself when a new release is available (postponed while a session is active). Set `BSK_AUTO_UPDATE=off` to disable it and upgrade manually with `bsk update`.
`bsk <command...> --help` for flags instead of guessing them.
```text Command-specific flags (timeouts, `--tab-id`, `--wait-until`, …): **`bsk <cmd> --help`**
session start|stop|list browsers status doctor update logs
navigate navigate-back navigate-forward reload wait-for-navigation wait-ms ## CLI command reference (one line each)
observe snapshot get-html screenshot console network
click hover fill select press evaluate Details and flags: **`bsk <cmd> --help`**
tab list|create|close|select|borrow|return window resize emulate
request-help record start|stop ### Diagnostics
| Command | Summary |
|---------|---------|
| `bsk status` | Connection health, connected browsers, active sessions |
| `bsk doctor` | Deep diagnostics and repair hints |
| `bsk browsers` | List connected browser instances (ids, labels, versions) |
### Session
| Command | Summary |
|---------|---------|
| `bsk session start` | Open Agent Window (`--width`/`--height` for initial size); prints **4-letter session id** |
| `bsk session start --no-focus` | Open Agent Window in the background without stealing focus |
| `bsk session stop <id>` | End session, close Agent Window, auto-return borrowed tabs |
| `bsk session stop --all` | Stop every active session |
| `bsk session list` | List active sessions |
### Window (require `--session <id>`)
| Command | Summary |
|---------|---------|
| `bsk window resize` | Resize the Agent Window (`--width`, `--height`; 100..=7680 CSS px) |
### Device emulation — `bsk emulate` (requires `--session <id>`)
Emulate a mobile device environment on the agent tab via CDP — viewport, User-Agent, and touch — to debug a page's mobile layout and behaviour:
```bash
bsk emulate --session <id> --device iphone-14
bsk emulate --session <id> --width 390 --height 844 --dpr 3 --mobile --ua "Mozilla/5.0 (iPhone…" --touch
bsk emulate --session <id> --off
``` ```
Required flags that are easy to get wrong: - Presets (`--device`): `iphone-14`, `iphone-14-pro-max`, `iphone-se`, `pixel-7`, `galaxy-s23`, `ipad-mini`, `galaxy-tab-s8`. Manual flags (`--width`/`--height`/`--dpr`/`--mobile`/`--ua`/`--accept-language`/`--touch`/`--max-touch-points`) also work without a preset, or override individual preset fields; `--no-mobile`/`--no-touch` turn a preset's mobile viewport / touch emulation back off.
- Repeated runs merge field by field onto the tab's current emulation state — only the flags you pass change. E.g. after `--device iphone-14`, `bsk emulate --session <id> --width 390 --height 844` keeps the preset's dpr (`3`) and mobile viewport. (The extension remembers the state per tab; after an extension reload the next run applies only the fields it carries.)
- `--off` clears every override (viewport, UA, touch) and the remembered state, restoring the tab's real environment.
- Scope: overrides apply to **one tab only** (CDP per-target) and are **not inherited by new tabs** — re-run `bsk emulate` after opening or switching to another tab (default target is the session's active tab; `--tab-id` overrides).
- Emulation covers viewport/UA/touch only; it does not throttle the network, fake geolocation, or synthesise real touch-event streams.
```text ### Tabs (require `--session <id>`)
bsk fill <ref> --value <text> bsk select <ref> --value <option-value>
bsk screenshot --out <path> bsk emulate --device <preset-id> | Command | Summary |
|---------|---------|
| `bsk tab list` | List tabs (`--scope user\|agent\|all`, default `all`) |
| `bsk tab create` | New tab in Agent Window (`--url`, `--no-active`, `--index`) |
| `bsk tab close <tab-id>` | Close an agent tab |
| `bsk tab select <tab-id>` | Focus an agent tab |
| `bsk tab borrow <tab-id>` | Move a user tab into the Agent Window |
| `bsk tab return <tab-id>` | Return a borrowed tab to its original window |
### Observation (require `--session` unless noted)
| Command | Summary |
|---------|---------|
| `bsk snapshot` | First-choice static page understanding: accessibility tree with `@eN` element refs |
| `bsk observe` | Semantic VOM observation with bounded perception probes for conditional surfaces |
| `bsk get-html` | Raw HTML dump after snapshot is insufficient (high token cost) |
| `bsk screenshot` | PNG capture after snapshot is insufficient: full visible tab, or `--ref @eN` to crop to one element (`--out` path optional) |
### Console & network debugging (read-only; require `--session`)
| Command | Summary |
|---------|---------|
| `bsk console` | Buffered page console messages, JS exceptions, and browser log entries (`--include-stack` for stack traces) |
| `bsk network` | Buffered network responses (status, method, URL, MIME/resource type) and failures (`net::ERR_*` reason) |
Both capture from the moment the tab is attached and read a bounded per-tab buffer: `--since <seq>` pages from a cursor (`next_since` in the result), `--limit` (default 50, max 200), `--max-text-chars` (default 1000, max 4096), `--tab-id` to target a non-active tab. Both are strictly read-only — they never intercept or modify traffic, and request/response headers, bodies, and timings are not captured.
### Navigation
| Command | Summary |
|---------|---------|
| `bsk navigate <url>` | Go to URL in agent tab (`--wait-until`, `--timeout`) |
| `bsk navigate-back` | History back one step |
| `bsk navigate-forward` | History forward one step |
| `bsk reload` | Reload current tab (`--hard` bypass cache) |
(`bsk navigate back` / `bsk navigate forward` are equivalent subcommands.)
### Interaction
| Command | Summary |
|---------|---------|
| `bsk click <ref-or-selector>` | Click element (`--button`, `--click-count`, `--modifiers`) |
| `bsk hover <ref-or-selector>` | Move the mouse to an element and wait for hover UI to settle (`--settle`, `--modifiers`) |
| `bsk fill <ref-or-selector> --value <text>` | Clear and type into input |
| `bsk select <ref-or-selector> --value <v>` | Set `<select>` option(s) by `value` (repeat `--value` for multi-select) |
| `bsk press <key>` | Key/combo (`Enter`, `Ctrl+A`, …; optional `--ref` to focus first) |
### Scripting & timing
| Command | Summary |
|---------|---------|
| `bsk evaluate <expression>` | Run JS in agent tab (see red lines). JS throw → stderr, **exit 0** (RPC success); use `--json` and check `.ok` to detect JS errors |
| `bsk wait-for-navigation` | Block until load/DOM idle/etc. (`--wait-until`, `--timeout`) |
| `bsk wait-ms <duration>` | Sleep (`500ms`, `2s`, `1m`; **no** `--session`) |
### Ask the human for help — `bsk request-help`
When a step needs a human (captcha, login, OTP) or you want the user to
confirm an important action, pause and ask:
bsk request-help --session <id> --prompt "Solve the captcha, then click Done only after the site accepts it" \
--title "Captcha required" --target @e7 --target "#submit" --timeout 5m
- `--prompt` (required): what the user should do.
- `--title` (optional): custom title for the overlay panel. When omitted,
the extension shows its default localized title.
- `--target` (repeatable): a snapshot ref (`@e7`) or CSS selector
(`#submit`) to scroll to and flash-highlight. **Strongly recommended** —
whenever the prompt refers to a concrete element (a button to click, a
field to fill, a checkbox to toggle), pass its `@eN` ref / selector so the
user is guided straight to the right spot instead of hunting for it. For
interaction scenarios, always include the relevant target(s); reserve a
prompt with no `--target` for cases where there is genuinely no specific
element to point at (e.g. "wait for the page to finish loading").
- `--timeout` (default `5m`): how long to wait.
- `--completion-criteria` (optional): JSON success detector. Use it only
when there is a concrete post-help success signal, e.g.
`{"any":[{"url_contains":"/dashboard"},{"selector_exists":"[data-testid='account-menu']"}],"stable_for_ms":1000}`.
The target tab is brought to the foreground; the page stays interactive
while the agent control mask is hidden. The call blocks until the user
explicitly acts, the timeout expires, cancellation arrives, or explicit
completion criteria match. Page reloads, SPA route changes, and captcha
refreshes do not return control by themselves. The result `outcome` is one of:
- `continued` — the user finished and clicked Done / return control (treat as confirm).
- `cancelled` — the user clicked Cancel (treat as reject/abort).
- `timed_out` — nobody acted within the timeout.
- `completed` — the explicit `--completion-criteria` matched while the user had control.
- `navigated` — deprecated legacy outcome. Do not rely on navigation as a completion signal.
`note` carries any text the user typed back. `resolved_targets` reports
which refs/selectors matched a live element.
`request-help` does not refresh the page model after the user returns
control. After a `continued` or `completed` result, issue a separate
observation tool call (usually `bsk snapshot --session <id>`) before using
new refs or reasoning about the post-help page state.
#### Disabling request-help (unattended mode)
Set `BSK_REQUEST_HELP=off` on unattended servers: `bsk request-help` then
returns immediately with `outcome="disabled"` (no overlay, no waiting,
exit 0). Any other value keeps it enabled. If you get `disabled`, do not
retry — complete the task autonomously or stop gracefully.
### Recording — `bsk record`
Capture the user's own actions in the Agent Window to a **trace bundle**, for later LLM-driven automation. New CLI builds request **trace v3** (page observations + action chain); older extensions may still return **trace v2** (actions only), which the CLI exports as a single `trace.json` without `states/`.
```bash
bsk record start --browser <instance-id-or-label> \
[--url https://…] [--purpose "publish a wiki doc"] \
[--max-page-tokens 3000] [--redact-values] \
[--output trace]
# `--url` is optional; default https://example.com/ when omitted (must be http(s)).
# Blocks until the user clicks Finish in the recording panel, then writes:
# trace/trace.json — action chain (+ state index when v3)
# trace/states/ — v3 only: one `sN.txt` observe snapshot per settled page state
bsk record stop [--output trace] # terminal fallback if the browser panel is unavailable
``` ```
`select` matches an option's `value` attribute, not its visible label. Device preset ids are - **v3 bundle (preferred):** `--output` is a directory (default `./trace`) containing `trace.json` and `states/`. `trace.json` lists `states[]` (page observation ids) and `steps[]` (each step binds `state` = observe snapshot *before* the action and `result.state` = snapshot *after* settle). Page bodies live in `states/sN.txt` using the same VOM format as `bsk observe`.
lowercase and hyphenated, such as `iphone-14`. - **v2 fallback:** when the connected extension is older, export may contain only legacy `trace.json` with `pages[]` action context and no `states/` observation files. Update the extension for full v3 bundles.
- Each `states[]` entry is one **settled page observation** (captured after recording start, navigation landing, or action settle — not on a timer).
- `target.ref` values like `@e12` exist **only inside** the bundle for disambiguation; do **not** copy `@eN` refs into SKILL.md or agent runbooks — use visible names from the observation text instead.
- `--purpose` is optional context metadata; it does **not** change what gets captured.
- `--redact-values` masks all form values in page files as `[filled]` / `[empty]`.
- Do **not** record on banking/SSO/password-manager pages; passwords are redacted but traces may still contain sensitive text.
- `console` and `network` provide bounded, read-only debugging evidence. ## Error handling
- `emulate` applies viewport, user-agent, and touch overrides to one tab; new tabs do not inherit
them. Use `--off` to restore the real environment.
- `evaluate` is a last resort when observe plus normal interactions cannot complete the task. With
`--json`, inspect `.ok`: a JavaScript exception may still have CLI exit code 0 because the RPC
succeeded. Never evaluate credential surfaces to read storage, cookies, or auth data.
- `record` captures a user's actions for later replay. Read `bsk record start --help` before use,
and never record banking, SSO, password-manager, or other sensitive pages.
## Recover without wandering ### Exit codes (`echo $?` after `bsk …`)
- Stale ref: observe again and retry the intended action once. | Code | Meaning | What to do |
- Unknown tab or session: list current tabs/sessions; never guess identifiers. |------|---------|------------|
- Timeout: inspect current page state before deciding whether one longer purposeful wait is useful. | `0` | Success (including `evaluate` where JS threw but RPC succeeded) | Continue; for `evaluate`, check `--json` `.ok` to distinguish JS success from RPC success |
- Unsupported command: continue with available capabilities; suggest updating only when the missing | `1` | User error — bad args, unknown session, tab not in Agent Window, stale ref | Fix args; `bsk session list`; re-snapshot |
command is necessary. | `2` | Protocol / transport — service unreachable, IPC failure | `bsk doctor`; check extension connected; retry the command |
- Unrecoverable failure: report the blocker and stop the session in a finally-style path. | `3` | Browser / CDP execution failed | Retry; simplify selector; check tab still open |
| `4` | Timeout | Increase `--timeout`; try `--wait-until domcontentloaded` |
| `5` | Unknown RPC method | This command is not implemented in the current build. Continue the task with other commands. If a newly added command is missing, suggest `bsk update`. |
The CLI's current help and error hints are authoritative for flags, parameters, and recovery Human errors print `error:` + `hint:` on stderr; `--json` includes `code`, `message`, `hint`, `exit_code`.
details.
### When to run diagnostics
| Situation | Command |
|-----------|---------|
| Before first task in a session | `bsk status` — extension connected? |
| Any failure you cannot fix in one retry | `bsk doctor` |
| Multiple browsers / wrong target | `bsk browsers` then `bsk session start --browser <id>` |
Always **`bsk session stop <id>`** in a `finally`-style path so the Agent Window closes and borrowed tabs return.
## Red lines
1. **No token theft** — do not `bsk evaluate` on sensitive sites to read `localStorage`, cookies, or auth headers for exfiltration.
2. **No long borrow** — do not leave a user's personal tab in the Agent Window across unrelated tasks.
3. **No skip stop** — always `bsk session stop <id>`; never assume idle timeout will clean up.
4. **No post-success control** — once the user’s goal (or last trace step) is met, do not keep operating the page; stop the session unless they asked to keep it open.
5. **No raw observe escalation before snapshot/observe** — use `bsk snapshot` first; use `bsk observe` when VOM semantics or conditional surfaces help. Only use `bsk get-html` or `bsk screenshot` when snapshot/observe is insufficient. Element screenshots (`--ref @eN`) still require a fresh snapshot/observe ref — never skip observation just to grab a visual.
6. **`evaluate` is powerful and risky** — use only when snapshot + click/fill/select cannot suffice; never on credential surfaces.
---
**More detail for any command:** `bsk <cmd> --help`
+17
View File
@@ -497,4 +497,21 @@ mod tests {
.contains("CSS selector") .contains("CSS selector")
); );
} }
#[test]
fn invalid_selector_explains_css_contract_in_json() {
let cli = CliError::from_rpc(RpcError {
code: ErrorCode::InvalidParams,
message: "selector is not valid CSS: text=submit".into(),
data: Some(serde_json::json!({ "reason": "invalid_selector" })),
});
let exit = cli.exit_code();
let hint = hint_for(&cli, render_info_for(&cli).as_ref());
let json = json_error_string(&cli, exit, hint);
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let hint = parsed.get("hint").and_then(|v| v.as_str()).unwrap();
assert!(hint.contains("CSS selector"));
assert!(hint.contains("text="));
assert!(hint.contains("@eN"));
}
} }
+8
View File
@@ -41,6 +41,7 @@ pub mod reason {
pub const AGENT_WINDOW_SCOPE: &str = "agent_window_scope"; pub const AGENT_WINDOW_SCOPE: &str = "agent_window_scope";
pub const ELEMENT_NOT_VISIBLE: &str = "element_not_visible"; pub const ELEMENT_NOT_VISIBLE: &str = "element_not_visible";
pub const REF_NOT_FOUND: &str = "ref_not_found"; pub const REF_NOT_FOUND: &str = "ref_not_found";
pub const INVALID_SELECTOR: &str = "invalid_selector";
pub const SELECTOR_NOT_FOUND: &str = "selector_not_found"; pub const SELECTOR_NOT_FOUND: &str = "selector_not_found";
pub const TARGET_NOT_FILLABLE: &str = "target_not_fillable"; pub const TARGET_NOT_FILLABLE: &str = "target_not_fillable";
pub const TARGET_NOT_SELECT: &str = "target_not_select"; pub const TARGET_NOT_SELECT: &str = "target_not_select";
@@ -221,6 +222,13 @@ pub fn info_for_error(code: ErrorCode, data: Option<&serde_json::Value>) -> Rend
hint: Some("verify the CSS selector or wait for the element to appear before retrying"), hint: Some("verify the CSS selector or wait for the element to appear before retrying"),
exit_code: base.exit_code, exit_code: base.exit_code,
}, },
(ErrorCode::InvalidParams, reason::INVALID_SELECTOR) => RenderInfo {
summary: "selector is not valid CSS",
hint: Some(
"use a standard CSS selector or a fresh @eN ref; Playwright text= selectors and XPath are not supported",
),
exit_code: base.exit_code,
},
(ErrorCode::InvalidParams, reason::TARGET_NOT_FILLABLE) => RenderInfo { (ErrorCode::InvalidParams, reason::TARGET_NOT_FILLABLE) => RenderInfo {
summary: "target element is not fillable", summary: "target element is not fillable",
hint: Some( hint: Some(
+25 -2
View File
@@ -223,6 +223,14 @@ impl BrowserRegistry {
"browser reconnect: replacing previous registration" "browser reconnect: replacing previous registration"
); );
} }
tracing::info!(
diagnostic = true,
event = "browser.registry.insert",
id = %client.id,
generation = client.generation,
previous_generation = guard.get(&client.id).map(|previous| previous.generation),
"diagnostic browser registry mutation"
);
guard.insert(client.id.clone(), client); guard.insert(client.id.clone(), client);
} }
@@ -239,16 +247,31 @@ impl BrowserRegistry {
/// took over the same `id` while the old socket was tearing down /// took over the same `id` while the old socket was tearing down
/// (review M4/M5 round 2 Important #1). Returns the removed entry /// (review M4/M5 round 2 Important #1). Returns the removed entry
/// when a removal actually happened. /// when a removal actually happened.
#[track_caller]
pub fn remove_if_generation_matches( pub fn remove_if_generation_matches(
&self, &self,
id: &BrowserId, id: &BrowserId,
generation: u64, generation: u64,
) -> Option<std::sync::Arc<BrowserClient>> { ) -> Option<std::sync::Arc<BrowserClient>> {
let caller = std::panic::Location::caller();
let mut guard = self.inner.lock().expect("browser registry poisoned"); let mut guard = self.inner.lock().expect("browser registry poisoned");
match guard.get(id) { let current_generation = guard.get(id).map(|current| current.generation);
let removed = match guard.get(id) {
Some(current) if current.generation == generation => guard.remove(id), Some(current) if current.generation == generation => guard.remove(id),
_ => None, _ => None,
} };
tracing::info!(
diagnostic = true,
event = "browser.registry.remove_if_generation_matches",
id = %id,
requested_generation = generation,
?current_generation,
removed = removed.is_some(),
caller_file = caller.file(),
caller_line = caller.line(),
"diagnostic browser registry mutation"
);
removed
} }
pub fn get(&self, id: &BrowserId) -> Option<std::sync::Arc<BrowserClient>> { pub fn get(&self, id: &BrowserId) -> Option<std::sync::Arc<BrowserClient>> {
+48 -3
View File
@@ -37,7 +37,7 @@ use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use tokio::sync::Notify; use tokio::sync::Notify;
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use tracing::{debug, warn}; use tracing::{debug, info, warn};
use super::abort::AbortRegistry; use super::abort::AbortRegistry;
use super::queue::{DEFAULT_TOOL_TIMEOUT, DispatchError}; use super::queue::{DEFAULT_TOOL_TIMEOUT, DispatchError};
@@ -197,7 +197,22 @@ pub fn full_handler(status: DaemonStatus, state: Arc<DaemonState>) -> RpcHandler
let status = status.clone(); let status = status.clone();
let state = Arc::clone(&state); let state = Arc::clone(&state);
Box::pin(async move { Box::pin(async move {
match method { let method_name = format!("{method:?}");
let diagnostic_rpc_id = rpc_id.clone();
let session_id = params
.get("session_id")
.and_then(Value::as_str)
.map(str::to_owned);
let started = Instant::now();
info!(
diagnostic = true,
event = "ipc.rpc.started",
rpc_id = %diagnostic_rpc_id,
method = %method_name,
session = session_id.as_deref().unwrap_or(""),
"diagnostic IPC RPC"
);
let body = match method {
Method::SystemPing => { Method::SystemPing => {
let result = PingResult { pong: true }; let result = PingResult { pong: true };
ResponseBody::Ok(serde_json::to_value(result).unwrap_or(Value::Null)) ResponseBody::Ok(serde_json::to_value(result).unwrap_or(Value::Null))
@@ -261,7 +276,23 @@ pub fn full_handler(status: DaemonStatus, state: Arc<DaemonState>) -> RpcHandler
message: format!("method not implemented yet: {other:?}"), message: format!("method not implemented yet: {other:?}"),
data: None, data: None,
}), }),
} };
let (outcome, error_code) = match &body {
ResponseBody::Ok(_) => ("ok", String::new()),
ResponseBody::Err(error) => ("error", format!("{:?}", error.code)),
};
info!(
diagnostic = true,
event = "ipc.rpc.completed",
rpc_id = %diagnostic_rpc_id,
method = %method_name,
session = session_id.as_deref().unwrap_or(""),
outcome,
error_code = %error_code,
elapsed_ms = started.elapsed().as_millis() as u64,
"diagnostic IPC RPC"
);
body
}) })
}) })
} }
@@ -465,8 +496,14 @@ fn handle_cancel(state: &Arc<DaemonState>, params: Value) -> ResponseBody {
}; };
let local = state.abort_registry.cancel(&params.rpc_id); let local = state.abort_registry.cancel(&params.rpc_id);
let mut cancelled = local; let mut cancelled = local;
let mut surface = if local { "abort_registry" } else { "none" };
if !local && let Some(snap) = state.tool_inflight.cancel(&params.rpc_id) { if !local && let Some(snap) = state.tool_inflight.cancel(&params.rpc_id) {
cancelled = true; cancelled = true;
surface = if snap.ws_rpc_id.is_some() {
"tool_inflight_forwarded"
} else {
"tool_inflight_queued"
};
// Forward the WS-side cancel only when the worker has // Forward the WS-side cancel only when the worker has
// already promoted the entry to "forwarded"; queued entries // already promoted the entry to "forwarded"; queued entries
// short-circuit on their own pre-flight without ever // short-circuit on their own pre-flight without ever
@@ -484,6 +521,14 @@ fn handle_cancel(state: &Arc<DaemonState>, params: Value) -> ResponseBody {
); );
} }
} }
info!(
diagnostic = true,
event = "ipc.cancel.resolved",
target_rpc_id = %params.rpc_id,
cancelled,
surface,
"diagnostic IPC cancellation"
);
ResponseBody::Ok(serde_json::to_value(CancelResult { cancelled }).unwrap_or(Value::Null)) ResponseBody::Ok(serde_json::to_value(CancelResult { cancelled }).unwrap_or(Value::Null))
} }
+48 -1
View File
@@ -41,7 +41,7 @@ use bsk_protocol::{ErrorCode, Frame, Method, RequestFrame, ResponseBody, RpcErro
use rand::Rng; use rand::Rng;
use serde_json::Value; use serde_json::Value;
use tokio::sync::{mpsc, oneshot}; use tokio::sync::{mpsc, oneshot};
use tracing::{debug, warn}; use tracing::{debug, info, warn};
use super::abort::AbortToken; use super::abort::AbortToken;
use super::browsers::BrowserRegistry; use super::browsers::BrowserRegistry;
@@ -515,6 +515,16 @@ async fn forward_one(
sessions: &Arc<SessionRegistry>, sessions: &Arc<SessionRegistry>,
job: &ToolJob, job: &ToolJob,
) -> Result<Value, RpcError> { ) -> Result<Value, RpcError> {
let started = std::time::Instant::now();
info!(
diagnostic = true,
event = "tool.dispatch.started",
session = %sid,
method = ?job.method,
has_inflight = job.inflight.is_some(),
has_lifecycle_cancel = job.lifecycle_cancel.is_some(),
"diagnostic tool dispatch"
);
// Pre-flight: a cancel that landed while this job was still in // Pre-flight: a cancel that landed while this job was still in
// the per-session channel must short-circuit before any session // the per-session channel must short-circuit before any session
// / browser resolution work, and before any WS frame leaves the // / browser resolution work, and before any WS frame leaves the
@@ -535,6 +545,13 @@ async fn forward_one(
)); ));
} }
let Some(session) = sessions.get(sid) else { let Some(session) = sessions.get(sid) else {
warn!(
diagnostic = true,
event = "tool.dispatch.session_missing",
session = %sid,
method = ?job.method,
"diagnostic tool dispatch"
);
return Err(RpcError { return Err(RpcError {
code: ErrorCode::NotFound, code: ErrorCode::NotFound,
message: format!("session {sid} no longer exists"), message: format!("session {sid} no longer exists"),
@@ -542,6 +559,14 @@ async fn forward_one(
}); });
}; };
let Some(client) = browsers.get(&session.browser_id) else { let Some(client) = browsers.get(&session.browser_id) else {
warn!(
diagnostic = true,
event = "tool.dispatch.browser_missing",
session = %sid,
method = ?job.method,
browser = %session.browser_id,
"diagnostic tool dispatch"
);
return Err(RpcError { return Err(RpcError {
code: ErrorCode::NotFound, code: ErrorCode::NotFound,
message: "owning browser is no longer connected".into(), message: "owning browser is no longer connected".into(),
@@ -558,6 +583,16 @@ async fn forward_one(
method: job.method.clone(), method: job.method.clone(),
params: Some(job.params.clone()), params: Some(job.params.clone()),
}); });
info!(
diagnostic = true,
event = "tool.dispatch.forwarding",
session = %sid,
method = ?job.method,
browser = %session.browser_id,
browser_generation = client.generation,
ws_rpc_id = %rpc_id,
"diagnostic tool dispatch"
);
// Promote the inflight entry to "forwarded" AND push the WS // Promote the inflight entry to "forwarded" AND push the WS
// request frame to the sink inside the same critical section // request frame to the sink inside the same critical section
// (review round 2 C1). The closure runs while the entry's inner // (review round 2 C1). The closure runs while the entry's inner
@@ -717,6 +752,18 @@ async fn forward_one(
}); });
} }
}; };
info!(
diagnostic = true,
event = "tool.dispatch.response",
session = %sid,
method = ?job.method,
browser = %session.browser_id,
browser_generation = client.generation,
ws_rpc_id = %rpc_id,
elapsed_ms = started.elapsed().as_millis() as u64,
response_is_error = matches!(&response.body, ResponseBody::Err(_)),
"diagnostic tool dispatch"
);
match response.body { match response.body {
ResponseBody::Ok(v) => Ok(v), ResponseBody::Ok(v) => Ok(v),
ResponseBody::Err(err) => Err(err), ResponseBody::Err(err) => Err(err),
+68 -2
View File
@@ -105,12 +105,22 @@ impl SessionRegistry {
pub fn insert(&self, session: Session) { pub fn insert(&self, session: Session) {
let session_id = session.id.clone(); let session_id = session.id.clone();
let browser_id = session.browser_id.clone();
let agent_window_id = session.agent_window_id;
let mut sessions = self.inner.lock().expect("session registry poisoned"); let mut sessions = self.inner.lock().expect("session registry poisoned");
sessions.insert(session_id.clone(), session); sessions.insert(session_id.clone(), session);
self.last_activity self.last_activity
.lock() .lock()
.expect("session activity registry poisoned") .expect("session activity registry poisoned")
.insert(session_id, Instant::now()); .insert(session_id.clone(), Instant::now());
tracing::info!(
diagnostic = true,
event = "session.registry.insert",
session = %session_id,
browser = %browser_id,
?agent_window_id,
"diagnostic session registry mutation"
);
} }
/// Reserve a fresh, collision-free [`SessionId`] under the registry /// Reserve a fresh, collision-free [`SessionId`] under the registry
@@ -152,6 +162,13 @@ impl SessionRegistry {
.lock() .lock()
.expect("session activity registry poisoned") .expect("session activity registry poisoned")
.insert(candidate.clone(), Instant::now()); .insert(candidate.clone(), Instant::now());
tracing::info!(
diagnostic = true,
event = "session.registry.reserve",
session = %candidate,
browser = %browser_id,
"diagnostic session registry mutation"
);
return Some(candidate); return Some(candidate);
} }
None None
@@ -169,13 +186,24 @@ impl SessionRegistry {
let mut guard = self.inner.lock().expect("session registry poisoned"); let mut guard = self.inner.lock().expect("session registry poisoned");
let session = guard.get_mut(session_id)?; let session = guard.get_mut(session_id)?;
session.agent_window_id = agent_window_id; session.agent_window_id = agent_window_id;
tracing::info!(
diagnostic = true,
event = "session.registry.commit",
session = %session_id,
browser = %session.browser_id,
?agent_window_id,
"diagnostic session registry mutation"
);
Some(session.clone()) Some(session.clone())
} }
/// Drop a placeholder reservation, used on extension error/timeout /// Drop a placeholder reservation, used on extension error/timeout
/// paths so failed reservations do not accumulate. /// paths so failed reservations do not accumulate.
#[track_caller]
pub fn cancel_reservation(&self, session_id: &SessionId) { pub fn cancel_reservation(&self, session_id: &SessionId) {
self.inner let caller = std::panic::Location::caller();
let removed = self
.inner
.lock() .lock()
.expect("session registry poisoned") .expect("session registry poisoned")
.remove(session_id); .remove(session_id);
@@ -183,9 +211,20 @@ impl SessionRegistry {
.lock() .lock()
.expect("session activity registry poisoned") .expect("session activity registry poisoned")
.remove(session_id); .remove(session_id);
tracing::info!(
diagnostic = true,
event = "session.registry.cancel_reservation",
session = %session_id,
existed = removed.is_some(),
caller_file = caller.file(),
caller_line = caller.line(),
"diagnostic session registry mutation"
);
} }
#[track_caller]
pub fn remove(&self, id: &SessionId) -> Option<Session> { pub fn remove(&self, id: &SessionId) -> Option<Session> {
let caller = std::panic::Location::caller();
let removed = self let removed = self
.inner .inner
.lock() .lock()
@@ -195,6 +234,17 @@ impl SessionRegistry {
.lock() .lock()
.expect("session activity registry poisoned") .expect("session activity registry poisoned")
.remove(id); .remove(id);
tracing::info!(
diagnostic = true,
event = "session.registry.remove",
session = %id,
existed = removed.is_some(),
browser = removed.as_ref().map(|session| session.browser_id.0.as_str()),
agent_window_id = removed.as_ref().and_then(|session| session.agent_window_id),
caller_file = caller.file(),
caller_line = caller.line(),
"diagnostic session registry mutation"
);
removed removed
} }
@@ -243,7 +293,9 @@ impl SessionRegistry {
} }
/// Drop all sessions owned by `browser_id` (e.g. on disconnect). /// Drop all sessions owned by `browser_id` (e.g. on disconnect).
#[track_caller]
pub fn purge_browser(&self, browser_id: &BrowserId) -> Vec<Session> { pub fn purge_browser(&self, browser_id: &BrowserId) -> Vec<Session> {
let caller = std::panic::Location::caller();
let mut guard = self.inner.lock().expect("session registry poisoned"); let mut guard = self.inner.lock().expect("session registry poisoned");
let drained: Vec<Session> = guard let drained: Vec<Session> = guard
.values() .values()
@@ -260,6 +312,20 @@ impl SessionRegistry {
for session in &drained { for session in &drained {
activity.remove(&session.id); activity.remove(&session.id);
} }
let session_ids: Vec<&str> = drained
.iter()
.map(|session| session.id.0.as_str())
.collect();
tracing::info!(
diagnostic = true,
event = "session.registry.purge_browser",
browser = %browser_id,
sessions = ?session_ids,
count = drained.len(),
caller_file = caller.file(),
caller_line = caller.line(),
"diagnostic session registry mutation"
);
drained drained
} }
+11 -2
View File
@@ -40,6 +40,8 @@ pub(crate) const DAEMONIZED_ENV: &str = "BSK_DAEMONIZED";
/// daemon lock, IPC socket, and WS port — before taking over. /// daemon lock, IPC socket, and WS port — before taking over.
pub(crate) const DAEMON_REPLACEMENT_WAIT_ENV: &str = "BSK_DAEMON_REPLACES_PID"; pub(crate) const DAEMON_REPLACEMENT_WAIT_ENV: &str = "BSK_DAEMON_REPLACES_PID";
const LUMI_DIAGNOSTIC_BUILD: &str = "lumi-session-loss-v2";
/// Concrete daemon configuration resolved from CLI flags / defaults. /// Concrete daemon configuration resolved from CLI flags / defaults.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct DaemonConfig { pub struct DaemonConfig {
@@ -251,6 +253,7 @@ pub fn run_foreground(cfg: DaemonConfig) -> Result<()> {
pid = info.pid, pid = info.pid,
ws_port = info.ws_port, ws_port = info.ws_port,
sock = %sock_path.display(), sock = %sock_path.display(),
diagnostic_build = LUMI_DIAGNOSTIC_BUILD,
"daemon ready" "daemon ready"
); );
@@ -588,7 +591,9 @@ pub(crate) fn spawn_update_check_task(
// The session gate is read after the fetch, as late // The session gate is read after the fetch, as late
// as possible before the binary gets replaced. // as possible before the binary gets replaced.
let active_sessions = state.sessions.len(); let active_sessions = state.sessions.len();
let auto_update = update::auto_update_enabled() && exe_path.is_some(); // Keep this local diagnostic build in place until a
// reproduction has uploaded its extension-side evidence.
let auto_update = false;
update::auto_update_step( update::auto_update_step(
candidate.as_ref(), candidate.as_ref(),
auto_update, auto_update,
@@ -732,7 +737,11 @@ fn init_tracing() -> Option<tracing_appender::non_blocking::WorkerGuard> {
use tracing_subscriber::prelude::*; use tracing_subscriber::prelude::*;
let log_dir = paths::log_dir().ok(); let log_dir = paths::log_dir().ok();
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); let env_filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info"))
// Lumi commonly runs with RUST_LOG=warn. This local build must retain
// BrowserSkill's INFO diagnostics even under that host-wide setting.
.add_directive("bsk=info".parse().expect("valid diagnostic log directive"));
let (file_layer, guard) = if let Some(dir) = log_dir { let (file_layer, guard) = if let Some(dir) = log_dir {
let appender = tracing_appender::rolling::daily(&dir, "daemon.log"); let appender = tracing_appender::rolling::daily(&dir, "daemon.log");
+65 -6
View File
@@ -233,6 +233,14 @@ async fn drive_connection(
.ok_or_else(|| anyhow!("handshake missing params"))?; .ok_or_else(|| anyhow!("handshake missing params"))?;
let params: HandshakeParams = serde_json::from_value(params_raw) let params: HandshakeParams = serde_json::from_value(params_raw)
.map_err(|err| anyhow!("invalid HandshakeParams: {err}"))?; .map_err(|err| anyhow!("invalid HandshakeParams: {err}"))?;
info!(
diagnostic = true,
event = "browser.handshake.received",
browser = %params.instance_id,
extension_version = %params.version,
extension_protocol = %params.protocol_version,
"diagnostic browser handshake"
);
// Version compatibility (design §10, M10.4): protocol_version only. // Version compatibility (design §10, M10.4): protocol_version only.
let our_app_version: Version = env!("CARGO_PKG_VERSION") let our_app_version: Version = env!("CARGO_PKG_VERSION")
@@ -350,7 +358,7 @@ async fn drive_connection(
// Pump loop: outbound frames from sink → ws; inbound from ws → resolve. // Pump loop: outbound frames from sink → ws; inbound from ws → resolve.
let pump_state = Arc::clone(&state); let pump_state = Arc::clone(&state);
let pump_browser = Arc::clone(&client); let pump_browser = Arc::clone(&client);
let result_outcome: anyhow::Result<()> = async { let result_outcome: anyhow::Result<String> = async {
loop { loop {
tokio::select! { tokio::select! {
outbound = rx.recv() => { outbound = rx.recv() => {
@@ -359,7 +367,7 @@ async fn drive_connection(
let json = serde_json::to_string(&frame)?; let json = serde_json::to_string(&frame)?;
writer.send(Message::Text(json)).await?; writer.send(Message::Text(json)).await?;
} }
None => break, None => break Ok("outbound_channel_closed".to_string()),
} }
} }
msg = reader.next() => { msg = reader.next() => {
@@ -381,18 +389,37 @@ async fn drive_connection(
pump_browser.touch(); pump_browser.touch();
} }
Some(Ok(Message::Frame(_))) => {} Some(Ok(Message::Frame(_))) => {}
Some(Ok(Message::Close(_))) | None => break, Some(Ok(Message::Close(frame))) => {
let reason = frame
.map(|frame| format!("peer_close code={:?} reason={}", frame.code, frame.reason))
.unwrap_or_else(|| "peer_close_without_frame".to_string());
break Ok(reason);
}
None => break Ok("peer_eof".to_string()),
Some(Err(err)) => { Some(Err(err)) => {
warn!(%err, "ws read error"); warn!(%err, "ws read error");
break; break Ok(format!("read_error: {err}"));
} }
} }
} }
} }
} }
Ok(())
} }
.await; .await;
let pump_exit_reason = match &result_outcome {
Ok(reason) => reason.clone(),
Err(err) => format!("pump_error: {err:#}"),
};
info!(
diagnostic = true,
event = "browser.websocket.pump_exit",
id = %browser_id,
generation,
reason = %pump_exit_reason,
last_seen_idle_ms = client.idle_for().as_millis() as u64,
session_ids = ?state.sessions.ids_for_browser(&browser_id),
"diagnostic browser websocket exit"
);
// Cleanup: drop browser + purge its sessions, but only if the // Cleanup: drop browser + purge its sessions, but only if the
// registry still holds *this* generation. If a reconnect already // registry still holds *this* generation. If a reconnect already
@@ -416,7 +443,7 @@ async fn drive_connection(
"browser disconnected; newer reconnect already owns this id, leaving sessions intact" "browser disconnected; newer reconnect already owns this id, leaving sessions intact"
); );
} }
result_outcome result_outcome.map(|_| ())
} }
async fn handle_inbound_text(state: &Arc<DaemonState>, client: &Arc<BrowserClient>, text: &str) { async fn handle_inbound_text(state: &Arc<DaemonState>, client: &Arc<BrowserClient>, text: &str) {
@@ -442,6 +469,38 @@ async fn handle_inbound_text(state: &Arc<DaemonState>, client: &Arc<BrowserClien
client.mark_heartbeat_seen(); client.mark_heartbeat_seen();
debug!(id = %client.id, "heartbeat"); debug!(id = %client.id, "heartbeat");
} }
bsk_protocol::EventKind::BrowserDiagnostic => {
let entries = ev.payload.get("entries").and_then(|value| value.as_array());
info!(
diagnostic = true,
event = "extension.diagnostic.batch",
browser = %client.id,
generation = client.generation,
worker_boot_id = ev.payload.get("worker_boot_id").and_then(|value| value.as_str()).unwrap_or("unknown"),
count = entries.map_or(0, Vec::len),
"received extension diagnostic batch"
);
if let Some(entries) = entries {
for entry in entries {
info!(
diagnostic = true,
event = "extension.diagnostic.entry",
browser = %client.id,
generation = client.generation,
entry = %entry,
"extension diagnostic"
);
}
} else {
warn!(
diagnostic = true,
event = "extension.diagnostic.invalid",
browser = %client.id,
payload = %ev.payload,
"extension diagnostic payload missing entries"
);
}
}
bsk_protocol::EventKind::SessionWindowClosed => { bsk_protocol::EventKind::SessionWindowClosed => {
handle_session_window_closed(state, &client.id, &ev.payload); handle_session_window_closed(state, &client.id, &ev.payload);
} }
+25
View File
@@ -58,6 +58,10 @@ pub enum EventKind {
BrowserDisconnected, BrowserDisconnected,
#[serde(rename = "browser.connected")] #[serde(rename = "browser.connected")]
BrowserConnected, BrowserConnected,
/// Structured diagnostics emitted by locally instrumented extension
/// builds. Production peers may ignore this event safely.
#[serde(rename = "browser.diagnostic")]
BrowserDiagnostic,
} }
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
@@ -256,6 +260,27 @@ mod tests {
assert_eq!(frame.event, EventKind::SystemHeartbeat); assert_eq!(frame.event, EventKind::SystemHeartbeat);
} }
#[test]
fn browser_diagnostic_event_frame_round_trips_from_extension_shape() {
let wire = serde_json::json!({
"event": "browser.diagnostic",
"payload": {
"schema_version": 1,
"worker_boot_id": "worker-1",
"entries": [{ "event": "extension.worker.started" }]
}
});
let frame: EventFrame = serde_json::from_value(wire).unwrap();
assert_eq!(frame.event, EventKind::BrowserDiagnostic);
assert_eq!(
frame
.payload
.get("worker_boot_id")
.and_then(|value| value.as_str()),
Some("worker-1")
);
}
#[test] #[test]
fn session_user_interrupt_serialises_as_snake_case() { fn session_user_interrupt_serialises_as_snake_case() {
let v = serde_json::to_value(EventKind::SessionUserInterrupt).unwrap(); let v = serde_json::to_value(EventKind::SessionUserInterrupt).unwrap();
+109 -66
View File
@@ -5,99 +5,142 @@ description: Browser automation through six injected domain tools.
# browser-skill for DeepSeek Harness # browser-skill for DeepSeek Harness
Drive the user's logged-in Chromium through this plugin's structured browser tools. Automation is Drive the user's real Chromium browser through the six structured domain tools provided by this
isolated in an Agent Window; user-window tabs remain protected unless explicitly borrowed. plugin. The browser keeps the user's existing logins and cookies while isolating automation in an
Agent Window. Every call must include the tool's `action` field; other fields depend on that action.
Loading this skill reveals six tools for the rest of the conversation: ## Tool availability
- `browser_session` owns session lifecycle. Loading this skill reveals the browser tools for the rest of the conversation. All browser work
- `browser_page` handles navigation and lifecycle waits. must use the injected tools directly. Do not invoke another process to control the browser. Direct
- `browser_inspect` reads semantic, visual, console, and network state. tool calls preserve session ownership, the live observation window, cancellation, attachments, and
- `browser_interact` performs normal page interactions. cleanup.
- `browser_tabs` manages Agent Window tabs and temporary user-tab borrowing.
- `browser_assist` handles human help, window size, and device emulation.
Every call includes an `action`. Treat each loaded tool schema as authoritative for its actions and The six tools are:
parameters; do not guess fields. All browser work must use the injected tools directly so session
ownership, cancellation, attachments, observation UI, and cleanup remain intact. Do not invoke - `browser_session`: start, stop, or list plugin-owned sessions.
another process to control the browser. - `browser_page`: navigate, back, forward, reload, or wait for navigation.
- `browser_inspect`: observe, snapshot, html, screenshot, console, or network.
- `browser_interact`: click, hover, fill, select, or press.
- `browser_tabs`: list, create, select, close, borrow, or return tabs.
- `browser_assist`: resize the Agent Window, emulate a device, or request help from the user.
## Mandatory workflow ## Mandatory workflow
Every task owns a bounded plugin session: Every browser task follows this lifecycle:
1. Call `browser_session` with `action: "start"` and retain its returned `sessionId`.
2. Use that session for every later call. Pass `session` explicitly when more than one exists.
3. Call `browser_session` with `action: "stop"` when the 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 sequence:
```text ```text
browser_session({ action: "start", ... }) browser_session({ action: "start", url: "https://example.com" })
... use the returned sessionId for browser work ... browser_inspect({ action: "observe", session: sessionId })
browser_interact({ action: "click", session: sessionId, target: "@e4" })
browser_inspect({ action: "observe", session: sessionId })
browser_session({ action: "stop", session: sessionId }) browser_session({ action: "stop", session: sessionId })
``` ```
Pass the session explicitly when more than one exists. Never guess or reuse an id owned by another The plugin owns only sessions it starts. Never guess or reuse a session id from another program.
program. Stop in a finally-style path on success and failure unless the user explicitly asks to keep When `session` is omitted, most actions use the current owned session, but explicit ids are safer.
the session open. Stopping also returns borrowed tabs.
## Work toward one observable goal ## Stop when the goal is met
- Derive a concrete success condition from the user's request. Browser work is bounded. Define an observable success condition, take the shortest path, and stop
- Take the shortest purposeful path: observe, act, then make at most one observation to confirm an as soon as success is visible. Do not refresh or click after success. If an action fails twice
ambiguous result. without progress, pause instead of brute-forcing. If state is ambiguous, make at most one fresh
- Once success is visible, do not click, refresh, navigate, switch tabs, or perform extra checks. observation and then ask the user.
- If a human-only step appears or two attempts make no progress, request help instead of
brute-forcing.
## Observe, act, observe ## Observe and interact
Use `browser_inspect` action `observe` as the primary semantic page view. It returns roles, states, Use `browser_inspect` with `action: "observe"` as the primary semantic page view. It returns roles,
text, and `@eN` refs. Prefer fresh refs over raw selectors. Refs invalidate after navigation and may states, text, and `@eN` refs. Use `action: "snapshot"` when a static accessibility tree is more useful.
also become stale after large DOM changes, so observe again before the next interaction. Prefer a fresh ref over a raw CSS selector for interactions.
Use `browser_interact` for click, hover, fill, select, and key actions. An observation marks a Refs invalidate after navigation and may become stale after large DOM changes. Observe again after
hover-only surface as `@e1 button "Products" [hover first: Shoes | Bags]`. The listed items are navigation or a meaningful DOM update before using another ref. If an observation identifies a
labels, not usable refs: hover the trigger, observe again, then act on the revealed item's own ref. hover-triggered surface, hover the trigger, observe again, then interact with the revealed control.
Do not click the trigger itself unless the user wants the trigger's action.
Escalate reading only as needed: Use `browser_interact` as follows:
1. `observe` for normal understanding and interaction refs. - `action: "click"`: requires `target`; optionally accepts `button` and `clickCount`.
2. `snapshot` when a stricter static accessibility tree is more useful. - `action: "hover"`: requires `target`; optionally accepts `modifiers`, `settleMs`, and `timeoutMs`.
3. `html` for exact markup or hidden metadata that semantic views cannot provide. - `action: "fill"`: requires `target` and `value`; `noClear` appends instead of replacing.
4. `screenshot` for layout, styling, canvas, images, or requested visual evidence. - `action: "select"`: requires `target` and a non-empty `values` array. Values are option value
attributes, not necessarily the visible labels.
- `action: "press"`: requires `key`; optionally focus `target` first and set `holdMs`.
Do not start with raw HTML or screenshots merely to discover ordinary controls. When interaction is ## Navigate and wait
needed, obtain a fresh observation before acting on screenshot or HTML findings.
Use `browser_page` for purposeful navigation, history, reload, or a lifecycle wait. Avoid speculative Use `browser_page` with these actions:
waits when no navigation is expected. After any page change, discard old refs and observe again.
## Respect the Agent Window boundary - `navigate` requires `url` and optionally accepts `waitUntil` and `timeoutMs`.
- `back` and `forward` move one history entry and optionally accept lifecycle wait fields.
- `reload` refreshes the current tab; set `hard: true` only when bypassing cache is necessary.
- `wait` waits for an expected navigation after an interaction that did not already wait.
Use `browser_tabs` to list returned tab ids before selecting, closing, borrowing, or returning tabs. Avoid speculative waits when no navigation is expected. After any page change, discard old refs and
Borrow a user tab only for the immediate task, and return it as soon as that step is complete. Never observe again.
invent a tab id or keep a personal tab borrowed across unrelated work.
## Ask the human when needed ## Reading priority
Use `browser_assist` action `request-help` for login, captcha, OTP, payment confirmation, consent, or Escalate page reading only as needed:
another step the user must complete. Give a precise prompt and highlight fresh targets when concrete
controls are involved. Use completion criteria only for a clear stable success signal.
Resume only after the user continues or the criteria complete. Treat cancellation as rejection and 1. `browser_inspect` with `action: "observe"` for normal understanding and refs.
timeout as a blocker rather than retrying. Observe again after control returns before reasoning about 2. The same tool with `action: "snapshot"` for a stricter static tree.
the new state or using refs. 3. `action: "html"` for exact markup that semantic views cannot provide; keep `maxBytes` bounded and
use only a fresh ref when scoping a subtree.
4. `action: "screenshot"` for layout, styling, canvas, or genuinely visual evidence. A fresh `ref`
crops to an element; omit it for the visible tab.
The same tool can resize the Agent Window or emulate a device when the task requires visual or Do not start with raw HTML or a screenshot merely to discover ordinary controls.
responsive testing. Emulation is scoped to one tab.
## Debug and recover without wandering ## Tabs and the Agent Window boundary
Use `browser_inspect` console or network actions only for relevant, bounded, read-only diagnostics. Use `browser_tabs` with these actions:
Continue from returned sequence cursors instead of rereading the same buffer.
- Stale ref: observe again and retry the intended action once. - `list` returns visible tabs; `scope: "user"` finds a user tab before borrowing.
- Unknown tab: list tabs instead of guessing. - `create` makes an Agent Window tab and optionally accepts `url`, `active`, and `index`.
- Unknown session: list owned sessions or start one; never try foreign ids. - `select` focuses an Agent Window tab and requires a returned `tabId`.
- Timeout: inspect current state before deciding whether one longer purposeful wait is useful. - `close` closes an Agent Window tab and requires a returned `tabId`.
- Unrecoverable failure: report the blocker and stop the owned session. - `borrow` moves a user tab into the Agent Window and requires a listed user `tabId`.
- `return` restores a borrowed tab and requires that borrowed `tabId`.
Arbitrary page-script evaluation and interaction recording are intentionally unsupported. Do not Return borrowed tabs as soon as the immediate task finishes. Stopping the session is only fallback
invent tools or route around those limits. cleanup. Never invent a tab id.
## Human help and display controls
Use `browser_assist` with `action: "request-help"` for login, captcha, OTP, payment confirmation,
consent, or another human-only step. `prompt` is required. Optional `targets` highlight fresh refs or
selectors. `completionCriteria` can detect explicit stable success through URL, selector, or text
conditions. After the user continues or criteria complete, observe again before reasoning about the
new state.
Use `action: "resize"` with `width` and `height` to resize the outer Agent Window. Use
`action: "emulate"` with a device preset or explicit width and height; `mobile: true` also requires
dimensions. Use `off: true` alone to clear emulation. Emulation is per tab.
## Read-only diagnostics
Use `browser_inspect` with `action: "console"` for buffered logs and exceptions, and
`action: "network"` for response and failure metadata. Both accept `tabId`, `since`, `limit`, and
`maxTextChars`; continue with the returned sequence cursor rather than rereading the same entries.
Console additionally accepts `includeStack`.
Arbitrary page script evaluation and interaction recording are intentionally unsupported. Do not
invent tool names or route around that limitation.
## Error handling and final checklist
On a stale ref, observe again and retry once. On an unknown tab, list tabs instead of guessing. On
an unknown session, list owned sessions or start one; never try foreign ids. On timeout, decide
whether one longer purposeful wait is justified, and never immediately repeat a failed history
action without first checking page state.
Before finishing, verify that the observable goal is met or the blocker is clear, borrowed tabs are
returned when practical, the owned session is stopped, and no extra page action occurs after
success.
@@ -47,10 +47,7 @@ describe("registerBskSkill", () => {
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).toContain("evaluation and interaction recording are intentionally unsupported");
// Keep the lazily injected instructions inside a bounded prompt budget, expect(content.length).toBeGreaterThan(6_000);
// while the lower bound catches accidental truncation of the guidance.
expect(content.length).toBeGreaterThan(3_000);
expect(content.length).toBeLessThan(6_000);
expect(skill.source).toBe("bundled"); expect(skill.source).toBe("bundled");
// Source frontmatter is registration metadata and must not leak into the body. // Source frontmatter is registration metadata and must not leak into the body.
expect(content.startsWith("---")).toBe(false); expect(content.startsWith("---")).toBe(false);
+283 -98
View File
@@ -1,142 +1,327 @@
--- ---
name: browser-skill name: browser-skill
description: | description: |
Use when the user asks to automate their logged-in Chromium browser: visit Use when the user asks to perform browser automation tasks against their
and read pages, fill forms, scrape data, click through flows, regression-test logged-in browser: visit and read pages, fill forms, scrape data, click
a PR's UI, validate a deployed page, or operate a tab they identify. Requires through a flow, regression-test a PR's UI, validate a deployed page.
the bsk CLI and browser extension. Requires the bsk CLI installed and the browser-skill extension loaded.
--- ---
# browser-skill # browser-skill
Drive the user's real Chromium browser through `bsk`. Automation runs in an isolated **Agent Drive the user's **real Chromium browser** (with their logins and cookies) through the `bsk` CLI. The extension opens an isolated **Agent Window** for automation; the user's normal windows stay protected unless you explicitly borrow a tab.
Window** with the user's existing logins and cookies. User-window tabs remain protected unless they
are explicitly borrowed.
Do not use this skill for tasks with no browser, for extension installation, or when the user only ## When to use
wants instructions. Never extract credentials, cookies, tokens, or other secrets from pages.
## Required lifecycle - Open pages, read titles/text, scrape structured data from sites the user can already access
- Fill forms, click through multi-step flows, smoke-test a UI change
- Understand pages with `bsk snapshot` first; use `bsk get-html` or `bsk screenshot` only when the snapshot is insufficient
- Operate on a specific user tab they point you at (after `bsk tab borrow`)
Every browser task owns a bounded session: ## When NOT to use
```text - Tasks with **no browser** involved (files, APIs, databases only)
1. bsk session start # retain the printed 4-letter session id - Installing or configuring the extension (point the user to setup docs instead)
2. bsk ... --session <id> # pass it to every session-scoped command - **Credential harvesting** — never run `bsk evaluate` on banking, SSO, or password-manager pages to extract tokens, cookies, or secrets
3. bsk session stop <id> # always run on success and error paths - Long-lived control of a user's personal login window — borrow only for the immediate step, then `bsk tab return` or end the session
- Replacing the user's manual browsing when they only wanted an explanation
## Prerequisites
1. `bsk` on `PATH` (Rust CLI from browser-skill)
2. browser-skill **extension** loaded in Chromium and connected (popup shows green, or an upgrade reminder — still connected; continue)
3. Any `bsk` command auto-starts background services as needed; use `bsk doctor` if anything fails
## Mandatory workflow
Every automation task **must** follow this lifecycle. Do **not** rely on idle timeouts (default session idle is 5 minutes).
```
1. bsk session start → capture the 4-letter session id printed on stdout
2. … every tool command … → always pass --session <id>
3. bsk session stop <id> → REQUIRED when done (even on error paths)
``` ```
Do not rely on the idle timeout for cleanup. Stop the session as soon as the goal is met unless the Optional: `bsk session start --browser <instance-id-or-label>` when multiple browsers are connected (`bsk browsers` / error output lists them). Add `--no-focus` to open the Agent Window in the background without stealing focus from the user's current window.
user explicitly asks to keep it open. Stopping also returns borrowed tabs.
Any `bsk` command auto-starts the background services it needs; never manage the daemon by hand. Emergency cleanup: `bsk session stop --all` or the Agent Window overlay **Stop all**.
When multiple browsers are connected, use `bsk browsers` and start with
`bsk session start --browser <id-or-label>`. Add `--no-focus` to that same start command when the
Agent Window does not need to interrupt the user's current work; it is not a flag on other commands.
Run `bsk doctor` when startup or transport problems persist after one retry.
## Work toward one observable goal ## Stop when the goal is met
- Derive a concrete success condition from the user's request or a supplied trace. Every task is a **bounded goal**, not open-ended browsing. The goal may come from the user's request, a recorded `trace.json`, or both.
- Take the shortest purposeful path: observe, act, then make at most one observation to confirm an
ambiguous result.
- Once success is visible, do not click, refresh, navigate, switch tabs, or perform extra checks.
- If a human-only step appears or two attempts make no progress, request help instead of
brute-forcing.
With a trace, follow its semantic target information and values in order, but treat its refs as 1. **Define success first** — one concrete, observable condition derived from the user's words, `purpose`, or the last meaningful step in a trace (e.g. "form submitted", "item added to cart", "playback started").
record-local hints. Stop when its purpose or last meaningful effect is satisfied. A trace guides the 2. **Take the shortest path** — snapshot → act → at most one check. Do not wander, re-try unrelated actions, or stack exploratory steps.
task; it does not expand the user's goal or authorize additional actions. 3. **Stop as soon as success is reached** — run `bsk session stop <id>` immediately unless the user explicitly asked to keep the session open (e.g. "don't close yet", "keep browsing").
4. **No post-success work** — once the goal is met, do not click, refresh, navigate, re-search, switch tabs, or "double-check" that it worked. Further verification is a new task.
5. **When blocked, pause — do not brute-force** — if the page requires human input (login, captcha, OTP, payment confirmation) or an action fails twice with no progress, call `bsk request-help` instead of retrying blindly. See **Ask the human for help** below.
6. **When unsure** — at most one extra `bsk snapshot`. If success looks met, stop. If not, ask the user; do not keep clicking.
## Observe, act, observe **With a trace:** replay steps in order using `target` role/name/tag and raw `value`/`selection` fields. After the last step (or when its `effect.navigated_to` / success hint is satisfied), apply rules 3–4 immediately. The trace guides execution; it does not extend control beyond the goal.
Use this default loop: **Without a trace:** the user's request *is* the success condition. Satisfying it ends the task — same stop rules apply.
```text ## Core interaction loop
Write operations only affect tabs in the **Agent Window** (or tabs you **borrowed** into it).
```
bsk navigate <url> --session <id> bsk navigate <url> --session <id>
bsk observe --session <id> bsk observe --session <id> → primary semantic VOM view; reveals hover/focus surfaces
bsk click|hover|fill|select|press ... --session <id> bsk snapshot --session <id> → static aria tree fallback when VOM is insufficient
bsk observe --session <id> # after navigation or a meaningful DOM change bsk hover @e3 --session <id> → reveal hover-triggered menus before re-observing/clicking
bsk click @e4 --session <id> → or bsk fill, bsk select, bsk press
bsk observe --session <id> → again after navigation / DOM change
``` ```
Prefer fresh `@eN` refs over CSS selectors. Navigation invalidates refs; large DOM changes may also **Refs invalidate after navigation** — always re-snapshot before clicking, filling, or selecting on a new page.
make them stale. Observe again before the next interaction.
An observation marks a hover-only surface as `@e1 button "Products" [hover first: Shoes | Bags]`. Prefer `@eN` refs from the latest snapshot over raw CSS selectors. Use `--ref` / `--selector` when ambiguous (`bsk click --help`).
The listed items are labels, not usable refs: hover the trigger, observe again, then act on the
revealed item's own ref. Do not click the trigger itself unless the user wants the trigger's action.
Escalate page reading only as needed: When VOM renders `[hover first: …]` on an element, the listed items are not currently clickable refs. Run `bsk hover <that-ref> --session <id>`, then immediately run `bsk snapshot` or `bsk observe` again and click the newly visible menu item ref. Do not click the trigger itself unless the user explicitly wants the trigger action.
1. `bsk observe` for normal semantic understanding, text, controls, and refs. ## Observation priority
2. `bsk snapshot` when a stricter static accessibility tree is more useful.
3. `bsk get-html` for exact markup or hidden metadata that semantic views cannot provide.
4. `bsk screenshot` for layout, styling, canvas, images, or requested visual evidence.
Do not start with raw HTML or screenshots merely to discover ordinary controls. When interaction is Start with `bsk observe` to understand page structure, text, controls, element refs, and conditional hover/focus surfaces. Use `bsk snapshot` only when you need the stricter static accessibility tree or VOM is insufficient. Only escalate to raw HTML or screenshots when the latest observation cannot answer the question:
needed, obtain a fresh observation before acting on screenshot or HTML findings.
## Respect the Agent Window boundary 1. `bsk observe` — primary semantic VOM observation; may run bounded perception probes such as hover-surface discovery
2. `bsk snapshot` — strict static accessibility tree fallback
3. `bsk get-html` — when hidden DOM, metadata, or markup details are required
4. `bsk screenshot` — when visual layout, canvas/image content, or styling cannot be inferred from the observation. Use `--ref @eN` (from the latest snapshot/observe) to crop to one element; omit `--ref` for the full visible tab.
Normal page writes affect only Agent Window tabs. To operate a user tab, first list it with Do **not** call `bsk get-html` or `bsk screenshot` first just to inspect a page.
`bsk tab list --scope user --session <id>`, then `bsk tab borrow <tab-id>`. Return it immediately
after the relevant step with `bsk tab return <tab-id>`; never invent a tab id or keep a personal tab
borrowed across unrelated work.
## Ask the human when needed ## Sandbox rules
Use `bsk request-help` for login, captcha, OTP, payment confirmation, consent, or another step the | Rule | Detail |
user must complete. Give a precise prompt and pass fresh `--target` refs/selectors when concrete |------|--------|
controls can be highlighted. Use completion criteria only when the page has a clear stable success | Agent Window | `bsk tab create`, `bsk navigate`, `bsk click`, etc. work on agent tabs by default |
signal. | User tabs | Read-only until borrowed: `bsk tab list --session <id> --scope user` then `bsk tab borrow <tab-id> --session <id>` |
| Return borrowed tabs | Call `bsk tab return <tab-id> --session <id>` when finished; unreturned tabs are **auto-returned** on `bsk session stop` |
| Writes off-agent | Commands that mutate the page fail if the tab is not in the Agent Window — borrow or create a tab first |
The result `outcome` is one of `continued`, `completed`, `cancelled`, `timed_out`, or `disabled` ## Global flags
(`navigated` is deprecated — never treat navigation as a completion signal). Resume only after
`continued` or `completed`. Treat `cancelled` as rejection, and `timed_out` or `disabled` as a
blocker rather than a reason to retry. After control returns, run a fresh `bsk observe` before
reasoning about the page or using refs.
## Command inventory | Flag | Purpose |
|------|---------|
| `--json` | Machine-readable JSON on stdout (errors too) |
| `--quiet` | Suppress informational stderr |
| `-v` / `-vv` | More verbose logging |
This list of names is complete. Never invent a command outside it; read Auto-update is **on by default** — the background daemon upgrades `bsk` itself when a new release is available (postponed while a session is active). Set `BSK_AUTO_UPDATE=off` to disable it and upgrade manually with `bsk update`.
`bsk <command...> --help` for flags instead of guessing them.
```text Command-specific flags (timeouts, `--tab-id`, `--wait-until`, …): **`bsk <cmd> --help`**
session start|stop|list browsers status doctor update logs
navigate navigate-back navigate-forward reload wait-for-navigation wait-ms ## CLI command reference (one line each)
observe snapshot get-html screenshot console network
click hover fill select press evaluate Details and flags: **`bsk <cmd> --help`**
tab list|create|close|select|borrow|return window resize emulate
request-help record start|stop ### Diagnostics
| Command | Summary |
|---------|---------|
| `bsk status` | Connection health, connected browsers, active sessions |
| `bsk doctor` | Deep diagnostics and repair hints |
| `bsk browsers` | List connected browser instances (ids, labels, versions) |
### Session
| Command | Summary |
|---------|---------|
| `bsk session start` | Open Agent Window (`--width`/`--height` for initial size); prints **4-letter session id** |
| `bsk session start --no-focus` | Open Agent Window in the background without stealing focus |
| `bsk session stop <id>` | End session, close Agent Window, auto-return borrowed tabs |
| `bsk session stop --all` | Stop every active session |
| `bsk session list` | List active sessions |
### Window (require `--session <id>`)
| Command | Summary |
|---------|---------|
| `bsk window resize` | Resize the Agent Window (`--width`, `--height`; 100..=7680 CSS px) |
### Device emulation — `bsk emulate` (requires `--session <id>`)
Emulate a mobile device environment on the agent tab via CDP — viewport, User-Agent, and touch — to debug a page's mobile layout and behaviour:
```bash
bsk emulate --session <id> --device iphone-14
bsk emulate --session <id> --width 390 --height 844 --dpr 3 --mobile --ua "Mozilla/5.0 (iPhone…" --touch
bsk emulate --session <id> --off
``` ```
Required flags that are easy to get wrong: - Presets (`--device`): `iphone-14`, `iphone-14-pro-max`, `iphone-se`, `pixel-7`, `galaxy-s23`, `ipad-mini`, `galaxy-tab-s8`. Manual flags (`--width`/`--height`/`--dpr`/`--mobile`/`--ua`/`--accept-language`/`--touch`/`--max-touch-points`) also work without a preset, or override individual preset fields; `--no-mobile`/`--no-touch` turn a preset's mobile viewport / touch emulation back off.
- Repeated runs merge field by field onto the tab's current emulation state — only the flags you pass change. E.g. after `--device iphone-14`, `bsk emulate --session <id> --width 390 --height 844` keeps the preset's dpr (`3`) and mobile viewport. (The extension remembers the state per tab; after an extension reload the next run applies only the fields it carries.)
- `--off` clears every override (viewport, UA, touch) and the remembered state, restoring the tab's real environment.
- Scope: overrides apply to **one tab only** (CDP per-target) and are **not inherited by new tabs** — re-run `bsk emulate` after opening or switching to another tab (default target is the session's active tab; `--tab-id` overrides).
- Emulation covers viewport/UA/touch only; it does not throttle the network, fake geolocation, or synthesise real touch-event streams.
```text ### Tabs (require `--session <id>`)
bsk fill <ref> --value <text> bsk select <ref> --value <option-value>
bsk screenshot --out <path> bsk emulate --device <preset-id> | Command | Summary |
|---------|---------|
| `bsk tab list` | List tabs (`--scope user\|agent\|all`, default `all`) |
| `bsk tab create` | New tab in Agent Window (`--url`, `--no-active`, `--index`) |
| `bsk tab close <tab-id>` | Close an agent tab |
| `bsk tab select <tab-id>` | Focus an agent tab |
| `bsk tab borrow <tab-id>` | Move a user tab into the Agent Window |
| `bsk tab return <tab-id>` | Return a borrowed tab to its original window |
### Observation (require `--session` unless noted)
| Command | Summary |
|---------|---------|
| `bsk snapshot` | First-choice static page understanding: accessibility tree with `@eN` element refs |
| `bsk observe` | Semantic VOM observation with bounded perception probes for conditional surfaces |
| `bsk get-html` | Raw HTML dump after snapshot is insufficient (high token cost) |
| `bsk screenshot` | PNG capture after snapshot is insufficient: full visible tab, or `--ref @eN` to crop to one element (`--out` path optional) |
### Console & network debugging (read-only; require `--session`)
| Command | Summary |
|---------|---------|
| `bsk console` | Buffered page console messages, JS exceptions, and browser log entries (`--include-stack` for stack traces) |
| `bsk network` | Buffered network responses (status, method, URL, MIME/resource type) and failures (`net::ERR_*` reason) |
Both capture from the moment the tab is attached and read a bounded per-tab buffer: `--since <seq>` pages from a cursor (`next_since` in the result), `--limit` (default 50, max 200), `--max-text-chars` (default 1000, max 4096), `--tab-id` to target a non-active tab. Both are strictly read-only — they never intercept or modify traffic, and request/response headers, bodies, and timings are not captured.
### Navigation
| Command | Summary |
|---------|---------|
| `bsk navigate <url>` | Go to URL in agent tab (`--wait-until`, `--timeout`) |
| `bsk navigate-back` | History back one step |
| `bsk navigate-forward` | History forward one step |
| `bsk reload` | Reload current tab (`--hard` bypass cache) |
(`bsk navigate back` / `bsk navigate forward` are equivalent subcommands.)
### Interaction
| Command | Summary |
|---------|---------|
| `bsk click <ref-or-selector>` | Click element (`--button`, `--click-count`, `--modifiers`) |
| `bsk hover <ref-or-selector>` | Move the mouse to an element and wait for hover UI to settle (`--settle`, `--modifiers`) |
| `bsk fill <ref-or-selector> --value <text>` | Clear and type into input |
| `bsk select <ref-or-selector> --value <v>` | Set `<select>` option(s) by `value` (repeat `--value` for multi-select) |
| `bsk press <key>` | Key/combo (`Enter`, `Ctrl+A`, …; optional `--ref` to focus first) |
### Scripting & timing
| Command | Summary |
|---------|---------|
| `bsk evaluate <expression>` | Run JS in agent tab (see red lines). JS throw → stderr, **exit 0** (RPC success); use `--json` and check `.ok` to detect JS errors |
| `bsk wait-for-navigation` | Block until load/DOM idle/etc. (`--wait-until`, `--timeout`) |
| `bsk wait-ms <duration>` | Sleep (`500ms`, `2s`, `1m`; **no** `--session`) |
### Ask the human for help — `bsk request-help`
When a step needs a human (captcha, login, OTP) or you want the user to
confirm an important action, pause and ask:
bsk request-help --session <id> --prompt "Solve the captcha, then click Done only after the site accepts it" \
--title "Captcha required" --target @e7 --target "#submit" --timeout 5m
- `--prompt` (required): what the user should do.
- `--title` (optional): custom title for the overlay panel. When omitted,
the extension shows its default localized title.
- `--target` (repeatable): a snapshot ref (`@e7`) or CSS selector
(`#submit`) to scroll to and flash-highlight. **Strongly recommended** —
whenever the prompt refers to a concrete element (a button to click, a
field to fill, a checkbox to toggle), pass its `@eN` ref / selector so the
user is guided straight to the right spot instead of hunting for it. For
interaction scenarios, always include the relevant target(s); reserve a
prompt with no `--target` for cases where there is genuinely no specific
element to point at (e.g. "wait for the page to finish loading").
- `--timeout` (default `5m`): how long to wait.
- `--completion-criteria` (optional): JSON success detector. Use it only
when there is a concrete post-help success signal, e.g.
`{"any":[{"url_contains":"/dashboard"},{"selector_exists":"[data-testid='account-menu']"}],"stable_for_ms":1000}`.
The target tab is brought to the foreground; the page stays interactive
while the agent control mask is hidden. The call blocks until the user
explicitly acts, the timeout expires, cancellation arrives, or explicit
completion criteria match. Page reloads, SPA route changes, and captcha
refreshes do not return control by themselves. The result `outcome` is one of:
- `continued` — the user finished and clicked Done / return control (treat as confirm).
- `cancelled` — the user clicked Cancel (treat as reject/abort).
- `timed_out` — nobody acted within the timeout.
- `completed` — the explicit `--completion-criteria` matched while the user had control.
- `navigated` — deprecated legacy outcome. Do not rely on navigation as a completion signal.
`note` carries any text the user typed back. `resolved_targets` reports
which refs/selectors matched a live element.
`request-help` does not refresh the page model after the user returns
control. After a `continued` or `completed` result, issue a separate
observation tool call (usually `bsk snapshot --session <id>`) before using
new refs or reasoning about the post-help page state.
#### Disabling request-help (unattended mode)
Set `BSK_REQUEST_HELP=off` on unattended servers: `bsk request-help` then
returns immediately with `outcome="disabled"` (no overlay, no waiting,
exit 0). Any other value keeps it enabled. If you get `disabled`, do not
retry — complete the task autonomously or stop gracefully.
### Recording — `bsk record`
Capture the user's own actions in the Agent Window to a **trace bundle**, for later LLM-driven automation. New CLI builds request **trace v3** (page observations + action chain); older extensions may still return **trace v2** (actions only), which the CLI exports as a single `trace.json` without `states/`.
```bash
bsk record start --browser <instance-id-or-label> \
[--url https://…] [--purpose "publish a wiki doc"] \
[--max-page-tokens 3000] [--redact-values] \
[--output trace]
# `--url` is optional; default https://example.com/ when omitted (must be http(s)).
# Blocks until the user clicks Finish in the recording panel, then writes:
# trace/trace.json — action chain (+ state index when v3)
# trace/states/ — v3 only: one `sN.txt` observe snapshot per settled page state
bsk record stop [--output trace] # terminal fallback if the browser panel is unavailable
``` ```
`select` matches an option's `value` attribute, not its visible label. Device preset ids are - **v3 bundle (preferred):** `--output` is a directory (default `./trace`) containing `trace.json` and `states/`. `trace.json` lists `states[]` (page observation ids) and `steps[]` (each step binds `state` = observe snapshot *before* the action and `result.state` = snapshot *after* settle). Page bodies live in `states/sN.txt` using the same VOM format as `bsk observe`.
lowercase and hyphenated, such as `iphone-14`. - **v2 fallback:** when the connected extension is older, export may contain only legacy `trace.json` with `pages[]` action context and no `states/` observation files. Update the extension for full v3 bundles.
- Each `states[]` entry is one **settled page observation** (captured after recording start, navigation landing, or action settle — not on a timer).
- `target.ref` values like `@e12` exist **only inside** the bundle for disambiguation; do **not** copy `@eN` refs into SKILL.md or agent runbooks — use visible names from the observation text instead.
- `--purpose` is optional context metadata; it does **not** change what gets captured.
- `--redact-values` masks all form values in page files as `[filled]` / `[empty]`.
- Do **not** record on banking/SSO/password-manager pages; passwords are redacted but traces may still contain sensitive text.
- `console` and `network` provide bounded, read-only debugging evidence. ## Error handling
- `emulate` applies viewport, user-agent, and touch overrides to one tab; new tabs do not inherit
them. Use `--off` to restore the real environment.
- `evaluate` is a last resort when observe plus normal interactions cannot complete the task. With
`--json`, inspect `.ok`: a JavaScript exception may still have CLI exit code 0 because the RPC
succeeded. Never evaluate credential surfaces to read storage, cookies, or auth data.
- `record` captures a user's actions for later replay. Read `bsk record start --help` before use,
and never record banking, SSO, password-manager, or other sensitive pages.
## Recover without wandering ### Exit codes (`echo $?` after `bsk …`)
- Stale ref: observe again and retry the intended action once. | Code | Meaning | What to do |
- Unknown tab or session: list current tabs/sessions; never guess identifiers. |------|---------|------------|
- Timeout: inspect current page state before deciding whether one longer purposeful wait is useful. | `0` | Success (including `evaluate` where JS threw but RPC succeeded) | Continue; for `evaluate`, check `--json` `.ok` to distinguish JS success from RPC success |
- Unsupported command: continue with available capabilities; suggest updating only when the missing | `1` | User error — bad args, unknown session, tab not in Agent Window, stale ref | Fix args; `bsk session list`; re-snapshot |
command is necessary. | `2` | Protocol / transport — service unreachable, IPC failure | `bsk doctor`; check extension connected; retry the command |
- Unrecoverable failure: report the blocker and stop the session in a finally-style path. | `3` | Browser / CDP execution failed | Retry; simplify selector; check tab still open |
| `4` | Timeout | Increase `--timeout`; try `--wait-until domcontentloaded` |
| `5` | Unknown RPC method | This command is not implemented in the current build. Continue the task with other commands. If a newly added command is missing, suggest `bsk update`. |
The CLI's current help and error hints are authoritative for flags, parameters, and recovery Human errors print `error:` + `hint:` on stderr; `--json` includes `code`, `message`, `hint`, `exit_code`.
details.
### When to run diagnostics
| Situation | Command |
|-----------|---------|
| Before first task in a session | `bsk status` — extension connected? |
| Any failure you cannot fix in one retry | `bsk doctor` |
| Multiple browsers / wrong target | `bsk browsers` then `bsk session start --browser <id>` |
Always **`bsk session stop <id>`** in a `finally`-style path so the Agent Window closes and borrowed tabs return.
## Red lines
1. **No token theft** — do not `bsk evaluate` on sensitive sites to read `localStorage`, cookies, or auth headers for exfiltration.
2. **No long borrow** — do not leave a user's personal tab in the Agent Window across unrelated tasks.
3. **No skip stop** — always `bsk session stop <id>`; never assume idle timeout will clean up.
4. **No post-success control** — once the user’s goal (or last trace step) is met, do not keep operating the page; stop the session unless they asked to keep it open.
5. **No raw observe escalation before snapshot/observe** — use `bsk snapshot` first; use `bsk observe` when VOM semantics or conditional surfaces help. Only use `bsk get-html` or `bsk screenshot` when snapshot/observe is insufficient. Element screenshots (`--ref @eN`) still require a fresh snapshot/observe ref — never skip observation just to grab a visual.
6. **`evaluate` is powerful and risky** — use only when snapshot + click/fill/select cannot suffice; never on credential surfaces.
---
**More detail for any command:** `bsk <cmd> --help`