diff --git a/apps/extension/src/entrypoints/__tests__/background-overlays.test.ts b/apps/extension/src/entrypoints/__tests__/background-overlays.test.ts index 469b723b..8521daf2 100644 --- a/apps/extension/src/entrypoints/__tests__/background-overlays.test.ts +++ b/apps/extension/src/entrypoints/__tests__/background-overlays.test.ts @@ -65,7 +65,7 @@ function event() { }; } -async function fixture() { +async function fixture(diagnosticGet = vi.fn(async () => ({}))) { const overlay = new OverlayController(); const onDetached = event<[number]>(); const sendMessage = vi.fn( @@ -75,6 +75,9 @@ async function fixture() { }, ); vi.stubGlobal("chrome", { + storage: { + local: { get: diagnosticGet, set: vi.fn(async () => {}), remove: vi.fn(async () => {}) }, + }, tabs: { sendMessage, get: vi.fn(async () => ({ url: "https://example.test" })), @@ -126,6 +129,14 @@ it.each([false, true])("hides an observed tab after detachment, remote=%s", asyn expect(f.deps.cdp?.releaseSessionTab).toHaveBeenCalledWith("one", 20); }); +it("continues normal background startup while the diagnostic preference read is pending", async () => { + const diagnosticGet = vi.fn(() => new Promise>(() => {})); + const f = await fixture(diagnosticGet); + expect(diagnosticGet).toHaveBeenCalledOnce(); + expect(f.task.sessionId).toBe("one"); + expect(f.deps.sessions.get("one")).toBe(f.task); +}); + it("still releases control and CDP when the content script cannot receive hidden", async () => { const f = await fixture(); f.task.observedTabs = new Set([20]); diff --git a/apps/extension/src/entrypoints/background.ts b/apps/extension/src/entrypoints/background.ts index 68482cea..6d730dc6 100644 --- a/apps/extension/src/entrypoints/background.ts +++ b/apps/extension/src/entrypoints/background.ts @@ -6,6 +6,10 @@ import { DebugManager } from "@/debug/manager"; import { getAuditEnabled } from "@/lib/audit"; import { attachAuditBridge } from "@/lib/audit-bridge"; import { ConnectionController } from "@/lib/connection-controller"; +import { + handleDiagnosticMessage, + startConnectionDiagnostics, +} from "@/lib/connection-diagnostics-runtime"; import { watchDaemonConnection } from "@/lib/daemon-connection-preference"; import { startHeartbeat } from "@/lib/heartbeat"; import { @@ -67,7 +71,16 @@ import { BrowserVideoHost } from "@/video/host"; import { VideoManager } from "@/video/manager"; import { VIDEO_OVERLAY } from "@/video/overlay"; +declare const __BSK_EXT_BUILD__: string; + export default defineBackground(() => { + const diagnostics = startConnectionDiagnostics(chrome.storage.local, { + bootId: crypto.randomUUID(), + build: `${__BSK_EXT_VERSION__}@${typeof __BSK_EXT_BUILD__ === "string" ? __BSK_EXT_BUILD__ : "unknown"}`, + }); + chrome.runtime.onMessage.addListener((message, sender, reply) => + handleDiagnosticMessage(diagnostics, message, sender.id, chrome.runtime.id, reply), + ); const controller = new ConnectionController(); let remoteEndpoint: RemoteEndpoint | null = null; let connectionPreferenceValid = false; diff --git a/apps/extension/src/entrypoints/connection-diagnostics/index.html b/apps/extension/src/entrypoints/connection-diagnostics/index.html new file mode 100644 index 00000000..2b139c6d --- /dev/null +++ b/apps/extension/src/entrypoints/connection-diagnostics/index.html @@ -0,0 +1,28 @@ + + + + + + BrowserSkill connection diagnostics + + + +

Connection diagnostics

+

Recording is off by default. Start before observing a connection problem, then close this page. After a failure, export the log before toggling the connection or opening worker DevTools.

+

Up to 2048 connection events are stored locally, without page content, URLs, tokens or RPC payloads. Nothing is uploaded automatically. Recording can affect worker scheduling; an empty log does not prove that the background was inactive.

+ + + + + +

Reading and exporting only inspect stored data. Start, stop and clear are explicit controls that may wake the background worker. Stop preserves the log; clear removes diagnostic events.

+

Reading stored state…

+

+    
+  
+
diff --git a/apps/extension/src/entrypoints/connection-diagnostics/main.ts b/apps/extension/src/entrypoints/connection-diagnostics/main.ts
new file mode 100644
index 00000000..c03f8d9e
--- /dev/null
+++ b/apps/extension/src/entrypoints/connection-diagnostics/main.ts
@@ -0,0 +1,87 @@
+import { DIAGNOSTIC_STORAGE_KEY, type DiagnosticSnapshot } from "../../lib/connection-diagnostics";
+import {
+  DIAGNOSTIC_CONTROL_KIND,
+  DIAGNOSTIC_ENABLED_KEY,
+  type DiagnosticCommand,
+} from "../../lib/connection-diagnostics-runtime";
+
+const status = document.getElementById("status")!;
+const log = document.getElementById("log")!;
+const button = (id: string) => document.getElementById(id) as HTMLButtonElement;
+const buttons = ["start", "stop", "refresh", "download", "clear"].map(button);
+let saved: DiagnosticSnapshot | null = null;
+let firstReadAt: string | null = null;
+function busy() {
+  for (const item of buttons) item.disabled = true;
+}
+
+async function read(): Promise {
+  firstReadAt ??= new Date().toISOString();
+  busy();
+  try {
+    const stored = await chrome.storage.local.get([DIAGNOSTIC_ENABLED_KEY, DIAGNOSTIC_STORAGE_KEY]);
+    const enabled = stored[DIAGNOSTIC_ENABLED_KEY] === true;
+    const value = stored[DIAGNOSTIC_STORAGE_KEY] as DiagnosticSnapshot | undefined;
+    saved = value?.schema === 1 && Array.isArray(value.events) ? value : null;
+    log.textContent = saved ? JSON.stringify(saved, null, 2) : "";
+    status.textContent = `${enabled ? "Recording on" : "Recording off"}. ${
+      saved
+        ? `${saved.events.length} events; ${saved.droppedEvents} dropped; ${saved.readFailures} read failures; ${saved.writeFailures} write failures. Snapshot: ${saved.capturedAt}.`
+        : "No valid stored log; this does not prove that the background was inactive."
+    } First read: ${firstReadAt}.`;
+    button("start").disabled = enabled;
+    button("stop").disabled = !enabled;
+    button("download").disabled = saved === null;
+  } catch {
+    saved = null;
+    log.textContent = "";
+    status.textContent =
+      "Unable to read stored diagnostics. Preserve the failure state and try reading again.";
+    button("stop").disabled = false;
+  } finally {
+    button("refresh").disabled = false;
+    button("clear").disabled = false;
+  }
+}
+
+async function control(command: DiagnosticCommand): Promise {
+  busy();
+  let failed = false;
+  try {
+    const response = await chrome.runtime.sendMessage(command);
+    if (response?.ok !== true) failed = true;
+  } catch {
+    failed = true;
+  }
+  await read();
+  if (failed)
+    status.textContent = "Unable to change diagnostics. Read stored state before trying again.";
+}
+
+button("start").addEventListener("click", () => {
+  void control({ kind: DIAGNOSTIC_CONTROL_KIND, action: "set-enabled", enabled: true });
+});
+button("stop").addEventListener("click", () => {
+  void control({ kind: DIAGNOSTIC_CONTROL_KIND, action: "set-enabled", enabled: false });
+});
+button("clear").addEventListener("click", () => {
+  void control({ kind: DIAGNOSTIC_CONTROL_KIND, action: "clear" });
+});
+button("refresh").addEventListener("click", () => {
+  void read();
+});
+button("download").addEventListener("click", () => {
+  if (!saved) return;
+  const exportedAt = new Date().toISOString();
+  const blob = new Blob(
+    [JSON.stringify({ readerSchema: 1, firstReadAt, exportedAt, snapshot: saved }, null, 2)],
+    { type: "application/json" },
+  );
+  const url = URL.createObjectURL(blob);
+  const anchor = document.createElement("a");
+  anchor.href = url;
+  anchor.download = `bsk-connection-diagnostics-${exportedAt.replace(/[:.]/g, "-")}.json`;
+  anchor.click();
+  URL.revokeObjectURL(url);
+});
+void read();
diff --git a/apps/extension/src/lib/__tests__/connection-diagnostics-integration.test.ts b/apps/extension/src/lib/__tests__/connection-diagnostics-integration.test.ts
new file mode 100644
index 00000000..4351a571
--- /dev/null
+++ b/apps/extension/src/lib/__tests__/connection-diagnostics-integration.test.ts
@@ -0,0 +1,174 @@
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { WSTransport } from "../../transport/ws-transport";
+import { ConnectionController } from "../connection-controller";
+import { createDiagnosticRecorder, setDiagnosticRecorder } from "../connection-diagnostics";
+import { KEEPALIVE_ALARM_NAME, startKeepalive } from "../keepalive";
+
+vi.mock("../instance-id", () => ({
+  getOrCreateInstanceId: vi.fn(async () => "private-instance"),
+  getLabel: vi.fn(async () => "PRIVATE"),
+}));
+class Socket {
+  readyState = 0;
+  listeners = new Map void>>();
+  addEventListener(type: string, callback: (event: unknown) => void) {
+    const listeners = this.listeners.get(type) ?? [];
+    listeners.push(callback);
+    this.listeners.set(type, listeners);
+  }
+  emit(type: string, event: unknown = {}) {
+    for (const callback of this.listeners.get(type) ?? []) callback(event);
+  }
+  open() {
+    this.readyState = 1;
+    this.emit("open");
+  }
+  close() {
+    this.readyState = 3;
+    this.emit("close", { code: 1000, wasClean: true, reason: "PRIVATE" });
+  }
+  serverClose() {
+    this.readyState = 3;
+    this.emit("close", { code: 1006, wasClean: false, reason: "PRIVATE" });
+  }
+  send(text: string) {
+    const frame = JSON.parse(text);
+    if (frame.method === "system.handshake")
+      queueMicrotask(() =>
+        this.emit("message", {
+          data: JSON.stringify({
+            id: frame.id,
+            result: {
+              server: "browser-skill-daemon",
+              version: "0.3.2",
+              protocol_version: "1.3",
+              min_compatible_protocol: "1.0",
+            },
+          }),
+        }),
+      );
+  }
+}
+async function drain() {
+  for (let i = 0; i < 20; i++) await Promise.resolve();
+}
+function setup() {
+  const recorder = createDiagnosticRecorder(
+    { read: async () => undefined, write: async () => {} },
+    { bootId: "test-boot", build: "issue395.1" },
+  );
+  setDiagnosticRecorder(recorder);
+  const sockets: Socket[] = [];
+  const transport = new WSTransport({
+    url: "ws://127.0.0.1:52800/?token=PRIVATE",
+    webSocketFactory() {
+      const socket = new Socket();
+      sockets.push(socket);
+      return socket as unknown as WebSocket;
+    },
+  });
+  return { recorder, sockets, transport };
+}
+beforeEach(() => vi.useFakeTimers());
+afterEach(() => {
+  setDiagnosticRecorder(null);
+  vi.useRealTimers();
+});
+
+it("records the actual keepalive callback and the controller gate for a pending physical connection", async () => {
+  const { recorder, transport } = setup();
+  const controller = new ConnectionController();
+  controller.requestConnect();
+  await controller.attach(transport, { name: "edge", version: "155" });
+  let alarmCallback!: (alarm: { name: string }) => void;
+  const keepalive = startKeepalive({
+    transport,
+    requestConnect: () => controller.requestConnect(),
+    alarms: {
+      create() {},
+      clear() {},
+      onAlarm: {
+        addListener(callback) {
+          alarmCallback = callback;
+        },
+        removeListener() {},
+      },
+    },
+  });
+  alarmCallback({ name: KEEPALIVE_ALARM_NAME });
+  await drain();
+  const events = recorder.snapshot().events;
+  expect(
+    events.some((e) => e.type === "controller.request" && e.details.gate === "not-ready"),
+  ).toBe(true);
+  expect(
+    events.some(
+      (e) =>
+        e.type === "controller.request" &&
+        e.details.gate === "transport-state" &&
+        e.details.state === "connecting",
+    ),
+  ).toBe(true);
+  expect(events.some((e) => e.type === "alarm.tick")).toBe(true);
+  keepalive.dispose();
+  await controller.setConnectionEnabled(false);
+});
+
+it("distinguishes an unfinished cleanup from a pending socket without releasing the gate", async () => {
+  const { recorder, sockets, transport } = setup();
+  let release!: () => void;
+  const pending = new Promise((resolve) => {
+    release = resolve;
+  });
+  const controller = new ConnectionController();
+  await controller.attach(transport, { name: "edge", version: "155" }, true, {
+    onDisconnected: () => pending,
+  });
+  sockets[0].open();
+  await drain();
+  expect(controller.snapshot().state).toBe("connected");
+  sockets[0].serverClose();
+  await drain();
+  controller.requestConnect();
+  try {
+    const events = recorder.snapshot().events;
+    expect(events.some((e) => e.type === "cleanup.begin")).toBe(true);
+    expect(events.some((e) => e.type === "cleanup.complete")).toBe(false);
+    expect(
+      events.some((e) => e.type === "controller.request" && e.details.gate === "transition"),
+    ).toBe(true);
+  } finally {
+    release();
+    await drain();
+    await controller.setConnectionEnabled(false);
+  }
+});
+
+it("records a native close code and generation without socket URL, close text or handshake data", async () => {
+  const { recorder, sockets, transport } = setup();
+  const pending = transport.connect();
+  sockets[0].open();
+  await pending;
+  sockets[0].serverClose();
+  expect(recorder.snapshot().events.find((e) => e.type === "socket.close")?.details).toMatchObject({
+    code: 1006,
+    generation: 1,
+    current: true,
+  });
+  expect(JSON.stringify(recorder.snapshot())).not.toContain("PRIVATE");
+  await transport.disconnect();
+});
+
+it("preserves transport behavior even when the observation sink throws", async () => {
+  const { sockets, transport } = setup();
+  setDiagnosticRecorder({
+    record() {
+      throw new Error("observer failed");
+    },
+  });
+  const pending = transport.connect();
+  sockets[0].open();
+  await expect(pending).resolves.toBeUndefined();
+  expect(transport.state).toBe("connected");
+  await expect(transport.disconnect()).resolves.toBeUndefined();
+});
diff --git a/apps/extension/src/lib/__tests__/connection-diagnostics-runtime.test.ts b/apps/extension/src/lib/__tests__/connection-diagnostics-runtime.test.ts
new file mode 100644
index 00000000..4811f612
--- /dev/null
+++ b/apps/extension/src/lib/__tests__/connection-diagnostics-runtime.test.ts
@@ -0,0 +1,180 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+  DIAGNOSTIC_STORAGE_KEY,
+  type DiagnosticSnapshot,
+  recordDiagnostic,
+  setDiagnosticRecorder,
+} from "../connection-diagnostics";
+import {
+  DIAGNOSTIC_CONTROL_KIND,
+  DIAGNOSTIC_ENABLED_KEY,
+  handleDiagnosticMessage,
+  startConnectionDiagnostics,
+} from "../connection-diagnostics-runtime";
+
+function store(enabled?: unknown) {
+  const values: Record = { [DIAGNOSTIC_ENABLED_KEY]: enabled };
+  return {
+    values,
+    get: vi.fn(async (keys: string | string[]) =>
+      Object.fromEntries(
+        (Array.isArray(keys) ? keys : [keys]).map((key) => [key, structuredClone(values[key])]),
+      ),
+    ),
+    set: vi.fn(async (items: Record) => {
+      Object.assign(values, structuredClone(items));
+    }),
+    remove: vi.fn(async (key: string) => {
+      delete values[key];
+    }),
+  };
+}
+const options = { bootId: "worker-a", build: "0.3.3@commit" };
+const saved = (storage: ReturnType) =>
+  storage.values[DIAGNOSTIC_STORAGE_KEY] as DiagnosticSnapshot;
+const enable = { kind: DIAGNOSTIC_CONTROL_KIND, action: "set-enabled", enabled: true } as const;
+const disable = { ...enable, enabled: false } as const;
+const clear = { kind: DIAGNOSTIC_CONTROL_KIND, action: "clear" } as const;
+async function drain() {
+  for (let i = 0; i < 30; i++) await Promise.resolve();
+}
+afterEach(() => setDiagnosticRecorder(null));
+
+describe("opt-in connection diagnostics runtime", () => {
+  it("isolates synchronous preference API failures from background startup", async () => {
+    const storage = store();
+    storage.get.mockImplementationOnce(() => {
+      throw new Error("storage unavailable");
+    });
+    const runtime = startConnectionDiagnostics(storage, options);
+    await expect(runtime.ready).resolves.toBeUndefined();
+    expect(storage.set).not.toHaveBeenCalled();
+  });
+
+  it.each([
+    undefined,
+    false,
+    "true",
+    1,
+  ])("does not read/write events while preference is %s", async (enabled) => {
+    const storage = store(enabled);
+    const runtime = startConnectionDiagnostics(storage, options);
+    await runtime.ready;
+    recordDiagnostic("alarm.tick", { state: "connecting" });
+    await drain();
+    expect(storage.get).toHaveBeenCalledExactlyOnceWith(DIAGNOSTIC_ENABLED_KEY);
+    expect(storage.set).not.toHaveBeenCalled();
+    expect(storage.remove).not.toHaveBeenCalled();
+  });
+
+  it("starts a worker observation only when opt-in was persisted", async () => {
+    const storage = store(true);
+    const runtime = startConnectionDiagnostics(storage, options);
+    await runtime.ready;
+    const events = saved(storage).events;
+    expect(events).toHaveLength(1);
+    expect(events[0]).toMatchObject({
+      type: "worker.start",
+      bootId: options.bootId,
+      build: options.build,
+    });
+    await runtime.control(disable);
+  });
+
+  it("enables explicitly, stops future observations, and resumes the same boot sequence", async () => {
+    const storage = store();
+    const runtime = startConnectionDiagnostics(storage, options);
+    await runtime.control(enable);
+    recordDiagnostic("socket.open");
+    await runtime.control(disable);
+    const stopped = structuredClone(saved(storage));
+    recordDiagnostic("socket.close");
+    await drain();
+    expect(storage.values[DIAGNOSTIC_STORAGE_KEY]).toEqual(stopped);
+    expect(stopped.events.map((e) => e.type)).toEqual(["diagnostics.enabled", "socket.open"]);
+    await runtime.control(enable);
+    await runtime.control(enable);
+    await runtime.control(disable);
+    expect(saved(storage).events.map((e) => e.sequence)).toEqual([0, 1, 2]);
+    expect(storage.values[DIAGNOSTIC_ENABLED_KEY]).toBe(false);
+  });
+
+  it("drains an already-issued write before clear removes the log", async () => {
+    const storage = store();
+    const write = storage.set.getMockImplementation()!;
+    let release!: () => void;
+    let started!: () => void;
+    const blocked = new Promise((yes) => {
+      release = yes;
+    });
+    const writing = new Promise((yes) => {
+      started = yes;
+    });
+    storage.set.mockImplementation(async (items) => {
+      if (DIAGNOSTIC_STORAGE_KEY in items) {
+        started();
+        await blocked;
+      }
+      await write(items);
+    });
+    const runtime = startConnectionDiagnostics(storage, options);
+    const enabling = runtime.control(enable);
+    await writing;
+    recordDiagnostic("socket.open");
+    const clearing = runtime.control(clear);
+    await drain();
+    expect(storage.remove).not.toHaveBeenCalled();
+    release();
+    await Promise.all([enabling, clearing]);
+    expect(storage.values[DIAGNOSTIC_STORAGE_KEY]).toBeUndefined();
+    expect(storage.values[DIAGNOSTIC_ENABLED_KEY]).toBe(false);
+    const calls = storage.set.mock.calls.length;
+    recordDiagnostic("alarm.tick");
+    await drain();
+    expect(storage.set).toHaveBeenCalledTimes(calls);
+    expect(storage.values[DIAGNOSTIC_STORAGE_KEY]).toBeUndefined();
+  });
+
+  it("keeps startup disabled after a preference read error", async () => {
+    const storage = store(true);
+    storage.get.mockRejectedValueOnce(new Error("private storage error"));
+    const runtime = startConnectionDiagnostics(storage, options);
+    await runtime.ready;
+    recordDiagnostic("socket.attempt");
+    await drain();
+    expect(storage.set).not.toHaveBeenCalled();
+  });
+
+  it("does not let a failed settings write poison subsequent controls", async () => {
+    const storage = store();
+    storage.set.mockRejectedValueOnce(new Error("private error"));
+    const runtime = startConnectionDiagnostics(storage, options);
+    await expect(runtime.control(enable)).rejects.toThrow("private error");
+    recordDiagnostic("socket.open");
+    await drain();
+    expect(storage.values[DIAGNOSTIC_STORAGE_KEY]).toBeUndefined();
+    await runtime.control(enable);
+    expect(saved(storage).events).toHaveLength(1);
+    await runtime.control(clear);
+  });
+
+  it("only accepts valid controls from this extension and replies generically on failure", async () => {
+    const runtime = { control: vi.fn(async () => {}) };
+    const reply = vi.fn();
+    expect(handleDiagnosticMessage(runtime, enable, "other", "self", reply)).toBe(false);
+    expect(
+      handleDiagnosticMessage(runtime, { ...enable, enabled: "yes" }, "self", "self", reply),
+    ).toBe(false);
+    expect(handleDiagnosticMessage(runtime, { kind: "unrelated" }, "self", "self", reply)).toBe(
+      false,
+    );
+    expect(runtime.control).not.toHaveBeenCalled();
+    runtime.control.mockRejectedValueOnce(new Error("PRIVATE"));
+    expect(handleDiagnosticMessage(runtime, enable, "self", "self", reply)).toBe(true);
+    await drain();
+    expect(reply).toHaveBeenLastCalledWith({ ok: false });
+    expect(handleDiagnosticMessage(runtime, clear, "self", "self", reply)).toBe(true);
+    await drain();
+    expect(reply).toHaveBeenLastCalledWith({ ok: true });
+  });
+});
diff --git a/apps/extension/src/lib/__tests__/connection-diagnostics.test.ts b/apps/extension/src/lib/__tests__/connection-diagnostics.test.ts
new file mode 100644
index 00000000..0bf8cd0a
--- /dev/null
+++ b/apps/extension/src/lib/__tests__/connection-diagnostics.test.ts
@@ -0,0 +1,254 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+  createDiagnosticRecorder,
+  type DiagnosticSnapshot,
+  MAX_DIAGNOSTIC_EVENTS,
+  recordDiagnostic,
+  setDiagnosticRecorder,
+} from "../connection-diagnostics";
+
+function memoryStore(initial?: unknown) {
+  let value = initial;
+  return {
+    read: vi.fn(async () => structuredClone(value)),
+    write: vi.fn(async (snapshot: DiagnosticSnapshot) => {
+      value = structuredClone(snapshot);
+    }),
+    value: () => value as DiagnosticSnapshot,
+  };
+}
+const options = (bootId = "boot-a") => ({ bootId, build: "issue395.1" });
+afterEach(() => {
+  setDiagnosticRecorder(null);
+  vi.restoreAllMocks();
+});
+
+describe("connection diagnostic recorder", () => {
+  it("ignores unknown fields that share names with object prototype properties", async () => {
+    const storage = memoryStore();
+    const recorder = createDiagnosticRecorder(storage, options());
+    recorder.record("controller.request", {
+      constructor: "PRIVATE",
+      toString: "PRIVATE",
+      state: "connecting",
+    });
+    await recorder.flush();
+    expect(storage.value().events.at(-1)?.details).toEqual({ state: "connecting" });
+  });
+
+  it("does not report a worker restart when recording is enabled later", async () => {
+    const storage = memoryStore();
+    const recorder = createDiagnosticRecorder(storage, {
+      ...options(),
+      initialEvent: "diagnostics.enabled",
+    });
+    await recorder.flush();
+    expect(storage.value().events.map((event) => event.type)).toEqual(["diagnostics.enabled"]);
+  });
+
+  it("continues retained sequence numbers when recording restarts in the same worker", async () => {
+    const storage = memoryStore();
+    const first = createDiagnosticRecorder(storage, options());
+    first.record("socket.open");
+    await first.flush();
+    const second = createDiagnosticRecorder(storage, {
+      ...options(),
+      initialEvent: "diagnostics.enabled",
+    });
+    second.record("socket.close");
+    await second.flush();
+    expect(storage.value().events.map((event) => event.sequence)).toEqual([0, 1, 2, 3]);
+  });
+
+  it("preserves earlier boot events when a new recorder reads persisted storage", async () => {
+    const storage = memoryStore();
+    const first = createDiagnosticRecorder(storage, options());
+    first.record("socket.open", { generation: 1 });
+    await first.flush();
+    const second = createDiagnosticRecorder(storage, options("boot-b"));
+    await second.flush();
+    expect(
+      second
+        .snapshot()
+        .events.filter((e) => e.type === "worker.start")
+        .map((e) => e.bootId),
+    ).toEqual(["boot-a", "boot-b"]);
+    expect(
+      storage.value().events.some((e) => e.type === "socket.open" && e.bootId === "boot-a"),
+    ).toBe(true);
+  });
+
+  it("bounds the buffer and counts discarded events across a restart", async () => {
+    const storage = memoryStore();
+    const recorder = createDiagnosticRecorder(storage, options());
+    for (let i = 0; i < MAX_DIAGNOSTIC_EVENTS + 5; i++)
+      recorder.record("socket.attempt", { generation: i });
+    await recorder.flush();
+    expect(recorder.snapshot().events).toHaveLength(MAX_DIAGNOSTIC_EVENTS);
+    expect(recorder.snapshot().droppedEvents).toBe(6);
+    const second = createDiagnosticRecorder(storage, options("boot-b"));
+    await second.flush();
+    expect(second.snapshot().droppedEvents).toBe(7);
+    expect(second.snapshot().events.at(-1)?.type).toBe("worker.start");
+  });
+
+  it("retains events produced while the asynchronous initial read is pending", async () => {
+    let finishRead!: (value: unknown) => void;
+    const storage = memoryStore();
+    storage.read.mockImplementation(
+      () =>
+        new Promise((resolve) => {
+          finishRead = resolve;
+        }),
+    );
+    const recorder = createDiagnosticRecorder(storage, options());
+    recorder.record("socket.attempt", { generation: 1 });
+    await Promise.resolve();
+    finishRead(undefined);
+    await recorder.flush();
+    expect(storage.value().events.map((e) => e.type)).toEqual(["worker.start", "socket.attempt"]);
+  });
+
+  it("serializes overlapping writes so a later event cannot be overwritten", async () => {
+    let releaseWrite!: () => void;
+    let signalWrite!: () => void;
+    const started = new Promise((resolve) => {
+      signalWrite = resolve;
+    });
+    const blocked = new Promise((resolve) => {
+      releaseWrite = resolve;
+    });
+    const storage = memoryStore();
+    const originalWrite = storage.write.getMockImplementation()!;
+    storage.write.mockImplementationOnce(async (snapshot) => {
+      signalWrite();
+      await blocked;
+      await originalWrite(snapshot);
+    });
+    const recorder = createDiagnosticRecorder(storage, options());
+    const pending = recorder.flush();
+    await started;
+    recorder.record("socket.open", { generation: 1 });
+    releaseWrite();
+    await pending;
+    await recorder.flush();
+    expect(storage.value().events.at(-1)?.type).toBe("socket.open");
+    expect(storage.write).toHaveBeenCalledTimes(2);
+  });
+
+  it("merges a synchronous event burst into one storage write without timers", async () => {
+    const timeout = vi.spyOn(globalThis, "setTimeout");
+    const interval = vi.spyOn(globalThis, "setInterval");
+    const storage = memoryStore();
+    const recorder = createDiagnosticRecorder(storage, options());
+    for (let i = 0; i < 50; i++) recorder.record("alarm.tick", { state: "connecting" });
+    await recorder.flush();
+    expect(storage.write).toHaveBeenCalledTimes(1);
+    expect(timeout).not.toHaveBeenCalled();
+    expect(interval).not.toHaveBeenCalled();
+  });
+
+  it("persists an event arriving between write completion and release of the writer", async () => {
+    const storage = memoryStore();
+    const recorder = createDiagnosticRecorder(storage, options());
+    await recorder.flush();
+    let release!: () => void;
+    let started!: () => void;
+    const writing = new Promise((resolve) => {
+      started = resolve;
+    });
+    const originalWrite = storage.write.getMockImplementation()!;
+    storage.write.mockImplementationOnce(
+      (snapshot) =>
+        new Promise((resolve) => {
+          void originalWrite(snapshot);
+          release = resolve;
+          started();
+        }),
+    );
+    recorder.record("socket.open", { generation: 1 });
+    const pending = recorder.flush();
+    await writing;
+    release();
+    queueMicrotask(() => recorder.record("socket.close", { generation: 1, code: 1006 }));
+    await pending;
+    for (let i = 0; i < 8; i++) await Promise.resolve();
+    expect(storage.value().events.at(-1)?.type).toBe("socket.close");
+  });
+
+  it("isolates a failed read and still records this worker", async () => {
+    const storage = memoryStore();
+    storage.read.mockRejectedValueOnce(new Error("private storage error"));
+    const recorder = createDiagnosticRecorder(storage, options());
+    await expect(recorder.flush()).resolves.toBeUndefined();
+    expect(recorder.snapshot().readFailures).toBe(1);
+    expect(storage.value().events.at(-1)?.type).toBe("worker.start");
+    expect(JSON.stringify(storage.value())).not.toContain("private storage error");
+  });
+
+  it("does not autonomously retry failed writes and reports failure on the next successful write", async () => {
+    const storage = memoryStore();
+    storage.write.mockRejectedValueOnce(new Error("quota"));
+    const recorder = createDiagnosticRecorder(storage, options());
+    await recorder.flush();
+    expect(recorder.snapshot().writeFailures).toBe(1);
+    expect(storage.write).toHaveBeenCalledTimes(1);
+    recorder.record("socket.open", { generation: 1 });
+    await recorder.flush();
+    expect(storage.value().writeFailures).toBe(1);
+    expect(storage.value().events.at(-1)?.type).toBe("socket.open");
+  });
+
+  it("drops arbitrary payload fields and invalid values from new and persisted events", async () => {
+    const storage = memoryStore();
+    const recorder = createDiagnosticRecorder(storage, options());
+    recorder.record("socket.close", {
+      code: 1006,
+      generation: 2,
+      reason: "PRIVATE",
+      url: "PRIVATE",
+      token: "PRIVATE",
+      gate: "PRIVATE",
+      payload: { secret: "PRIVATE" },
+    });
+    await recorder.flush();
+    const poisoned = storage.value();
+    Object.assign(poisoned.events[0], { payload: "PRIVATE" });
+    Object.assign(poisoned.events[0].details, { token: "PRIVATE" });
+    storage.read.mockResolvedValue(poisoned);
+    const second = createDiagnosticRecorder(storage, options("boot-b"));
+    await second.flush();
+    expect(JSON.stringify(second.snapshot())).not.toContain("PRIVATE");
+    expect(second.snapshot().events.find((e) => e.type === "socket.close")?.details).toEqual({
+      code: 1006,
+      generation: 2,
+    });
+  });
+
+  it("returns detached snapshots and records per-boot ordering plus both clocks", async () => {
+    const storage = memoryStore();
+    const recorder = createDiagnosticRecorder(storage, {
+      ...options(),
+      clock: () => ({ at: "2026-10-17T00:00:00.000Z", monotonicMs: 42 }),
+    });
+    recorder.record("socket.open", { generation: 1 });
+    const snapshot = recorder.snapshot();
+    snapshot.events.at(-1)!.details.generation = 99;
+    expect(recorder.snapshot().events.at(-1)).toMatchObject({
+      sequence: 1,
+      monotonicMs: 42,
+      at: "2026-10-17T00:00:00.000Z",
+      details: { generation: 1 },
+    });
+    await recorder.flush();
+  });
+
+  it("never lets an observer exception escape into the connection caller", () => {
+    setDiagnosticRecorder({
+      record() {
+        throw new Error("observer failed");
+      },
+    });
+    expect(() => recordDiagnostic("socket.open", { generation: 1 })).not.toThrow();
+  });
+});
diff --git a/apps/extension/src/lib/__tests__/diagnostics-page.test.ts b/apps/extension/src/lib/__tests__/diagnostics-page.test.ts
new file mode 100644
index 00000000..4645b328
--- /dev/null
+++ b/apps/extension/src/lib/__tests__/diagnostics-page.test.ts
@@ -0,0 +1,130 @@
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { DIAGNOSTIC_STORAGE_KEY } from "../connection-diagnostics";
+import { DIAGNOSTIC_CONTROL_KIND, DIAGNOSTIC_ENABLED_KEY } from "../connection-diagnostics-runtime";
+
+const snapshot = {
+  schema: 1,
+  capturedAt: "2026-10-17T00:00:00.000Z",
+  droppedEvents: 0,
+  readFailures: 0,
+  writeFailures: 0,
+  events: [{ bootId: "boot-a", type: "worker.start" }],
+};
+let get: ReturnType;
+let sendMessage: ReturnType;
+const button = (id: string) => document.getElementById(id) as HTMLButtonElement;
+const loaded = () => vi.waitFor(() => expect(button("refresh").disabled).toBe(false));
+beforeEach(() => {
+  vi.resetModules();
+  document.body.innerHTML =
+    '

';
+  get = vi.fn(async () => ({
+    [DIAGNOSTIC_ENABLED_KEY]: false,
+    [DIAGNOSTIC_STORAGE_KEY]: structuredClone(snapshot),
+  }));
+  sendMessage = vi.fn(async () => ({ ok: true }));
+  vi.stubGlobal("chrome", { storage: { local: { get } }, runtime: { sendMessage } });
+});
+afterEach(() => {
+  vi.restoreAllMocks();
+  vi.unstubAllGlobals();
+  document.body.innerHTML = "";
+});
+
+it("initial read and refresh only read storage, leaving the worker untouched", async () => {
+  await import("../../entrypoints/connection-diagnostics/main");
+  await loaded();
+  expect(get).toHaveBeenCalledWith([DIAGNOSTIC_ENABLED_KEY, DIAGNOSTIC_STORAGE_KEY]);
+  expect(document.getElementById("log")!.textContent).toContain("boot-a");
+  expect(button("start").disabled).toBe(false);
+  expect(button("stop").disabled).toBe(true);
+  button("refresh").click();
+  await loaded();
+  expect(get).toHaveBeenCalledTimes(2);
+  expect(sendMessage).not.toHaveBeenCalled();
+});
+
+it("exports a stored snapshot and timestamps only after explicit download", async () => {
+  let exported!: Blob;
+  const create = vi.spyOn(URL, "createObjectURL").mockImplementation((blob) => {
+    exported = blob as Blob;
+    return "blob:test";
+  });
+  vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
+  const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
+  await import("../../entrypoints/connection-diagnostics/main");
+  await loaded();
+  expect(create).not.toHaveBeenCalled();
+  button("download").click();
+  expect(click).toHaveBeenCalledOnce();
+  const data = JSON.parse(await exported.text());
+  expect(data.snapshot).toEqual(snapshot);
+  expect(data.firstReadAt).toMatch(/^\d{4}-/);
+  expect(data.exportedAt).toMatch(/^\d{4}-/);
+  expect(sendMessage).not.toHaveBeenCalled();
+});
+
+it("only sends a recording control on explicit start and locks controls while pending", async () => {
+  let finish!: (value: { ok: boolean }) => void;
+  sendMessage.mockImplementationOnce(
+    () =>
+      new Promise((yes) => {
+        finish = yes;
+      }),
+  );
+  await import("../../entrypoints/connection-diagnostics/main");
+  await loaded();
+  button("start").click();
+  expect(sendMessage).toHaveBeenCalledExactlyOnceWith({
+    kind: DIAGNOSTIC_CONTROL_KIND,
+    action: "set-enabled",
+    enabled: true,
+  });
+  expect(button("start").disabled).toBe(true);
+  expect(button("clear").disabled).toBe(true);
+  get.mockResolvedValue({ [DIAGNOSTIC_ENABLED_KEY]: true, [DIAGNOSTIC_STORAGE_KEY]: snapshot });
+  finish({ ok: true });
+  await loaded();
+  expect(button("stop").disabled).toBe(false);
+  button("stop").click();
+  await loaded();
+  expect(sendMessage).toHaveBeenLastCalledWith({
+    kind: DIAGNOSTIC_CONTROL_KIND,
+    action: "set-enabled",
+    enabled: false,
+  });
+});
+
+it("clears through the background command, without direct storage mutations", async () => {
+  await import("../../entrypoints/connection-diagnostics/main");
+  await loaded();
+  get.mockResolvedValue({ [DIAGNOSTIC_ENABLED_KEY]: false });
+  button("clear").click();
+  await loaded();
+  expect(sendMessage).toHaveBeenCalledExactlyOnceWith({
+    kind: DIAGNOSTIC_CONTROL_KIND,
+    action: "clear",
+  });
+  expect(button("download").disabled).toBe(true);
+  expect(document.getElementById("log")!.textContent).toBe("");
+});
+
+it("reports read failures without displaying arbitrary storage error text", async () => {
+  get.mockRejectedValue(new Error("PRIVATE"));
+  await import("../../entrypoints/connection-diagnostics/main");
+  await loaded();
+  expect(document.getElementById("status")!.textContent).toContain("Unable to read");
+  expect(document.body.textContent).not.toContain("PRIVATE");
+  expect(button("download").disabled).toBe(true);
+  expect(sendMessage).not.toHaveBeenCalled();
+});
+
+it("shows failed controls as errors even when refreshing stored state succeeds", async () => {
+  sendMessage.mockResolvedValue({ ok: false });
+  await import("../../entrypoints/connection-diagnostics/main");
+  await loaded();
+  button("start").click();
+  await loaded();
+  expect(document.getElementById("status")!.textContent).toContain("Unable to change");
+  expect(button("start").disabled).toBe(false);
+});
diff --git a/apps/extension/src/lib/connection-controller.ts b/apps/extension/src/lib/connection-controller.ts
index 013b7152..a577713b 100644
--- a/apps/extension/src/lib/connection-controller.ts
+++ b/apps/extension/src/lib/connection-controller.ts
@@ -6,6 +6,7 @@ import {
 } from "../transport/handshake";
 import type { Transport } from "../transport/transport";
 import type { ConnectionState, HandshakeResult } from "../transport/types";
+import { recordDiagnostic } from "./connection-diagnostics";
 import { getLabel, getOrCreateInstanceId } from "./instance-id";
 import { compareProtocol, parseProtocolMajor } from "./semver";
 
@@ -162,6 +163,30 @@ export class ConnectionController {
 
   /** Shared entry point for startup, wake events, keepalive, and handshake retries. */
   requestConnect(): void {
+    recordDiagnostic("controller.request", {
+      state: this.transport?.state ?? "missing",
+      generation: this.connectionGeneration,
+      enabled: this.connectionEnabled,
+      ready: this.ready,
+      transition: this.transition !== null,
+      cleanupNeeded: this.cleanupNeeded,
+      handshakeRetry: this.handshakeRetryTimer !== null,
+      gate: !this.ready
+        ? "not-ready"
+        : !this.connectionEnabled
+          ? "disabled"
+          : this.transition
+            ? "transition"
+            : this.cleanupNeeded
+              ? "cleanup"
+              : this.handshakeRetryTimer
+                ? "handshake-retry"
+                : !this.transport
+                  ? "no-transport"
+                  : this.transport.state !== "disconnected"
+                    ? "transport-state"
+                    : "connect",
+    });
     if (!this.ready || !this.connectionEnabled || this.transition) return;
     if (this.cleanupNeeded) {
       void this.teardown(false);
@@ -210,6 +235,7 @@ export class ConnectionController {
     if (!this.transport) return;
     if (!this.connectionEnabled || this.transition) return;
     this.setState("connecting");
+    recordDiagnostic("handshake.begin", { generation });
     try {
       const outcome = await performHandshake(
         this.transport,
@@ -224,6 +250,10 @@ export class ConnectionController {
       if (generation !== this.connectionGeneration || signal.aborted) return;
       this.handshake = outcome.result;
       const verdict = computeConnectedState(outcome.result);
+      recordDiagnostic("handshake.complete", {
+        generation,
+        outcome: verdict.kind === "rejected" ? "rejected" : "success",
+      });
       if (verdict.kind === "rejected") {
         this.lastError = `version_too_old: ${verdict.reason}`;
         this.handshake = null;
@@ -235,6 +265,16 @@ export class ConnectionController {
       this.lastError = null;
       this.setState(verdict.kind);
     } catch (err) {
+      recordDiagnostic("handshake.failed", {
+        generation,
+        outcome:
+          signal.aborted || isAbortError(err)
+            ? "aborted"
+            : err instanceof Error &&
+                err.message === "[handshake] timed out waiting for daemon response"
+              ? "timeout"
+              : "error",
+      });
       if (generation !== this.connectionGeneration || signal.aborted || isAbortError(err)) return;
       this.handshake = null;
       this.lastError = err instanceof Error ? err.message : String(err);
@@ -256,6 +296,10 @@ export class ConnectionController {
   /** Only finite teardown work is shared; the next connect is interruptible. */
   private teardown(cleanupBeforeDisconnect: boolean): Promise {
     if (this.transition) return this.transition;
+    recordDiagnostic("cleanup.begin", {
+      phase: cleanupBeforeDisconnect ? "before-disconnect" : "after-disconnect",
+      generation: this.connectionGeneration,
+    });
     this.cleanupNeeded = true;
     this.cancelHandshake();
     this.clearHandshakeRetry();
@@ -281,7 +325,12 @@ export class ConnectionController {
         this.cleanupNeeded = false;
         this.hasConnected = false;
         this.setState("disconnected");
+        recordDiagnostic("cleanup.complete", { generation: this.connectionGeneration });
       } catch (err) {
+        recordDiagnostic("cleanup.failed", {
+          generation: this.connectionGeneration,
+          outcome: "error",
+        });
         // Keep the pending configuration and cleanup gate for a later retry.
         this.reportError(err);
       } finally {
@@ -313,6 +362,8 @@ export class ConnectionController {
   }
 
   private cancelHandshake(): void {
+    if (this.handshakeAbort)
+      recordDiagnostic("handshake.cancel", { generation: this.connectionGeneration });
     this.connectionGeneration += 1;
     this.handshakeAbort?.abort();
     this.handshakeAbort = null;
@@ -320,6 +371,10 @@ export class ConnectionController {
 
   private scheduleHandshakeRetry(): void {
     if (this.handshakeRetryTimer || !this.connectionEnabled) return;
+    recordDiagnostic("handshake.retry-scheduled", {
+      delayMs: HANDSHAKE_RETRY_DELAY_MS,
+      generation: this.connectionGeneration,
+    });
     this.handshakeRetryTimer = setTimeout(() => {
       this.handshakeRetryTimer = null;
       this.requestConnect();
@@ -335,6 +390,7 @@ export class ConnectionController {
   private setState(next: ConnectionState): void {
     if (this.currentState === next) return;
     this.currentState = next;
+    recordDiagnostic("controller.state", { state: next, generation: this.connectionGeneration });
     this.fire();
   }
 
diff --git a/apps/extension/src/lib/connection-diagnostics-runtime.ts b/apps/extension/src/lib/connection-diagnostics-runtime.ts
new file mode 100644
index 00000000..bed13d93
--- /dev/null
+++ b/apps/extension/src/lib/connection-diagnostics-runtime.ts
@@ -0,0 +1,101 @@
+import {
+  createDiagnosticRecorder,
+  DIAGNOSTIC_STORAGE_KEY,
+  type DiagnosticRecorder,
+  setDiagnosticRecorder,
+} from "./connection-diagnostics";
+
+export const DIAGNOSTIC_ENABLED_KEY = "bsk_connection_diagnostics_enabled";
+export const DIAGNOSTIC_CONTROL_KIND = "bsk:connection-diagnostics";
+
+interface DiagnosticsStorage {
+  get(keys: string | string[]): Promise>;
+  set(items: Record): Promise;
+  remove(key: string): Promise;
+}
+export type DiagnosticCommand =
+  | { kind: typeof DIAGNOSTIC_CONTROL_KIND; action: "set-enabled"; enabled: boolean }
+  | { kind: typeof DIAGNOSTIC_CONTROL_KIND; action: "clear" };
+
+/** Background owns writes and control ordering; the options page only reads events. */
+export function startConnectionDiagnostics(
+  storage: DiagnosticsStorage,
+  options: { bootId: string; build: string },
+) {
+  let active: DiagnosticRecorder | null = null;
+  setDiagnosticRecorder(null);
+  async function start(initialEvent: "worker.start" | "diagnostics.enabled") {
+    if (active) return;
+    active = createDiagnosticRecorder(
+      {
+        read: async () => (await storage.get(DIAGNOSTIC_STORAGE_KEY))[DIAGNOSTIC_STORAGE_KEY],
+        write: (snapshot) => storage.set({ [DIAGNOSTIC_STORAGE_KEY]: snapshot }),
+      },
+      { ...options, initialEvent },
+    );
+    setDiagnosticRecorder(active);
+    await active.flush();
+  }
+  async function stop() {
+    setDiagnosticRecorder(null);
+    const previous = active;
+    active = null;
+    await previous?.flush();
+  }
+  // Connection startup never waits for this optional preference or event storage.
+  const ready = Promise.resolve()
+    .then(() => storage.get(DIAGNOSTIC_ENABLED_KEY))
+    .then(async (items) => {
+      if (items[DIAGNOSTIC_ENABLED_KEY] === true) await start("worker.start");
+    })
+    .catch(() => {
+      // An unreadable preference must not silently opt the user in.
+    });
+  let tail = ready;
+  function control(command: DiagnosticCommand): Promise {
+    const next = tail.then(async () => {
+      if (command.action === "set-enabled" && command.enabled) {
+        await storage.set({ [DIAGNOSTIC_ENABLED_KEY]: true });
+        await start("diagnostics.enabled");
+      } else {
+        await stop();
+        await storage.set({ [DIAGNOSTIC_ENABLED_KEY]: false });
+        if (command.action === "clear") await storage.remove(DIAGNOSTIC_STORAGE_KEY);
+      }
+    });
+    // A failed explicit command is reported to its caller without poisoning later controls.
+    tail = next.catch(() => {});
+    return next;
+  }
+  return { ready, control };
+}
+
+/** Only explicit controls from this extension may wake/manage its recorder. */
+export function handleDiagnosticMessage(
+  runtime: { control(command: DiagnosticCommand): Promise },
+  message: unknown,
+  senderId: string | undefined,
+  extensionId: string,
+  reply: (response: { ok: boolean }) => void,
+): boolean {
+  if (senderId !== extensionId || !message || typeof message !== "object") return false;
+  const command = message as Partial;
+  if (command.kind !== DIAGNOSTIC_CONTROL_KIND) return false;
+  if (
+    command.action !== "clear" &&
+    !(command.action === "set-enabled" && typeof command.enabled === "boolean")
+  )
+    return false;
+  const respond = (ok: boolean) => {
+    try {
+      reply({ ok });
+    } catch {
+      /* The options page may have closed. */
+    }
+  };
+  void runtime.control(command as DiagnosticCommand).then(
+    () => respond(true),
+    () => respond(false),
+  );
+  return true;
+}
diff --git a/apps/extension/src/lib/connection-diagnostics.ts b/apps/extension/src/lib/connection-diagnostics.ts
new file mode 100644
index 00000000..a9afe575
--- /dev/null
+++ b/apps/extension/src/lib/connection-diagnostics.ts
@@ -0,0 +1,261 @@
+/** Optional observations; no connection policy or timers live in this module. */
+export const DIAGNOSTIC_STORAGE_KEY = "bsk_connection_diagnostics_v1";
+export const MAX_DIAGNOSTIC_EVENTS = 2048;
+
+const EVENT_TYPES = [
+  "worker.start",
+  "diagnostics.enabled",
+  "socket.attempt",
+  "socket.factory-failed",
+  "socket.open",
+  "socket.close",
+  "socket.error",
+  "socket.explicit-disconnect",
+  "socket.retry-scheduled",
+  "transport.state",
+  "controller.request",
+  "controller.state",
+  "cleanup.begin",
+  "cleanup.complete",
+  "cleanup.failed",
+  "handshake.begin",
+  "handshake.complete",
+  "handshake.failed",
+  "handshake.cancel",
+  "handshake.retry-scheduled",
+  "alarm.tick",
+  "alarm.skipped",
+  "heartbeat.send-failed",
+] as const;
+export type DiagnosticEventType = (typeof EVENT_TYPES)[number];
+type Details = Record;
+export interface DiagnosticEvent {
+  bootId: string;
+  build: string;
+  sequence: number;
+  at: string;
+  monotonicMs: number;
+  type: DiagnosticEventType;
+  details: Details;
+}
+export interface DiagnosticSnapshot {
+  schema: 1;
+  capturedAt: string;
+  events: DiagnosticEvent[];
+  droppedEvents: number;
+  readFailures: number;
+  writeFailures: number;
+}
+export interface DiagnosticStorage {
+  read(): Promise;
+  write(snapshot: DiagnosticSnapshot): Promise;
+}
+export interface DiagnosticRecorder {
+  record(type: DiagnosticEventType, details?: Record): void;
+  snapshot(): DiagnosticSnapshot;
+  flush(): Promise;
+}
+
+const strings: Record = {
+  state: ["missing", "disconnected", "connecting", "connected", "version_skew"],
+  gate: [
+    "not-ready",
+    "disabled",
+    "transition",
+    "cleanup",
+    "handshake-retry",
+    "no-transport",
+    "transport-state",
+    "connect",
+    "connected",
+    "predicate",
+  ],
+  outcome: ["success", "timeout", "aborted", "error", "rejected"],
+  phase: ["before-disconnect", "after-disconnect"],
+};
+const numbers = new Set(["generation", "attempt", "delayMs", "code", "elapsedMs"]);
+const booleans = new Set([
+  "enabled",
+  "ready",
+  "transition",
+  "cleanupNeeded",
+  "handshakeRetry",
+  "current",
+  "wasClean",
+]);
+function sanitizeDetails(input: unknown): Details {
+  const result: Details = {};
+  if (!input || typeof input !== "object") return result;
+  for (const [key, value] of Object.entries(input)) {
+    if (typeof value === "string" && Object.hasOwn(strings, key) && strings[key].includes(value))
+      result[key] = value;
+    if (typeof value === "number" && numbers.has(key) && Number.isFinite(value) && value >= 0)
+      result[key] = value;
+    if (typeof value === "boolean" && booleans.has(key)) result[key] = value;
+  }
+  return result;
+}
+function count(input: unknown): number {
+  return typeof input === "number" && Number.isSafeInteger(input) && input >= 0 ? input : 0;
+}
+function sanitizeEvent(input: unknown): DiagnosticEvent | null {
+  if (!input || typeof input !== "object") return null;
+  const e = input as DiagnosticEvent;
+  if (
+    !EVENT_TYPES.includes(e.type) ||
+    typeof e.bootId !== "string" ||
+    !/^[a-zA-Z0-9-]{1,64}$/.test(e.bootId) ||
+    typeof e.build !== "string" ||
+    !/^[a-zA-Z0-9._@+-]{1,96}$/.test(e.build) ||
+    !Number.isSafeInteger(e.sequence) ||
+    e.sequence < 0 ||
+    typeof e.at !== "string" ||
+    !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(e.at) ||
+    !Number.isFinite(Date.parse(e.at)) ||
+    !Number.isFinite(e.monotonicMs) ||
+    e.monotonicMs < 0
+  )
+    return null;
+  return {
+    bootId: e.bootId,
+    build: e.build,
+    sequence: e.sequence,
+    at: e.at,
+    monotonicMs: e.monotonicMs,
+    type: e.type,
+    details: sanitizeDetails(e.details),
+  };
+}
+
+export function createDiagnosticRecorder(
+  storage: DiagnosticStorage,
+  options: {
+    bootId: string;
+    build: string;
+    initialEvent?: "worker.start" | "diagnostics.enabled";
+    clock?: () => { at: string; monotonicMs: number };
+  },
+): DiagnosticRecorder {
+  const clock =
+    options.clock ?? (() => ({ at: new Date().toISOString(), monotonicMs: performance.now() }));
+  let events: DiagnosticEvent[] = [];
+  let droppedEvents = 0;
+  let readFailures = 0;
+  let writeFailures = 0;
+  let sequence = 0;
+  let revision = 0;
+  let savedRevision = -1;
+  let initialized = false;
+  let queued = false;
+  let writing: Promise | null = null;
+  function bound() {
+    if (events.length > MAX_DIAGNOSTIC_EVENTS) {
+      droppedEvents += events.length - MAX_DIAGNOSTIC_EVENTS;
+      events = events.slice(-MAX_DIAGNOSTIC_EVENTS);
+    }
+  }
+  function schedule() {
+    if (!initialized || queued || writing) return;
+    queued = true;
+    queueMicrotask(() => {
+      queued = false;
+      void flush();
+    });
+  }
+  function record(type: DiagnosticEventType, details: Record = {}) {
+    if (!EVENT_TYPES.includes(type)) return;
+    events.push({
+      ...clock(),
+      bootId: options.bootId,
+      build: options.build,
+      sequence: sequence++,
+      type,
+      details: sanitizeDetails(details),
+    });
+    bound();
+    revision++;
+    schedule();
+  }
+  function snapshot(): DiagnosticSnapshot {
+    return {
+      schema: 1,
+      capturedAt: clock().at,
+      events: events.map((event) => ({ ...event, details: { ...event.details } })),
+      droppedEvents,
+      readFailures,
+      writeFailures,
+    };
+  }
+  const ready = Promise.resolve()
+    .then(() => storage.read())
+    .then((raw) => {
+      if (raw === undefined) return;
+      const previous = raw as Partial | null;
+      if (!previous || previous.schema !== 1 || !Array.isArray(previous.events)) {
+        readFailures++;
+        return;
+      }
+      const history = previous.events
+        .map(sanitizeEvent)
+        .filter((event): event is DiagnosticEvent => event !== null);
+      droppedEvents += count(previous.droppedEvents) + previous.events.length - history.length;
+      readFailures += count(previous.readFailures);
+      writeFailures += count(previous.writeFailures);
+      const offset = history.reduce(
+        (next, event) =>
+          event.bootId === options.bootId ? Math.max(next, event.sequence + 1) : next,
+        0,
+      );
+      for (const event of events) event.sequence += offset;
+      sequence += offset;
+      events = [...history, ...events];
+      bound();
+    })
+    .catch(() => {
+      readFailures++;
+    })
+    .then(() => {
+      initialized = true;
+      schedule();
+    });
+
+  async function flush(): Promise {
+    await ready;
+    if (writing) return writing;
+    let failed = false;
+    writing = (async () => {
+      while (savedRevision !== revision) {
+        const target = revision;
+        try {
+          await storage.write(snapshot());
+          savedRevision = target;
+        } catch {
+          writeFailures++;
+          failed = true;
+          break;
+        } // A later real event may retry; never install a retry timer.
+      }
+    })().finally(() => {
+      writing = null;
+      if (!failed && savedRevision !== revision) schedule();
+    });
+    return writing;
+  }
+  record(options.initialEvent ?? "worker.start");
+  return { record, snapshot, flush };
+}
+
+let recorder: Pick | null = null;
+export function setDiagnosticRecorder(next: Pick | null): void {
+  recorder = next;
+}
+export function recordDiagnostic(
+  type: DiagnosticEventType,
+  details?: Record,
+): void {
+  try {
+    recorder?.record(type, details);
+  } catch {
+    /* Observations must not alter connection control flow. */
+  }
+}
diff --git a/apps/extension/src/lib/heartbeat.ts b/apps/extension/src/lib/heartbeat.ts
index 7869291d..66a924c2 100644
--- a/apps/extension/src/lib/heartbeat.ts
+++ b/apps/extension/src/lib/heartbeat.ts
@@ -22,6 +22,7 @@
  */
 
 import type { ProtocolFrame } from "@/transport/types";
+import { recordDiagnostic } from "./connection-diagnostics";
 
 export const HEARTBEAT_EVENT = "system.heartbeat";
 /** 20s: the interval Chrome's own WebSocket keepalive sample recommends. */
@@ -75,6 +76,7 @@ export function startHeartbeat(options: HeartbeatOptions): HeartbeatHandle {
     try {
       options.send({ event: HEARTBEAT_EVENT, payload: {} });
     } catch (err) {
+      recordDiagnostic("heartbeat.send-failed", { outcome: "error" });
       // The socket raced a close between the state change and this
       // tick. The transport's own reconnect path recovers; swallow so
       // the interval keeps running (it is cleared on the disconnect
diff --git a/apps/extension/src/lib/keepalive.ts b/apps/extension/src/lib/keepalive.ts
index 5e7fc67f..2418ebd9 100644
--- a/apps/extension/src/lib/keepalive.ts
+++ b/apps/extension/src/lib/keepalive.ts
@@ -21,6 +21,7 @@
  */
 
 import type { Transport } from "@/transport/transport";
+import { recordDiagnostic } from "./connection-diagnostics";
 
 export const KEEPALIVE_ALARM_NAME = "bh-keepalive";
 export const KEEPALIVE_PERIOD_MIN = 0.5; // 30s, the smallest Chrome accepts.
@@ -81,8 +82,14 @@ export function startKeepalive(options: KeepaliveOptions): KeepaliveHandle {
   const period = options.periodMin ?? KEEPALIVE_PERIOD_MIN;
 
   const tick = async () => {
-    if (options.shouldConnect && !options.shouldConnect()) return;
-    if (options.transport.state === "connected") return;
+    if (options.shouldConnect && !options.shouldConnect()) {
+      recordDiagnostic("alarm.skipped", { gate: "predicate", state: options.transport.state });
+      return;
+    }
+    if (options.transport.state === "connected") {
+      recordDiagnostic("alarm.skipped", { gate: "connected", state: options.transport.state });
+      return;
+    }
     try {
       if (options.requestConnect) await options.requestConnect();
       else await options.transport.connect();
@@ -102,6 +109,7 @@ export function startKeepalive(options: KeepaliveOptions): KeepaliveHandle {
 
   const handler = (alarm: { name: string }) => {
     if (alarm.name !== name) return;
+    recordDiagnostic("alarm.tick", { state: options.transport.state });
     void tick();
   };
   alarms.onAlarm.addListener(handler);
diff --git a/apps/extension/src/transport/ws-transport.ts b/apps/extension/src/transport/ws-transport.ts
index 2248dd0b..56caaa3f 100644
--- a/apps/extension/src/transport/ws-transport.ts
+++ b/apps/extension/src/transport/ws-transport.ts
@@ -1,3 +1,4 @@
+import { recordDiagnostic } from "../lib/connection-diagnostics";
 import type { ConnectionStateHandler, Disposable, FrameHandler, Transport } from "./transport";
 import type { ConnectionState, ProtocolFrame } from "./types";
 
@@ -23,6 +24,7 @@ export interface WSTransportOptions {
 interface CloseLikeEvent {
   code?: number;
   reason?: string;
+  wasClean?: boolean;
 }
 
 interface MessageLikeEvent {
@@ -103,6 +105,7 @@ export class WSTransport implements Transport {
   }
 
   async disconnect(): Promise {
+    recordDiagnostic("socket.explicit-disconnect", { generation: this.socketGeneration });
     this.explicitlyClosed = true;
     if (this.reconnectTimer) {
       clearTimeout(this.reconnectTimer);
@@ -161,6 +164,7 @@ export class WSTransport implements Transport {
     try {
       socket = this.factory(this.url);
     } catch (err) {
+      recordDiagnostic("socket.factory-failed", { generation: this.socketGeneration });
       // A rejected configuration never enters the connecting state. Settle
       // the pending attempt so callers can report it and retry after recovery.
       const reject = this.rejectConnect;
@@ -173,8 +177,13 @@ export class WSTransport implements Transport {
     this.setState("connecting");
     const generation = ++this.socketGeneration;
     this.socket = socket;
+    recordDiagnostic("socket.attempt", { generation });
 
     socket.addEventListener("open", () => {
+      recordDiagnostic("socket.open", {
+        generation,
+        current: this.isCurrentSocket(socket, generation),
+      });
       if (!this.isCurrentSocket(socket, generation)) return;
       this.reconnectAttempt = 0;
       this.setState("connected");
@@ -195,6 +204,10 @@ export class WSTransport implements Transport {
     });
 
     socket.addEventListener("error", () => {
+      recordDiagnostic("socket.error", {
+        generation,
+        current: this.isCurrentSocket(socket, generation),
+      });
       // The 'close' handler always fires after 'error' in browsers, so we
       // just record the error and let close drive reconnect logic.
     });
@@ -217,7 +230,13 @@ export class WSTransport implements Transport {
     }
   }
 
-  private handleClose(socket: WebSocket, generation: number, _ev: CloseLikeEvent): void {
+  private handleClose(socket: WebSocket, generation: number, ev: CloseLikeEvent): void {
+    recordDiagnostic("socket.close", {
+      generation,
+      code: ev.code,
+      wasClean: ev.wasClean,
+      current: this.isCurrentSocket(socket, generation),
+    });
     if (!this.isCurrentSocket(socket, generation)) return;
     this.socket = null;
     if (this.explicitlyClosed) {
@@ -232,6 +251,11 @@ export class WSTransport implements Transport {
     if (this.reconnectTimer) return;
     const delay = Math.min(this.initialDelayMs * 2 ** this.reconnectAttempt, this.maxDelayMs);
     this.reconnectAttempt += 1;
+    recordDiagnostic("socket.retry-scheduled", {
+      generation: this.socketGeneration,
+      attempt: this.reconnectAttempt,
+      delayMs: delay,
+    });
     this.reconnectTimer = setTimeout(() => {
       this.reconnectTimer = null;
       if (this.explicitlyClosed) return;
@@ -248,6 +272,7 @@ export class WSTransport implements Transport {
   private setState(next: ConnectionState): void {
     if (this.currentState === next) return;
     this.currentState = next;
+    recordDiagnostic("transport.state", { state: next, generation: this.socketGeneration });
     for (const h of this.stateHandlers) {
       try {
         h(next);
diff --git a/apps/extension/wxt.config.ts b/apps/extension/wxt.config.ts
index 683d9329..f0011718 100644
--- a/apps/extension/wxt.config.ts
+++ b/apps/extension/wxt.config.ts
@@ -34,6 +34,7 @@ export default defineConfig({
   modules: ["@wxt-dev/module-react"],
   manifest: {
     name: "BrowserSkill",
+    options_ui: { page: "connection-diagnostics.html", open_in_tab: true },
     description:
       "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.
diff --git a/docs/connection-diagnostics.md b/docs/connection-diagnostics.md
new file mode 100644
index 00000000..dacc1e35
--- /dev/null
+++ b/docs/connection-diagnostics.md
@@ -0,0 +1,28 @@
+# Extension connection diagnostics
+
+Connection diagnostics support the investigation and eventual fix of [#395](https://github.com/Tencent/BrowserSkill/issues/395). The goal is to capture extension-side evidence during a real reconnect failure, correlate it with daemon logs, and identify the cause before choosing a fix. Recorded events can help distinguish worker/alarm delivery gaps, an outstanding socket attempt, and reconnect requests skipped by the connection controller, subject to the storage and scheduling limits below. Recording is **off by default**. The reported failure remains unreproduced locally; this diagnostic step does not yet resolve it or change retry policy.
+
+## Capture a failure
+
+1. Open the BrowserSkill extension's **Details → Extension options** page.
+2. Select **Start recording** before the observation period. Close the options page and use the browser normally. Keep the extension installed and leave its connection setting unchanged.
+3. If a failure occurs, note its time. Before toggling the connection or opening worker DevTools, open the options page and select **Export JSON**. Preserve daemon logs for the same time window.
+4. Use **Stop recording** when finished. It preserves the existing log. **Clear and stop** disables recording and removes only the diagnostic events.
+
+Opening, refreshing or exporting this page reads stored data without asking the worker for a live response. Start, stop and clear are explicit control actions and may wake the worker. No diagnostic data is uploaded automatically.
+
+## Recorded data
+
+The extension retains at most 2048 events in local storage. Events include UTC and monotonic timestamps, worker boot identity, build provenance, a sequence number, socket generation and lifecycle, controller gates, cleanup and application-handshake outcomes, alarm delivery, and heartbeat send failures. A full ring drops its oldest entries and counts the drops. Storage read/write failures are counted when a snapshot can be persisted.
+
+Fields and string values are allowlisted. The recorder excludes page content, tab URLs, authentication tokens, labels, arbitrary error/close text and RPC payloads. Exports still reveal operational timing and build information: inspect them before sharing.
+
+If recording was already enabled when a worker started, its initial observation is `worker.start`. Enabling later in the same worker records `diagnostics.enabled`, not a worker restart. Stop/start within the same worker continues the sequence of retained events. A new worker has a new boot ID and retains earlier events unless they have been cleared or dropped from a full ring.
+
+## Limits
+
+Diagnostic storage operations can affect worker scheduling, so a failure disappearing while recording is enabled does not prove that it is fixed. An empty or old snapshot does not prove that no connection attempt occurred; writes may have failed or the worker may not have run. Clearing waits for already-issued diagnostic writes before removing the log, so a stalled storage operation can delay that explicit command.
+
+A disconnected daemon log at INFO level does not distinguish all socket, worker and controller states. Correlate the exported events with the daemon timeline. A new unpacked extension gets a different extension ID and fresh storage, which may hide a problem in an existing store installation. Keep that difference explicit when comparing experiments.
+
+These controls do not add permissions, network requests, polling, keepalive or reconnect timers. The local enabled preference survives worker/browser restarts until recording is stopped.
diff --git a/docs/investigations/395-reconnect.md b/docs/investigations/395-reconnect.md
new file mode 100644
index 00000000..2db52434
--- /dev/null
+++ b/docs/investigations/395-reconnect.md
@@ -0,0 +1,64 @@
+# Investigation: extension reconnect silence after a daemon restart
+
+Status on 2026-10-17: **not reproduced; root cause unconfirmed**. Related issue: [#395](https://github.com/Tencent/BrowserSkill/issues/395). The investigation and optional extension-side diagnostics are intended to advance the diagnosis and eventual fix of #395. Their immediate purpose is to capture the missing extension-side evidence during a real failure so a targeted fix can be developed and verified. The current contribution does not yet resolve the reported failure.
+
+## Reported failure and remaining environment differences
+
+The reporter measured a daemon-ready to browser-connected gap of approximately 11 minutes on Windows with bsk 0.3.2 and the official Edge extension. Connection resumed after the popup connection setting was toggled. The daemon was reachable, and its logs had no browser-connected event during the gap. These daemon logs do not establish the extension's worker, alarm or physical socket state.
+
+The reporter's setup used the Edge store extension, Windows build 26300.9550, Edge 155.0.4283.45, a weeks-old default daemon home and a persistent foreground daemon host. Local tests used an unpacked official 0.3.2 ZIP and isolated test data. Local Windows/Edge builds changed during the investigation. The exact store-extension build and existing extension state have not been replicated.
+
+The host's Job Object breakaway refusal (`os error 5`) is a separate daemon auto-start issue. Tests below started the daemon explicitly; they do not validate client-triggered auto-start on that host.
+
+## Real Windows daemon and sleep cycles
+
+All six local cycles reconnected automatically, with no active test session and without opening the extension popup or worker DevTools during the observation window.
+
+| Condition | Daemon ready to first browser connection |
+|---|---:|
+| Awake, measured stop/restart interval about 73 seconds | 1.579 s |
+| Awake, interval about 45 seconds | 0.251 s |
+| Awake, interval about 120 seconds | 8.462 s |
+| Daemon stopped before short sleep, about 10m26s sleep | 2.071 s |
+| Connected before about 10 minutes sleep; daemon exited after resume and was restarted | 3.943 s |
+| Connected before about 10h13m sleep; daemon exited after resume and was restarted | 0.061 s |
+
+The overnight cycle was **not** an equivalent overnight daemon outage: its daemon exited after resume, approximately 75 seconds before ready. Sleep duration, time without log lines and actual process downtime are different measurements. It does not isolate the reporter's inferred 13.5-hour interval. Earlier local builds were Edge 155.0.4283.45 / Windows 26200.9457; later tests used Edge 155.0.4283.60 / Windows 26200.9711.
+
+## Controlled physical and application handshake faults
+
+A loopback protocol peer accepted connections from the actual installed extension worker. It replied to the application handshake and observed heartbeat frames; it did not issue browser-operation commands. This was a test peer, **not the real daemon**. Popup and worker DevTools remained closed during fault observation.
+
+| Fault injected once | Official 0.3.2 worker | Diagnostic 0.3.2 worker |
+|---|---|---|
+| HTTP Upgrade withheld; later attempts allowed | Close at 240.004 s; new handshake replied at 242.014 s | Close at 240.001 s; new handshake replied at 242.009 s |
+| Application handshake reply withheld | Client close at 10.006 s; recovery at 11.015 s | Client close at 10.004 s; recovery at 11.017 s |
+
+Each recovered connection produced continuing heartbeats. These faults did not reproduce the reported prolonged failure. A normal-page WebSocket experiment had similar timing but was kept separate from the actual MV3 worker evidence.
+
+During the diagnostic worker's physical wait, persistent events showed eight actual alarm callbacks roughly 30 seconds apart. Each was followed by a controller request with `state=connecting`, `gate=transport-state`, `transition=false`, and `cleanupNeeded=false`. There was one socket attempt in that window, followed by native error/close and a scheduled retry. One additional controller request lacked an adjacent alarm event; its source was not identified.
+
+This explains one local silent-endpoint window: the worker was active and checking, while an existing physical attempt prevented concurrent connections. It does **not** explain the reporter's 11-minute gap.
+
+## Diagnostic persistence and observation limits
+
+The user exported two local diagnostic snapshots around a real extension reload. The first contained 189 events; the second contained 203, retaining every first-export event exactly and adding a new worker boot identity. Reported storage failures and dropped events were zero. Five paired internal/endpoint events aligned within 1 ms. This validates the experimental recorder in that scenario, not every worker eviction or crash path.
+
+The experimental build was separately named, always recorded and used fresh extension storage. The proposed general feature is default-off and uses normal build provenance. No successful run establishes that recording has no scheduling effect. New unpacked IDs, old store state, unavailable storage and natural idle termination remain relevant differences. An empty/stale diagnostic snapshot cannot prove inactivity.
+
+## Cleanup-chain hypothesis
+
+External probes used the actual controller, transport, session manager and session-stop functions, replacing only storage/socket/Chrome boundaries. They advanced **11 minutes of simulated time**, with 22 callbacks through the actual registered alarm handler; no real user tabs or windows were manipulated.
+
+- With zero local sessions, session browser cleanup APIs were not called and reconnection completed.
+- With one local session, holding `windows.get`, `tabs.move`, `tabs.remove`, `tabs.query`, or `windows.remove` pending blocked cleanup and reconnect. The popup connection toggle did not bypass that pending transition. Completing the same original API promise then allowed cleanup and recovery.
+- The actual Chromium CDP wrapper already bounded a pending native debugger detach with its 1000 ms cleanup wait.
+- A rejected window removal remained retryable; the next alarm retried cleanup and recovered.
+
+Eight characterization probes and 115 related existing tests passed. Injecting a pending API proves a conditional blocking path, not that Edge actually hung there. No daemon session does not prove an empty local extension session list. Nevertheless, this simple pending-cleanup hypothesis does not match toggle-based recovery and is not sufficient justification to skip safe cleanup or add an arbitrary timeout.
+
+## Next evidence and contribution scope
+
+Before repeating ordinary restart/sleep cycles, capture extension-side boot, alarm, socket and gate events during a real failure and correlate them with daemon logs. Preserve the current installation/state where possible. Avoid opening DevTools or toggling the connection until stored observations have been exported.
+
+To advance #395 toward a verified fix, the contribution provides [optional bounded diagnostics](../connection-diagnostics.md), regression coverage for the recorder/control lifecycle, and this investigation record. The next step is to correlate events from a real failure with daemon logs, identify the cause, and add a targeted regression test and fix. Retry delays, handshake deadlines, cleanup ordering and keepalive policy remain unchanged at this diagnostic stage. Keep #395 open until the reported failure has been addressed and the fix verified.