Просмотр исходного кода

feat(inspector): serve Runtime through a CDP Worker

imccyu 2 недель назад
Родитель
Сommit
bcee99e39d
48 измененных файлов с 6661 добавлено и 0 удалено
  1. 304 0
      packages/experimental/inspector/src/worker/bridge/endpoint.ts
  2. 316 0
      packages/experimental/inspector/src/worker/bridge/hub.ts
  3. 328 0
      packages/experimental/inspector/src/worker/bridge/runtime-rpc.ts
  4. 26 0
      packages/experimental/inspector/src/worker/bridge/session.ts
  5. 192 0
      packages/experimental/inspector/src/worker/bridge/source-rpc.ts
  6. 52 0
      packages/experimental/inspector/src/worker/cdp/domains/debugger/cdp-params.ts
  7. 6 0
      packages/experimental/inspector/src/worker/cdp/domains/debugger/index.ts
  8. 114 0
      packages/experimental/inspector/src/worker/cdp/domains/debugger/projector.ts
  9. 117 0
      packages/experimental/inspector/src/worker/cdp/domains/debugger/script-registry.ts
  10. 350 0
      packages/experimental/inspector/src/worker/cdp/domains/debugger/session.ts
  11. 49 0
      packages/experimental/inspector/src/worker/cdp/domains/native.ts
  12. 256 0
      packages/experimental/inspector/src/worker/cdp/domains/runtime/cdp-params.ts
  13. 3 0
      packages/experimental/inspector/src/worker/cdp/domains/runtime/index.ts
  14. 323 0
      packages/experimental/inspector/src/worker/cdp/domains/runtime/object-table.ts
  15. 455 0
      packages/experimental/inspector/src/worker/cdp/domains/runtime/session.ts
  16. 53 0
      packages/experimental/inspector/src/worker/cdp/ids.ts
  17. 82 0
      packages/experimental/inspector/src/worker/cdp/protocol.ts
  18. 128 0
      packages/experimental/inspector/src/worker/cdp/realm-sessions.ts
  19. 117 0
      packages/experimental/inspector/src/worker/cdp/session.ts
  20. 77 0
      packages/experimental/inspector/src/worker/cdp/target.ts
  21. 55 0
      packages/experimental/inspector/src/worker/entry.ts
  22. 116 0
      packages/experimental/inspector/src/worker/inspection/realm-store.ts
  23. 54 0
      packages/experimental/inspector/src/worker/inspection/realm.ts
  24. 26 0
      packages/experimental/inspector/src/worker/realms/client/bridge.ts
  25. 40 0
      packages/experimental/inspector/src/worker/realms/client/console.ts
  26. 11 0
      packages/experimental/inspector/src/worker/realms/client/debugger.ts
  27. 104 0
      packages/experimental/inspector/src/worker/realms/client/index.ts
  28. 160 0
      packages/experimental/inspector/src/worker/realms/client/runtime.ts
  29. 27 0
      packages/experimental/inspector/src/worker/realms/client/scripts.ts
  30. 122 0
      packages/experimental/inspector/src/worker/realms/client/sources.ts
  31. 162 0
      packages/experimental/inspector/src/worker/realms/client/values.ts
  32. 164 0
      packages/experimental/inspector/src/worker/realms/host/bridge.ts
  33. 90 0
      packages/experimental/inspector/src/worker/realms/host/console.ts
  34. 167 0
      packages/experimental/inspector/src/worker/realms/host/debugger.ts
  35. 67 0
      packages/experimental/inspector/src/worker/realms/host/index.ts
  36. 322 0
      packages/experimental/inspector/src/worker/realms/host/runtime.ts
  37. 13 0
      packages/experimental/inspector/src/worker/realms/host/scripts.ts
  38. 99 0
      packages/experimental/inspector/src/worker/realms/host/sources.ts
  39. 34 0
      packages/experimental/inspector/src/worker/realms/host/values.ts
  40. 111 0
      packages/experimental/inspector/src/worker/server.ts
  41. 56 0
      packages/experimental/inspector/tests/built-lib.e2e.ts
  42. 257 0
      packages/experimental/inspector/tests/client-browser.e2e.ts
  43. 30 0
      packages/experimental/inspector/tests/client-stack.host.spec.ts
  44. 198 0
      packages/experimental/inspector/tests/debugger.e2e.ts
  45. 114 0
      packages/experimental/inspector/tests/fixtures/client-source.client.ts
  46. 141 0
      packages/experimental/inspector/tests/fixtures/client-source.host.ts
  47. 28 0
      packages/experimental/inspector/tests/fixtures/debug-host.ts
  48. 545 0
      packages/experimental/inspector/tests/integration.host.spec.ts

+ 304 - 0
packages/experimental/inspector/src/worker/bridge/endpoint.ts

@@ -0,0 +1,304 @@
+/** Worker-owned HTTP discovery, DevTools CDP, and Client-ingest endpoints. */
+
+import { createServer, type IncomingMessage, type Server } from 'node:http'
+import type { AddressInfo } from 'node:net'
+import type { Duplex } from 'node:stream'
+import { WebSocketServer, type RawData, type WebSocket } from 'ws'
+import type { InspectorWorkerConfig } from '../../shared/bridge/messages/control.ts'
+import type { WorkerToSourceFrame } from '../../shared/bridge/messages/observation.ts'
+import { CdpSession } from '../cdp/session.ts'
+import type { CdpTransport } from '../cdp/protocol.ts'
+import type { NetworkDomain } from '../cdp/domains/network/session.ts'
+import type { CordisDomBackend } from '../cdp/domains/dom/index.ts'
+import type { CordisRuntimeTreeReader } from '../../shared/cordis/reader.ts'
+import type { InspectorQueryRouter } from '../inspection/query-router.ts'
+import type { InspectorRealmRegistry } from '../inspection/realm-store.ts'
+import type { InspectorSourceRegistry, SourceConnection } from './hub.ts'
+
+/** Bound endpoint information returned to the Host controller. */
+export interface InspectorEndpointInfo {
+  readonly host: string
+  readonly port: number
+  readonly targetId: string
+}
+
+/** Worker-owned network endpoint. */
+export class InspectorEndpoint {
+  private server: Server | undefined
+  private readonly cdpServer: WebSocketServer
+  private readonly ingestServer: WebSocketServer
+  private readonly cdpSessions = new Map<WebSocket, CdpSession>()
+  private readonly ingestConnections = new Map<WebSocket, SourceConnection>()
+
+  constructor(
+    private readonly config: InspectorWorkerConfig,
+    private readonly sources: InspectorSourceRegistry,
+    private readonly network: NetworkDomain,
+    private readonly realms: InspectorRealmRegistry,
+    private readonly cordisDom: CordisDomBackend,
+    private readonly cordisTrees: CordisRuntimeTreeReader,
+    private readonly queries: InspectorQueryRouter,
+  ) {
+    this.cdpServer = new WebSocketServer({ noServer: true, maxPayload: config.maxSourceFrameBytes })
+    this.ingestServer = new WebSocketServer({ noServer: true, maxPayload: config.maxSourceFrameBytes })
+  }
+
+  /**
+   * Bind the loopback endpoint.
+   * @returns The actual bound address and target id.
+   */
+  async start(): Promise<InspectorEndpointInfo> {
+    let candidate = this.config.startPort
+    while (true) {
+      const server = this.createServer()
+      this.server = server
+      try {
+        const address = await listen(server, candidate, this.config.host)
+        server.on('error', () => {
+          // An established server error is connection-local or reported by
+          // the operating system; active sockets retain their own handlers.
+        })
+        return { host: this.config.host, port: address.port, targetId: this.config.targetId }
+      } catch (error) {
+        this.server = undefined
+        if (!isAddressInUse(error) || candidate === 0) throw error
+        if (candidate === 65_535) {
+          throw new Error(`inspector: no available port from ${String(this.config.startPort)} through 65535`, {
+            cause: error,
+          })
+        }
+        candidate += 1
+      }
+    }
+  }
+
+  /** Stop admission, dispose CDP sessions, terminate sockets, and await server close. */
+  async close(): Promise<void> {
+    const server = this.requireServer()
+    for (const [socket, session] of this.cdpSessions) {
+      session.close()
+      socket.terminate()
+    }
+    this.cdpSessions.clear()
+    for (const [socket, connection] of this.ingestConnections) {
+      this.sources.disconnect(connection, 'Client ingest endpoint stopped')
+      socket.terminate()
+    }
+    this.ingestConnections.clear()
+    await Promise.all([
+      closeWebSocketServer(this.cdpServer),
+      closeWebSocketServer(this.ingestServer),
+      new Promise<void>((resolve) => {
+        server.close(() => { resolve() })
+        server.closeAllConnections()
+      }),
+    ])
+  }
+
+  private handleHttp(request: IncomingMessage, response: import('node:http').ServerResponse): void {
+    const pathname = new URL(request.url ?? '/', 'http://inspector.invalid').pathname
+    if (pathname === '/json' || pathname === '/json/list') {
+      this.json(response, [this.target()])
+      return
+    }
+    if (pathname === '/json/version') {
+      this.json(response, {
+        Browser: 'dsh-experimental-inspector/0',
+        'Protocol-Version': '1.3',
+        webSocketDebuggerUrl: this.cdpUrl(),
+      })
+      return
+    }
+    response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
+    response.end('not found')
+  }
+
+  private handleUpgrade(request: IncomingMessage, socket: Duplex, head: Buffer): void {
+    let pathname: string
+    try {
+      pathname = new URL(request.url ?? '/', 'http://inspector.invalid').pathname
+    } catch {
+      socket.destroy()
+      return
+    }
+    if (pathname === `/devtools/page/${this.config.targetId}`) {
+      this.cdpServer.handleUpgrade(request, socket, head, (ws) => { this.acceptCdp(ws) })
+      return
+    }
+    if (pathname === '/ingest') {
+      if (!this.authorizedClient(request)) {
+        socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n')
+        return
+      }
+      this.ingestServer.handleUpgrade(request, socket, head, (ws) => { this.acceptIngest(ws) })
+      return
+    }
+    socket.destroy()
+  }
+
+  private acceptCdp(socket: WebSocket): void {
+    const transport: CdpTransport = {
+      send: (payload) => {
+        if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(payload))
+      },
+      close: () => { socket.close(1008, 'invalid CDP request') },
+    }
+    const session = new CdpSession(
+      transport,
+      { targetId: this.config.targetId, title: 'DeepSeek Harness Host' },
+      this.sources,
+      this.network,
+      this.realms,
+      this.cordisDom,
+      this.cordisTrees,
+    )
+    this.cdpSessions.set(socket, session)
+    socket.on('message', (data) => {
+      try {
+        session.receive(JSON.parse(rawText(data)) as unknown)
+      } catch {
+        socket.close(1008, 'CDP frame must be JSON')
+      }
+    })
+    socket.once('close', () => {
+      this.cdpSessions.delete(socket)
+      session.close()
+    })
+    socket.on('error', () => {
+      // The close event performs connection-owned cleanup.
+    })
+  }
+
+  private acceptIngest(socket: WebSocket): void {
+    const queryPeer = this.queries.open({
+      send: (frame) => {
+        if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(frame))
+      },
+      close: (code, reason) => { socket.close(code, reason) },
+    })
+    const connection: SourceConnection = {
+      kind: 'client',
+      send: (frame: WorkerToSourceFrame) => {
+        if (socket.readyState !== socket.OPEN) return
+        socket.send(JSON.stringify(frame))
+        if (frame.t === 'source/accepted') queryPeer.accept(frame.sourceId, frame.generation)
+      },
+      close: (code, reason) => { socket.close(code, reason.slice(0, 123)) },
+    }
+    this.ingestConnections.set(socket, connection)
+    socket.on('message', (data) => {
+      try {
+        const value = JSON.parse(rawText(data)) as unknown
+        if (!queryPeer.receive(value)) this.sources.receive(connection, value)
+      } catch {
+        connection.close(1008, 'source frame must be JSON')
+      }
+    })
+    socket.once('close', () => {
+      this.ingestConnections.delete(socket)
+      queryPeer.close()
+      this.sources.disconnect(connection, 'Client source disconnected')
+    })
+    socket.on('error', () => {
+      // The close event performs connection-owned cleanup.
+    })
+  }
+
+  private authorizedClient(request: IncomingMessage): boolean {
+    const protocols = (request.headers['sec-websocket-protocol'] ?? '')
+      .split(',')
+      .map(value => value.trim())
+    if (!protocols.includes(this.config.clientToken)) return false
+    const origin = request.headers.origin
+    if (origin === undefined) return true
+    if (this.config.clientOrigins.includes(origin)) return true
+    try {
+      const hostname = new URL(origin).hostname
+      return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1'
+    } catch {
+      return false
+    }
+  }
+
+  private target(): object {
+    return {
+      id: this.config.targetId,
+      type: 'page',
+      title: 'DeepSeek Harness Host',
+      description: 'Experimental cross-realm Inspector target',
+      url: 'dsh://host',
+      webSocketDebuggerUrl: this.cdpUrl(),
+      devtoolsFrontendUrl: `devtools://devtools/bundled/devtools_app.html?ws=${this.config.host}:${this.boundPort()}/devtools/page/${this.config.targetId}&panel=elements&noJavaScriptCompletion=true`,
+    }
+  }
+
+  private cdpUrl(): string {
+    return `ws://${this.config.host}:${String(this.boundPort())}/devtools/page/${this.config.targetId}`
+  }
+
+  private boundPort(): number {
+    const address = this.requireServer().address()
+    if (address === null || typeof address === 'string') {
+      throw new Error('inspector: endpoint is not bound to a TCP port')
+    }
+    return address.port
+  }
+
+  private createServer(): Server {
+    const server = createServer((request, response) => { this.handleHttp(request, response) })
+    server.on('upgrade', (request, socket, head) => { this.handleUpgrade(request, socket, head) })
+    return server
+  }
+
+  private requireServer(): Server {
+    if (this.server === undefined) throw new Error('inspector: endpoint is not started')
+    return this.server
+  }
+
+  private json(response: import('node:http').ServerResponse, value: unknown): void {
+    response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
+    response.end(JSON.stringify(value))
+  }
+}
+
+function listen(server: Server, port: number, host: string): Promise<AddressInfo> {
+  return new Promise((resolve, reject) => {
+    const finish = (): void => {
+      server.off('error', onError)
+      server.off('listening', onListening)
+    }
+    const onError = (error: Error): void => {
+      finish()
+      reject(error)
+    }
+    const onListening = (): void => {
+      finish()
+      const address = server.address()
+      if (address === null || typeof address === 'string') {
+        reject(new Error('inspector: endpoint did not bind a TCP port'))
+        return
+      }
+      resolve(address)
+    }
+    server.once('error', onError)
+    server.once('listening', onListening)
+    server.listen(port, host)
+  })
+}
+
+function isAddressInUse(error: unknown): boolean {
+  return error instanceof Error && (error as NodeJS.ErrnoException).code === 'EADDRINUSE'
+}
+
+function rawText(data: RawData): string {
+  const bytes = data instanceof ArrayBuffer
+    ? Buffer.from(new Uint8Array(data))
+    : Array.isArray(data) ? Buffer.concat(data) : data
+  return bytes.toString('utf8')
+}
+
+function closeWebSocketServer(server: WebSocketServer): Promise<void> {
+  return new Promise((resolve) => {
+    server.close(() => { resolve() })
+  })
+}

+ 316 - 0
packages/experimental/inspector/src/worker/bridge/hub.ts

@@ -0,0 +1,316 @@
+/** Worker-owned source generations, observation dispatch, and extension transport. */
+
+import { jsonByteLength, type InspectorJsonValue } from '../../shared/json.ts'
+import {
+  INSPECTOR_PROTOCOL_VERSION,
+  parseSourceFrame,
+  type InspectorRecordInput,
+  type InspectorSourceDescriptor,
+  type InspectorSourceKind,
+  type SourceToWorkerFrame,
+  type WorkerToSourceFrame,
+} from '../../shared/bridge/messages/observation.ts'
+import type { ClientConsoleEventFrame, ClientRuntimeResponseFrame } from '../../shared/bridge/messages/runtime/index.ts'
+import type { ClientSourceResponseFrame } from '../../shared/bridge/messages/sources/index.ts'
+
+/** One validated record with its source-local sequence. */
+export interface IngestedInspectorRecord extends InspectorRecordInput {
+  readonly sequence: number
+}
+
+/** One connected source's reply and close operations. */
+export interface SourceConnection {
+  readonly kind: InspectorSourceKind
+  send(frame: WorkerToSourceFrame): void
+  close(code: number, reason: string): void
+}
+
+/** Consumer of source lifecycle and records. */
+export interface InspectorRecordConsumer {
+  readonly topics: ReadonlySet<string>
+  replace(source: InspectorSourceDescriptor, records: readonly IngestedInspectorRecord[]): void
+  append(source: InspectorSourceDescriptor, records: readonly IngestedInspectorRecord[]): void
+  close(source: InspectorSourceDescriptor, reason: string): void
+}
+
+interface SourceState {
+  readonly source: InspectorSourceDescriptor
+  readonly topics: ReadonlySet<string>
+  readonly connection: SourceConnection
+  expectedSequence: number
+  dropped: number
+  readonly topicCounts: Map<string, number>
+}
+
+/** Source lifecycle and typed extension frames observed inside the Worker. */
+export type InspectorSourceEvent =
+  | { readonly type: 'opened'; readonly source: InspectorSourceDescriptor }
+  | { readonly type: 'closed'; readonly source: InspectorSourceDescriptor; readonly reason: string }
+  | {
+    readonly type: 'client-runtime-response'
+    readonly source: InspectorSourceDescriptor
+    readonly frame: ClientRuntimeResponseFrame
+  }
+  | {
+    readonly type: 'client-console-event'
+    readonly source: InspectorSourceDescriptor
+    readonly frame: ClientConsoleEventFrame
+  }
+  | {
+    readonly type: 'client-source-response'
+    readonly source: InspectorSourceDescriptor
+    readonly frame: ClientSourceResponseFrame
+  }
+
+/** Read-only diagnostic for `DSHInspector.getSources`. */
+export interface InspectorSourceView {
+  readonly sourceId: string
+  readonly generation: string
+  readonly kind: InspectorSourceKind
+  readonly label: string
+  readonly capabilities: readonly string[]
+  readonly expectedSequence: number
+  readonly dropped: number
+  readonly topics: Readonly<Record<string, number>>
+}
+
+/** Serial Worker-side owner of every Host and Client source generation. */
+export class InspectorSourceRegistry {
+  private readonly sources = new Map<string, SourceState>()
+  private readonly statusListeners = new Set<() => void>()
+  private readonly eventListeners = new Set<(event: InspectorSourceEvent) => void>()
+
+  constructor(
+    private readonly consumers: readonly InspectorRecordConsumer[],
+    private readonly maxFrameBytes: number,
+    private readonly maxRecordsPerFrame: number,
+  ) {}
+
+  /**
+   * Parse and apply one frame; malformed input closes only its source transport.
+   * @param connection - Carrier that delivered the frame.
+   * @param value - Untrusted decoded frame.
+   */
+  receive(connection: SourceConnection, value: unknown): void {
+    try {
+      const frame = parseSourceFrame(value, this.maxRecordsPerFrame)
+      if (jsonByteLength(frame as unknown as InspectorJsonValue) > this.maxFrameBytes) {
+        throw new Error(`inspector protocol: source frame exceeds ${String(this.maxFrameBytes)} bytes`)
+      }
+      this.apply(connection, frame)
+    } catch (error) {
+      const message = error instanceof Error ? error.message : String(error)
+      connection.send({ v: INSPECTOR_PROTOCOL_VERSION, t: 'source/rejected', code: 'invalid-frame', message })
+      connection.close(1008, message)
+    }
+  }
+
+  /**
+   * Remove every generation carried by a closed connection.
+   * @param connection - Closed source carrier.
+   * @param reason - Diagnostic propagated to domain consumers.
+   */
+  disconnect(connection: SourceConnection, reason: string): void {
+    for (const [sourceId, state] of this.sources) {
+      if (state.connection !== connection) continue
+      this.sources.delete(sourceId)
+      for (const consumer of this.consumers) consumer.close(state.source, reason)
+      this.emit({ type: 'closed', source: state.source, reason })
+    }
+    this.notifyStatus()
+  }
+
+  /**
+   * Read current source status for the diagnostic CDP domain.
+   * @returns A detached status row for every active source.
+   */
+  describe(): InspectorSourceView[] {
+    return [...this.sources.values()].map(state => ({
+      sourceId: state.source.sourceId,
+      generation: state.source.generation,
+      kind: state.source.kind,
+      label: state.source.label,
+      capabilities: state.source.capabilities.map(capability => capability.type),
+      expectedSequence: state.expectedSequence,
+      dropped: state.dropped,
+      topics: Object.fromEntries(state.topicCounts),
+    }))
+  }
+
+  /**
+   * Subscribe to source status changes.
+   * @param listener - Status observer.
+   * @returns A disposer that removes the observer.
+   */
+  subscribeStatus(listener: () => void): () => void {
+    this.statusListeners.add(listener)
+    return () => { this.statusListeners.delete(listener) }
+  }
+
+  /**
+   * Subscribe to source admission, removal, and typed extension frames.
+   * @param listener - Source protocol observer.
+   * @returns A disposer that removes the observer.
+   */
+  subscribeEvents(listener: (event: InspectorSourceEvent) => void): () => void {
+    this.eventListeners.add(listener)
+    return () => { this.eventListeners.delete(listener) }
+  }
+
+  /**
+   * Send a typed control frame only to its still-active source generation.
+   * @param source - Expected active source generation.
+   * @param frame - Validated Worker-to-source frame.
+   * @returns Whether the generation was still active and accepted the send.
+   */
+  send(source: InspectorSourceDescriptor, frame: WorkerToSourceFrame): boolean {
+    const state = this.sources.get(source.sourceId)
+    if (state === undefined || state.source.generation !== source.generation) return false
+    if (jsonByteLength(frame as unknown as InspectorJsonValue) > this.maxFrameBytes) {
+      throw new Error(`inspector protocol: Worker source frame exceeds ${String(this.maxFrameBytes)} bytes`)
+    }
+    state.connection.send(frame)
+    return true
+  }
+
+  /** Close every source and forget all state. */
+  close(): void {
+    for (const state of this.sources.values()) {
+      for (const consumer of this.consumers) consumer.close(state.source, 'inspector worker stopped')
+      this.emit({ type: 'closed', source: state.source, reason: 'inspector worker stopped' })
+    }
+    this.sources.clear()
+    this.notifyStatus()
+  }
+
+  private apply(connection: SourceConnection, frame: SourceToWorkerFrame): void {
+    if (frame.t === 'source/open') {
+      this.open(connection, frame.source, frame.topics)
+      return
+    }
+    const state = this.sources.get(frame.sourceId)
+    if (state === undefined || state.connection !== connection || state.source.generation !== frame.generation) {
+      throw new Error('inspector protocol: frame does not belong to the active source generation')
+    }
+    if (frame.t === 'source/close') {
+      this.sources.delete(frame.sourceId)
+      for (const consumer of this.consumers) consumer.close(state.source, 'source closed')
+      this.emit({ type: 'closed', source: state.source, reason: 'source closed' })
+      this.notifyStatus()
+      return
+    }
+    if (frame.t === 'client-runtime/response') {
+      if (state.source.kind !== 'client'
+        || !state.source.capabilities.some(capability => capability.type === 'client-runtime')) {
+        throw new Error('inspector protocol: source did not declare Client Runtime')
+      }
+      this.emit({ type: 'client-runtime-response', source: state.source, frame })
+      return
+    }
+    if (frame.t === 'client-console/event') {
+      if (state.source.kind !== 'client'
+        || !state.source.capabilities.some(capability => capability.type === 'client-console')) {
+        throw new Error('inspector protocol: source did not declare Client Console')
+      }
+      this.emit({ type: 'client-console-event', source: state.source, frame })
+      return
+    }
+    if (frame.t === 'client-sources/response') {
+      if (state.source.kind !== 'client'
+        || !state.source.capabilities.some(capability => capability.type === 'client-sources')) {
+        throw new Error('inspector protocol: source did not declare Client Sources')
+      }
+      this.emit({ type: 'client-source-response', source: state.source, frame })
+      return
+    }
+    this.assertTopics(state, frame.records)
+    if (frame.t === 'source/replace') {
+      state.expectedSequence = frame.nextSequence
+      for (const consumer of this.consumers) consumer.replace(
+        state.source,
+        frame.records.map((record, index) => ({ ...record, sequence: frame.nextSequence + index })),
+      )
+      this.count(state, frame.records)
+      this.notifyStatus()
+      return
+    }
+    const gap = frame.firstSequence - state.expectedSequence
+    if (gap < 0 || gap !== frame.droppedBefore) {
+      connection.send({
+        v: INSPECTOR_PROTOCOL_VERSION,
+        t: 'source/resnapshot',
+        sourceId: state.source.sourceId,
+        generation: state.source.generation,
+        expectedSequence: state.expectedSequence,
+        reason: `expected sequence ${String(state.expectedSequence)}, received ${String(frame.firstSequence)}`,
+      })
+      return
+    }
+    state.dropped += frame.droppedBefore
+    const records = frame.records.map((record, index) => ({ ...record, sequence: frame.firstSequence + index }))
+    state.expectedSequence = frame.firstSequence + frame.records.length
+    for (const consumer of this.consumers) consumer.append(state.source, records)
+    this.count(state, frame.records)
+    this.notifyStatus()
+  }
+
+  private open(connection: SourceConnection, source: InspectorSourceDescriptor, topics: readonly string[]): void {
+    if (source.kind !== connection.kind) throw new Error('inspector protocol: source kind does not match its carrier')
+    const accepted = new Set(topics)
+    const prior = this.sources.get(source.sourceId)
+    if (prior !== undefined) {
+      for (const consumer of this.consumers) consumer.close(prior.source, 'source generation replaced')
+      this.emit({ type: 'closed', source: prior.source, reason: 'source generation replaced' })
+    }
+    this.sources.set(source.sourceId, {
+      source,
+      topics: accepted,
+      connection,
+      expectedSequence: 1,
+      dropped: 0,
+      topicCounts: new Map(),
+    })
+    connection.send({
+      v: INSPECTOR_PROTOCOL_VERSION,
+      t: 'source/accepted',
+      sourceId: source.sourceId,
+      generation: source.generation,
+    })
+    this.emit({ type: 'opened', source })
+    this.notifyStatus()
+  }
+
+  private assertTopics(state: SourceState, records: readonly InspectorRecordInput[]): void {
+    for (const record of records) {
+      if (!state.topics.has('*') && !state.topics.has(record.topic)) {
+        throw new Error(`inspector protocol: source did not declare topic ${JSON.stringify(record.topic)}`)
+      }
+    }
+  }
+
+  private count(state: SourceState, records: readonly InspectorRecordInput[]): void {
+    for (const record of records) {
+      state.topicCounts.set(record.topic, (state.topicCounts.get(record.topic) ?? 0) + 1)
+    }
+  }
+
+  private notifyStatus(): void {
+    for (const listener of [...this.statusListeners]) {
+      try {
+        listener()
+      } catch {
+        // A diagnostic observer is isolated from source admission and later observers.
+      }
+    }
+  }
+
+  private emit(event: InspectorSourceEvent): void {
+    for (const listener of [...this.eventListeners]) {
+      try {
+        listener(event)
+      } catch {
+        // A protocol consumer is isolated from source admission and sibling consumers.
+      }
+    }
+  }
+}

+ 328 - 0
packages/experimental/inspector/src/worker/bridge/runtime-rpc.ts

@@ -0,0 +1,328 @@
+/** Worker-owned routing between synthetic Client contexts and source generations. */
+
+import { randomUUID } from 'node:crypto'
+import type {
+  ClientConsoleEventFrame,
+  ClientRuntimeCapability,
+  ClientRuntimeCommand,
+  ClientRuntimeError,
+  ClientRuntimeResponseFrame,
+  ClientRuntimeResult,
+} from '../../shared/bridge/messages/runtime/index.ts'
+import {
+  inspectorId,
+  type ClientRemoteObjectHandle,
+  type ClientRuntimeRequestId,
+  type ClientRuntimeSessionId,
+} from '../../shared/bridge/ids.ts'
+import { INSPECTOR_PROTOCOL_VERSION, type InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts'
+import { sendClientSessionClosed } from './session.ts'
+import type { InspectorSourceEvent, InspectorSourceRegistry } from './hub.ts'
+import type { RuntimeConsoleBackendEvent } from '../../shared/cdp/index.ts'
+
+/** One connected projection of a Client realm into a synthetic CDP execution context. */
+export interface ClientRuntimeTarget {
+  readonly contextId: number
+  readonly uniqueContextId: string
+  readonly source: InspectorSourceDescriptor
+  readonly capability: ClientRuntimeCapability
+}
+
+/** Runtime target admission or removal. */
+export type ClientRuntimeTargetEvent =
+  | { readonly type: 'opened'; readonly target: ClientRuntimeTarget }
+  | { readonly type: 'closed'; readonly target: ClientRuntimeTarget }
+
+interface PendingRequest {
+  readonly target: ClientRuntimeTarget
+  readonly sessionId: ClientRuntimeSessionId
+  readonly op: ClientRuntimeCommand['op']
+  readonly resolve: (result: ClientRuntimeResult) => void
+  readonly reject: (error: Error) => void
+  readonly timer: ReturnType<typeof setTimeout>
+}
+
+interface ConsoleSubscription {
+  readonly target: ClientRuntimeTarget
+  readonly sessionId: ClientRuntimeSessionId
+  readonly listener: (event: RuntimeConsoleBackendEvent<ClientRemoteObjectHandle>) => void
+}
+
+/** Error returned deliberately by the Client Runtime executor. */
+export class ClientRuntimeRemoteError extends Error {
+  constructor(readonly code: ClientRuntimeError['code'], message: string) {
+    super(message)
+  }
+}
+
+/** Runtime context registry and correlated Worker-to-Client request owner. */
+export class ClientRuntimeRouter {
+  private readonly targetsBySource = new Map<string, ClientRuntimeTarget>()
+  private readonly pending = new Map<ClientRuntimeRequestId, PendingRequest>()
+  private readonly consoleSubscriptions = new Set<ConsoleSubscription>()
+  private readonly listeners = new Set<(event: ClientRuntimeTargetEvent) => void>()
+  private readonly unsubscribeSources: () => void
+  private nextContextId = -1
+  private closed = false
+
+  constructor(private readonly sources: InspectorSourceRegistry, private readonly timeoutMs: number) {
+    this.unsubscribeSources = sources.subscribeEvents((event) => { this.receiveSourceEvent(event) })
+  }
+
+  /**
+   * Snapshot all active Client execution contexts.
+   * @returns Active targets in admission order.
+   */
+  targets(): ClientRuntimeTarget[] {
+    return [...this.targetsBySource.values()]
+  }
+
+  /**
+   * Resolve the Client target for one active source generation.
+   * @param source - Source identity stored with a semantic node.
+   * @returns Its active Runtime target, when the generation still matches.
+   */
+  bySource(source: InspectorSourceDescriptor): ClientRuntimeTarget | undefined {
+    const target = this.targetsBySource.get(source.sourceId)
+    return target?.source.generation === source.generation ? target : undefined
+  }
+
+  /**
+   * Subscribe to synthetic execution-context lifecycle.
+   * @param listener - Context lifecycle observer.
+   * @returns A disposer that removes the observer.
+   */
+  subscribe(listener: (event: ClientRuntimeTargetEvent) => void): () => void {
+    this.listeners.add(listener)
+    return () => { this.listeners.delete(listener) }
+  }
+
+  /**
+   * Enable Console events for one Client realm and DevTools session.
+   * @param target - Active Client realm.
+   * @param sessionId - DevTools Runtime session retaining event arguments.
+   * @param listener - Consumer of validated Client Console events.
+   * @returns A disposer that disables this Console session.
+   */
+  subscribeConsole(
+    target: ClientRuntimeTarget,
+    sessionId: ClientRuntimeSessionId,
+    listener: (event: RuntimeConsoleBackendEvent<ClientRemoteObjectHandle>) => void,
+  ): () => void {
+    const subscription: ConsoleSubscription = { target, sessionId, listener }
+    if (!this.sources.send(target.source, {
+      v: INSPECTOR_PROTOCOL_VERSION,
+      t: 'client-console/enable',
+      sourceId: target.source.sourceId,
+      generation: target.source.generation,
+      sessionId,
+    })) {
+      throw new Error('Client Console source disconnected before enable')
+    }
+    this.consoleSubscriptions.add(subscription)
+    return () => {
+      if (!this.consoleSubscriptions.delete(subscription)) return
+      try {
+        this.sources.send(target.source, {
+          v: INSPECTOR_PROTOCOL_VERSION,
+          t: 'client-console/disable',
+          sourceId: target.source.sourceId,
+          generation: target.source.generation,
+          sessionId,
+        })
+      } catch {
+        // Source removal also disables Console observation in the Client.
+      }
+    }
+  }
+
+  /**
+   * Execute one typed command in its currently active source generation.
+   * @param target - Active Client source and context.
+   * @param sessionId - Calling DevTools Runtime session.
+   * @param command - Validated Client Runtime operation.
+   * @returns The correlated result, or a rejection on timeout or disconnect.
+   */
+  request(
+    target: ClientRuntimeTarget,
+    sessionId: ClientRuntimeSessionId,
+    command: ClientRuntimeCommand,
+  ): Promise<ClientRuntimeResult> {
+    if (this.closed || this.targetsBySource.get(target.source.sourceId) !== target) {
+      return Promise.reject(new Error('Client execution context is no longer available'))
+    }
+    const requestId = inspectorId<'ClientRuntimeRequestId'>(randomUUID(), 'requestId')
+    return new Promise((resolve, reject) => {
+      const timer = setTimeout(() => {
+        this.pending.delete(requestId)
+        reject(new Error(`Client Runtime ${command.op} timed out after ${String(this.timeoutMs)}ms`))
+      }, this.timeoutMs)
+      timer.unref()
+      this.pending.set(requestId, { target, sessionId, op: command.op, resolve, reject, timer })
+      try {
+        const sent = this.sources.send(target.source, {
+          v: INSPECTOR_PROTOCOL_VERSION,
+          t: 'client-runtime/request',
+          sourceId: target.source.sourceId,
+          generation: target.source.generation,
+          sessionId,
+          requestId,
+          command,
+        })
+        if (!sent) this.rejectPending(requestId, new Error('Client execution context disconnected before dispatch'))
+      } catch (error) {
+        this.rejectPending(requestId, renderError(error))
+      }
+    })
+  }
+
+  /**
+   * Close one realm-local Runtime session without notifying sibling Client realms.
+   * @param target - Client realm that owns the session.
+   * @param sessionId - Closing DevTools Runtime session.
+   */
+  closeTargetSession(target: ClientRuntimeTarget, sessionId: ClientRuntimeSessionId): void {
+    for (const [requestId, pending] of this.pending) {
+      if (pending.target !== target || pending.sessionId !== sessionId) continue
+      this.rejectPending(requestId, new Error('DevTools Runtime session closed'))
+    }
+    for (const subscription of [...this.consoleSubscriptions]) {
+      if (subscription.target === target && subscription.sessionId === sessionId) {
+        this.consoleSubscriptions.delete(subscription)
+      }
+    }
+    sendClientSessionClosed(this.sources, target.source, {
+      v: INSPECTOR_PROTOCOL_VERSION,
+      t: 'client-runtime/session-closed',
+      sourceId: target.source.sourceId,
+      generation: target.source.generation,
+      sessionId,
+    })
+  }
+
+  /** Stop routing and reject every outstanding operation. */
+  close(): void {
+    if (this.closed) return
+    this.closed = true
+    this.unsubscribeSources()
+    for (const requestId of [...this.pending.keys()]) {
+      this.rejectPending(requestId, new Error('Client Runtime router closed'))
+    }
+    this.targetsBySource.clear()
+    this.consoleSubscriptions.clear()
+    this.listeners.clear()
+  }
+
+  private receiveSourceEvent(event: InspectorSourceEvent): void {
+    switch (event.type) {
+      case 'opened':
+        this.open(event.source)
+        return
+      case 'closed':
+        this.remove(event.source, event.reason)
+        return
+      case 'client-runtime-response':
+        this.settle(event.source, event.frame)
+        return
+      case 'client-console-event':
+        this.consoleEvent(event.source, event.frame)
+        return
+      case 'client-source-response':
+        return
+      default:
+        assertNever(event)
+    }
+  }
+
+  private open(source: InspectorSourceDescriptor): void {
+    const capability = source.capabilities.find(
+      (candidate): candidate is ClientRuntimeCapability => candidate.type === 'client-runtime',
+    )
+    if (capability === undefined) return
+    const target: ClientRuntimeTarget = {
+      contextId: this.nextContextId--,
+      uniqueContextId: `dsh-client:${source.sourceId}:${source.generation}`,
+      source,
+      capability,
+    }
+    this.targetsBySource.set(source.sourceId, target)
+    this.emit({ type: 'opened', target })
+  }
+
+  private remove(source: InspectorSourceDescriptor, reason: string): void {
+    const target = this.targetsBySource.get(source.sourceId)
+    if (target === undefined || target.source.generation !== source.generation) return
+    this.targetsBySource.delete(source.sourceId)
+    for (const [requestId, pending] of this.pending) {
+      if (pending.target !== target) continue
+      this.rejectPending(requestId, new Error(`Client execution context closed: ${reason}`))
+    }
+    for (const subscription of [...this.consoleSubscriptions]) {
+      if (subscription.target === target) this.consoleSubscriptions.delete(subscription)
+    }
+    this.emit({ type: 'closed', target })
+  }
+
+  private consoleEvent(source: InspectorSourceDescriptor, frame: ClientConsoleEventFrame): void {
+    const target = this.targetsBySource.get(source.sourceId)
+    if (target === undefined || target.source.generation !== source.generation) return
+    for (const subscription of [...this.consoleSubscriptions]) {
+      if (subscription.target !== target || subscription.sessionId !== frame.sessionId) continue
+      try {
+        subscription.listener(frame.event)
+      } catch {
+        // One DevTools Console session cannot disrupt sibling sessions.
+      }
+    }
+  }
+
+  private settle(source: InspectorSourceDescriptor, frame: ClientRuntimeResponseFrame): void {
+    const pending = this.pending.get(frame.requestId)
+    if (pending === undefined) return
+    if (pending.target.source.sourceId !== source.sourceId
+      || pending.target.source.generation !== source.generation
+      || pending.sessionId !== frame.sessionId) {
+      this.rejectPending(frame.requestId, new Error('Client Runtime response correlation mismatch'))
+      return
+    }
+    if (!frame.outcome.ok) {
+      this.rejectPending(frame.requestId, new ClientRuntimeRemoteError(frame.outcome.error.code, frame.outcome.error.message))
+      return
+    }
+    if (frame.outcome.result.op !== pending.op) {
+      this.rejectPending(frame.requestId, new Error(
+        `Client Runtime response op ${frame.outcome.result.op} does not match ${pending.op}`,
+      ))
+      return
+    }
+    clearTimeout(pending.timer)
+    this.pending.delete(frame.requestId)
+    pending.resolve(frame.outcome.result)
+  }
+
+  private rejectPending(requestId: ClientRuntimeRequestId, error: Error): void {
+    const pending = this.pending.get(requestId)
+    if (pending === undefined) return
+    clearTimeout(pending.timer)
+    this.pending.delete(requestId)
+    pending.reject(error)
+  }
+
+  private emit(event: ClientRuntimeTargetEvent): void {
+    for (const listener of [...this.listeners]) {
+      try {
+        listener(event)
+      } catch {
+        // One CDP session cannot disrupt context delivery to another session.
+      }
+    }
+  }
+}
+
+function renderError(error: unknown): Error {
+  return error instanceof Error ? error : new Error(String(error))
+}
+
+function assertNever(value: never): never {
+  throw new Error(`Unexpected source event: ${JSON.stringify(value)}`)
+}

+ 26 - 0
packages/experimental/inspector/src/worker/bridge/session.ts

@@ -0,0 +1,26 @@
+/** Shared cleanup delivery for Worker-owned Client sessions. */
+
+import type { ClientRuntimeSessionClosedFrame } from '../../shared/bridge/messages/runtime/index.ts'
+import type { ClientSourceSessionClosedFrame } from '../../shared/bridge/messages/sources/index.ts'
+import type { InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts'
+import type { InspectorSourceRegistry } from './hub.ts'
+
+type ClientSessionClosedFrame = ClientRuntimeSessionClosedFrame | ClientSourceSessionClosedFrame
+
+/**
+ * Send cleanup to an active Client generation when its transport is still usable.
+ * @param sources - Worker source registry owning the transport.
+ * @param source - Generation whose session closed.
+ * @param frame - Typed Runtime or source-catalog cleanup frame.
+ */
+export function sendClientSessionClosed(
+  sources: InspectorSourceRegistry,
+  source: InspectorSourceDescriptor,
+  frame: ClientSessionClosedFrame,
+): void {
+  try {
+    sources.send(source, frame)
+  } catch {
+    // Source removal already invalidates every session owned by this generation.
+  }
+}

+ 192 - 0
packages/experimental/inspector/src/worker/bridge/source-rpc.ts

@@ -0,0 +1,192 @@
+/** Worker-owned request routing for Client read-only source catalogs. */
+
+import { randomUUID } from 'node:crypto'
+import type {
+  ClientSourceCommand,
+  ClientSourceError,
+  ClientSourceResponseFrame,
+  ClientSourceResult,
+} from '../../shared/bridge/messages/sources/index.ts'
+import {
+  inspectorId,
+  type ClientSourceRequestId,
+  type ClientSourceSessionId,
+} from '../../shared/bridge/ids.ts'
+import { INSPECTOR_PROTOCOL_VERSION, type InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts'
+import { sendClientSessionClosed } from './session.ts'
+import type { InspectorSourceEvent, InspectorSourceRegistry } from './hub.ts'
+
+interface PendingSourceRequest {
+  readonly source: InspectorSourceDescriptor
+  readonly sessionId: ClientSourceSessionId
+  readonly command: ClientSourceCommand
+  readonly resolve: (result: ClientSourceResult) => void
+  readonly reject: (error: Error) => void
+  readonly timer: ReturnType<typeof setTimeout>
+}
+
+/** Deliberate error returned by the Client source catalog. */
+export class ClientSourceRemoteError extends Error {
+  constructor(readonly code: ClientSourceError['code'], message: string) {
+    super(message)
+  }
+}
+
+/** Correlates bounded source requests with one active Client source generation. */
+export class ClientSourceRouter {
+  /** Maximum decoded bytes requested in one source-content response. */
+  readonly chunkBytes: number
+  private readonly pending = new Map<ClientSourceRequestId, PendingSourceRequest>()
+  private readonly unsubscribeSources: () => void
+  private closed = false
+
+  constructor(
+    private readonly sources: InspectorSourceRegistry,
+    private readonly timeoutMs: number,
+    readonly maxContentBytes: number,
+    maxFrameBytes: number,
+  ) {
+    this.chunkBytes = Math.max(1, Math.floor((maxFrameBytes - 4_096) * 3 / 4))
+    this.unsubscribeSources = sources.subscribeEvents((event) => { this.receiveSourceEvent(event) })
+  }
+
+  /**
+   * Execute one operation against an active Client source generation.
+   * @param source - Client source that owns the script catalog.
+   * @param sessionId - DevTools connection-local source session.
+   * @param command - Validated read-only source command.
+   * @returns The correlated result.
+   */
+  request(
+    source: InspectorSourceDescriptor,
+    sessionId: ClientSourceSessionId,
+    command: ClientSourceCommand,
+  ): Promise<ClientSourceResult> {
+    if (this.closed) return Promise.reject(new Error('Client source router is closed'))
+    const requestId = inspectorId<'ClientSourceRequestId'>(randomUUID(), 'requestId')
+    return new Promise((resolve, reject) => {
+      const timer = setTimeout(() => {
+        this.pending.delete(requestId)
+        reject(new Error(`Client source ${command.op} timed out after ${String(this.timeoutMs)}ms`))
+      }, this.timeoutMs)
+      timer.unref()
+      this.pending.set(requestId, { source, sessionId, command, resolve, reject, timer })
+      try {
+        const sent = this.sources.send(source, {
+          v: INSPECTOR_PROTOCOL_VERSION,
+          t: 'client-sources/request',
+          sourceId: source.sourceId,
+          generation: source.generation,
+          sessionId,
+          requestId,
+          command,
+        })
+        if (!sent) this.rejectPending(requestId, new Error('Client source disconnected before dispatch'))
+      } catch (error) {
+        this.rejectPending(requestId, renderError(error))
+      }
+    })
+  }
+
+  /**
+   * Reject pending operations and notify one Client source session that it closed.
+   * @param source - Source generation owning the session.
+   * @param sessionId - Closing source session.
+   */
+  closeSession(source: InspectorSourceDescriptor, sessionId: ClientSourceSessionId): void {
+    for (const [requestId, pending] of this.pending) {
+      if (pending.source.sourceId !== source.sourceId
+        || pending.source.generation !== source.generation
+        || pending.sessionId !== sessionId) continue
+      this.rejectPending(requestId, new Error('DevTools source session closed'))
+    }
+    sendClientSessionClosed(this.sources, source, {
+      v: INSPECTOR_PROTOCOL_VERSION,
+      t: 'client-sources/session-closed',
+      sourceId: source.sourceId,
+      generation: source.generation,
+      sessionId,
+    })
+  }
+
+  /** Stop routing and reject every outstanding source operation. */
+  close(): void {
+    if (this.closed) return
+    this.closed = true
+    this.unsubscribeSources()
+    for (const requestId of [...this.pending.keys()]) {
+      this.rejectPending(requestId, new Error('Client source router closed'))
+    }
+  }
+
+  private receiveSourceEvent(event: InspectorSourceEvent): void {
+    switch (event.type) {
+      case 'closed':
+        for (const [requestId, pending] of this.pending) {
+          if (pending.source.sourceId === event.source.sourceId
+            && pending.source.generation === event.source.generation) {
+            this.rejectPending(requestId, new Error(`Client source closed: ${event.reason}`))
+          }
+        }
+        return
+      case 'client-source-response':
+        this.settle(event.source, event.frame)
+        return
+      case 'opened':
+      case 'client-runtime-response':
+      case 'client-console-event':
+        return
+      default:
+        assertNever(event)
+    }
+  }
+
+  private settle(source: InspectorSourceDescriptor, frame: ClientSourceResponseFrame): void {
+    const pending = this.pending.get(frame.requestId)
+    if (pending === undefined) return
+    if (pending.source.sourceId !== source.sourceId
+      || pending.source.generation !== source.generation
+      || pending.sessionId !== frame.sessionId) {
+      this.rejectPending(frame.requestId, new Error('Client source response correlation mismatch'))
+      return
+    }
+    if (!frame.outcome.ok) {
+      this.rejectPending(
+        frame.requestId,
+        new ClientSourceRemoteError(frame.outcome.error.code, frame.outcome.error.message),
+      )
+      return
+    }
+    if (!matchesCommand(pending.command, frame.outcome.result)) {
+      this.rejectPending(frame.requestId, new Error('Client source response does not match its request'))
+      return
+    }
+    clearTimeout(pending.timer)
+    this.pending.delete(frame.requestId)
+    pending.resolve(frame.outcome.result)
+  }
+
+  private rejectPending(requestId: ClientSourceRequestId, error: Error): void {
+    const pending = this.pending.get(requestId)
+    if (pending === undefined) return
+    clearTimeout(pending.timer)
+    this.pending.delete(requestId)
+    pending.reject(error)
+  }
+}
+
+function matchesCommand(command: ClientSourceCommand, result: ClientSourceResult): boolean {
+  if (command.op !== result.op) return false
+  if (command.op === 'list-scripts' || result.op === 'list-scripts') return true
+  return result.scriptKey === command.scriptKey
+    && result.content === command.content
+    && (!result.available || result.offset === command.offset)
+}
+
+function renderError(error: unknown): Error {
+  return error instanceof Error ? error : new Error(String(error))
+}
+
+function assertNever(value: never): never {
+  throw new Error(`Unexpected source event: ${JSON.stringify(value)}`)
+}

+ 52 - 0
packages/experimental/inspector/src/worker/cdp/domains/debugger/cdp-params.ts

@@ -0,0 +1,52 @@
+/** Validation for CDP Debugger requests handled by the shared domain. */
+
+import type { RuntimeCallFrameEvaluationRequest } from '../../../../shared/cdp/index.ts'
+import { exactKeys, optionalBoolean, optionalString } from '../../../../shared/validation.ts'
+
+/**
+ * Parse Debugger.evaluateOnCallFrame without silently accepting unsupported options.
+ * @param params - Untrusted CDP parameters.
+ * @returns The common call-frame evaluation request.
+ */
+export function parseCallFrameEvaluation(
+  params: Readonly<Record<string, unknown>>,
+): RuntimeCallFrameEvaluationRequest {
+  exactKeys(params, [
+    'callFrameId', 'expression', 'objectGroup', 'includeCommandLineAPI', 'silent', 'returnByValue',
+    'generatePreview', 'throwOnSideEffect', 'timeout',
+  ], 'Debugger.evaluateOnCallFrame parameters')
+  if (typeof params.callFrameId !== 'string' || typeof params.expression !== 'string') {
+    throw new Error('Debugger.evaluateOnCallFrame requires callFrameId and expression')
+  }
+  if (params.timeout !== undefined
+    && (typeof params.timeout !== 'number' || !Number.isFinite(params.timeout) || params.timeout < 0)) {
+    throw new Error('Debugger.evaluateOnCallFrame timeout must be a non-negative number')
+  }
+  return {
+    callFrameId: params.callFrameId,
+    expression: params.expression,
+    ...optionalString(params, 'objectGroup'),
+    ...optionalBoolean(params, 'includeCommandLineAPI'),
+    ...optionalBoolean(params, 'silent'),
+    ...optionalBoolean(params, 'returnByValue'),
+    ...optionalBoolean(params, 'generatePreview'),
+    ...optionalBoolean(params, 'throwOnSideEffect'),
+    ...(params.timeout === undefined ? {} : { timeoutMs: params.timeout }),
+  }
+}
+
+/**
+ * Find a ScriptId carried directly or by a Debugger location parameter.
+ * @param params - Parsed CDP parameter record.
+ * @returns The targeted script id when the request names one.
+ */
+export function requestScriptId(params: Readonly<Record<string, unknown>>): string | undefined {
+  if (typeof params.scriptId === 'string') return params.scriptId
+  for (const key of ['location', 'start', 'end'] as const) {
+    const value = params[key]
+    if (typeof value !== 'object' || value === null || Array.isArray(value)) continue
+    const scriptId = (value as Readonly<Record<string, unknown>>).scriptId
+    if (typeof scriptId === 'string') return scriptId
+  }
+  return undefined
+}

+ 6 - 0
packages/experimental/inspector/src/worker/cdp/domains/debugger/index.ts

@@ -0,0 +1,6 @@
+/** Shared Debugger domain exports. */
+
+export * from './cdp-params.ts'
+export * from './projector.ts'
+export * from './script-registry.ts'
+export * from './session.ts'

+ 114 - 0
packages/experimental/inspector/src/worker/cdp/domains/debugger/projector.ts

@@ -0,0 +1,114 @@
+/** CDP projection for realm-neutral scripts and debugger events. */
+
+import type { RuntimeDebuggerEvent, RuntimeDebuggerLocation, RuntimeScript, RuntimeStackTrace } from '../../../../shared/cdp/index.ts'
+import type { RuntimeBackendObjectHandle } from '../../../../shared/cdp/ids.ts'
+import type { CdpNotification } from '../../protocol.ts'
+import type { InspectorRealmSession } from '../../../inspection/realm.ts'
+import type { RuntimeDomainSession } from '../runtime/index.ts'
+import { cdpScriptId } from './script-registry.ts'
+
+/**
+ * Project one common script descriptor to Debugger.scriptParsed.
+ * @param realm - Realm session that owns the script.
+ * @param script - Realm-neutral script descriptor.
+ * @returns A CDP scriptParsed notification.
+ */
+export function scriptParsedEvent(realm: InspectorRealmSession, script: RuntimeScript): CdpNotification {
+  return {
+    method: 'Debugger.scriptParsed',
+    params: {
+      scriptId: cdpScriptId(script.scriptKey),
+      url: script.url,
+      startLine: script.startLine,
+      startColumn: script.startColumn,
+      endLine: script.endLine,
+      endColumn: script.endColumn,
+      executionContextId: script.executionContextId
+        ?? (realm.context.kind === 'synthetic' ? realm.context.id : 0),
+      hash: script.hash,
+      buildId: script.buildId ?? '',
+      ...(script.sourceMapUrl === undefined ? {} : { sourceMapURL: script.sourceMapUrl }),
+      ...(script.isModule === undefined ? {} : { isModule: script.isModule }),
+      ...(script.length === undefined ? {} : { length: script.length }),
+    },
+  }
+}
+
+/**
+ * Project one common debugger event and all nested Runtime objects to CDP.
+ * @param realm - Realm session that emitted the event.
+ * @param event - Realm-neutral debugger event.
+ * @param runtime - Connection-local Runtime object projector.
+ * @returns The corresponding CDP notification.
+ */
+export function debuggerEvent(
+  realm: InspectorRealmSession,
+  event: RuntimeDebuggerEvent<RuntimeBackendObjectHandle>,
+  runtime: RuntimeDomainSession,
+): CdpNotification {
+  switch (event.type) {
+    case 'paused':
+      return {
+        method: 'Debugger.paused',
+        params: {
+          callFrames: event.callFrames.map(frame => ({
+            callFrameId: frame.callFrameId,
+            functionName: frame.functionName,
+            ...(frame.functionLocation === undefined ? {} : { functionLocation: location(frame.functionLocation) }),
+            location: location(frame.location),
+            url: frame.url,
+            scopeChain: frame.scopeChain.map(scope => ({
+              type: scope.type,
+              object: runtime.projectRemoteObject(realm, scope.object, 'backtrace'),
+              ...(scope.name === undefined ? {} : { name: scope.name }),
+              ...(scope.startLocation === undefined ? {} : { startLocation: location(scope.startLocation) }),
+              ...(scope.endLocation === undefined ? {} : { endLocation: location(scope.endLocation) }),
+            })),
+            this: runtime.projectRemoteObject(realm, frame.thisObject, 'backtrace'),
+            ...(frame.returnValue === undefined
+              ? {}
+              : { returnValue: runtime.projectRemoteObject(realm, frame.returnValue, 'backtrace') }),
+          })),
+          reason: event.reason,
+          ...(event.data === undefined ? {} : { data: event.data }),
+          ...(event.hitBreakpoints === undefined ? {} : { hitBreakpoints: event.hitBreakpoints }),
+          ...(event.asyncStackTrace === undefined ? {} : { asyncStackTrace: stackTrace(event.asyncStackTrace) }),
+        },
+      }
+    case 'resumed':
+      return { method: 'Debugger.resumed', params: {} }
+    case 'breakpoint-resolved':
+      return {
+        method: 'Debugger.breakpointResolved',
+        params: { breakpointId: event.breakpointId, location: location(event.location) },
+      }
+    default:
+      return assertNever(event)
+  }
+}
+
+function location(value: RuntimeDebuggerLocation): Readonly<Record<string, unknown>> {
+  return {
+    scriptId: cdpScriptId(value.scriptKey),
+    lineNumber: value.lineNumber,
+    ...(value.columnNumber === undefined ? {} : { columnNumber: value.columnNumber }),
+  }
+}
+
+function stackTrace(value: RuntimeStackTrace): Readonly<Record<string, unknown>> {
+  return {
+    ...(value.description === undefined ? {} : { description: value.description }),
+    callFrames: value.callFrames.map(frame => ({
+      functionName: frame.functionName,
+      scriptId: frame.scriptKey === undefined ? '0' : cdpScriptId(frame.scriptKey),
+      url: frame.url,
+      lineNumber: frame.lineNumber,
+      columnNumber: frame.columnNumber,
+    })),
+    ...(value.parent === undefined ? {} : { parent: stackTrace(value.parent) }),
+  }
+}
+
+function assertNever(value: never): never {
+  throw new Error(`Unexpected debugger event: ${JSON.stringify(value)}`)
+}

+ 117 - 0
packages/experimental/inspector/src/worker/cdp/domains/debugger/script-registry.ts

@@ -0,0 +1,117 @@
+/** Connection-local routing from CDP ScriptId values to realm source backends. */
+
+import type { RuntimeScriptKey } from '../../../../shared/cdp/ids.ts'
+import type { RuntimeScript } from '../../../../shared/cdp/index.ts'
+import type { SourceBackend } from '../../../../shared/cdp/realm.ts'
+import type { InspectorRealmSession } from '../../../inspection/realm.ts'
+import { cdpStringId, type CdpScriptId } from '../../ids.ts'
+
+/** One script and the realm source backend that owns its content. */
+export interface DebuggerScriptRoute {
+  readonly realm: InspectorRealmSession
+  readonly source: SourceBackend
+  readonly script: RuntimeScript
+}
+
+/** Tracks active and retired scripts without exposing source transport ids. */
+export class DebuggerScriptRegistry {
+  private readonly routes = new Map<CdpScriptId, DebuggerScriptRoute>()
+  private readonly retiredUnsupported = new Set<CdpScriptId>()
+
+  /**
+   * Register one realm script under its globally unique Runtime script key.
+   * @param route - Script descriptor and owning realm session.
+   * @returns The CDP ScriptId and whether this is its first announcement.
+   */
+  register(route: DebuggerScriptRoute): { readonly scriptId: CdpScriptId; readonly fresh: boolean } {
+    const scriptId = cdpScriptId(route.script.scriptKey)
+    const current = this.routes.get(scriptId)
+    if (current !== undefined && current.realm !== route.realm) {
+      throw new Error(`Inspector realms produced the same script key ${scriptId}`)
+    }
+    this.routes.set(scriptId, route)
+    return { scriptId, fresh: current === undefined }
+  }
+
+  /**
+   * Resolve an active CDP ScriptId.
+   * @param scriptId - Connection-visible script id.
+   * @returns The active route when the script remains connected.
+   */
+  resolve(scriptId: string): DebuggerScriptRoute | undefined {
+    return this.routes.get(cdpStringId<'CdpScriptId'>(scriptId, 'scriptId'))
+  }
+
+  /**
+   * Resolve a script by its exact URL.
+   * @param url - Script URL from a CDP request.
+   * @returns The active route when one script has that URL.
+   */
+  byUrl(url: string): DebuggerScriptRoute | undefined {
+    for (const route of this.routes.values()) {
+      if (route.script.url === url) return route
+    }
+    return undefined
+  }
+
+  /**
+   * Resolve a script by its exact content hash.
+   * @param hash - Script hash from a breakpoint request.
+   * @returns The active route when one script has that hash.
+   */
+  byHash(hash: string): DebuggerScriptRoute | undefined {
+    for (const route of this.routes.values()) {
+      if (route.script.hash === hash) return route
+    }
+    return undefined
+  }
+
+  /**
+   * Resolve the first script whose URL matches a breakpoint regular expression.
+   * @param pattern - JavaScript regular-expression source accepted by CDP.
+   * @returns The first matching active route.
+   */
+  byUrlPattern(pattern: string): DebuggerScriptRoute | undefined {
+    const expression = new RegExp(pattern, 'u')
+    for (const route of this.routes.values()) {
+      if (expression.test(route.script.url)) return route
+    }
+    return undefined
+  }
+
+  /**
+   * Test whether a disconnected script belonged to a realm without active debugging.
+   * @param scriptId - Script id from a later CDP request.
+   * @returns Whether the id must still fail as an unsupported Client script.
+   */
+  wasUnsupported(scriptId: string): boolean {
+    return this.retiredUnsupported.has(cdpStringId<'CdpScriptId'>(scriptId, 'scriptId'))
+  }
+
+  /**
+   * Forget scripts for one closed realm while retaining their unsupported identity.
+   * @param realm - Realm session being removed.
+   */
+  removeRealm(realm: InspectorRealmSession): void {
+    for (const [scriptId, route] of this.routes) {
+      if (route.realm !== realm) continue
+      this.routes.delete(scriptId)
+      if (realm.debugger.state === 'unsupported') this.retiredUnsupported.add(scriptId)
+    }
+  }
+
+  /** Forget all active and retired script routes. */
+  clear(): void {
+    this.routes.clear()
+    this.retiredUnsupported.clear()
+  }
+}
+
+/**
+ * Preserve a branded script key as its CDP wire identifier.
+ * @param scriptKey - Realm-wide Runtime script key.
+ * @returns The corresponding CDP ScriptId text.
+ */
+export function cdpScriptId(scriptKey: RuntimeScriptKey): CdpScriptId {
+  return cdpStringId<'CdpScriptId'>(scriptKey, 'scriptId')
+}

+ 350 - 0
packages/experimental/inspector/src/worker/cdp/domains/debugger/session.ts

@@ -0,0 +1,350 @@
+/** Per-DevTools Debugger and source routing across Host and Client realms. */
+
+import { respondToCdpRequest, sendCdpFailure, type CdpRequest, type CdpTransport } from '../../protocol.ts'
+import type {
+  DebuggerBackend,
+  NativeDomainBackend,
+  SourceBackend,
+} from '../../../../shared/cdp/realm.ts'
+import type { RuntimeBackendObjectHandle } from '../../../../shared/cdp/ids.ts'
+import type { InspectorRealmSession } from '../../../inspection/realm.ts'
+import type { InspectorRealmSessionEvent, InspectorRealmSessionSet } from '../../realm-sessions.ts'
+import type {
+  RuntimeDebuggerEnableRequest,
+  RuntimeDebuggerEvent,
+  RuntimeScript,
+} from '../../../../shared/cdp/index.ts'
+import { exactKeys, optionalBoolean } from '../../../../shared/validation.ts'
+import type { RuntimeDomainSession } from '../runtime/index.ts'
+import { parseCallFrameEvaluation, requestScriptId } from './cdp-params.ts'
+import { debuggerEvent, scriptParsedEvent } from './projector.ts'
+import { DebuggerScriptRegistry } from './script-registry.ts'
+
+/** Owns Debugger lifecycle, shared script projection, and Host-native fallback. */
+export class DebuggerDomainSession {
+  private readonly scripts = new DebuggerScriptRegistry()
+  private readonly sourceDisposers = new Map<string, () => void>()
+  private readonly debuggerDisposers = new Map<string, () => void>()
+  private readonly callFrameRealms = new Map<string, InspectorRealmSession>()
+  private readonly unsubscribeRealms: () => void
+  private readonly native: NativeDomainBackend
+  private debuggerEnableRequest: RuntimeDebuggerEnableRequest = {}
+  private enabled = false
+  private closed = false
+
+  constructor(
+    private readonly transport: CdpTransport,
+    private readonly realms: InspectorRealmSessionSet,
+    private readonly runtime: RuntimeDomainSession,
+  ) {
+    const native = realms.all()
+      .map(realm => realm.nativeDomains)
+      .find(capability => capability.state === 'supported')
+    if (native === undefined) throw new Error('Inspector has no native Host debugger transport')
+    this.native = native.backend
+    this.unsubscribeRealms = realms.subscribe((event) => { this.receiveRealm(event) })
+  }
+
+  /**
+   * Handle one Debugger request, including Client read-only source operations.
+   * @param request - Parsed CDP request.
+   * @returns Whether the method belongs to the Debugger domain.
+   */
+  handle(request: CdpRequest): boolean {
+    if (!request.method.startsWith('Debugger.')) return false
+    switch (request.method) {
+      case 'Debugger.enable':
+        this.respond(request, () => this.enable(request.params))
+        return true
+      case 'Debugger.disable':
+        exactKeys(request.params, [], 'Debugger.disable parameters')
+        this.respond(request, () => this.disable())
+        return true
+      case 'Debugger.getScriptSource':
+        this.respond(request, () => this.getScriptSource(request.params))
+        return true
+      case 'Debugger.searchInContent':
+        this.respond(request, () => this.searchInContent(request.params))
+        return true
+      case 'Debugger.evaluateOnCallFrame':
+        this.respond(request, () => this.evaluateOnCallFrame(request.params))
+        return true
+      case 'Debugger.pause':
+        exactKeys(request.params, [], 'Debugger.pause parameters')
+        this.respond(request, () => this.pause())
+        return true
+      case 'Debugger.resume':
+        this.respond(request, () => this.resume(request.params))
+        return true
+      default:
+        this.forwardNative(request)
+        return true
+    }
+  }
+
+  /** Release source and debugger subscriptions. */
+  close(): void {
+    if (this.closed) return
+    this.closed = true
+    this.unsubscribeRealms()
+    this.detachCapabilities()
+    this.callFrameRealms.clear()
+    this.scripts.clear()
+    this.runtime.releaseProjectedGroup('backtrace')
+  }
+
+  private async enable(params: Readonly<Record<string, unknown>>): Promise<Readonly<Record<string, unknown>>> {
+    exactKeys(params, ['maxScriptsCacheSize'], 'Debugger.enable parameters')
+    if (this.enabled) return {}
+    const maxScriptsCacheSize = params.maxScriptsCacheSize
+    if (maxScriptsCacheSize !== undefined
+      && (typeof maxScriptsCacheSize !== 'number' || !Number.isFinite(maxScriptsCacheSize) || maxScriptsCacheSize < 0)) {
+      throw new Error('Debugger.enable maxScriptsCacheSize must be a non-negative number')
+    }
+    const enableRequest = maxScriptsCacheSize === undefined ? {} : { maxScriptsCacheSize }
+    this.debuggerEnableRequest = enableRequest
+    this.enabled = true
+    try {
+      for (const realm of this.realms.all()) this.attachCapabilities(realm)
+      const results = await Promise.all(this.realms.all().map(async realm =>
+        realm.debugger.state === 'supported' ? realm.debugger.backend.enable(enableRequest) : {}))
+      await Promise.all(this.realms.all().map(async realm => this.publishCatalog(realm)))
+      return mergeResults(results)
+    } catch (error) {
+      this.enabled = false
+      this.debuggerEnableRequest = {}
+      this.detachCapabilities()
+      this.scripts.clear()
+      await Promise.allSettled(this.realms.all().map(async (realm) => {
+        if (realm.debugger.state === 'supported') await realm.debugger.backend.disable()
+      }))
+      throw error
+    }
+  }
+
+  private async disable(): Promise<Readonly<Record<string, unknown>>> {
+    this.enabled = false
+    this.debuggerEnableRequest = {}
+    this.detachCapabilities()
+    this.callFrameRealms.clear()
+    this.scripts.clear()
+    this.runtime.releaseProjectedGroup('backtrace')
+    const results = await Promise.all(this.realms.all().map(async realm =>
+      realm.debugger.state === 'supported' ? realm.debugger.backend.disable() : {}))
+    return mergeResults(results)
+  }
+
+  private async getScriptSource(params: Readonly<Record<string, unknown>>): Promise<object> {
+    exactKeys(params, ['scriptId'], 'Debugger.getScriptSource parameters')
+    if (typeof params.scriptId !== 'string') throw new Error('Debugger.getScriptSource requires scriptId')
+    const route = this.scripts.resolve(params.scriptId)
+    if (route !== undefined) return { scriptSource: await route.source.getScriptSource(route.script.scriptKey) }
+    if (this.scripts.wasUnsupported(params.scriptId) || params.scriptId.startsWith('client:')) {
+      throw new Error('Client script is no longer available')
+    }
+    return this.native.request('Debugger.getScriptSource', params)
+  }
+
+  private async searchInContent(params: Readonly<Record<string, unknown>>): Promise<object> {
+    exactKeys(params, ['scriptId', 'query', 'caseSensitive', 'isRegex'], 'Debugger.searchInContent parameters')
+    if (typeof params.scriptId !== 'string' || typeof params.query !== 'string') {
+      throw new Error('Debugger.searchInContent requires scriptId and query')
+    }
+    if (params.caseSensitive !== undefined && typeof params.caseSensitive !== 'boolean') {
+      throw new Error('Debugger.searchInContent caseSensitive must be a boolean')
+    }
+    if (params.isRegex !== undefined && typeof params.isRegex !== 'boolean') {
+      throw new Error('Debugger.searchInContent isRegex must be a boolean')
+    }
+    const route = this.scripts.resolve(params.scriptId)
+    if (route === undefined) {
+      if (this.scripts.wasUnsupported(params.scriptId) || params.scriptId.startsWith('client:')) {
+        throw new Error('Client script is no longer available')
+      }
+      return this.native.request('Debugger.searchInContent', params)
+    }
+    const source = await route.source.getScriptSource(route.script.scriptKey)
+    return {
+      result: searchLines(
+        source,
+        params.query,
+        params.caseSensitive === true,
+        params.isRegex === true,
+      ),
+    }
+  }
+
+  private async evaluateOnCallFrame(params: Readonly<Record<string, unknown>>): Promise<object> {
+    const parsed = parseCallFrameEvaluation(params)
+    if (parsed.callFrameId.startsWith('client:')) throw new Error('Client native debugging is unavailable')
+    const realm = this.callFrameRealms.get(parsed.callFrameId) ?? this.supportedDebugger()
+    const objectGroup = parsed.objectGroup ?? 'backtrace'
+    const completion = await debuggerBackend(realm).evaluateOnCallFrame({ ...parsed, objectGroup })
+    return this.runtime.projectCompletion(realm, completion, objectGroup)
+  }
+
+  private async pause(): Promise<object> {
+    const supported = this.realms.all().filter(realm => realm.debugger.state === 'supported')
+    if (supported.length === 0) throw new Error('Debugger.pause is unsupported by every active realm')
+    const results = await Promise.all(supported.map(async realm => debuggerBackend(realm).pause()))
+    return mergeResults(results)
+  }
+
+  private async resume(params: Readonly<Record<string, unknown>>): Promise<object> {
+    exactKeys(params, ['terminateOnResume'], 'Debugger.resume parameters')
+    const request = optionalBoolean(params, 'terminateOnResume')
+    const supported = this.realms.all().filter(realm => realm.debugger.state === 'supported')
+    if (supported.length === 0) throw new Error('Debugger.resume is unsupported by every active realm')
+    const results = await Promise.all(supported.map(async realm => debuggerBackend(realm).resume(request)))
+    return mergeResults(results)
+  }
+
+  private forwardNative(request: CdpRequest): void {
+    let params: Readonly<Record<string, unknown>>
+    try {
+      const unsupported = this.unsupportedRoute(request.params)
+      if (unsupported !== undefined) throw new Error(unsupported)
+      params = this.runtime.nativeParameters(request.params)
+    } catch (error) {
+      sendCdpFailure(this.transport, request, error)
+      return
+    }
+    respondToCdpRequest(this.transport, request, async () => this.native.request(request.method, params))
+  }
+
+  private unsupportedRoute(params: Readonly<Record<string, unknown>>): string | undefined {
+    const scriptId = requestScriptId(params)
+    if (scriptId !== undefined) {
+      const route = this.scripts.resolve(scriptId)
+      if (route?.realm.debugger.state === 'unsupported') return route.realm.debugger.reason
+      if (route === undefined && this.scripts.wasUnsupported(scriptId)) return 'Client script is no longer available'
+    }
+    if (typeof params.url === 'string') {
+      const route = this.scripts.byUrl(params.url)
+      if (route?.realm.debugger.state === 'unsupported') return route.realm.debugger.reason
+    }
+    if (typeof params.urlRegex === 'string') {
+      const route = this.scripts.byUrlPattern(params.urlRegex)
+      if (route?.realm.debugger.state === 'unsupported') return route.realm.debugger.reason
+    }
+    if (typeof params.scriptHash === 'string') {
+      const route = this.scripts.byHash(params.scriptHash)
+      if (route?.realm.debugger.state === 'unsupported') return route.realm.debugger.reason
+    }
+    if (typeof params.objectId === 'string') {
+      const route = this.runtime.objectRoute(params.objectId)
+      if (route?.realm.debugger.state === 'unsupported') return route.realm.debugger.reason
+    }
+    return undefined
+  }
+
+  private receiveRealm(event: InspectorRealmSessionEvent): void {
+    if (event.type === 'opened') {
+      if (this.enabled) void this.enableRealm(event.session).catch((error: unknown) => {
+        console.error(`Inspector could not enable Debugger realm ${event.session.descriptor.label}:`, error)
+      })
+      return
+    }
+    this.sourceDisposers.get(event.session.descriptor.realmId)?.()
+    this.sourceDisposers.delete(event.session.descriptor.realmId)
+    this.debuggerDisposers.get(event.session.descriptor.realmId)?.()
+    this.debuggerDisposers.delete(event.session.descriptor.realmId)
+    for (const [callFrameId, realm] of this.callFrameRealms) {
+      if (realm === event.session) this.callFrameRealms.delete(callFrameId)
+    }
+    this.scripts.removeRealm(event.session)
+  }
+
+  private async enableRealm(realm: InspectorRealmSession): Promise<void> {
+    this.attachCapabilities(realm)
+    if (realm.debugger.state === 'supported') await realm.debugger.backend.enable(this.debuggerEnableRequest)
+    await this.publishCatalog(realm)
+  }
+
+  private attachCapabilities(realm: InspectorRealmSession): void {
+    if (realm.sources.state === 'supported' && !this.sourceDisposers.has(realm.descriptor.realmId)) {
+      const source = realm.sources.backend
+      this.sourceDisposers.set(realm.descriptor.realmId, source.subscribe((script) => {
+        if (this.enabled) this.publishScript(realm, source, script)
+      }))
+    }
+    if (realm.debugger.state === 'supported' && !this.debuggerDisposers.has(realm.descriptor.realmId)) {
+      this.debuggerDisposers.set(realm.descriptor.realmId, realm.debugger.backend.subscribe((event) => {
+        if (this.enabled) this.publishDebuggerEvent(realm, event)
+      }))
+    }
+  }
+
+  private async publishCatalog(realm: InspectorRealmSession): Promise<void> {
+    if (!this.enabled || realm.sources.state === 'unsupported') return
+    const scripts = await realm.sources.backend.listScripts()
+    for (const script of scripts) this.publishScript(realm, realm.sources.backend, script)
+  }
+
+  private publishScript(realm: InspectorRealmSession, source: SourceBackend, script: RuntimeScript): void {
+    const registered = this.scripts.register({ realm, source, script })
+    if (registered.fresh) this.transport.send(scriptParsedEvent(realm, script))
+  }
+
+  private publishDebuggerEvent(
+    realm: InspectorRealmSession,
+    event: RuntimeDebuggerEvent<RuntimeBackendObjectHandle>,
+  ): void {
+    if (event.type === 'paused') {
+      for (const frame of event.callFrames) this.callFrameRealms.set(frame.callFrameId, realm)
+    } else if (event.type === 'resumed') {
+      for (const [callFrameId, owner] of this.callFrameRealms) {
+        if (owner === realm) this.callFrameRealms.delete(callFrameId)
+      }
+      this.runtime.releaseProjectedGroup('backtrace')
+    }
+    this.transport.send(debuggerEvent(realm, event, this.runtime))
+  }
+
+  private supportedDebugger(): InspectorRealmSession {
+    const realm = this.realms.all().find(candidate => candidate.debugger.state === 'supported')
+    if (realm === undefined) throw new Error('No active realm supports call-frame evaluation')
+    return realm
+  }
+
+  private detachCapabilities(): void {
+    for (const dispose of this.sourceDisposers.values()) dispose()
+    this.sourceDisposers.clear()
+    for (const dispose of this.debuggerDisposers.values()) dispose()
+    this.debuggerDisposers.clear()
+  }
+
+  private respond(request: CdpRequest, operation: () => Promise<object>): void {
+    respondToCdpRequest(this.transport, request, operation)
+  }
+}
+
+function debuggerBackend(realm: InspectorRealmSession): DebuggerBackend {
+  if (realm.debugger.state === 'unsupported') throw new Error(realm.debugger.reason)
+  return realm.debugger.backend
+}
+
+function mergeResults(results: readonly Readonly<Record<string, unknown>>[]): Readonly<Record<string, unknown>> {
+  const merged: Record<string, unknown> = {}
+  for (const result of results) Object.assign(merged, result)
+  return merged
+}
+
+function searchLines(
+  source: string,
+  query: string,
+  caseSensitive: boolean,
+  isRegex: boolean,
+): ReadonlyArray<{ readonly lineNumber: number; readonly lineContent: string }> {
+  const expression = isRegex
+    ? new RegExp(query, caseSensitive ? 'u' : 'iu')
+    : undefined
+  const expected = caseSensitive ? query : query.toLowerCase()
+  const result: Array<{ readonly lineNumber: number; readonly lineContent: string }> = []
+  for (const [lineNumber, lineContent] of source.split('\n').entries()) {
+    const matches = expression?.test(lineContent)
+      ?? (caseSensitive ? lineContent : lineContent.toLowerCase()).includes(expected)
+    if (matches) result.push({ lineNumber, lineContent })
+  }
+  return result
+}

+ 49 - 0
packages/experimental/inspector/src/worker/cdp/domains/native.ts

@@ -0,0 +1,49 @@
+/** Explicit adapter for Host-only native CDP methods during realm migration. */
+
+import { respondToCdpRequest, type CdpRequest, type CdpTransport } from '../protocol.ts'
+import type { NativeDomainBackend } from '../../../shared/cdp/realm.ts'
+
+/** Forwards one explicit Host-native domain through a transport-neutral Node session. */
+export class HostNativeDomainSession {
+  private readonly unsubscribe: () => void
+
+  constructor(
+    private readonly transport: CdpTransport,
+    private readonly target: NativeDomainBackend,
+  ) {
+    this.unsubscribe = target.subscribe((message) => {
+      if (!this.owns(message.method)
+        || message.method === 'Runtime.consoleAPICalled'
+        || message.method === 'Runtime.exceptionThrown') return
+      this.transport.send(message)
+    })
+  }
+
+  /**
+   * Execute one Host-native CDP request and send its correlated result.
+   * @param request - Parsed request owned by a native Host domain.
+   * @returns Whether this adapter owns the request's domain.
+   */
+  handle(request: CdpRequest): boolean {
+    if (!this.owns(request.method)) return false
+    respondToCdpRequest(this.transport, request, async () => this.target.request(request.method, request.params))
+    return true
+  }
+
+  /**
+   * Test whether this adapter owns a CDP method.
+   * @param method - CDP method name.
+   * @returns Whether the method belongs to an explicit Host-native domain.
+   */
+  owns(method: string): boolean {
+    return NATIVE_DOMAINS.has(method.slice(0, method.indexOf('.')))
+  }
+
+  /** Stop forwarding native notifications to this DevTools connection. */
+  close(): void {
+    this.unsubscribe()
+  }
+
+}
+
+const NATIVE_DOMAINS = new Set(['Runtime', 'Profiler', 'HeapProfiler', 'Schema'])

+ 256 - 0
packages/experimental/inspector/src/worker/cdp/domains/runtime/cdp-params.ts

@@ -0,0 +1,256 @@
+/** Validation and normalization of CDP Runtime parameters routed to a Client realm. */
+
+import type { RuntimeBackendObjectHandle } from '../../../../shared/cdp/ids.ts'
+import { isJsonValue, isPlainObject, type InspectorJsonValue } from '../../../../shared/json.ts'
+import type {
+  RuntimeAwaitPromiseRequest,
+  RuntimeCallFunctionRequest,
+  RuntimeEvaluateRequest,
+  RuntimeGetPropertiesRequest,
+} from '../../../../shared/cdp/index.ts'
+import { exactKeys, optionalBoolean, optionalString } from '../../../../shared/validation.ts'
+
+/** Numeric or globally unique selector for one execution context. */
+export interface CdpExecutionContextSelector {
+  readonly contextId?: number
+  readonly executionContextId?: number
+  readonly uniqueContextId?: string
+}
+
+/** Validated Runtime.evaluate parameters and their routing selector. */
+export interface ParsedEvaluate extends CdpExecutionContextSelector {
+  readonly request: RuntimeEvaluateRequest
+}
+
+/** Client-independent call argument before object ids are routed. */
+export type CdpCallArgument =
+  | { readonly kind: 'value'; readonly value: InspectorJsonValue }
+  | { readonly kind: 'unserializable'; readonly value: string }
+  | { readonly kind: 'object'; readonly objectId: string }
+  | { readonly kind: 'undefined' }
+
+/** Validated Runtime.callFunctionOn parameters before object-id routing. */
+export interface ParsedCallFunction extends CdpExecutionContextSelector {
+  readonly objectId?: string
+  readonly arguments: readonly CdpCallArgument[]
+  readonly request: Omit<RuntimeCallFunctionRequest<RuntimeBackendObjectHandle>, 'receiver' | 'arguments'>
+}
+
+/**
+ * Parse realm-routed `Runtime.evaluate` parameters.
+ * @param params - Untrusted CDP parameters.
+ * @returns A context selector and normalized Runtime request.
+ */
+export function parseEvaluate(params: Readonly<Record<string, unknown>>): ParsedEvaluate {
+  exactKeys(params, [
+    'expression', 'objectGroup', 'includeCommandLineAPI', 'silent', 'contextId', 'returnByValue',
+    'generatePreview', 'userGesture', 'awaitPromise', 'throwOnSideEffect', 'timeout', 'disableBreaks',
+    'replMode', 'allowUnsafeEvalBlockedByCSP', 'uniqueContextId', 'serializationOptions',
+  ], 'Runtime.evaluate params')
+  if (typeof params.expression !== 'string') throw new Error('Runtime.evaluate expression must be a string')
+  const selector = parseContextSelector(params, 'contextId')
+  const timeout = params.timeout
+  if (timeout !== undefined && (typeof timeout !== 'number' || !Number.isFinite(timeout) || timeout < 0)) {
+    throw new Error('Runtime.evaluate timeout must be a non-negative finite number')
+  }
+  return {
+    ...selector,
+    request: {
+      expression: params.expression,
+      ...optionalString(params, 'objectGroup'),
+      ...optionalBoolean(params, 'includeCommandLineAPI'),
+      ...optionalBoolean(params, 'silent'),
+      ...optionalBoolean(params, 'returnByValue'),
+      ...optionalBoolean(params, 'generatePreview'),
+      ...optionalBoolean(params, 'userGesture'),
+      ...optionalBoolean(params, 'awaitPromise'),
+      ...optionalBoolean(params, 'disableBreaks'),
+      ...optionalBoolean(params, 'replMode'),
+      ...optionalBoolean(params, 'allowUnsafeEvalBlockedByCSP'),
+      ...optionalBoolean(params, 'throwOnSideEffect'),
+      ...optionalJsonObject(params, 'serializationOptions'),
+      ...(timeout === undefined ? {} : { timeoutMs: timeout }),
+    },
+  }
+}
+
+/**
+ * Parse realm-routed `Runtime.getProperties` parameters.
+ * @param params - Untrusted CDP parameters.
+ * @returns The external object id and handle-free Runtime request.
+ */
+export function parseGetProperties(
+  params: Readonly<Record<string, unknown>>,
+): {
+  readonly objectId: string
+  readonly request: Omit<RuntimeGetPropertiesRequest<RuntimeBackendObjectHandle>, 'handle'>
+} {
+  exactKeys(params, [
+    'objectId', 'ownProperties', 'accessorPropertiesOnly', 'generatePreview', 'nonIndexedPropertiesOnly',
+  ], 'Runtime.getProperties params')
+  if (typeof params.objectId !== 'string') throw new Error('Runtime.getProperties objectId must be a string')
+  return {
+    objectId: params.objectId,
+    request: {
+      ...optionalBoolean(params, 'ownProperties'),
+      ...optionalBoolean(params, 'accessorPropertiesOnly'),
+      ...optionalBoolean(params, 'generatePreview'),
+      ...optionalBoolean(params, 'nonIndexedPropertiesOnly'),
+    },
+  }
+}
+
+/**
+ * Parse Client-routed `Runtime.callFunctionOn` parameters.
+ * @param params - Untrusted CDP parameters.
+ * @returns Routing fields, arguments, and a handle-free Runtime request.
+ */
+export function parseCallFunction(params: Readonly<Record<string, unknown>>): ParsedCallFunction {
+  exactKeys(params, [
+    'functionDeclaration', 'objectId', 'arguments', 'silent', 'returnByValue', 'generatePreview', 'userGesture',
+    'awaitPromise', 'executionContextId', 'objectGroup', 'throwOnSideEffect', 'uniqueContextId', 'serializationOptions',
+  ], 'Runtime.callFunctionOn params')
+  if (typeof params.functionDeclaration !== 'string') {
+    throw new Error('Runtime.callFunctionOn functionDeclaration must be a string')
+  }
+  const selector = parseContextSelector(params, 'executionContextId')
+  const objectId = optionalObjectId(params.objectId, 'Runtime.callFunctionOn objectId')
+  if (objectId === undefined
+    && selector.executionContextId === undefined
+    && selector.uniqueContextId === undefined) {
+    throw new Error('Runtime.callFunctionOn requires objectId or an execution context')
+  }
+  if (objectId !== undefined && (selector.executionContextId !== undefined || selector.uniqueContextId !== undefined)) {
+    throw new Error('Runtime.callFunctionOn objectId and execution context are mutually exclusive')
+  }
+  let args: readonly CdpCallArgument[] = []
+  if (params.arguments !== undefined) {
+    if (!Array.isArray(params.arguments)) throw new Error('Runtime.callFunctionOn arguments must be an array')
+    args = params.arguments.map(parseCallArgument)
+  }
+  return {
+    ...selector,
+    ...(objectId === undefined ? {} : { objectId }),
+    arguments: args,
+    request: {
+      functionDeclaration: params.functionDeclaration,
+      ...optionalString(params, 'objectGroup'),
+      ...optionalBoolean(params, 'silent'),
+      ...optionalBoolean(params, 'returnByValue'),
+      ...optionalBoolean(params, 'generatePreview'),
+      ...optionalBoolean(params, 'userGesture'),
+      ...optionalBoolean(params, 'awaitPromise'),
+      ...optionalBoolean(params, 'throwOnSideEffect'),
+      ...optionalJsonObject(params, 'serializationOptions'),
+    },
+  }
+}
+
+/**
+ * Parse Client-routed `Runtime.awaitPromise` parameters.
+ * @param params - Untrusted CDP parameters.
+ * @returns The external promise id and handle-free Runtime request.
+ */
+export function parseAwaitPromise(params: Readonly<Record<string, unknown>>): {
+  readonly promiseObjectId: string
+  readonly request: Omit<RuntimeAwaitPromiseRequest<RuntimeBackendObjectHandle>, 'promise'>
+} {
+  exactKeys(params, ['promiseObjectId', 'returnByValue', 'generatePreview'], 'Runtime.awaitPromise params')
+  if (typeof params.promiseObjectId !== 'string') throw new Error('Runtime.awaitPromise promiseObjectId must be a string')
+  return {
+    promiseObjectId: params.promiseObjectId,
+    request: {
+      ...optionalBoolean(params, 'returnByValue'),
+      ...optionalBoolean(params, 'generatePreview'),
+    },
+  }
+}
+
+/**
+ * Parse one required object id.
+ * @param params - Untrusted CDP parameters.
+ * @returns The object id.
+ */
+export function parseReleaseObject(params: Readonly<Record<string, unknown>>): string {
+  exactKeys(params, ['objectId'], 'Runtime.releaseObject params')
+  if (typeof params.objectId !== 'string') throw new Error('Runtime.releaseObject objectId must be a string')
+  return params.objectId
+}
+
+/**
+ * Parse one required object-group name.
+ * @param params - Untrusted CDP parameters.
+ * @returns The object-group name.
+ */
+export function parseReleaseObjectGroup(params: Readonly<Record<string, unknown>>): string {
+  exactKeys(params, ['objectGroup'], 'Runtime.releaseObjectGroup params')
+  if (typeof params.objectGroup !== 'string') throw new Error('Runtime.releaseObjectGroup objectGroup must be a string')
+  return params.objectGroup
+}
+
+/**
+ * Parse `Runtime.globalLexicalScopeNames` context selection.
+ * @param params - Untrusted CDP parameters.
+ * @returns The validated context selector.
+ */
+export function parseGlobalLexicalScopeNames(params: Readonly<Record<string, unknown>>): CdpExecutionContextSelector {
+  exactKeys(params, ['executionContextId', 'uniqueContextId'], 'Runtime.globalLexicalScopeNames params')
+  return parseContextSelector(params, 'executionContextId')
+}
+
+function parseCallArgument(value: unknown): CdpCallArgument {
+  if (!isPlainObject(value)) throw new Error('Runtime.callFunctionOn argument must be an object')
+  exactKeys(value, ['value', 'unserializableValue', 'objectId'], 'Runtime.callFunctionOn argument')
+  const present = ['value', 'unserializableValue', 'objectId'].filter(key => Object.hasOwn(value, key))
+  if (present.length > 1) throw new Error('Runtime.callFunctionOn argument has multiple value representations')
+  if (present.length === 0) return { kind: 'undefined' }
+  if (present[0] === 'value') {
+    if (!isJsonValue(value.value)) throw new Error('Runtime.callFunctionOn argument value must be JSON')
+    return { kind: 'value', value: value.value }
+  }
+  if (present[0] === 'unserializableValue') {
+    if (typeof value.unserializableValue !== 'string') {
+      throw new Error('Runtime.callFunctionOn unserializableValue must be a string')
+    }
+    return { kind: 'unserializable', value: value.unserializableValue }
+  }
+  if (typeof value.objectId !== 'string') throw new Error('Runtime.callFunctionOn argument objectId must be a string')
+  return { kind: 'object', objectId: value.objectId }
+}
+
+function parseContextSelector(
+  params: Readonly<Record<string, unknown>>,
+  numericKey: 'contextId' | 'executionContextId',
+): CdpExecutionContextSelector {
+  const numeric = params[numericKey]
+  const unique = params.uniqueContextId
+  if (numeric !== undefined && (!Number.isSafeInteger(numeric))) {
+    throw new Error(`Runtime ${numericKey} must be an integer`)
+  }
+  if (unique !== undefined && typeof unique !== 'string') throw new Error('Runtime uniqueContextId must be a string')
+  if (numeric !== undefined && unique !== undefined) throw new Error('Runtime context selectors are mutually exclusive')
+  return {
+    ...(numeric === undefined
+      ? {}
+      : numericKey === 'contextId'
+        ? { contextId: numeric as number }
+        : { executionContextId: numeric as number }),
+    ...(unique === undefined ? {} : { uniqueContextId: unique }),
+  }
+}
+
+function optionalObjectId(value: unknown, label: string): string | undefined {
+  if (value === undefined) return undefined
+  if (typeof value !== 'string') throw new Error(`${label} must be a string`)
+  return value
+}
+
+function optionalJsonObject<Key extends string>(
+  value: Readonly<Record<string, unknown>>,
+  key: Key,
+): Partial<Record<Key, Readonly<Record<string, InspectorJsonValue>>>> {
+  const item = value[key]
+  if (item === undefined) return {}
+  if (!isPlainObject(item) || !isJsonValue(item)) throw new Error(`Runtime ${key} must be a JSON object`)
+  return { [key]: item } as Partial<Record<Key, Readonly<Record<string, InspectorJsonValue>>>>
+}

+ 3 - 0
packages/experimental/inspector/src/worker/cdp/domains/runtime/index.ts

@@ -0,0 +1,3 @@
+/** Client-aware Runtime domain exports. */
+
+export { RuntimeDomainSession } from './session.ts'

+ 323 - 0
packages/experimental/inspector/src/worker/cdp/domains/runtime/object-table.ts

@@ -0,0 +1,323 @@
+/** Per-CDP-connection routing and projection for every realm's Runtime objects. */
+
+import type {
+  RuntimeCompletion,
+  RuntimeConsoleBackendEvent,
+  RuntimeExceptionDetails,
+  RuntimeInternalPropertyDescriptor,
+  RuntimePrivatePropertyDescriptor,
+  RuntimeProperties,
+  RuntimePropertyDescriptor,
+  RuntimeRemoteObject,
+  RuntimeStackTrace,
+} from '../../../../shared/cdp/index.ts'
+import type { InspectorObjectReference } from '../../../../shared/cordis/object-reference.ts'
+import type { RuntimeBackendObjectHandle } from '../../../../shared/cdp/ids.ts'
+import type { InspectorRealmDescriptor, InspectorRealmSession } from '../../../inspection/realm.ts'
+import { cdpStringId, type CdpRemoteObjectId, type InspectorConnectionId } from '../../ids.ts'
+
+/** Object retained behind one connection-local CDP object id. */
+export interface RuntimeObjectRoute {
+  readonly realm: InspectorRealmSession
+  readonly handle: RuntimeBackendObjectHandle
+  readonly group: string | undefined
+}
+
+/** Semantic presentation applied when an object belongs to a projected node. */
+export interface RuntimeObjectPresentation {
+  readonly subtype: 'node'
+  readonly className: string
+  readonly description: string
+}
+
+/** Observer of newly exposed Runtime object ids. */
+export type RuntimeObjectObserver = (
+  objectId: CdpRemoteObjectId,
+  realm: InspectorRealmDescriptor,
+  reference: InspectorObjectReference,
+  group: string | undefined,
+) => RuntimeObjectPresentation | undefined
+
+/** CDP Runtime payload derived from one realm completion. */
+export interface CdpRuntimeCompletion {
+  readonly result: Readonly<Record<string, unknown>>
+  readonly exceptionDetails?: Readonly<Record<string, unknown>>
+}
+
+/** CDP Runtime payload derived from one realm's property descriptors. */
+export interface CdpGetPropertiesResult {
+  readonly result: readonly Readonly<Record<string, unknown>>[]
+  readonly internalProperties?: readonly Readonly<Record<string, unknown>>[]
+  readonly privateProperties?: readonly Readonly<Record<string, unknown>>[]
+  readonly exceptionDetails?: Readonly<Record<string, unknown>>
+}
+
+/** One CDP notification projected from a realm Console event. */
+export interface CdpRuntimeEvent {
+  readonly method: 'Runtime.consoleAPICalled' | 'Runtime.exceptionThrown'
+  readonly params: Readonly<Record<string, unknown>>
+}
+
+/** Maps every realm's backend handles to object ids scoped to one CDP connection. */
+export class RuntimeObjectTable {
+  private readonly routes = new Map<CdpRemoteObjectId, RuntimeObjectRoute>()
+  private nextObjectId = 1
+  private nextExceptionId = 1
+  private observer: RuntimeObjectObserver | undefined
+
+  constructor(private readonly connectionId: InspectorConnectionId) {}
+
+  /**
+   * Install Cordis object recognition after Runtime and DOM sessions are assembled.
+   * @param observer - Callback mapping a semantic reference to node presentation.
+   */
+  setObserver(observer: RuntimeObjectObserver): void {
+    this.observer = observer
+  }
+
+  /**
+   * Resolve one connection-local object id.
+   * @param objectId - CDP object id allocated by this table.
+   * @returns Its realm and backend handle when current.
+   */
+  resolve(objectId: string): RuntimeObjectRoute | undefined {
+    return this.routes.get(cdpStringId<'CdpRemoteObjectId'>(objectId, 'objectId'))
+  }
+
+  /**
+   * Convert a realm completion to CDP fields.
+   * @param realm - Realm session that produced the value.
+   * @param value - Engine-independent completion.
+   * @param group - Object group inherited by exposed handles.
+   * @returns CDP Runtime completion fields.
+   */
+  completion(
+    realm: InspectorRealmSession,
+    value: RuntimeCompletion<RuntimeBackendObjectHandle>,
+    group: string | undefined,
+  ): CdpRuntimeCompletion {
+    return {
+      result: this.remote(realm, value.result, group),
+      ...(value.exceptionDetails === undefined
+        ? {}
+        : { exceptionDetails: this.exception(realm, value.exceptionDetails, group) }),
+    }
+  }
+
+  /**
+   * Convert realm property descriptors to CDP fields.
+   * @param realm - Realm session that owns returned object references.
+   * @param value - Engine-independent property result.
+   * @param group - Object group inherited from the inspected object.
+   * @returns CDP Runtime property result fields.
+   */
+  properties(
+    realm: InspectorRealmSession,
+    value: RuntimeProperties<RuntimeBackendObjectHandle>,
+    group: string | undefined,
+  ): CdpGetPropertiesResult {
+    return {
+      result: value.properties.map(property => this.property(realm, property, group)),
+      ...(value.internalProperties === undefined
+        ? {}
+        : { internalProperties: value.internalProperties.map(property => this.internalProperty(realm, property, group)) }),
+      ...(value.privateProperties === undefined
+        ? {}
+        : { privateProperties: value.privateProperties.map(property => this.privateProperty(realm, property, group)) }),
+      ...(value.exceptionDetails === undefined
+        ? {}
+        : { exceptionDetails: this.exception(realm, value.exceptionDetails, group) }),
+    }
+  }
+
+  /**
+   * Project one realm Console event to a CDP Runtime notification.
+   * @param realm - Realm session that emitted the event.
+   * @param value - Realm-neutral Console or exception event.
+   * @returns CDP method and parameters.
+   */
+  consoleEvent(
+    realm: InspectorRealmSession,
+    value: RuntimeConsoleBackendEvent<RuntimeBackendObjectHandle>,
+  ): CdpRuntimeEvent {
+    if (value.type === 'console-api') {
+      const contextId = value.event.contextId
+        ?? (realm.context.kind === 'synthetic' ? realm.context.id : undefined)
+      return {
+        method: 'Runtime.consoleAPICalled',
+        params: {
+          type: value.event.type,
+          args: value.event.arguments.map(argument => this.remote(realm, argument, 'console')),
+          timestamp: value.event.timestamp,
+          ...(contextId === undefined ? {} : { executionContextId: contextId }),
+          ...(value.event.stackTrace === undefined ? {} : { stackTrace: cdpStackTrace(value.event.stackTrace) }),
+        },
+      }
+    }
+    const contextId = value.event.contextId
+      ?? (realm.context.kind === 'synthetic' ? realm.context.id : undefined)
+    return {
+      method: 'Runtime.exceptionThrown',
+      params: {
+        timestamp: value.event.timestamp,
+        exceptionDetails: {
+          ...this.exception(realm, value.event.details, 'console'),
+          ...(contextId === undefined ? {} : { executionContextId: contextId }),
+        },
+      },
+    }
+  }
+
+  /**
+   * List realm sessions retaining at least one object in a group.
+   * @param group - DevTools object-group name.
+   * @returns Distinct realm sessions that must receive the release.
+   */
+  realmsInGroup(group: string): InspectorRealmSession[] {
+    const realms = new Set<InspectorRealmSession>()
+    for (const route of this.routes.values()) {
+      if (route.group === group) realms.add(route.realm)
+    }
+    return [...realms]
+  }
+
+  /**
+   * Forget one externally visible object id.
+   * @param objectId - Released CDP object id.
+   */
+  release(objectId: string): void {
+    this.routes.delete(cdpStringId<'CdpRemoteObjectId'>(objectId, 'objectId'))
+  }
+
+  /**
+   * Forget all ids retained under one object group.
+   * @param group - Released object-group name.
+   */
+  releaseGroup(group: string): void {
+    for (const [objectId, route] of this.routes) {
+      if (route.group === group) this.routes.delete(objectId)
+    }
+  }
+
+  /**
+   * Forget every object owned by one closed realm session.
+   * @param realm - Closed realm session.
+   */
+  releaseRealm(realm: InspectorRealmSession): void {
+    for (const [objectId, route] of this.routes) {
+      if (route.realm === realm) this.routes.delete(objectId)
+    }
+  }
+
+  /** Forget every object exposed on this DevTools connection. */
+  clear(): void {
+    this.routes.clear()
+  }
+
+  /**
+   * Project one common Runtime value and retain its backend handle for this connection.
+   * @param realm - Realm session that owns the value.
+   * @param value - Realm-neutral Runtime value.
+   * @param group - Object group assigned to any exposed handle.
+   * @returns CDP RemoteObject fields.
+   */
+  remote(
+    realm: InspectorRealmSession,
+    value: RuntimeRemoteObject<RuntimeBackendObjectHandle>,
+    group: string | undefined,
+  ): Readonly<Record<string, unknown>> {
+    const objectId = value.object === undefined
+      ? undefined
+      : this.expose(realm, value.object.handle, group)
+    const presentation = objectId === undefined || value.semanticReference === undefined
+      ? undefined
+      : this.observer?.(objectId, realm.descriptor, value.semanticReference, group)
+    const descriptor = value.descriptor
+    return {
+      ...descriptor,
+      ...(presentation?.subtype === undefined ? {} : { subtype: presentation.subtype }),
+      ...(presentation?.className === undefined ? {} : { className: presentation.className }),
+      ...(presentation?.description === undefined ? {} : { description: presentation.description }),
+      ...(objectId === undefined ? {} : { objectId }),
+    }
+  }
+
+  private property(
+    realm: InspectorRealmSession,
+    property: RuntimePropertyDescriptor<RuntimeBackendObjectHandle>,
+    group: string | undefined,
+  ): Readonly<Record<string, unknown>> {
+    return {
+      ...property,
+      ...(property.value === undefined ? {} : { value: this.remote(realm, property.value, group) }),
+      ...(property.get === undefined ? {} : { get: this.remote(realm, property.get, group) }),
+      ...(property.set === undefined ? {} : { set: this.remote(realm, property.set, group) }),
+      ...(property.symbol === undefined ? {} : { symbol: this.remote(realm, property.symbol, group) }),
+    }
+  }
+
+  private internalProperty(
+    realm: InspectorRealmSession,
+    property: RuntimeInternalPropertyDescriptor<RuntimeBackendObjectHandle>,
+    group: string | undefined,
+  ): Readonly<Record<string, unknown>> {
+    return {
+      name: property.name,
+      ...(property.value === undefined ? {} : { value: this.remote(realm, property.value, group) }),
+    }
+  }
+
+  private privateProperty(
+    realm: InspectorRealmSession,
+    property: RuntimePrivatePropertyDescriptor<RuntimeBackendObjectHandle>,
+    group: string | undefined,
+  ): Readonly<Record<string, unknown>> {
+    return {
+      name: property.name,
+      ...(property.value === undefined ? {} : { value: this.remote(realm, property.value, group) }),
+      ...(property.get === undefined ? {} : { get: this.remote(realm, property.get, group) }),
+      ...(property.set === undefined ? {} : { set: this.remote(realm, property.set, group) }),
+    }
+  }
+
+  private exception(
+    realm: InspectorRealmSession,
+    details: RuntimeExceptionDetails<RuntimeBackendObjectHandle>,
+    group: string | undefined,
+  ): Readonly<Record<string, unknown>> {
+    return {
+      ...details,
+      exceptionId: this.nextExceptionId++,
+      ...(realm.context.kind === 'synthetic' ? { executionContextId: realm.context.id } : {}),
+      ...(details.stackTrace === undefined ? {} : { stackTrace: cdpStackTrace(details.stackTrace) }),
+      ...(details.exception === undefined ? {} : { exception: this.remote(realm, details.exception, group) }),
+    }
+  }
+
+  private expose(
+    realm: InspectorRealmSession,
+    handle: RuntimeBackendObjectHandle,
+    group: string | undefined,
+  ): CdpRemoteObjectId {
+    const objectId = cdpStringId<'CdpRemoteObjectId'>(
+      `runtime:${this.connectionId}:${String(this.nextObjectId++)}`,
+      'objectId',
+    )
+    this.routes.set(objectId, { realm, handle, group })
+    return objectId
+  }
+}
+
+function cdpStackTrace(stack: RuntimeStackTrace): Readonly<Record<string, unknown>> {
+  return {
+    ...(stack.description === undefined ? {} : { description: stack.description }),
+    callFrames: stack.callFrames.map(frame => ({
+      functionName: frame.functionName,
+      scriptId: frame.scriptKey ?? '0',
+      url: frame.url,
+      lineNumber: frame.lineNumber,
+      columnNumber: frame.columnNumber,
+    })),
+    ...(stack.parent === undefined ? {} : { parent: cdpStackTrace(stack.parent) }),
+  }
+}

+ 455 - 0
packages/experimental/inspector/src/worker/cdp/domains/runtime/session.ts

@@ -0,0 +1,455 @@
+/** Per-DevTools-session Runtime routing across uniform Host and Client realms. */
+
+import type { InspectorSourceDescriptor } from '../../../../shared/bridge/messages/observation.ts'
+import type { InspectorRealmId, RuntimeBackendObjectHandle } from '../../../../shared/cdp/ids.ts'
+import type { RuntimeCallArgument, RuntimeCompletion, RuntimeRemoteObject } from '../../../../shared/cdp/index.ts'
+import type { RuntimeBackend } from '../../../../shared/cdp/realm.ts'
+import { cdpError, respondToCdpRequest, type CdpRequest, type CdpTransport } from '../../protocol.ts'
+import type { InspectorRealmSession } from '../../../inspection/realm.ts'
+import type { InspectorRealmSessionEvent, InspectorRealmSessionSet } from '../../realm-sessions.ts'
+import {
+  parseAwaitPromise,
+  parseCallFunction,
+  parseEvaluate,
+  parseGetProperties,
+  parseGlobalLexicalScopeNames,
+  parseReleaseObject,
+  parseReleaseObjectGroup,
+  type CdpCallArgument,
+  type CdpExecutionContextSelector,
+} from './cdp-params.ts'
+import { RuntimeObjectTable, type RuntimeObjectObserver } from './object-table.ts'
+import type { RuntimeObjectRoute } from './object-table.ts'
+
+/** Runtime router layered over the common per-connection realm sessions. */
+export class RuntimeDomainSession {
+  private readonly objects: RuntimeObjectTable
+  private readonly announcedContexts = new Set<number>()
+  private readonly consoleDisposers = new Map<InspectorRealmId, () => void>()
+  private readonly unsubscribeRealms: () => void
+  private enabled = false
+  private closed = false
+
+  constructor(
+    private readonly transport: CdpTransport,
+    private readonly realms: InspectorRealmSessionSet,
+  ) {
+    this.objects = new RuntimeObjectTable(realms.connectionId)
+    this.unsubscribeRealms = realms.subscribe((event) => { this.receiveRealm(event) })
+  }
+
+  /**
+   * Handle methods that require cross-realm Runtime coordination.
+   * @param request - Parsed CDP request.
+   * @returns Whether this domain owns the method or object id.
+   */
+  handle(request: CdpRequest): boolean {
+    switch (request.method) {
+      case 'Runtime.enable':
+        this.respond(request, () => this.enable())
+        return true
+      case 'Runtime.disable':
+        this.respond(request, () => this.disable())
+        return true
+      case 'Runtime.evaluate':
+        this.respond(request, () => this.evaluate(request.params))
+        return true
+      case 'Runtime.getProperties':
+        return this.getProperties(request)
+      case 'Runtime.callFunctionOn':
+        return this.callFunction(request)
+      case 'Runtime.awaitPromise':
+        return this.awaitPromise(request)
+      case 'Runtime.releaseObject':
+        return this.releaseObject(request)
+      case 'Runtime.releaseObjectGroup':
+        this.respond(request, () => this.releaseObjectGroup(request.params))
+        return true
+      case 'Runtime.globalLexicalScopeNames':
+        this.respond(request, () => this.globalLexicalScopeNames(request.params))
+        return true
+      case 'Runtime.discardConsoleEntries':
+        this.respond(request, () => this.discardConsoleEntries())
+        return true
+      default:
+        if (request.method.startsWith('Runtime.')) {
+          const reason = this.unsupportedNativeRoute(request.params)
+          if (reason !== undefined) {
+            this.sendError(request, reason)
+            return true
+          }
+        }
+        return false
+    }
+  }
+
+  /** Release this connection's object routes and realm subscription. */
+  close(): void {
+    if (this.closed) return
+    this.closed = true
+    this.unsubscribeRealms()
+    for (const dispose of this.consoleDisposers.values()) dispose()
+    this.consoleDisposers.clear()
+    this.objects.clear()
+    this.announcedContexts.clear()
+  }
+
+  /**
+   * Install semantic object recognition shared with the DOM adapter.
+   * @param observer - Callback invoked for objects carrying semantic references.
+   */
+  setObjectObserver(observer: RuntimeObjectObserver): void {
+    this.objects.setObserver(observer)
+  }
+
+  /**
+   * Resolve a connection-local CDP object id for another domain adapter.
+   * @param objectId - CDP object id allocated by this Runtime session.
+   * @returns Its realm and backend handle when still live.
+   */
+  objectRoute(objectId: string): RuntimeObjectRoute | undefined {
+    return this.objects.resolve(objectId)
+  }
+
+  /**
+   * Project a completion produced by another domain through this connection's object table.
+   * @param realm - Realm session that owns the completion.
+   * @param completion - Realm-neutral result and exception fields.
+   * @param group - Object group assigned to exposed handles.
+   * @returns CDP Runtime result fields.
+   */
+  projectCompletion(
+    realm: InspectorRealmSession,
+    completion: RuntimeCompletion<RuntimeBackendObjectHandle>,
+    group: string | undefined,
+  ): object {
+    return this.objects.completion(realm, completion, group)
+  }
+
+  /**
+   * Project one Runtime value produced by another domain.
+   * @param realm - Realm session that owns the value.
+   * @param value - Realm-neutral Runtime value.
+   * @param group - Object group assigned to an exposed handle.
+   * @returns CDP RemoteObject fields.
+   */
+  projectRemoteObject(
+    realm: InspectorRealmSession,
+    value: RuntimeRemoteObject<RuntimeBackendObjectHandle>,
+    group: string | undefined,
+  ): Readonly<Record<string, unknown>> {
+    return this.objects.remote(realm, value, group)
+  }
+
+  /**
+   * Forget connection-local ids retained for another domain's object group.
+   * @param group - Object group whose projected ids have expired.
+   */
+  releaseProjectedGroup(group: string): void {
+    this.objects.releaseGroup(group)
+  }
+
+  /**
+   * Replace common object ids with native backend handles in a Host-only request.
+   * @param params - Parsed CDP parameters that may contain nested object ids.
+   * @returns A detached parameter record suitable for the native Host protocol.
+   */
+  nativeParameters(params: Readonly<Record<string, unknown>>): Readonly<Record<string, unknown>> {
+    const visit = (value: unknown, key: string | undefined): unknown => {
+      if ((key === 'objectId' || key?.endsWith('ObjectId') === true) && typeof value === 'string') {
+        const route = this.objects.resolve(value)
+        if (route === undefined) return value
+        if (route.realm.nativeDomains.state === 'unsupported') throw new Error(route.realm.nativeDomains.reason)
+        return route.handle
+      }
+      if (Array.isArray(value)) return value.map(item => visit(item, undefined))
+      if (typeof value !== 'object' || value === null) return value
+      return Object.fromEntries(Object.entries(value).map(([name, item]) => [name, visit(item, name)]))
+    }
+    return visit(params, undefined) as Readonly<Record<string, unknown>>
+  }
+
+  /**
+   * Resolve one realm-registry expression to a connection-local object id.
+   * @param source - Source generation that owns the Cordis tree node.
+   * @param expression - Side-effect-free realm object lookup.
+   * @param objectGroup - Optional DevTools retention group.
+   * @returns The CDP RemoteObject fields.
+   */
+  async resolveObject(
+    source: InspectorSourceDescriptor,
+    expression: string,
+    objectGroup: string | undefined,
+  ): Promise<Readonly<Record<string, unknown>>> {
+    const realm = this.realms.bySource(source)
+    if (realm === undefined) throw new Error('Cordis realm is no longer connected')
+    const runtime = runtimeBackend(realm)
+    const completion = await runtime.evaluate({
+      expression,
+      generatePreview: true,
+      ...(objectGroup === undefined ? {} : { objectGroup }),
+    })
+    if (completion.exceptionDetails !== undefined) throw new Error('Cordis object lookup failed')
+    return this.objects.completion(realm, completion, objectGroup).result
+  }
+
+  private async enable(): Promise<object> {
+    this.enabled = true
+    try {
+      await Promise.all(this.realms.all().map(async (realm) => { await runtimeBackend(realm).enable() }))
+      for (const realm of this.realms.all()) {
+        this.attachConsole(realm)
+        this.announce(realm)
+      }
+      return {}
+    } catch (error) {
+      this.enabled = false
+      for (const dispose of this.consoleDisposers.values()) dispose()
+      this.consoleDisposers.clear()
+      this.announcedContexts.clear()
+      await Promise.allSettled(this.realms.all().map(async (realm) => { await runtimeBackend(realm).disable() }))
+      throw error
+    }
+  }
+
+  private async disable(): Promise<object> {
+    for (const dispose of this.consoleDisposers.values()) dispose()
+    this.consoleDisposers.clear()
+    try {
+      await Promise.all(this.realms.all().map(async (realm) => { await runtimeBackend(realm).disable() }))
+    } finally {
+      this.enabled = false
+      this.objects.clear()
+      this.announcedContexts.clear()
+    }
+    return {}
+  }
+
+  private async evaluate(params: Readonly<Record<string, unknown>>): Promise<object> {
+    const parsed = parseEvaluate(params)
+    const realm = this.realmFromSelector(parsed, 'contextId')
+    const completion = await runtimeBackend(realm).evaluate(parsed.request)
+    return this.objects.completion(realm, completion, parsed.request.objectGroup)
+  }
+
+  private getProperties(request: CdpRequest): boolean {
+    const objectId = request.params.objectId
+    if (typeof objectId !== 'string') return false
+    const route = this.objects.resolve(objectId)
+    if (route === undefined) return false
+    this.respond(request, async () => {
+      const parsed = parseGetProperties(request.params)
+      const properties = await runtimeBackend(route.realm).getProperties({ ...parsed.request, handle: route.handle })
+      return this.objects.properties(route.realm, properties, route.group)
+    })
+    return true
+  }
+
+  private callFunction(request: CdpRequest): boolean {
+    const objectId = typeof request.params.objectId === 'string' ? request.params.objectId : undefined
+    const receiver = objectId === undefined ? undefined : this.objects.resolve(objectId)
+    const selected = this.realmFromOptionalSelector(request.params, 'executionContextId')
+    if (receiver === undefined && selected === undefined && objectId !== undefined) return false
+    const realm = receiver?.realm ?? selected ?? this.realms.host()
+    if (receiver !== undefined && selected !== undefined && receiver.realm !== selected) {
+      this.sendError(request, 'Runtime.callFunctionOn receiver and execution context belong to different realms')
+      return true
+    }
+    this.respond(request, async () => {
+      const parsed = parseCallFunction(request.params)
+      const group = parsed.request.objectGroup ?? receiver?.group
+      const completion = await runtimeBackend(realm).callFunction({
+        ...parsed.request,
+        ...(receiver === undefined ? {} : { receiver: receiver.handle }),
+        arguments: parsed.arguments.map(argument => this.routeArgument(realm, argument)),
+      })
+      return this.objects.completion(realm, completion, group)
+    })
+    return true
+  }
+
+  private awaitPromise(request: CdpRequest): boolean {
+    const objectId = request.params.promiseObjectId
+    if (typeof objectId !== 'string') return false
+    const route = this.objects.resolve(objectId)
+    if (route === undefined) return false
+    this.respond(request, async () => {
+      const parsed = parseAwaitPromise(request.params)
+      const completion = await runtimeBackend(route.realm).awaitPromise({ ...parsed.request, promise: route.handle })
+      return this.objects.completion(route.realm, completion, route.group)
+    })
+    return true
+  }
+
+  private releaseObject(request: CdpRequest): boolean {
+    const objectId = request.params.objectId
+    if (typeof objectId !== 'string') return false
+    const route = this.objects.resolve(objectId)
+    if (route === undefined) return false
+    this.respond(request, async () => {
+      parseReleaseObject(request.params)
+      await runtimeBackend(route.realm).releaseObject(route.handle)
+      this.objects.release(objectId)
+      return {}
+    })
+    return true
+  }
+
+  private async releaseObjectGroup(params: Readonly<Record<string, unknown>>): Promise<object> {
+    const group = parseReleaseObjectGroup(params)
+    const realms = this.objects.realmsInGroup(group)
+    try {
+      await Promise.all(realms.map(async (realm) => { await runtimeBackend(realm).releaseObjectGroup(group) }))
+    } finally {
+      this.objects.releaseGroup(group)
+    }
+    return {}
+  }
+
+  private async globalLexicalScopeNames(params: Readonly<Record<string, unknown>>): Promise<object> {
+    const parsed = parseGlobalLexicalScopeNames(params)
+    const realm = this.realmFromSelector(parsed, 'executionContextId')
+    return { names: await runtimeBackend(realm).globalLexicalScopeNames() }
+  }
+
+  private async discardConsoleEntries(): Promise<object> {
+    await Promise.all(this.realms.all().map(async (realm) => {
+      if (realm.console.state === 'supported') await realm.console.backend.clear()
+      await runtimeBackend(realm).releaseObjectGroup('console')
+    }))
+    this.objects.releaseGroup('console')
+    return {}
+  }
+
+  private realmFromSelector(
+    params: CdpExecutionContextSelector,
+    numericKey: 'contextId' | 'executionContextId',
+  ): InspectorRealmSession {
+    return this.realmFromOptionalSelector(params, numericKey) ?? this.realms.host()
+  }
+
+  private realmFromOptionalSelector(
+    params: CdpExecutionContextSelector,
+    numericKey: 'contextId' | 'executionContextId',
+  ): InspectorRealmSession | undefined {
+    const numeric = params[numericKey]
+    if (typeof numeric === 'number' && Number.isSafeInteger(numeric)) {
+      const realm = this.realms.byContextId(numeric)
+      if (realm !== undefined) return realm
+      if (numeric < 0) throw new Error('Client execution context is no longer available')
+      return this.realms.host()
+    }
+    const unique = params.uniqueContextId
+    if (typeof unique === 'string') {
+      const realm = this.realms.byUniqueContextId(unique)
+      if (realm !== undefined) return realm
+      if (unique.startsWith('dsh-client:')) throw new Error('Client execution context is no longer available')
+      return this.realms.host()
+    }
+    return undefined
+  }
+
+  private routeArgument(
+    realm: InspectorRealmSession,
+    argument: CdpCallArgument,
+  ): RuntimeCallArgument<RuntimeBackendObjectHandle> {
+    if (argument.kind !== 'object') return argument
+    const route = this.objects.resolve(argument.objectId)
+    if (route === undefined || route.realm !== realm) {
+      throw new Error('Runtime.callFunctionOn cannot pass an object between realms')
+    }
+    return { kind: 'object', handle: route.handle }
+  }
+
+  private unsupportedNativeRoute(params: Readonly<Record<string, unknown>>): string | undefined {
+    for (const key of ['contextId', 'executionContextId'] as const) {
+      const contextId = params[key]
+      if (typeof contextId !== 'number') continue
+      const realm = this.realms.byContextId(contextId)
+      if (realm?.nativeDomains.state === 'unsupported') return realm.nativeDomains.reason
+      if (contextId < 0 && realm === undefined) return 'Client execution context is no longer available'
+    }
+    if (typeof params.uniqueContextId === 'string') {
+      const realm = this.realms.byUniqueContextId(params.uniqueContextId)
+      if (realm?.nativeDomains.state === 'unsupported') return realm.nativeDomains.reason
+      if (params.uniqueContextId.startsWith('dsh-client:') && realm === undefined) {
+        return 'Client execution context is no longer available'
+      }
+    }
+    for (const [key, value] of Object.entries(params)) {
+      if (!key.endsWith('ObjectId') && key !== 'objectId') continue
+      if (typeof value !== 'string') continue
+      const route = this.objects.resolve(value)
+      if (route?.realm.nativeDomains.state === 'unsupported') return route.realm.nativeDomains.reason
+    }
+    return undefined
+  }
+
+  private receiveRealm(event: InspectorRealmSessionEvent): void {
+    if (event.type === 'opened') {
+      if (this.enabled) {
+        void runtimeBackend(event.session).enable().then(
+          () => {
+            this.attachConsole(event.session)
+            this.announce(event.session)
+          },
+          () => { event.session.close() },
+        )
+      }
+      return
+    }
+    this.consoleDisposers.get(event.session.descriptor.realmId)?.()
+    this.consoleDisposers.delete(event.session.descriptor.realmId)
+    this.objects.releaseRealm(event.session)
+    this.destroy(event.session)
+  }
+
+  private attachConsole(realm: InspectorRealmSession): void {
+    if (realm.console.state === 'unsupported' || this.consoleDisposers.has(realm.descriptor.realmId)) return
+    this.consoleDisposers.set(realm.descriptor.realmId, realm.console.backend.subscribe((event) => {
+      if (!this.enabled) return
+      this.transport.send(this.objects.consoleEvent(realm, event))
+    }))
+  }
+
+  private announce(realm: InspectorRealmSession): void {
+    if (!this.enabled || realm.context.kind !== 'synthetic' || this.announcedContexts.has(realm.context.id)) return
+    this.announcedContexts.add(realm.context.id)
+    this.transport.send({
+      method: 'Runtime.executionContextCreated',
+      params: {
+        context: {
+          id: realm.context.id,
+          uniqueId: realm.context.uniqueId,
+          origin: realm.context.origin,
+          name: `Client — ${realm.descriptor.label}`,
+          auxData: { isDefault: false, type: 'dsh-client', sourceId: realm.descriptor.sourceId },
+        },
+      },
+    })
+  }
+
+  private destroy(realm: InspectorRealmSession): void {
+    if (realm.context.kind !== 'synthetic' || !this.announcedContexts.delete(realm.context.id)) return
+    this.transport.send({
+      method: 'Runtime.executionContextDestroyed',
+      params: {
+        executionContextId: realm.context.id,
+        executionContextUniqueId: realm.context.uniqueId,
+      },
+    })
+  }
+
+  private respond(request: CdpRequest, operation: () => Promise<object>): void {
+    respondToCdpRequest(this.transport, request, operation)
+  }
+
+  private sendError(request: CdpRequest, message: string): void {
+    this.transport.send(cdpError(request.id, -32000, message))
+  }
+}
+
+function runtimeBackend(realm: InspectorRealmSession): RuntimeBackend {
+  if (realm.runtime.state === 'unsupported') throw new Error(realm.runtime.reason)
+  return realm.runtime.backend
+}

+ 53 - 0
packages/experimental/inspector/src/worker/cdp/ids.ts

@@ -0,0 +1,53 @@
+/** Opaque identifiers owned by one Worker-side Chrome DevTools connection. */
+
+import type { InspectorId } from '../../shared/identity.ts'
+
+declare const cdpNumericIdBrand: unique symbol
+
+/** Number branded with one Chrome CDP identity role. */
+export type CdpNumericId<Role extends string> = number & { readonly [cdpNumericIdBrand]: Role }
+
+/** Identity of one DevTools connection inside the Worker. */
+export type InspectorConnectionId = InspectorId<'InspectorConnectionId'>
+
+/** Runtime object id scoped to one DevTools connection. */
+export type CdpRemoteObjectId = InspectorId<'CdpRemoteObjectId'>
+
+/** Debugger script id scoped to one DevTools connection. */
+export type CdpScriptId = InspectorId<'CdpScriptId'>
+
+/** Debugger call-frame id scoped to one paused DevTools session. */
+export type CdpCallFrameId = InspectorId<'CdpCallFrameId'>
+
+/** Runtime execution-context id scoped to one DevTools target. */
+export type CdpExecutionContextId = CdpNumericId<'CdpExecutionContextId'>
+
+/** DOM frontend node id scoped to one DevTools document. */
+export type CdpNodeId = CdpNumericId<'CdpNodeId'>
+
+/** DOM backend node id stable across connection-local document projections. */
+export type CdpBackendNodeId = CdpNumericId<'CdpBackendNodeId'>
+
+/**
+ * Validate and brand a string id allocated or accepted by the CDP adapter.
+ * @param value - CDP identifier text.
+ * @param label - Field named in validation failures.
+ * @returns The branded CDP identifier.
+ */
+export function cdpStringId<Role extends string>(value: string, label: string): InspectorId<Role> {
+  if (value.length === 0 || value.length > 16_384) {
+    throw new Error(`inspector CDP: ${label} must contain 1 to 16384 characters`)
+  }
+  return value as InspectorId<Role>
+}
+
+/**
+ * Validate and brand a positive numeric id allocated by the CDP adapter.
+ * @param value - CDP identifier number.
+ * @param label - Field named in validation failures.
+ * @returns The branded numeric identifier.
+ */
+export function cdpNumericId<Role extends string>(value: number, label: string): CdpNumericId<Role> {
+  if (!Number.isSafeInteger(value) || value < 1) throw new Error(`inspector CDP: ${label} must be a positive integer`)
+  return value as CdpNumericId<Role>
+}

+ 82 - 0
packages/experimental/inspector/src/worker/cdp/protocol.ts

@@ -0,0 +1,82 @@
+/** Minimal CDP request and transport types owned by the Worker. */
+
+import { isPlainObject } from '../../shared/json.ts'
+
+/** Parsed client request. */
+export interface CdpRequest {
+  readonly id: number
+  readonly method: string
+  readonly params: Readonly<Record<string, unknown>>
+}
+
+/** Outbound CDP event. */
+export interface CdpNotification {
+  readonly method: string
+  readonly params: Readonly<Record<string, unknown>>
+}
+
+/** A connected DevTools transport. */
+export interface CdpTransport {
+  send(payload: unknown): void
+  close(): void
+}
+
+/**
+ * Parse one DevTools request before routing it.
+ * @param value - Untrusted decoded WebSocket payload.
+ * @returns The validated request envelope.
+ */
+export function parseCdpRequest(value: unknown): CdpRequest {
+  if (!isPlainObject(value)
+    || !Number.isSafeInteger(value.id)
+    || (value.id as number) < 0
+    || typeof value.method !== 'string'
+    || value.method.length === 0
+    || (value.params !== undefined && !isPlainObject(value.params))) {
+    throw new Error('inspector CDP: invalid request')
+  }
+  return {
+    id: value.id as number,
+    method: value.method,
+    params: value.params ?? {},
+  }
+}
+
+/**
+ * Build a stable CDP error response.
+ * @param id - Request id copied from the caller.
+ * @param code - JSON-RPC error code.
+ * @param message - Human-readable failure reason.
+ * @returns The CDP error envelope.
+ */
+export function cdpError(id: number, code: number, message: string): object {
+  return { id, error: { code, message } }
+}
+
+/**
+ * Send one failed CDP operation using the domain error code.
+ * @param transport - Connection receiving the response.
+ * @param request - Request supplying the response id.
+ * @param error - Rejection or synchronous error to render.
+ */
+export function sendCdpFailure(transport: CdpTransport, request: CdpRequest, error: unknown): void {
+  const message = error instanceof Error ? error.message : String(error)
+  transport.send(cdpError(request.id, -32000, message))
+}
+
+/**
+ * Settle an asynchronous CDP operation through one transport.
+ * @param transport - Connection receiving the response.
+ * @param request - Request supplying the response id.
+ * @param operation - Domain operation that produces the result.
+ */
+export function respondToCdpRequest(
+  transport: CdpTransport,
+  request: CdpRequest,
+  operation: () => Promise<object>,
+): void {
+  void operation().then(
+    (result) => { transport.send({ id: request.id, result }) },
+    (error: unknown) => { sendCdpFailure(transport, request, error) },
+  )
+}

+ 128 - 0
packages/experimental/inspector/src/worker/cdp/realm-sessions.ts

@@ -0,0 +1,128 @@
+/** Per-DevTools-connection sessions opened from the shared realm registry. */
+
+import { randomUUID } from 'node:crypto'
+import { inspectorId } from '../../shared/identity.ts'
+import type { InspectorRealmId } from '../../shared/cdp/ids.ts'
+import type { InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts'
+import type { InspectorRealmEvent, InspectorRealmRegistry } from '../inspection/realm-store.ts'
+import type { InspectorRealm, InspectorRealmSession } from '../inspection/realm.ts'
+import type { InspectorConnectionId } from './ids.ts'
+
+/** Realm-session lifecycle observed by connection-local CDP domains. */
+export type InspectorRealmSessionEvent =
+  | { readonly type: 'opened'; readonly session: InspectorRealmSession }
+  | { readonly type: 'closed'; readonly session: InspectorRealmSession }
+
+/** Owns exactly one backend session per active realm for one DevTools connection. */
+export class InspectorRealmSessionSet {
+  /** Opaque identity shared by every domain and object table on this DevTools connection. */
+  readonly connectionId: InspectorConnectionId = inspectorId<'InspectorConnectionId'>(randomUUID(), 'connectionId')
+  private readonly sessions = new Map<InspectorRealmId, InspectorRealmSession>()
+  private readonly listeners = new Set<(event: InspectorRealmSessionEvent) => void>()
+  private readonly unsubscribeRealms: () => void
+  private closed = false
+
+  constructor(private readonly realms: InspectorRealmRegistry) {
+    for (const realm of realms.realms()) this.open(realm)
+    this.unsubscribeRealms = realms.subscribe((event) => { this.receiveRealm(event) })
+  }
+
+  /**
+   * Return active sessions in the registry's deterministic order.
+   * @returns Host followed by connected Clients.
+   */
+  all(): InspectorRealmSession[] {
+    return this.realms.realms()
+      .map(realm => this.sessions.get(realm.descriptor.realmId))
+      .filter((session): session is InspectorRealmSession => session !== undefined)
+  }
+
+  /**
+   * Return the required Host session.
+   * @returns The connection-local Host realm session.
+   */
+  host(): InspectorRealmSession {
+    const session = this.sessions.get(this.realms.host.descriptor.realmId)
+    if (session === undefined) throw new Error('Host Inspector realm session is unavailable')
+    return session
+  }
+
+  /**
+   * Resolve one synthetic Client context.
+   * @param contextId - Numeric CDP execution-context id.
+   * @returns Its realm session when currently connected.
+   */
+  byContextId(contextId: number): InspectorRealmSession | undefined {
+    const realm = this.realms.byContextId(contextId)
+    return realm === undefined ? undefined : this.sessions.get(realm.descriptor.realmId)
+  }
+
+  /**
+   * Resolve one globally unique Client context.
+   * @param uniqueId - CDP unique execution-context id.
+   * @returns Its realm session when currently connected.
+   */
+  byUniqueContextId(uniqueId: string): InspectorRealmSession | undefined {
+    const realm = this.realms.byUniqueContextId(uniqueId)
+    return realm === undefined ? undefined : this.sessions.get(realm.descriptor.realmId)
+  }
+
+  /**
+   * Resolve one active source generation to this connection's realm session.
+   * @param source - Source identity retained by a Cordis tree node.
+   * @returns The matching realm session.
+   */
+  bySource(source: InspectorSourceDescriptor): InspectorRealmSession | undefined {
+    const realm = this.realms.bySource(source)
+    return realm === undefined ? undefined : this.sessions.get(realm.descriptor.realmId)
+  }
+
+  /**
+   * Subscribe to connection-local realm session lifecycle.
+   * @param listener - Session observer.
+   * @returns A disposer removing the observer.
+   */
+  subscribe(listener: (event: InspectorRealmSessionEvent) => void): () => void {
+    this.listeners.add(listener)
+    return () => { this.listeners.delete(listener) }
+  }
+
+  /** Close all realm sessions and stop tracking the registry. */
+  close(): void {
+    if (this.closed) return
+    this.closed = true
+    this.unsubscribeRealms()
+    for (const session of this.sessions.values()) session.close()
+    this.sessions.clear()
+    this.listeners.clear()
+  }
+
+  private receiveRealm(event: InspectorRealmEvent): void {
+    if (event.type === 'opened') {
+      const session = this.open(event.realm)
+      this.emit({ type: 'opened', session })
+      return
+    }
+    const session = this.sessions.get(event.realm.descriptor.realmId)
+    if (session === undefined) return
+    this.sessions.delete(event.realm.descriptor.realmId)
+    session.close()
+    this.emit({ type: 'closed', session })
+  }
+
+  private open(realm: InspectorRealm): InspectorRealmSession {
+    const session = realm.openSession()
+    this.sessions.set(realm.descriptor.realmId, session)
+    return session
+  }
+
+  private emit(event: InspectorRealmSessionEvent): void {
+    for (const listener of [...this.listeners]) {
+      try {
+        listener(event)
+      } catch {
+        // One CDP domain cannot prevent sibling domains from observing realm lifecycle.
+      }
+    }
+  }
+}

+ 117 - 0
packages/experimental/inspector/src/worker/cdp/session.ts

@@ -0,0 +1,117 @@
+/** One DevTools connection: explicit local-domain routing plus a private Host V8 session. */
+
+import { cdpError, parseCdpRequest, type CdpTransport } from './protocol.ts'
+import { NetworkDomain, type NetworkSink } from './domains/network/session.ts'
+import { CDP_METHOD_NOT_HANDLED, handleScaffold, type CdpTargetDescriptor } from './target.ts'
+import { RuntimeDomainSession } from './domains/runtime/index.ts'
+import { DebuggerDomainSession } from './domains/debugger/index.ts'
+import { CordisDomSession, type CordisDomBackend } from './domains/dom/index.ts'
+import type { InspectorSourceRegistry } from '../bridge/hub.ts'
+import { HostNativeDomainSession } from './domains/native.ts'
+import { InspectorRealmSessionSet } from './realm-sessions.ts'
+import type { InspectorRealmRegistry } from '../inspection/realm-store.ts'
+import type { CordisRuntimeTreeReader } from '../../shared/cordis/reader.ts'
+
+/** Per-connection CDP dispatcher. */
+export class CdpSession implements NetworkSink {
+  private readonly realms: InspectorRealmSessionSet
+  private readonly nativeDomains: HostNativeDomainSession
+  private readonly runtime: RuntimeDomainSession
+  private readonly debugger: DebuggerDomainSession
+  private readonly dom: CordisDomSession
+  private diagnosticsEnabled = false
+  private readonly unsubscribeSources: () => void
+
+  constructor(
+    private readonly transport: CdpTransport,
+    private readonly target: CdpTargetDescriptor,
+    private readonly sources: InspectorSourceRegistry,
+    private readonly network: NetworkDomain,
+    realmRegistry: InspectorRealmRegistry,
+    domBackend: CordisDomBackend,
+    private readonly cordisTrees: CordisRuntimeTreeReader,
+  ) {
+    this.realms = new InspectorRealmSessionSet(realmRegistry)
+    const native = this.realms.host().nativeDomains
+    if (native.state === 'unsupported') throw new Error(native.reason)
+    this.nativeDomains = new HostNativeDomainSession(transport, native.backend)
+    this.runtime = new RuntimeDomainSession(transport, this.realms)
+    this.debugger = new DebuggerDomainSession(transport, this.realms, this.runtime)
+    this.dom = new CordisDomSession(transport, domBackend, this.runtime)
+    this.runtime.setObjectObserver((objectId, realm, reference, group) =>
+      this.dom.bindObject(objectId, realm, reference, group))
+    this.unsubscribeSources = sources.subscribeStatus(() => {
+      if (this.diagnosticsEnabled) this.sendEvent('DSHInspector.sourcesChanged', { sources: this.sources.describe() })
+    })
+  }
+
+  /**
+   * Parse and dispatch one raw CDP request. Invalid frames close this client only.
+   * @param value - Untrusted decoded WebSocket payload.
+   */
+  receive(value: unknown): void {
+    let request
+    try {
+      request = parseCdpRequest(value)
+    } catch {
+      this.transport.close()
+      return
+    }
+    try {
+      if (request.method === 'Runtime.releaseObject') this.dom.releaseObject(request.params.objectId)
+      if (request.method === 'Runtime.releaseObjectGroup') this.dom.releaseObjectGroup(request.params.objectGroup)
+      if (this.dom.handle(request)) return
+      if (this.runtime.handle(request)) return
+      if (this.debugger.handle(request)) return
+      if (this.nativeDomains.owns(request.method)) {
+        this.nativeDomains.handle({ ...request, params: this.runtime.nativeParameters(request.params) })
+        return
+      }
+      let result: unknown
+      if (request.method.startsWith('Network.')) {
+        result = this.network.handle(request.method, request.params, this)
+      } else if (request.method === 'DSHInspector.enable') {
+        this.diagnosticsEnabled = true
+        result = { sources: this.sources.describe() }
+      } else if (request.method === 'DSHInspector.disable') {
+        this.diagnosticsEnabled = false
+        result = {}
+      } else if (request.method === 'DSHInspector.getSources') {
+        result = { sources: this.sources.describe() }
+      } else if (request.method === 'DSHInspector.getCordisTree') {
+        void this.cordisTrees.getTree().then(
+          (tree) => { this.transport.send({ id: request.id, result: { tree } }) },
+          (error: unknown) => {
+            this.transport.send(cdpError(request.id, -32000, error instanceof Error ? error.message : String(error)))
+          },
+        )
+        return
+      } else {
+        result = handleScaffold(request, this.target)
+        if (result === CDP_METHOD_NOT_HANDLED) {
+          this.transport.send(cdpError(request.id, -32601, `Method not found: ${request.method}`))
+          return
+        }
+      }
+      this.transport.send({ id: request.id, result })
+    } catch (error) {
+      this.transport.send(cdpError(request.id, -32000, error instanceof Error ? error.message : String(error)))
+    }
+  }
+
+  /** Push one CDP event. */
+  sendEvent(method: string, params: Readonly<Record<string, unknown>>): void {
+    this.transport.send({ method, params })
+  }
+
+  /** Release every connection-owned V8 and domain resource. */
+  close(): void {
+    this.unsubscribeSources()
+    this.network.detach(this)
+    this.dom.close()
+    this.runtime.close()
+    this.debugger.close()
+    this.nativeDomains.close()
+    this.realms.close()
+  }
+}

+ 77 - 0
packages/experimental/inspector/src/worker/cdp/target.ts

@@ -0,0 +1,77 @@
+/** Minimal page-target CDP methods required to expose Network, Console, and Sources together. */
+
+import type { CdpRequest } from './protocol.ts'
+
+/** Sentinel distinguishing an unowned method from an owned method returning undefined. */
+export const CDP_METHOD_NOT_HANDLED = Symbol('CDP_METHOD_NOT_HANDLED')
+
+/** Page-target identity used by discovery and scaffold responses. */
+export interface CdpTargetDescriptor {
+  readonly targetId: string
+  readonly title: string
+}
+
+/**
+ * Handle one Worker-local identity or page scaffold method.
+ * @param request - Parsed CDP request.
+ * @param target - Synthetic page-target identity.
+ * @returns A response result or the unowned-method sentinel.
+ */
+export function handleScaffold(
+  request: CdpRequest,
+  target: CdpTargetDescriptor,
+): object | typeof CDP_METHOD_NOT_HANDLED {
+  const frame = {
+    id: 'dsh-inspector-host-frame',
+    loaderId: 'dsh-inspector-loader',
+    url: 'dsh://host',
+    domainAndRegistry: '',
+    securityOrigin: 'dsh://host',
+    mimeType: 'text/html',
+    secureContextType: 'Secure',
+    crossOriginIsolatedContextType: 'NotIsolated',
+    gatedAPIFeatures: [],
+  }
+  switch (request.method) {
+    case 'Page.enable':
+    case 'Page.disable':
+    case 'Page.setLifecycleEventsEnabled':
+    case 'Target.setDiscoverTargets':
+    case 'Target.setAutoAttach':
+    case 'Log.enable':
+    case 'Log.disable':
+    case 'Console.enable':
+    case 'Console.disable':
+      return {}
+    case 'Page.getFrameTree':
+      return { frameTree: { frame, childFrames: [] } }
+    case 'Page.getResourceTree':
+      return { frameTree: { frame, resources: [] } }
+    case 'Page.getNavigationHistory':
+      return {
+        currentIndex: 0,
+        entries: [{ id: 1, url: frame.url, userTypedURL: frame.url, title: target.title, transitionType: 'typed' }],
+      }
+    case 'Target.getTargetInfo':
+      return {
+        targetInfo: {
+          targetId: target.targetId,
+          type: 'page',
+          title: target.title,
+          url: frame.url,
+          attached: true,
+          canAccessOpener: false,
+        },
+      }
+    case 'Browser.getVersion':
+      return {
+        protocolVersion: '1.3',
+        product: 'dsh-experimental-inspector/0',
+        revision: '@experimental',
+        userAgent: 'dsh-experimental-inspector',
+        jsVersion: process.versions.v8,
+      }
+    default:
+      return CDP_METHOD_NOT_HANDLED
+  }
+}

+ 55 - 0
packages/experimental/inspector/src/worker/entry.ts

@@ -0,0 +1,55 @@
+/** Node Worker bootstrap for the experimental Inspector. */
+
+import { MessagePort, parentPort, workerData } from 'node:worker_threads'
+import type { InspectorWorkerBoot, InspectorWorkerControl } from '../shared/bridge/messages/control.ts'
+import { parseInspectorHostControl, parseInspectorWorkerConfig } from '../shared/bridge/control-codec.ts'
+import { isPlainObject } from '../shared/json.ts'
+import { startInspectorWorker } from './server.ts'
+
+if (parentPort === null) throw new Error('experimental inspector: Worker entry loaded on the main thread')
+const controlPort = parentPort
+
+const bootData = workerData as unknown
+if (!isPlainObject(bootData)
+  || !(bootData.hostSourcePort instanceof MessagePort)) {
+  throw new Error('experimental inspector: invalid Worker boot data')
+}
+const boot: InspectorWorkerBoot<MessagePort> = {
+  hostSourcePort: bootData.hostSourcePort,
+  config: parseInspectorWorkerConfig(bootData.config),
+}
+
+let runtime: Awaited<ReturnType<typeof startInspectorWorker>> | undefined
+let stopping: Promise<void> | undefined
+
+const stop = (): Promise<void> => {
+  stopping ??= (async () => {
+    await runtime?.close()
+    controlPort.postMessage({ type: 'stopped' } satisfies InspectorWorkerControl)
+    controlPort.close()
+  })()
+  return stopping
+}
+
+controlPort.on('message', (message: unknown) => {
+  try {
+    parseInspectorHostControl(message)
+    void stop()
+  } catch (error) {
+    controlPort.postMessage({
+      type: 'failure',
+      message: error instanceof Error ? error.message : String(error),
+    } satisfies InspectorWorkerControl)
+  }
+})
+
+try {
+  runtime = await startInspectorWorker(boot)
+  controlPort.postMessage({ type: 'ready', ...runtime.endpoint } satisfies InspectorWorkerControl)
+} catch (error) {
+  controlPort.postMessage({
+    type: 'failure',
+    message: error instanceof Error ? error.message : String(error),
+  } satisfies InspectorWorkerControl)
+  await stop()
+}

+ 116 - 0
packages/experimental/inspector/src/worker/inspection/realm-store.ts

@@ -0,0 +1,116 @@
+/** Worker-owned registry of Host and Client realm definitions. */
+
+import type { ClientRuntimeRouter, ClientRuntimeTargetEvent } from '../bridge/runtime-rpc.ts'
+import type { ClientSourceRouter } from '../bridge/source-rpc.ts'
+import type { InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts'
+import { ClientInspectorRealm } from '../realms/client/index.ts'
+import type { InspectorRealm } from './realm.ts'
+
+/** Realm admission and removal observed by each DevTools connection. */
+export type InspectorRealmEvent =
+  | { readonly type: 'opened'; readonly realm: InspectorRealm }
+  | { readonly type: 'closed'; readonly realm: InspectorRealm }
+
+/** Authoritative collection of all currently executable realms. */
+export class InspectorRealmRegistry {
+  private readonly clientsBySource = new Map<string, ClientInspectorRealm>()
+  private readonly listeners = new Set<(event: InspectorRealmEvent) => void>()
+  private readonly unsubscribeClients: () => void
+
+  constructor(
+    readonly host: InspectorRealm,
+    private readonly clients: ClientRuntimeRouter,
+    private readonly clientSources: ClientSourceRouter,
+  ) {
+    for (const target of clients.targets()) this.openClient(target)
+    this.unsubscribeClients = clients.subscribe((event) => { this.receiveClient(event) })
+  }
+
+  /**
+   * Return the realm admission order used by every connection-local session set.
+   * @returns Host followed by active Clients.
+   */
+  realms(): InspectorRealm[] {
+    return [this.host, ...this.clientsBySource.values()]
+  }
+
+  /**
+   * Resolve one synthetic Client execution context.
+   * @param contextId - Numeric CDP execution-context id.
+   * @returns The active realm when the id belongs to a Client.
+   */
+  byContextId(contextId: number): InspectorRealm | undefined {
+    for (const realm of this.clientsBySource.values()) {
+      if (realm.context.kind === 'synthetic' && realm.context.id === contextId) return realm
+    }
+    return undefined
+  }
+
+  /**
+   * Resolve one globally unique Client execution context.
+   * @param uniqueId - CDP unique execution-context id.
+   * @returns The active realm when the id belongs to a Client.
+   */
+  byUniqueContextId(uniqueId: string): InspectorRealm | undefined {
+    for (const realm of this.clientsBySource.values()) {
+      if (realm.context.kind === 'synthetic' && realm.context.uniqueId === uniqueId) return realm
+    }
+    return undefined
+  }
+
+  /**
+   * Resolve the realm for one active source generation.
+   * @param source - Source identity retained by a Cordis tree node.
+   * @returns The matching active realm.
+   */
+  bySource(source: InspectorSourceDescriptor): InspectorRealm | undefined {
+    if (source.kind === 'host') return this.host
+    const realm = this.clientsBySource.get(source.sourceId)
+    return realm?.descriptor.generation === source.generation ? realm : undefined
+  }
+
+  /**
+   * Subscribe to Client realm admission and removal.
+   * @param listener - Registry observer.
+   * @returns A disposer removing the observer.
+   */
+  subscribe(listener: (event: InspectorRealmEvent) => void): () => void {
+    this.listeners.add(listener)
+    return () => { this.listeners.delete(listener) }
+  }
+
+  /** Stop observing Client targets and clear registry listeners. */
+  close(): void {
+    this.unsubscribeClients()
+    this.clientsBySource.clear()
+    this.listeners.clear()
+  }
+
+  private receiveClient(event: ClientRuntimeTargetEvent): void {
+    if (event.type === 'opened') {
+      const realm = this.openClient(event.target)
+      this.emit({ type: 'opened', realm })
+      return
+    }
+    const realm = this.clientsBySource.get(event.target.source.sourceId)
+    if (realm === undefined || realm.target !== event.target) return
+    this.clientsBySource.delete(event.target.source.sourceId)
+    this.emit({ type: 'closed', realm })
+  }
+
+  private openClient(target: ClientRuntimeTargetEvent['target']): ClientInspectorRealm {
+    const realm = new ClientInspectorRealm(target, this.clients, this.clientSources)
+    this.clientsBySource.set(target.source.sourceId, realm)
+    return realm
+  }
+
+  private emit(event: InspectorRealmEvent): void {
+    for (const listener of [...this.listeners]) {
+      try {
+        listener(event)
+      } catch {
+        // One DevTools connection cannot disrupt realm delivery to sibling connections.
+      }
+    }
+  }
+}

+ 54 - 0
packages/experimental/inspector/src/worker/inspection/realm.ts

@@ -0,0 +1,54 @@
+/** Worker-owned lifecycle model for active Host and Client JavaScript realms. */
+
+import type { InspectorSourceGeneration, InspectorSourceId } from '../../shared/bridge/ids.ts'
+import type { InspectorRealmCapabilities } from '../../shared/cdp/capabilities.ts'
+import type { InspectorRealmId } from '../../shared/cdp/ids.ts'
+import type {
+  ConsoleBackend,
+  DebuggerBackend,
+  NativeDomainBackend,
+  RealmCapability,
+  RuntimeBackend,
+  SourceBackend,
+} from '../../shared/cdp/realm.ts'
+
+/** Stable description of one active realm generation. */
+export interface InspectorRealmDescriptor {
+  readonly realmId: InspectorRealmId
+  readonly sourceId: InspectorSourceId
+  readonly generation: InspectorSourceGeneration
+  readonly kind: 'host' | 'client'
+  readonly label: string
+}
+
+/** Execution-context ownership for one realm. */
+export type InspectorRealmContext =
+  | { readonly kind: 'native' }
+  | {
+    readonly kind: 'synthetic'
+    readonly id: number
+    readonly uniqueId: string
+    readonly origin: string
+  }
+
+/** Capabilities bound to one realm and one DevTools connection. */
+export interface InspectorRealmSession {
+  readonly descriptor: InspectorRealmDescriptor
+  readonly context: InspectorRealmContext
+  readonly runtime: RealmCapability<RuntimeBackend>
+  readonly console: RealmCapability<ConsoleBackend>
+  readonly sources: RealmCapability<SourceBackend>
+  readonly debugger: RealmCapability<DebuggerBackend>
+  readonly nativeDomains: RealmCapability<NativeDomainBackend>
+  /** Release every connection-owned backend resource. */
+  close(): void
+}
+
+/** Active realm that can create isolated state for each DevTools connection. */
+export interface InspectorRealm {
+  readonly descriptor: InspectorRealmDescriptor
+  readonly context: InspectorRealmContext
+  readonly capabilities: InspectorRealmCapabilities
+  /** @returns Isolated backend state for one DevTools connection. */
+  openSession(): InspectorRealmSession
+}

+ 26 - 0
packages/experimental/inspector/src/worker/realms/client/bridge.ts

@@ -0,0 +1,26 @@
+/** Worker-side bridge dependencies for one connected Client realm. */
+
+import type { ClientRuntimeRouter, ClientRuntimeTarget } from '../../bridge/runtime-rpc.ts'
+import type { ClientSourceRouter } from '../../bridge/source-rpc.ts'
+
+/** Typed bridge services used by all Client realm backend adapters. */
+export interface ClientRealmBridge {
+  readonly target: ClientRuntimeTarget
+  readonly runtime: ClientRuntimeRouter
+  readonly sources: ClientSourceRouter
+}
+
+/**
+ * Bind one Client source generation to the Worker bridge services that can address it.
+ * @param target - Active Client source generation and execution context.
+ * @param runtime - Runtime and Console RPC router.
+ * @param sources - Source-catalog RPC router.
+ * @returns The immutable Client realm bridge.
+ */
+export function createClientRealmBridge(
+  target: ClientRuntimeTarget,
+  runtime: ClientRuntimeRouter,
+  sources: ClientSourceRouter,
+): ClientRealmBridge {
+  return { target, runtime, sources }
+}

+ 40 - 0
packages/experimental/inspector/src/worker/realms/client/console.ts

@@ -0,0 +1,40 @@
+/** ConsoleBackend over the typed Client Console event transport. */
+
+import type { ClientRuntimeSessionId } from '../../../shared/bridge/ids.ts'
+import type { RuntimeBackendObjectHandle } from '../../../shared/cdp/ids.ts'
+import type { RuntimeConsoleBackendEvent } from '../../../shared/cdp/index.ts'
+import type { ClientRuntimeRouter, ClientRuntimeTarget } from '../../bridge/runtime-rpc.ts'
+import type { ConsoleBackend } from '../../../shared/cdp/realm.ts'
+import { clientConsoleEvent } from './values.ts'
+import type { ClientScriptIdentity } from './scripts.ts'
+
+/** Adapts session-local Client Console events to common Runtime values. */
+export class ClientConsoleBackend implements ConsoleBackend {
+  private readonly disposers = new Set<() => void>()
+
+  constructor(
+    private readonly target: ClientRuntimeTarget,
+    private readonly sessionId: ClientRuntimeSessionId,
+    private readonly router: ClientRuntimeRouter,
+    private readonly scriptIds: ClientScriptIdentity,
+  ) {}
+
+  subscribe(listener: (event: RuntimeConsoleBackendEvent<RuntimeBackendObjectHandle>) => void): () => void {
+    const dispose = this.router.subscribeConsole(this.target, this.sessionId, (event) => {
+      listener(clientConsoleEvent(event, scriptKey => this.scriptIds.toRuntime(scriptKey)))
+    })
+    this.disposers.add(dispose)
+    return () => {
+      if (!this.disposers.delete(dispose)) return
+      dispose()
+    }
+  }
+
+  async clear(): Promise<void> {}
+
+  /** Disable every active Console subscription for this connection. */
+  close(): void {
+    for (const dispose of this.disposers) dispose()
+    this.disposers.clear()
+  }
+}

+ 11 - 0
packages/experimental/inspector/src/worker/realms/client/debugger.ts

@@ -0,0 +1,11 @@
+/** Explicit Client debugger capability until a pause-safe page agent exists. */
+
+import type { DebuggerBackend, RealmCapability } from '../../../shared/cdp/realm.ts'
+
+/**
+ * Report the unavailable Client debugger backend.
+ * @returns The typed unsupported result used by every Client realm session.
+ */
+export function clientDebuggerCapability(): RealmCapability<DebuggerBackend> {
+  return { state: 'unsupported', reason: 'Client native debugging is unavailable' }
+}

+ 104 - 0
packages/experimental/inspector/src/worker/realms/client/index.ts

@@ -0,0 +1,104 @@
+/** Client realm definition assembled from independent Runtime, Console, and Source backends. */
+
+import { randomUUID } from 'node:crypto'
+import { inspectorId } from '../../../shared/identity.ts'
+import { ClientConsoleBackend } from './console.ts'
+import { ClientRuntimeBackend } from './runtime.ts'
+import { ClientSourceBackend } from './sources.ts'
+import { ClientScriptIdentity } from './scripts.ts'
+import type { ClientRuntimeRouter, ClientRuntimeTarget } from '../../bridge/runtime-rpc.ts'
+import type { ClientSourceRouter } from '../../bridge/source-rpc.ts'
+import type { InspectorRealm, InspectorRealmDescriptor, InspectorRealmSession } from '../../inspection/realm.ts'
+import { createClientRealmBridge, type ClientRealmBridge } from './bridge.ts'
+import { clientDebuggerCapability } from './debugger.ts'
+
+const CLIENT_RUNTIME_OPERATIONS = [
+  'evaluate',
+  'get-properties',
+  'call-function',
+  'await-promise',
+  'release-object',
+  'release-object-group',
+  'global-lexical-scope-names',
+] as const
+
+/** Active Client realm exposed through the common Worker realm model. */
+export class ClientInspectorRealm implements InspectorRealm {
+  readonly descriptor: InspectorRealmDescriptor
+  readonly context: InspectorRealm['context']
+  readonly capabilities: InspectorRealm['capabilities']
+  private readonly scriptIds: ClientScriptIdentity
+  private readonly bridge: ClientRealmBridge
+
+  constructor(
+    target: ClientRuntimeTarget,
+    runtimeRouter: ClientRuntimeRouter,
+    sourceRouter: ClientSourceRouter,
+  ) {
+    this.bridge = createClientRealmBridge(target, runtimeRouter, sourceRouter)
+    this.descriptor = {
+      realmId: inspectorId<'InspectorRealmId'>(randomUUID(), 'realmId'),
+      sourceId: target.source.sourceId,
+      generation: target.source.generation,
+      kind: 'client',
+      label: target.source.label,
+    }
+    this.context = {
+      kind: 'synthetic',
+      id: target.contextId,
+      uniqueId: target.uniqueContextId,
+      origin: target.capability.origin,
+    }
+    this.scriptIds = new ClientScriptIdentity(target.contextId)
+    this.capabilities = {
+      runtime: CLIENT_RUNTIME_OPERATIONS,
+      console: supports(target, 'client-console') ? ['events', 'exceptions', 'clear'] : [],
+      sources: supports(target, 'client-sources') ? ['catalog', 'content', 'source-map'] : [],
+      debugger: [],
+    }
+  }
+
+  /** Active source generation represented by this realm. */
+  get target(): ClientRuntimeTarget {
+    return this.bridge.target
+  }
+
+  /** Open one isolated set of Client backends for a DevTools connection. */
+  openSession(): InspectorRealmSession {
+    const runtimeSessionId = inspectorId<'ClientRuntimeSessionId'>(randomUUID(), 'runtimeSessionId')
+    const runtime = new ClientRuntimeBackend(this.target, runtimeSessionId, this.bridge.runtime, this.scriptIds)
+    const console = supports(this.target, 'client-console')
+      ? new ClientConsoleBackend(this.target, runtimeSessionId, this.bridge.runtime, this.scriptIds)
+      : undefined
+    const sources = supports(this.target, 'client-sources')
+      ? new ClientSourceBackend(
+        this.target,
+        inspectorId<'ClientSourceSessionId'>(randomUUID(), 'sourceSessionId'),
+        this.bridge.sources,
+        this.scriptIds,
+      )
+      : undefined
+    return {
+      descriptor: this.descriptor,
+      context: this.context,
+      runtime: { state: 'supported', backend: runtime },
+      console: console === undefined
+        ? { state: 'unsupported', reason: 'Client source does not provide Console events' }
+        : { state: 'supported', backend: console },
+      sources: sources === undefined
+        ? { state: 'unsupported', reason: 'Client source does not provide a script catalog' }
+        : { state: 'supported', backend: sources },
+      debugger: clientDebuggerCapability(),
+      nativeDomains: { state: 'unsupported', reason: 'Client realm has no native CDP transport' },
+      close: () => {
+        console?.close()
+        sources?.close()
+        runtime.close()
+      },
+    }
+  }
+}
+
+function supports(target: ClientRuntimeTarget, capability: 'client-console' | 'client-sources'): boolean {
+  return target.source.capabilities.some(candidate => candidate.type === capability)
+}

+ 160 - 0
packages/experimental/inspector/src/worker/realms/client/runtime.ts

@@ -0,0 +1,160 @@
+/** RuntimeBackend over the typed Worker-to-Client transport. */
+
+import type {
+  ClientCallArgument,
+  ClientRuntimeCommand,
+  ClientRuntimeResult,
+} from '../../../shared/bridge/messages/runtime/index.ts'
+import type { ClientRuntimeSessionId } from '../../../shared/bridge/ids.ts'
+import type { RuntimeBackendObjectHandle } from '../../../shared/cdp/ids.ts'
+import type { RuntimeCallArgument } from '../../../shared/cdp/index.ts'
+import type { ClientRuntimeRouter, ClientRuntimeTarget } from '../../bridge/runtime-rpc.ts'
+import type { RuntimeBackend } from '../../../shared/cdp/realm.ts'
+import {
+  clientCompletion,
+  clientException,
+  clientHandle,
+  clientInternalProperty,
+  clientProperty,
+} from './values.ts'
+import type { ClientScriptIdentity } from './scripts.ts'
+
+/** Adapts one connection-local Client Runtime session to the common backend API. */
+export class ClientRuntimeBackend implements RuntimeBackend {
+  private closed = false
+
+  constructor(
+    private readonly target: ClientRuntimeTarget,
+    private readonly sessionId: ClientRuntimeSessionId,
+    private readonly router: ClientRuntimeRouter,
+    private readonly scriptIds: ClientScriptIdentity,
+  ) {}
+
+  enable(): Promise<void> {
+    return Promise.resolve()
+  }
+
+  disable(): Promise<void> {
+    this.router.closeTargetSession(this.target, this.sessionId)
+    return Promise.resolve()
+  }
+
+  async evaluate(request: Parameters<RuntimeBackend['evaluate']>[0]): ReturnType<RuntimeBackend['evaluate']> {
+    assertClientEvaluationOptions(request)
+    const { throwOnSideEffect: _throwOnSideEffect, serializationOptions: _serializationOptions, ...supported } = request
+    return clientCompletion(
+      expectResult(await this.request({ op: 'evaluate', ...supported }), 'evaluate'),
+      scriptKey => this.scriptIds.toRuntime(scriptKey),
+    )
+  }
+
+  async getProperties(request: Parameters<RuntimeBackend['getProperties']>[0]): ReturnType<RuntimeBackend['getProperties']> {
+    const result = expectResult(await this.request({
+      op: 'get-properties',
+      ...request,
+      handle: clientHandle(request.handle),
+    }), 'get-properties')
+    return {
+      properties: result.properties.map(clientProperty),
+      ...(result.internalProperties === undefined
+        ? {}
+        : { internalProperties: result.internalProperties.map(clientInternalProperty) }),
+      ...(result.exceptionDetails === undefined
+        ? {}
+        : {
+          exceptionDetails: clientException(
+            result.exceptionDetails,
+            scriptKey => this.scriptIds.toRuntime(scriptKey),
+          ),
+        }),
+    }
+  }
+
+  async callFunction(request: Parameters<RuntimeBackend['callFunction']>[0]): ReturnType<RuntimeBackend['callFunction']> {
+    assertClientCallOptions(request)
+    const {
+      receiver,
+      arguments: args,
+      throwOnSideEffect: _throwOnSideEffect,
+      serializationOptions: _serializationOptions,
+      ...options
+    } = request
+    const command: Extract<ClientRuntimeCommand, { op: 'call-function' }> = {
+      op: 'call-function',
+      ...options,
+      ...(receiver === undefined ? {} : { receiver: clientHandle(receiver) }),
+      ...(args === undefined ? {} : { arguments: args.map(argumentToClient) }),
+    }
+    return clientCompletion(
+      expectResult(await this.request(command), 'call-function'),
+      scriptKey => this.scriptIds.toRuntime(scriptKey),
+    )
+  }
+
+  async awaitPromise(request: Parameters<RuntimeBackend['awaitPromise']>[0]): ReturnType<RuntimeBackend['awaitPromise']> {
+    return clientCompletion(
+      expectResult(await this.request({
+        op: 'await-promise',
+        ...request,
+        promise: clientHandle(request.promise),
+      }), 'await-promise'),
+      scriptKey => this.scriptIds.toRuntime(scriptKey),
+    )
+  }
+
+  async globalLexicalScopeNames(): Promise<readonly string[]> {
+    return expectResult(await this.request({ op: 'global-lexical-scope-names' }), 'global-lexical-scope-names').names
+  }
+
+  async releaseObject(handle: RuntimeBackendObjectHandle): Promise<void> {
+    expectResult(await this.request({ op: 'release-object', handle: clientHandle(handle) }), 'release-object')
+  }
+
+  async releaseObjectGroup(group: string): Promise<void> {
+    expectResult(await this.request({ op: 'release-object-group', objectGroup: group }), 'release-object-group')
+  }
+
+  /** Close this connection's session and reject further requests. */
+  close(): void {
+    if (this.closed) return
+    this.closed = true
+    this.router.closeTargetSession(this.target, this.sessionId)
+  }
+
+  private request(command: ClientRuntimeCommand): Promise<ClientRuntimeResult> {
+    if (this.closed) return Promise.reject(new Error('Client realm session is closed'))
+    return this.router.request(this.target, this.sessionId, command)
+  }
+}
+
+function argumentToClient(value: RuntimeCallArgument<RuntimeBackendObjectHandle>): ClientCallArgument {
+  return value.kind === 'object' ? { kind: 'object', handle: clientHandle(value.handle) } : value
+}
+
+function expectResult<Operation extends ClientRuntimeResult['op']>(
+  result: ClientRuntimeResult,
+  operation: Operation,
+): Extract<ClientRuntimeResult, { op: Operation }> {
+  if (result.op !== operation) throw new Error(`Client Runtime returned ${result.op} for ${operation}`)
+  return result as Extract<ClientRuntimeResult, { op: Operation }>
+}
+
+function assertClientEvaluationOptions(request: Parameters<RuntimeBackend['evaluate']>[0]): void {
+  if (request.throwOnSideEffect === true) throw new Error('Client Runtime does not support throwOnSideEffect')
+  if (request.serializationOptions !== undefined) throw new Error('Client Runtime does not support serializationOptions')
+  if (request.disableBreaks === true) throw new Error('Client Runtime does not support disableBreaks')
+  if (request.replMode === true) throw new Error('Client Runtime does not support replMode')
+  if (request.userGesture === true) throw new Error('Client Runtime does not support userGesture')
+  if (request.allowUnsafeEvalBlockedByCSP === true) {
+    throw new Error('Client Runtime cannot bypass the page Content Security Policy')
+  }
+  if (request.timeoutMs !== undefined && request.awaitPromise !== true) {
+    throw new Error('Client Runtime supports timeout only when awaitPromise is enabled')
+  }
+}
+
+function assertClientCallOptions(request: Parameters<RuntimeBackend['callFunction']>[0]): void {
+  if (request.throwOnSideEffect === true) throw new Error('Client Runtime does not support throwOnSideEffect')
+  if (request.serializationOptions !== undefined) throw new Error('Client Runtime does not support serializationOptions')
+  if (request.userGesture === true) throw new Error('Client Runtime does not support userGesture')
+}

+ 27 - 0
packages/experimental/inspector/src/worker/realms/client/scripts.ts

@@ -0,0 +1,27 @@
+/** Realm-stable translation between Client catalog keys and common Runtime script keys. */
+
+import { inspectorId } from '../../../shared/identity.ts'
+import type { RuntimeScriptKey } from '../../../shared/cdp/ids.ts'
+
+/** Allocates one shared script identity namespace for all backends in a Client realm. */
+export class ClientScriptIdentity {
+  private readonly publicByLocal = new Map<RuntimeScriptKey, RuntimeScriptKey>()
+
+  constructor(private readonly contextId: number) {}
+
+  /**
+   * Convert a Client-local key to the realm's public Runtime script key.
+   * @param localKey - Script key used on the Client wire.
+   * @returns Stable key shared by this realm's Runtime, Console, and Sources backends.
+   */
+  toRuntime(localKey: RuntimeScriptKey): RuntimeScriptKey {
+    let scriptKey = this.publicByLocal.get(localKey)
+    if (scriptKey !== undefined) return scriptKey
+    scriptKey = inspectorId<'RuntimeScriptKey'>(
+      `client:${String(Math.abs(this.contextId))}:${String(this.publicByLocal.size + 1)}`,
+      'scriptKey',
+    )
+    this.publicByLocal.set(localKey, scriptKey)
+    return scriptKey
+  }
+}

+ 122 - 0
packages/experimental/inspector/src/worker/realms/client/sources.ts

@@ -0,0 +1,122 @@
+/** Client SourceBackend over the bounded browser source-catalog transport. */
+
+import type { ClientScriptDescriptor, ClientSourceResult } from '../../../shared/bridge/messages/sources/index.ts'
+import type { ClientSourceSessionId } from '../../../shared/bridge/ids.ts'
+import type { RuntimeScriptKey } from '../../../shared/cdp/ids.ts'
+import type { RuntimeScript } from '../../../shared/cdp/index.ts'
+import type { ClientRuntimeTarget } from '../../bridge/runtime-rpc.ts'
+import type { ClientSourceRouter } from '../../bridge/source-rpc.ts'
+import type { SourceBackend } from '../../../shared/cdp/realm.ts'
+import type { ClientScriptIdentity } from './scripts.ts'
+
+interface ClientScriptRoute {
+  readonly localKey: RuntimeScriptKey
+}
+
+/** Presents one Client bundle catalog through the common read-only source model. */
+export class ClientSourceBackend implements SourceBackend {
+  private readonly scripts = new Map<RuntimeScriptKey, ClientScriptRoute>()
+  private catalog: Promise<readonly RuntimeScript[]> | undefined
+  private closed = false
+
+  constructor(
+    private readonly target: ClientRuntimeTarget,
+    private readonly sessionId: ClientSourceSessionId,
+    private readonly router: ClientSourceRouter,
+    private readonly scriptIds: ClientScriptIdentity,
+  ) {}
+
+  async listScripts(): Promise<readonly RuntimeScript[]> {
+    if (this.closed) throw new Error('Client source session is closed')
+    this.catalog ??= this.loadCatalog()
+    return this.catalog
+  }
+
+  async getScriptSource(scriptKey: RuntimeScriptKey): Promise<string> {
+    const route = await this.route(scriptKey)
+    const source = await this.read(route.localKey, 'source')
+    if (source === undefined) throw new Error('Client script source is unavailable')
+    return source
+  }
+
+  async getSourceMap(scriptKey: RuntimeScriptKey): Promise<string | undefined> {
+    const route = await this.route(scriptKey)
+    return this.read(route.localKey, 'source-map')
+  }
+
+  subscribe(_listener: (script: RuntimeScript) => void): () => void {
+    return () => {}
+  }
+
+  /** Reject pending reads owned by this DevTools connection. */
+  close(): void {
+    if (this.closed) return
+    this.closed = true
+    this.router.closeSession(this.target.source, this.sessionId)
+    this.scripts.clear()
+  }
+
+  private async loadCatalog(): Promise<readonly RuntimeScript[]> {
+    const result = expectResult(await this.router.request(
+      this.target.source,
+      this.sessionId,
+      { op: 'list-scripts' },
+    ), 'list-scripts')
+    return result.scripts.map(script => this.register(script))
+  }
+
+  private register(script: ClientScriptDescriptor): RuntimeScript {
+    const scriptKey = this.scriptIds.toRuntime(script.scriptKey)
+    const descriptor: RuntimeScript = {
+      ...script,
+      scriptKey,
+      executionContextId: this.target.contextId,
+    }
+    this.scripts.set(scriptKey, { localKey: script.scriptKey })
+    return descriptor
+  }
+
+  private async route(scriptKey: RuntimeScriptKey): Promise<ClientScriptRoute> {
+    await this.listScripts()
+    const route = this.scripts.get(scriptKey)
+    if (route === undefined) throw new Error('Client script is no longer available')
+    return route
+  }
+
+  private async read(
+    scriptKey: RuntimeScriptKey,
+    content: 'source' | 'source-map',
+  ): Promise<string | undefined> {
+    const chunks: Uint8Array[] = []
+    let offset = 0
+    while (true) {
+      const result = expectResult(await this.router.request(this.target.source, this.sessionId, {
+        op: 'get-content-chunk',
+        scriptKey,
+        content,
+        offset,
+        maxBytes: this.router.chunkBytes,
+      }), 'get-content-chunk')
+      if (!result.available) return undefined
+      const bytes = Buffer.from(result.data, 'base64')
+      if (bytes.byteLength > this.router.chunkBytes
+        || result.nextOffset !== offset + bytes.byteLength
+        || (!result.eof && result.nextOffset === offset)
+        || result.nextOffset > this.router.maxContentBytes) {
+        throw new Error('Client source returned an invalid content chunk')
+      }
+      chunks.push(bytes)
+      offset = result.nextOffset
+      if (result.eof) break
+    }
+    return new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks))
+  }
+}
+
+function expectResult<Operation extends ClientSourceResult['op']>(
+  result: ClientSourceResult,
+  operation: Operation,
+): Extract<ClientSourceResult, { op: Operation }> {
+  if (result.op !== operation) throw new Error(`Client source returned ${result.op} for ${operation}`)
+  return result as Extract<ClientSourceResult, { op: Operation }>
+}

+ 162 - 0
packages/experimental/inspector/src/worker/realms/client/values.ts

@@ -0,0 +1,162 @@
+/** Conversion from Client wire values to realm-neutral Runtime values. */
+
+import type {
+  ClientRuntimeExceptionDetails,
+  ClientRuntimePropertyDescriptor,
+  ClientRuntimeRemoteObject,
+  ClientRuntimeResult,
+} from '../../../shared/bridge/messages/runtime/index.ts'
+import {
+  type ClientRemoteObjectHandle,
+} from '../../../shared/bridge/ids.ts'
+import { inspectorId } from '../../../shared/identity.ts'
+import type { RuntimeBackendObjectHandle, RuntimeScriptKey } from '../../../shared/cdp/ids.ts'
+import type {
+  RuntimeCompletion,
+  RuntimeConsoleBackendEvent,
+  RuntimeExceptionDetails,
+  RuntimeInternalPropertyDescriptor,
+  RuntimePropertyDescriptor,
+  RuntimeRemoteObject,
+  RuntimeStackTrace,
+} from '../../../shared/cdp/index.ts'
+
+/** Maps a Client-local script key into its realm-wide Runtime identity. */
+export type ClientScriptKeyMapper = (scriptKey: RuntimeScriptKey) => RuntimeScriptKey
+
+/**
+ * Convert one Client completion and all nested objects.
+ * @param result - Successful Client Runtime command result.
+ * @param mapScriptKey - Realm-wide script identity mapper.
+ * @returns A realm-neutral Runtime completion.
+ */
+export function clientCompletion(
+  result: Extract<ClientRuntimeResult, { op: 'evaluate' | 'call-function' | 'await-promise' }>,
+  mapScriptKey: ClientScriptKeyMapper,
+): RuntimeCompletion<RuntimeBackendObjectHandle> {
+  return {
+    result: clientRemoteObject(result.completion.result),
+    ...(result.completion.exceptionDetails === undefined
+      ? {}
+      : { exceptionDetails: clientException(result.completion.exceptionDetails, mapScriptKey) }),
+  }
+}
+
+/**
+ * Convert one Client property descriptor and all nested objects.
+ * @param value - Client wire property descriptor.
+ * @returns A realm-neutral property descriptor.
+ */
+export function clientProperty(
+  value: ClientRuntimePropertyDescriptor,
+): RuntimePropertyDescriptor<RuntimeBackendObjectHandle> {
+  const { value: propertyValue, get, set, symbol, ...descriptor } = value
+  return {
+    ...descriptor,
+    ...(propertyValue === undefined ? {} : { value: clientRemoteObject(propertyValue) }),
+    ...(get === undefined ? {} : { get: clientRemoteObject(get) }),
+    ...(set === undefined ? {} : { set: clientRemoteObject(set) }),
+    ...(symbol === undefined ? {} : { symbol: clientRemoteObject(symbol) }),
+  }
+}
+
+/**
+ * Convert one Client internal property descriptor.
+ * @param value - Client wire internal property.
+ * @returns A realm-neutral internal property.
+ */
+export function clientInternalProperty(
+  value: RuntimeInternalPropertyDescriptor<ClientRemoteObjectHandle>,
+): RuntimeInternalPropertyDescriptor<RuntimeBackendObjectHandle> {
+  return {
+    name: value.name,
+    ...(value.value === undefined ? {} : { value: clientRemoteObject(value.value) }),
+  }
+}
+
+/**
+ * Convert Client exception details and their optional object.
+ * @param value - Client wire exception details.
+ * @param mapScriptKey - Realm-wide script identity mapper.
+ * @returns Realm-neutral exception details.
+ */
+export function clientException(
+  value: ClientRuntimeExceptionDetails,
+  mapScriptKey: ClientScriptKeyMapper,
+): RuntimeExceptionDetails<RuntimeBackendObjectHandle> {
+  const { exception, ...details } = value
+  return {
+    ...details,
+    ...(value.stackTrace === undefined ? {} : { stackTrace: clientStackTrace(value.stackTrace, mapScriptKey) }),
+    ...(exception === undefined ? {} : { exception: clientRemoteObject(exception) }),
+  }
+}
+
+/**
+ * Convert a Client Console event recursively.
+ * @param value - Client wire Console event.
+ * @param mapScriptKey - Realm-wide script identity mapper.
+ * @returns A realm-neutral Console event.
+ */
+export function clientConsoleEvent(
+  value: RuntimeConsoleBackendEvent<ClientRemoteObjectHandle>,
+  mapScriptKey: ClientScriptKeyMapper,
+): RuntimeConsoleBackendEvent<RuntimeBackendObjectHandle> {
+  if (value.type === 'console-api') {
+    return {
+      type: value.type,
+      event: {
+        ...value.event,
+        arguments: value.event.arguments.map(clientRemoteObject),
+        ...(value.event.stackTrace === undefined
+          ? {}
+          : { stackTrace: clientStackTrace(value.event.stackTrace, mapScriptKey) }),
+      },
+    }
+  }
+  return {
+    type: value.type,
+    event: { ...value.event, details: clientException(value.event.details, mapScriptKey) },
+  }
+}
+
+/**
+ * Convert a Client RemoteObject into the backend-neutral handle slot.
+ * @param value - Client wire RemoteObject.
+ * @returns A realm-neutral Runtime value.
+ */
+export function clientRemoteObject(
+  value: ClientRuntimeRemoteObject,
+): RuntimeRemoteObject<RuntimeBackendObjectHandle> {
+  return {
+    descriptor: value.descriptor,
+    ...(value.object === undefined
+      ? {}
+      : { object: { handle: backendHandle(value.object.handle) } }),
+    ...(value.semanticReference === undefined ? {} : { semanticReference: value.semanticReference }),
+  }
+}
+
+/**
+ * Rebrand a common backend handle for the Client transport that owns it.
+ * @param value - Backend handle from a routed Runtime request.
+ * @returns The same opaque text under its Client wire role.
+ */
+export function clientHandle(value: string): ClientRemoteObjectHandle {
+  return inspectorId<'ClientRemoteObjectHandle'>(value, 'Client object handle')
+}
+
+function backendHandle(value: string): RuntimeBackendObjectHandle {
+  return inspectorId<'RuntimeBackendObjectHandle'>(value, 'Runtime backend object handle')
+}
+
+function clientStackTrace(value: RuntimeStackTrace, mapScriptKey: ClientScriptKeyMapper): RuntimeStackTrace {
+  return {
+    ...value,
+    callFrames: value.callFrames.map(frame => ({
+      ...frame,
+      ...(frame.scriptKey === undefined ? {} : { scriptKey: mapScriptKey(frame.scriptKey) }),
+    })),
+    ...(value.parent === undefined ? {} : { parent: clientStackTrace(value.parent, mapScriptKey) }),
+  }
+}

+ 164 - 0
packages/experimental/inspector/src/worker/realms/host/bridge.ts

@@ -0,0 +1,164 @@
+/** Per-DevTools-connection bridge to the Host main thread's real V8 inspector target. */
+
+import { Session } from 'node:inspector'
+import type { NativeProtocolNotification } from '../../../shared/cdp/realm.ts'
+
+/** Notification emitted by Node's native inspector session. */
+export type HostInspectorNotification = NativeProtocolNotification
+
+interface DynamicInspectorSession {
+  connectToMainThread(): void
+  disconnect(): void
+  on(event: 'inspectorNotification', listener: (message: HostInspectorNotification) => void): this
+  post(
+    method: string,
+    params: Readonly<Record<string, unknown>> | undefined,
+    callback: (error: Error | null, result?: Readonly<Record<string, unknown>>) => void,
+  ): void
+}
+
+/** Connection-local carrier for requests and notifications from the Host V8 inspector. */
+export class HostInspectorSession {
+  private readonly session = new Session() as unknown as DynamicInspectorSession
+  private readonly listeners = new Set<(message: HostInspectorNotification) => void>()
+  private connected = false
+  private failure: string | undefined
+
+  constructor(private readonly contextName: string) {
+    this.session.on('inspectorNotification', (message) => {
+      const rewritten = this.rewriteContextName(message)
+      for (const listener of [...this.listeners]) {
+        try {
+          listener(rewritten)
+        } catch {
+          // One domain subscriber cannot starve notifications for sibling domains.
+        }
+      }
+    })
+  }
+
+  /**
+   * Subscribe to native inspector notifications.
+   * @param listener - Consumer owned by one Worker domain adapter.
+   * @returns A disposer removing the consumer.
+   */
+  subscribe(listener: (message: HostInspectorNotification) => void): () => void {
+    this.listeners.add(listener)
+    return () => { this.listeners.delete(listener) }
+  }
+
+  /**
+   * Execute one Host V8 request for a Worker-owned composite Runtime operation.
+   * @param method - CDP method name.
+   * @param params - Validated request parameters.
+   * @returns The Host inspector result.
+   */
+  request(method: string, params: Readonly<Record<string, unknown>>): Promise<Readonly<Record<string, unknown>>> {
+    const failure = this.connect()
+    if (failure !== undefined) return Promise.reject(new Error(failure))
+    return new Promise((resolve, reject) => {
+      try {
+        this.session.post(method, params, (error, result) => {
+          if (error !== null) reject(error)
+          else resolve(result ?? {})
+        })
+      } catch (error) {
+        reject(new Error(renderError(error)))
+      }
+    })
+  }
+
+  /** Disconnect this DevTools client's V8 session. */
+  close(): void {
+    this.listeners.clear()
+    if (!this.connected || this.failure !== undefined) return
+    this.connected = false
+    try {
+      this.session.disconnect()
+    } catch {
+      // The underlying inspector session is already disconnected.
+    }
+  }
+
+  private connect(): string | undefined {
+    if (this.connected) return this.failure
+    this.connected = true
+    try {
+      this.session.connectToMainThread()
+    } catch (error) {
+      this.failure = `Host V8 inspector is unavailable: ${renderError(error)}`
+    }
+    return this.failure
+  }
+
+  private rewriteContextName(message: HostInspectorNotification): HostInspectorNotification {
+    if (message.method !== 'Runtime.executionContextCreated') return message
+    const params = message.params
+    const context = params?.context
+    if (typeof context !== 'object' || context === null) return message
+    const record = context as Readonly<Record<string, unknown>>
+    const auxData = record.auxData
+    if (typeof auxData !== 'object' || auxData === null || (auxData as Readonly<Record<string, unknown>>).isDefault !== true) {
+      return message
+    }
+    return {
+      method: message.method,
+      params: {
+        ...params,
+        context: { ...record, name: this.contextName },
+      },
+    }
+  }
+}
+
+/** Serializes accepted native notifications and isolates sibling consumers. */
+export class HostNotificationChannel<Event> {
+  private readonly listeners = new Set<(event: Event) => void>()
+  private readonly unsubscribe: () => void
+  private delivery = Promise.resolve()
+
+  constructor(
+    target: HostInspectorSession,
+    private readonly accepts: (message: HostInspectorNotification) => boolean,
+    private readonly project: (message: HostInspectorNotification) => Promise<Event | undefined>,
+  ) {
+    this.unsubscribe = target.subscribe((message) => { this.receive(message) })
+  }
+
+  /**
+   * Subscribe to projected native notifications.
+   * @param listener - Consumer invoked in subscription order.
+   * @returns A disposer removing the consumer.
+   */
+  subscribe(listener: (event: Event) => void): () => void {
+    this.listeners.add(listener)
+    return () => { this.listeners.delete(listener) }
+  }
+
+  /** Release the native notification subscription and all consumers. */
+  close(): void {
+    this.unsubscribe()
+    this.listeners.clear()
+  }
+
+  private receive(message: HostInspectorNotification): void {
+    if (!this.accepts(message)) return
+    this.delivery = this.delivery.then(async () => {
+      const event = await this.project(message)
+      if (event === undefined) return
+      for (const listener of [...this.listeners]) {
+        try {
+          listener(event)
+        } catch {
+          // One notification consumer cannot prevent delivery to its siblings.
+        }
+      }
+    }).catch(() => {
+      // Malformed optional native notifications do not interrupt request handling.
+    })
+  }
+}
+
+function renderError(error: unknown): string {
+  return error instanceof Error ? error.message : String(error)
+}

+ 90 - 0
packages/experimental/inspector/src/worker/realms/host/console.ts

@@ -0,0 +1,90 @@
+/** ConsoleBackend implementation over native Node Runtime notifications. */
+
+import type { RuntimeBackendObjectHandle } from '../../../shared/cdp/ids.ts'
+import type {
+  RuntimeConsoleBackendEvent,
+  RuntimeConsoleType,
+} from '../../../shared/cdp/index.ts'
+import type { HostInspectorSession } from './bridge.ts'
+import type { ConsoleBackend } from '../../../shared/cdp/realm.ts'
+import { isNativeRecord } from './values.ts'
+import { HostNotificationChannel } from './bridge.ts'
+import type { HostRuntimeBackend } from './runtime.ts'
+
+const CONSOLE_TYPES = new Set<RuntimeConsoleType>([
+  'log', 'debug', 'info', 'error', 'warning', 'dir', 'dirxml', 'table', 'trace', 'clear',
+  'startGroup', 'startGroupCollapsed', 'endGroup', 'assert', 'profile', 'profileEnd', 'count', 'timeEnd',
+])
+
+/** Converts native Runtime notifications to realm-neutral Console events. */
+export class HostConsoleBackend implements ConsoleBackend {
+  private readonly events: HostNotificationChannel<RuntimeConsoleBackendEvent<RuntimeBackendObjectHandle>>
+
+  constructor(
+    private readonly target: HostInspectorSession,
+    private readonly runtime: HostRuntimeBackend,
+  ) {
+    this.events = new HostNotificationChannel(
+      target,
+      message => message.method === 'Runtime.consoleAPICalled' || message.method === 'Runtime.exceptionThrown',
+      async message => message.method === 'Runtime.consoleAPICalled'
+        ? this.consoleEvent(message.params)
+        : this.exceptionEvent(message.params),
+    )
+  }
+
+  /**
+   * Subscribe to native Console and exception events.
+   * @param listener - Connection-local event consumer.
+   * @returns A disposer removing the consumer.
+   */
+  subscribe(listener: (event: RuntimeConsoleBackendEvent<RuntimeBackendObjectHandle>) => void): () => void {
+    return this.events.subscribe(listener)
+  }
+
+  async clear(): Promise<void> {
+    await this.target.request('Runtime.discardConsoleEntries', {})
+  }
+
+  /** Release the native notification subscription. */
+  close(): void {
+    this.events.close()
+  }
+
+  private async consoleEvent(
+    params: Readonly<Record<string, unknown>> | undefined,
+  ): Promise<RuntimeConsoleBackendEvent<RuntimeBackendObjectHandle> | undefined> {
+    const type = params?.type
+    const args = params?.args
+    const timestamp = params?.timestamp
+    const stackTrace = params?.stackTrace
+    if (!CONSOLE_TYPES.has(type as RuntimeConsoleType) || !Array.isArray(args) || typeof timestamp !== 'number') return undefined
+    return {
+      type: 'console-api',
+      event: {
+        type: type as RuntimeConsoleType,
+        arguments: await Promise.all(args.map(value => this.runtime.remoteObject(value))),
+        timestamp,
+        ...(typeof params?.executionContextId === 'number' ? { contextId: params.executionContextId } : {}),
+        ...(isNativeRecord(stackTrace) ? { stackTrace: this.runtime.stackTrace(stackTrace) } : {}),
+      },
+    }
+  }
+
+  private async exceptionEvent(
+    params: Readonly<Record<string, unknown>> | undefined,
+  ): Promise<RuntimeConsoleBackendEvent<RuntimeBackendObjectHandle> | undefined> {
+    const timestamp = params?.timestamp
+    const exceptionDetails = params?.exceptionDetails
+    const contextId = params?.executionContextId
+    if (typeof timestamp !== 'number' || exceptionDetails === undefined) return undefined
+    return {
+      type: 'exception',
+      event: {
+        timestamp,
+        ...(typeof contextId === 'number' ? { contextId } : {}),
+        details: await this.runtime.exceptionDetails(exceptionDetails),
+      },
+    }
+  }
+}

+ 167 - 0
packages/experimental/inspector/src/worker/realms/host/debugger.ts

@@ -0,0 +1,167 @@
+/** DebuggerBackend implementation over one native Node inspector session. */
+
+import type { RuntimeBackendObjectHandle } from '../../../shared/cdp/ids.ts'
+import { isJsonValue } from '../../../shared/json.ts'
+import type {
+  RuntimeDebuggerCallFrame,
+  RuntimeDebuggerEvent,
+  RuntimeDebuggerLocation,
+  RuntimeDebuggerScope,
+} from '../../../shared/cdp/index.ts'
+import type { DebuggerBackend } from '../../../shared/cdp/realm.ts'
+import type { HostInspectorSession } from './bridge.ts'
+import { optionalNativeField, requireNativeRecord } from './values.ts'
+import { HostNotificationChannel } from './bridge.ts'
+import type { HostRuntimeBackend } from './runtime.ts'
+import { hostScriptKey } from './scripts.ts'
+
+/** Native Host debugger adapted to common commands, Runtime values, and events. */
+export class HostDebuggerBackend implements DebuggerBackend {
+  private readonly events: HostNotificationChannel<RuntimeDebuggerEvent<RuntimeBackendObjectHandle>>
+
+  constructor(
+    private readonly target: HostInspectorSession,
+    private readonly runtime: HostRuntimeBackend,
+  ) {
+    this.events = new HostNotificationChannel(
+      target,
+      message => message.method === 'Debugger.resumed'
+        || message.method === 'Debugger.breakpointResolved'
+        || message.method === 'Debugger.paused',
+      async message => message.method === 'Debugger.resumed'
+        ? { type: 'resumed' }
+        : message.method === 'Debugger.breakpointResolved'
+          ? breakpointResolved(message.params)
+          : this.paused(message.params),
+    )
+  }
+
+  async enable(request: Parameters<DebuggerBackend['enable']>[0]): Promise<Readonly<Record<string, unknown>>> {
+    return this.target.request('Debugger.enable', {
+      ...optionalNativeField('maxScriptsCacheSize', request.maxScriptsCacheSize),
+    })
+  }
+
+  async disable(): Promise<Readonly<Record<string, unknown>>> {
+    return this.target.request('Debugger.disable', {})
+  }
+
+  async pause(): Promise<Readonly<Record<string, unknown>>> {
+    return this.target.request('Debugger.pause', {})
+  }
+
+  async resume(request: Parameters<DebuggerBackend['resume']>[0]): Promise<Readonly<Record<string, unknown>>> {
+    return this.target.request('Debugger.resume', {
+      ...optionalNativeField('terminateOnResume', request.terminateOnResume),
+    })
+  }
+
+  async evaluateOnCallFrame(
+    request: Parameters<DebuggerBackend['evaluateOnCallFrame']>[0],
+  ): ReturnType<DebuggerBackend['evaluateOnCallFrame']> {
+    return this.runtime.completion(await this.target.request('Debugger.evaluateOnCallFrame', {
+      callFrameId: request.callFrameId,
+      expression: request.expression,
+      ...optionalNativeField('objectGroup', request.objectGroup),
+      ...optionalNativeField('includeCommandLineAPI', request.includeCommandLineAPI),
+      ...optionalNativeField('silent', request.silent),
+      ...optionalNativeField('returnByValue', request.returnByValue),
+      ...optionalNativeField('generatePreview', request.generatePreview),
+      ...optionalNativeField('throwOnSideEffect', request.throwOnSideEffect),
+      ...optionalNativeField('timeout', request.timeoutMs),
+    }))
+  }
+
+  subscribe(listener: (event: RuntimeDebuggerEvent<RuntimeBackendObjectHandle>) => void): () => void {
+    return this.events.subscribe(listener)
+  }
+
+  /** Release the native notification subscription. */
+  close(): void {
+    this.events.close()
+  }
+
+  private async paused(
+    params: Readonly<Record<string, unknown>> | undefined,
+  ): Promise<RuntimeDebuggerEvent<RuntimeBackendObjectHandle> | undefined> {
+    if (!Array.isArray(params?.callFrames) || typeof params.reason !== 'string') return undefined
+    const callFrames = await Promise.all(params.callFrames.map(async frame => this.callFrame(frame)))
+    const data = params.data
+    const hitBreakpoints = params.hitBreakpoints
+    return {
+      type: 'paused',
+      callFrames,
+      reason: params.reason,
+      ...(data === undefined || !isJsonValue(data) ? {} : { data }),
+      ...(isStringArray(hitBreakpoints)
+        ? { hitBreakpoints: hitBreakpoints }
+        : {}),
+      ...(params.asyncStackTrace === undefined
+        ? {}
+        : { asyncStackTrace: this.runtime.stackTrace(params.asyncStackTrace) }),
+    }
+  }
+
+  private async callFrame(value: unknown): Promise<RuntimeDebuggerCallFrame<RuntimeBackendObjectHandle>> {
+    const record = requireNativeRecord(value, 'Host Debugger call frame')
+    if (typeof record.callFrameId !== 'string'
+      || typeof record.functionName !== 'string'
+      || typeof record.url !== 'string'
+      || !Array.isArray(record.scopeChain)) {
+      throw new Error('Host Debugger returned an invalid call frame')
+    }
+    return {
+      callFrameId: record.callFrameId,
+      functionName: record.functionName,
+      ...(record.functionLocation === undefined ? {} : { functionLocation: location(record.functionLocation) }),
+      location: location(record.location),
+      url: record.url,
+      scopeChain: await Promise.all(record.scopeChain.map(async scope => this.scope(scope))),
+      thisObject: await this.runtime.remoteObject(record.this),
+      ...(record.returnValue === undefined ? {} : { returnValue: await this.runtime.remoteObject(record.returnValue) }),
+    }
+  }
+
+  private async scope(value: unknown): Promise<RuntimeDebuggerScope<RuntimeBackendObjectHandle>> {
+    const record = requireNativeRecord(value, 'Host Debugger scope')
+    if (typeof record.type !== 'string') throw new Error('Host Debugger returned an invalid scope')
+    return {
+      type: record.type,
+      object: await this.runtime.remoteObject(record.object),
+      ...(typeof record.name === 'string' ? { name: record.name } : {}),
+      ...(record.startLocation === undefined ? {} : { startLocation: location(record.startLocation) }),
+      ...(record.endLocation === undefined ? {} : { endLocation: location(record.endLocation) }),
+    }
+  }
+
+}
+
+function breakpointResolved(
+  params: Readonly<Record<string, unknown>> | undefined,
+): Extract<RuntimeDebuggerEvent<RuntimeBackendObjectHandle>, { type: 'breakpoint-resolved' }> | undefined {
+  if (typeof params?.breakpointId !== 'string' || params.location === undefined) return undefined
+  return {
+    type: 'breakpoint-resolved',
+    breakpointId: params.breakpointId,
+    location: location(params.location),
+  }
+}
+
+function location(value: unknown): RuntimeDebuggerLocation {
+  const record = requireNativeRecord(value, 'Host Debugger location')
+  if (typeof record.scriptId !== 'string' || !Number.isSafeInteger(record.lineNumber)) {
+    throw new Error('Host Debugger returned an invalid location')
+  }
+  if (record.columnNumber !== undefined && !Number.isSafeInteger(record.columnNumber)) {
+    throw new Error('Host Debugger returned an invalid location column')
+  }
+  return {
+    scriptKey: hostScriptKey(record.scriptId),
+    lineNumber: record.lineNumber as number,
+    ...(record.columnNumber === undefined ? {} : { columnNumber: record.columnNumber as number }),
+  }
+}
+
+function isStringArray(value: unknown): value is string[] {
+  return Array.isArray(value) && value.every(item => typeof item === 'string')
+}

+ 67 - 0
packages/experimental/inspector/src/worker/realms/host/index.ts

@@ -0,0 +1,67 @@
+/** Host realm adapter backed by a connection-local Node inspector session. */
+
+import { randomUUID } from 'node:crypto'
+import { inspectorId } from '../../../shared/identity.ts'
+import { HostConsoleBackend } from './console.ts'
+import { HostDebuggerBackend } from './debugger.ts'
+import { HostRuntimeBackend } from './runtime.ts'
+import { HostSourceBackend } from './sources.ts'
+import { HostInspectorSession } from './bridge.ts'
+import type { InspectorRealm, InspectorRealmDescriptor, InspectorRealmSession } from '../../inspection/realm.ts'
+
+const HOST_RUNTIME_OPERATIONS = [
+  'evaluate',
+  'get-properties',
+  'call-function',
+  'await-promise',
+  'release-object',
+  'release-object-group',
+  'global-lexical-scope-names',
+] as const
+
+/** Host realm definition that opens one native V8 session per DevTools connection. */
+export class HostInspectorRealm implements InspectorRealm {
+  readonly descriptor: InspectorRealmDescriptor
+  readonly context: InspectorRealm['context'] = { kind: 'native' }
+  readonly capabilities: InspectorRealm['capabilities'] = {
+    runtime: HOST_RUNTIME_OPERATIONS,
+    console: ['events', 'exceptions', 'clear'],
+    sources: ['catalog', 'content', 'source-map'],
+    debugger: ['breakpoint', 'pause', 'resume', 'step', 'call-frame'],
+  }
+
+  constructor(private readonly label: string) {
+    this.descriptor = {
+      realmId: inspectorId<'InspectorRealmId'>(randomUUID(), 'realmId'),
+      sourceId: inspectorId<'InspectorSourceId'>('host-runtime', 'sourceId'),
+      generation: inspectorId<'InspectorSourceGeneration'>(randomUUID(), 'generation'),
+      kind: 'host',
+      label,
+    }
+  }
+
+  /** Open a native Host inspector session for one DevTools connection. */
+  openSession(): InspectorRealmSession {
+    const target = new HostInspectorSession(this.label)
+    const runtime = new HostRuntimeBackend(target)
+    const console = new HostConsoleBackend(target, runtime)
+    const sources = new HostSourceBackend(target)
+    const debug = new HostDebuggerBackend(target, runtime)
+    return {
+      descriptor: this.descriptor,
+      context: this.context,
+      runtime: { state: 'supported', backend: runtime },
+      console: { state: 'supported', backend: console },
+      sources: { state: 'supported', backend: sources },
+      debugger: { state: 'supported', backend: debug },
+      nativeDomains: { state: 'supported', backend: target },
+      close: () => {
+        sources.close()
+        debug.close()
+        console.close()
+        runtime.close()
+        target.close()
+      },
+    }
+  }
+}

+ 322 - 0
packages/experimental/inspector/src/worker/realms/host/runtime.ts

@@ -0,0 +1,322 @@
+/** RuntimeBackend implementation over one native Node inspector session. */
+
+import { inspectorId } from '../../../shared/identity.ts'
+import type { RuntimeBackendObjectHandle } from '../../../shared/cdp/ids.ts'
+import { isJsonValue } from '../../../shared/json.ts'
+import { IDENTIFY_REALM_OBJECT_FUNCTION } from '../../../shared/cordis/object-registry.ts'
+import { parseInspectorObjectReference, type InspectorObjectReference } from '../../../shared/cordis/object-reference.ts'
+import type {
+  RuntimeCallArgument,
+  RuntimeCompletion,
+  RuntimeExceptionDetails,
+  RuntimeInternalPropertyDescriptor,
+  RuntimePrivatePropertyDescriptor,
+  RuntimeProperties,
+  RuntimePropertyDescriptor,
+  RuntimeRemoteObject,
+  RuntimeRemoteObjectDescriptor,
+  RuntimeStackTrace,
+} from '../../../shared/cdp/index.ts'
+import type { HostInspectorNotification, HostInspectorSession } from './bridge.ts'
+import type { RuntimeBackend } from '../../../shared/cdp/realm.ts'
+import { isNativeRecord, optionalNativeField, requireNativeRecord } from './values.ts'
+import { hostScriptKey } from './scripts.ts'
+
+/** Host Runtime adapter preserving native V8 semantics behind common values. */
+export class HostRuntimeBackend implements RuntimeBackend {
+  private defaultContextId: number | undefined
+  private readonly unsubscribe: () => void
+
+  constructor(private readonly target: HostInspectorSession) {
+    this.unsubscribe = target.subscribe((message) => { this.observeContext(message) })
+  }
+
+  async enable(): Promise<void> {
+    await this.target.request('Runtime.enable', {})
+  }
+
+  async disable(): Promise<void> {
+    await this.target.request('Runtime.disable', {})
+    this.defaultContextId = undefined
+  }
+
+  async evaluate(request: Parameters<RuntimeBackend['evaluate']>[0]): ReturnType<RuntimeBackend['evaluate']> {
+    return this.completion(await this.target.request('Runtime.evaluate', {
+      expression: request.expression,
+      ...optionalNativeField('objectGroup', request.objectGroup),
+      ...optionalNativeField('includeCommandLineAPI', request.includeCommandLineAPI),
+      ...optionalNativeField('silent', request.silent),
+      ...optionalNativeField('returnByValue', request.returnByValue),
+      ...optionalNativeField('generatePreview', request.generatePreview),
+      ...optionalNativeField('userGesture', request.userGesture),
+      ...optionalNativeField('awaitPromise', request.awaitPromise),
+      ...optionalNativeField('disableBreaks', request.disableBreaks),
+      ...optionalNativeField('replMode', request.replMode),
+      ...optionalNativeField('allowUnsafeEvalBlockedByCSP', request.allowUnsafeEvalBlockedByCSP),
+      ...optionalNativeField('throwOnSideEffect', request.throwOnSideEffect),
+      ...optionalNativeField('serializationOptions', request.serializationOptions),
+      ...optionalNativeField('timeout', request.timeoutMs),
+    }))
+  }
+
+  async getProperties(request: Parameters<RuntimeBackend['getProperties']>[0]): ReturnType<RuntimeBackend['getProperties']> {
+    const response = await this.target.request('Runtime.getProperties', {
+      objectId: request.handle,
+      ...optionalNativeField('ownProperties', request.ownProperties),
+      ...optionalNativeField('accessorPropertiesOnly', request.accessorPropertiesOnly),
+      ...optionalNativeField('generatePreview', request.generatePreview),
+      ...optionalNativeField('nonIndexedPropertiesOnly', request.nonIndexedPropertiesOnly),
+    })
+    return this.properties(response)
+  }
+
+  async callFunction(request: Parameters<RuntimeBackend['callFunction']>[0]): ReturnType<RuntimeBackend['callFunction']> {
+    const receiver = request.receiver
+    const contextId = receiver === undefined ? this.defaultContextId : undefined
+    if (receiver === undefined && contextId === undefined) {
+      throw new Error('Host Runtime default execution context is unavailable')
+    }
+    return this.completion(await this.target.request('Runtime.callFunctionOn', {
+      functionDeclaration: request.functionDeclaration,
+      ...(receiver === undefined ? { executionContextId: contextId } : { objectId: receiver }),
+      ...(request.arguments === undefined ? {} : { arguments: request.arguments.map(toNativeArgument) }),
+      ...optionalNativeField('objectGroup', request.objectGroup),
+      ...optionalNativeField('silent', request.silent),
+      ...optionalNativeField('returnByValue', request.returnByValue),
+      ...optionalNativeField('generatePreview', request.generatePreview),
+      ...optionalNativeField('userGesture', request.userGesture),
+      ...optionalNativeField('awaitPromise', request.awaitPromise),
+      ...optionalNativeField('throwOnSideEffect', request.throwOnSideEffect),
+      ...optionalNativeField('serializationOptions', request.serializationOptions),
+    }))
+  }
+
+  async awaitPromise(request: Parameters<RuntimeBackend['awaitPromise']>[0]): ReturnType<RuntimeBackend['awaitPromise']> {
+    return this.completion(await this.target.request('Runtime.awaitPromise', {
+      promiseObjectId: request.promise,
+      ...optionalNativeField('returnByValue', request.returnByValue),
+      ...optionalNativeField('generatePreview', request.generatePreview),
+    }))
+  }
+
+  async globalLexicalScopeNames(): Promise<readonly string[]> {
+    const response = await this.target.request('Runtime.globalLexicalScopeNames', {
+      ...optionalNativeField('executionContextId', this.defaultContextId),
+    })
+    if (!Array.isArray(response.names) || !response.names.every(name => typeof name === 'string')) {
+      throw new Error('Host Runtime returned invalid lexical scope names')
+    }
+    return response.names
+  }
+
+  async releaseObject(handle: RuntimeBackendObjectHandle): Promise<void> {
+    await this.target.request('Runtime.releaseObject', { objectId: handle })
+  }
+
+  async releaseObjectGroup(group: string): Promise<void> {
+    await this.target.request('Runtime.releaseObjectGroup', { objectGroup: group })
+  }
+
+  /** Release the native-context observer owned by this backend. */
+  close(): void {
+    this.unsubscribe()
+  }
+
+  /**
+   * Convert a native Runtime completion returned through another Node domain.
+   * @param value - Native result and optional exception details.
+   * @returns The realm-neutral completion.
+   */
+  async completion(value: Readonly<Record<string, unknown>>): Promise<RuntimeCompletion<RuntimeBackendObjectHandle>> {
+    return {
+      result: await this.remoteObject(value.result),
+      ...(value.exceptionDetails === undefined
+        ? {}
+        : { exceptionDetails: await this.exceptionDetails(value.exceptionDetails) }),
+    }
+  }
+
+  private async properties(value: Readonly<Record<string, unknown>>): Promise<RuntimeProperties<RuntimeBackendObjectHandle>> {
+    if (!Array.isArray(value.result)) throw new Error('Host Runtime returned invalid properties')
+    return {
+      properties: await Promise.all(value.result.map(item => this.property(item))),
+      ...(value.internalProperties === undefined
+        ? {}
+        : { internalProperties: await this.internalProperties(value.internalProperties) }),
+      ...(value.privateProperties === undefined
+        ? {}
+        : { privateProperties: await this.privateProperties(value.privateProperties) }),
+      ...(value.exceptionDetails === undefined
+        ? {}
+        : { exceptionDetails: await this.exceptionDetails(value.exceptionDetails) }),
+    }
+  }
+
+  private async property(value: unknown): Promise<RuntimePropertyDescriptor<RuntimeBackendObjectHandle>> {
+    const record = requireNativeRecord(value, 'Host Runtime property descriptor')
+    if (typeof record.name !== 'string'
+      || typeof record.configurable !== 'boolean'
+      || typeof record.enumerable !== 'boolean') {
+      throw new Error('Host Runtime returned invalid property descriptor')
+    }
+    return {
+      ...record,
+      name: record.name,
+      configurable: record.configurable,
+      enumerable: record.enumerable,
+      ...(record.value === undefined ? {} : { value: await this.remoteObject(record.value) }),
+      ...(record.get === undefined ? {} : { get: await this.remoteObject(record.get) }),
+      ...(record.set === undefined ? {} : { set: await this.remoteObject(record.set) }),
+      ...(record.symbol === undefined ? {} : { symbol: await this.remoteObject(record.symbol) }),
+    }
+  }
+
+  private async internalProperties(value: unknown): Promise<RuntimeInternalPropertyDescriptor<RuntimeBackendObjectHandle>[]> {
+    if (!Array.isArray(value)) throw new Error('Host Runtime returned invalid internal properties')
+    return Promise.all(value.map(async (item) => {
+      const record = requireNativeRecord(item, 'Host Runtime internal property')
+      if (typeof record.name !== 'string') throw new Error('Host Runtime returned invalid internal property')
+      return {
+        name: record.name,
+        ...(record.value === undefined ? {} : { value: await this.remoteObject(record.value) }),
+      }
+    }))
+  }
+
+  private async privateProperties(value: unknown): Promise<RuntimePrivatePropertyDescriptor<RuntimeBackendObjectHandle>[]> {
+    if (!Array.isArray(value)) throw new Error('Host Runtime returned invalid private properties')
+    return Promise.all(value.map(async (item) => {
+      const record = requireNativeRecord(item, 'Host Runtime private property')
+      if (typeof record.name !== 'string') throw new Error('Host Runtime returned invalid private property')
+      return {
+        name: record.name,
+        ...(record.value === undefined ? {} : { value: await this.remoteObject(record.value) }),
+        ...(record.get === undefined ? {} : { get: await this.remoteObject(record.get) }),
+        ...(record.set === undefined ? {} : { set: await this.remoteObject(record.set) }),
+      }
+    }))
+  }
+
+  /**
+   * Convert native exception details to the common Runtime model.
+   * @param value - Native `Runtime.ExceptionDetails` fields.
+   * @returns Exception details with normalized object references.
+   */
+  async exceptionDetails(value: unknown): Promise<RuntimeExceptionDetails<RuntimeBackendObjectHandle>> {
+    const record = requireNativeRecord(value, 'Host Runtime exception details')
+    if (typeof record.text !== 'string'
+      || !Number.isSafeInteger(record.lineNumber)
+      || !Number.isSafeInteger(record.columnNumber)) {
+      throw new Error('Host Runtime returned invalid exception details')
+    }
+    return {
+      ...record,
+      text: record.text,
+      lineNumber: record.lineNumber as number,
+      columnNumber: record.columnNumber as number,
+      ...(record.stackTrace === undefined ? {} : { stackTrace: this.stackTrace(record.stackTrace) }),
+      ...(record.exception === undefined ? {} : { exception: await this.remoteObject(record.exception) }),
+    }
+  }
+
+  /**
+   * Convert one native V8 RemoteObject to the common Runtime model.
+   * @param value - Native `Runtime.RemoteObject` fields.
+   * @returns Descriptor, backend handle, and optional Cordis identity.
+   */
+  async remoteObject(value: unknown): Promise<RuntimeRemoteObject<RuntimeBackendObjectHandle>> {
+    const record = requireNativeRecord(value, 'Host Runtime RemoteObject')
+    if (typeof record.type !== 'string') throw new Error('Host Runtime returned an invalid RemoteObject')
+    const descriptor = { ...record }
+    Reflect.deleteProperty(descriptor, 'objectId')
+    if (!isJsonValue(descriptor)) throw new Error('Host Runtime returned a non-JSON RemoteObject descriptor')
+    const objectId = typeof record.objectId === 'string' ? record.objectId : undefined
+    const semanticReference = objectId === undefined ? undefined : await this.identifyObject(objectId)
+    return {
+      descriptor: descriptor as unknown as RuntimeRemoteObjectDescriptor,
+      ...(objectId === undefined ? {} : { object: { handle: backendHandle(objectId) } }),
+      ...(semanticReference === undefined ? {} : { semanticReference }),
+    }
+  }
+
+  /**
+   * Convert a native stack trace while retaining native script identities.
+   * @param value - Native `Runtime.StackTrace` fields.
+   * @returns Realm-neutral stack frames.
+   */
+  stackTrace(value: unknown): RuntimeStackTrace {
+    const record = requireNativeRecord(value, 'Host Runtime stack trace')
+    if (!Array.isArray(record.callFrames)) throw new Error('Host Runtime returned an invalid stack trace')
+    return {
+      ...(typeof record.description === 'string' ? { description: record.description } : {}),
+      callFrames: record.callFrames.map((frame) => {
+        const fields = requireNativeRecord(frame, 'Host Runtime call frame')
+        if (typeof fields.functionName !== 'string'
+          || typeof fields.url !== 'string'
+          || !Number.isSafeInteger(fields.lineNumber)
+          || !Number.isSafeInteger(fields.columnNumber)) {
+          throw new Error('Host Runtime returned an invalid call frame')
+        }
+        return {
+          functionName: fields.functionName,
+          ...(typeof fields.scriptId === 'string'
+            ? { scriptKey: hostScriptKey(fields.scriptId) }
+            : {}),
+          url: fields.url,
+          lineNumber: fields.lineNumber as number,
+          columnNumber: fields.columnNumber as number,
+        }
+      }),
+      ...(record.parent === undefined ? {} : { parent: this.stackTrace(record.parent) }),
+    }
+  }
+
+  private observeContext(message: HostInspectorNotification): void {
+    if (message.method === 'Runtime.executionContextCreated') {
+      const context = isNativeRecord(message.params?.context) ? message.params.context : undefined
+      const auxData = isNativeRecord(context?.auxData) ? context.auxData : undefined
+      if (context !== undefined && auxData?.isDefault === true && Number.isSafeInteger(context.id)) {
+        this.defaultContextId = context.id as number
+      }
+      return
+    }
+    if (message.method !== 'Runtime.executionContextDestroyed') return
+    if (message.params?.executionContextId === this.defaultContextId) this.defaultContextId = undefined
+  }
+
+  private async identifyObject(objectId: string): Promise<InspectorObjectReference | undefined> {
+    try {
+      const response = await this.target.request('Runtime.callFunctionOn', {
+        objectId,
+        functionDeclaration: IDENTIFY_REALM_OBJECT_FUNCTION,
+        returnByValue: true,
+        silent: true,
+      })
+      if (response.exceptionDetails !== undefined || !isNativeRecord(response.result)) return undefined
+      return response.result.value === undefined
+        ? undefined
+        : parseInspectorObjectReference(response.result.value)
+    } catch {
+      // Semantic recognition is optional metadata; preserve the Runtime value on failure.
+      return undefined
+    }
+  }
+}
+
+function toNativeArgument(value: RuntimeCallArgument<RuntimeBackendObjectHandle>): Readonly<Record<string, unknown>> {
+  switch (value.kind) {
+    case 'value': return { value: value.value }
+    case 'unserializable': return { unserializableValue: value.value }
+    case 'object': return { objectId: value.handle }
+    case 'undefined': return {}
+    default: return assertNever(value)
+  }
+}
+
+function backendHandle(value: string): RuntimeBackendObjectHandle {
+  return inspectorId<'RuntimeBackendObjectHandle'>(value, 'Runtime backend object handle')
+}
+
+function assertNever(value: never): never {
+  throw new Error(`Unexpected Runtime call argument: ${JSON.stringify(value)}`)
+}

+ 13 - 0
packages/experimental/inspector/src/worker/realms/host/scripts.ts

@@ -0,0 +1,13 @@
+/** Host-native script identity conversion for normalized source and debugger values. */
+
+import { inspectorId } from '../../../shared/identity.ts'
+import type { RuntimeScriptKey } from '../../../shared/cdp/ids.ts'
+
+/**
+ * Convert a Node inspector script id into the realm backend identity namespace.
+ * @param value - Native Node inspector script id.
+ * @returns The corresponding normalized script key.
+ */
+export function hostScriptKey(value: string): RuntimeScriptKey {
+  return inspectorId<'RuntimeScriptKey'>(value, 'scriptKey')
+}

+ 99 - 0
packages/experimental/inspector/src/worker/realms/host/sources.ts

@@ -0,0 +1,99 @@
+/** SourceBackend implementation over native Node Debugger notifications. */
+
+import type { RuntimeScriptKey } from '../../../shared/cdp/ids.ts'
+import type { RuntimeScript } from '../../../shared/cdp/index.ts'
+import type { HostInspectorNotification, HostInspectorSession } from './bridge.ts'
+import type { SourceBackend } from '../../../shared/cdp/realm.ts'
+import { hostScriptKey } from './scripts.ts'
+
+interface HostScript {
+  readonly descriptor: RuntimeScript
+  readonly nativeId: string
+}
+
+/** Maintains one connection-local catalog of scripts reported by Node's inspector. */
+export class HostSourceBackend implements SourceBackend {
+  private readonly scripts = new Map<RuntimeScriptKey, HostScript>()
+  private readonly listeners = new Set<(script: RuntimeScript) => void>()
+  private readonly unsubscribe: () => void
+
+  constructor(
+    private readonly target: HostInspectorSession,
+  ) {
+    this.unsubscribe = target.subscribe((message) => { this.receive(message) })
+  }
+
+  listScripts(): Promise<readonly RuntimeScript[]> {
+    return Promise.resolve([...this.scripts.values()].map(script => script.descriptor))
+  }
+
+  async getScriptSource(scriptKey: RuntimeScriptKey): Promise<string> {
+    const script = this.scripts.get(scriptKey)
+    if (script === undefined) throw new Error('Host script is no longer available')
+    const result = await this.target.request('Debugger.getScriptSource', { scriptId: script.nativeId })
+    if (typeof result.scriptSource !== 'string') throw new Error('Host Debugger returned no script source')
+    return result.scriptSource
+  }
+
+  getSourceMap(_scriptKey: RuntimeScriptKey): Promise<string | undefined> {
+    return Promise.resolve(undefined)
+  }
+
+  /**
+   * Subscribe to scripts discovered after the initial catalog read.
+   * @param listener - Consumer of newly discovered scripts.
+   * @returns A disposer removing the consumer.
+   */
+  subscribe(listener: (script: RuntimeScript) => void): () => void {
+    this.listeners.add(listener)
+    return () => { this.listeners.delete(listener) }
+  }
+
+  /** Release the native notification subscription and cached catalog. */
+  close(): void {
+    this.unsubscribe()
+    this.scripts.clear()
+    this.listeners.clear()
+  }
+
+  private receive(message: HostInspectorNotification): void {
+    if (message.method !== 'Debugger.scriptParsed') return
+    const params = message.params
+    if (params === undefined
+      || typeof params.scriptId !== 'string'
+      || typeof params.url !== 'string'
+      || !isInteger(params.startLine)
+      || !isInteger(params.startColumn)
+      || !isInteger(params.endLine)
+      || !isInteger(params.endColumn)) return
+    const scriptKey = hostScriptKey(params.scriptId)
+    const descriptor: RuntimeScript = {
+      scriptKey,
+      url: params.url,
+      hash: typeof params.hash === 'string' ? params.hash : '',
+      ...(typeof params.buildId === 'string' ? { buildId: params.buildId } : {}),
+      startLine: params.startLine,
+      startColumn: params.startColumn,
+      endLine: params.endLine,
+      endColumn: params.endColumn,
+      ...(typeof params.sourceMapURL === 'string' && params.sourceMapURL.length > 0
+        ? { sourceMapUrl: params.sourceMapURL }
+        : {}),
+      ...(isInteger(params.executionContextId) ? { executionContextId: params.executionContextId } : {}),
+      ...(typeof params.isModule === 'boolean' ? { isModule: params.isModule } : {}),
+      ...(isInteger(params.length) ? { length: params.length } : {}),
+    }
+    this.scripts.set(scriptKey, { descriptor, nativeId: params.scriptId })
+    for (const listener of [...this.listeners]) {
+      try {
+        listener(descriptor)
+      } catch {
+        // One source consumer cannot prevent delivery to sibling consumers.
+      }
+    }
+  }
+}
+
+function isInteger(value: unknown): value is number {
+  return Number.isSafeInteger(value) && (value as number) >= 0
+}

+ 34 - 0
packages/experimental/inspector/src/worker/realms/host/values.ts

@@ -0,0 +1,34 @@
+/** Small validators for values returned by Node's native Inspector protocol. */
+
+/**
+ * Test whether a native protocol value is a non-array object record.
+ * @param value - Native protocol value.
+ * @returns Whether the value can be read as named fields.
+ */
+export function isNativeRecord(value: unknown): value is Readonly<Record<string, unknown>> {
+  return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+/**
+ * Require a native protocol object record.
+ * @param value - Native protocol value.
+ * @param label - Subject named in the validation error.
+ * @returns The validated object record.
+ */
+export function requireNativeRecord(value: unknown, label: string): Readonly<Record<string, unknown>> {
+  if (!isNativeRecord(value)) throw new Error(`${label} must be an object`)
+  return value
+}
+
+/**
+ * Include an optional field only when the native request supplied a value.
+ * @param key - Native protocol field name.
+ * @param value - Optional field value.
+ * @returns An empty record or the requested field.
+ */
+export function optionalNativeField<Key extends string, Value>(
+  key: Key,
+  value: Value | undefined,
+): Partial<Record<Key, Value>> {
+  return value === undefined ? {} : { [key]: value } as Partial<Record<Key, Value>>
+}

+ 111 - 0
packages/experimental/inspector/src/worker/server.ts

@@ -0,0 +1,111 @@
+/** Inspector Worker assembly over one Host source port and one loopback endpoint. */
+
+import type { MessagePort } from 'node:worker_threads'
+import type { InspectorWorkerBoot } from '../shared/bridge/messages/control.ts'
+import type { WorkerToSourceFrame } from '../shared/bridge/messages/observation.ts'
+import { createCordisRuntimeTreeReader } from '../shared/cordis/reader.ts'
+import { NetworkDomain } from './cdp/domains/network/session.ts'
+import { NetworkStore } from './inspection/network-store.ts'
+import { CordisDomBackend } from './cdp/domains/dom/index.ts'
+import { ClientRuntimeRouter } from './bridge/runtime-rpc.ts'
+import { ClientSourceRouter } from './bridge/source-rpc.ts'
+import { CordisTreeStore } from './inspection/cordis-store.ts'
+import { InspectorEndpoint, type InspectorEndpointInfo } from './bridge/endpoint.ts'
+import { InspectorQueryRouter } from './inspection/query-router.ts'
+import { InspectorRealmRegistry } from './inspection/realm-store.ts'
+import { HostInspectorRealm } from './realms/host/index.ts'
+import { InspectorSourceRegistry, type SourceConnection } from './bridge/hub.ts'
+
+/** Live Worker runtime. */
+export interface InspectorWorkerRuntime {
+  readonly endpoint: InspectorEndpointInfo
+  close(): Promise<void>
+}
+
+/**
+ * Assemble and start the Worker-owned source registry, Runtime router, Network domain, and endpoints.
+ * @param boot - Validated Worker configuration and transferred Host source port.
+ * @returns The listening endpoint and quiescent shutdown owner.
+ */
+export async function startInspectorWorker(boot: InspectorWorkerBoot<MessagePort>): Promise<InspectorWorkerRuntime> {
+  const networkStore = new NetworkStore({
+    maxRetainedRequests: boot.config.maxRetainedRequests,
+    maxJournalBytes: boot.config.maxJournalBytes,
+  })
+  const network = new NetworkDomain(networkStore)
+  const cordisTrees = new CordisTreeStore({
+    maxNodes: boot.config.maxCordisNodes,
+    maxDisconnectedTrees: boot.config.maxDisconnectedCordisTrees,
+  })
+  const sources = new InspectorSourceRegistry(
+    [networkStore, cordisTrees],
+    boot.config.maxSourceFrameBytes,
+    boot.config.maxSourceRecordsPerFrame,
+  )
+  const clientRuntime = new ClientRuntimeRouter(sources, boot.config.clientRuntimeTimeoutMs)
+  const clientSources = new ClientSourceRouter(
+    sources,
+    boot.config.clientRuntimeTimeoutMs,
+    boot.config.maxClientSourceBytes,
+    boot.config.maxSourceFrameBytes,
+  )
+  const realms = new InspectorRealmRegistry(new HostInspectorRealm('Host'), clientRuntime, clientSources)
+  const cordisDom = new CordisDomBackend(cordisTrees)
+  const cordisReader = createCordisRuntimeTreeReader(() => cordisTrees.readTree())
+  const queries = new InspectorQueryRouter(cordisReader, boot.config.maxSourceFrameBytes)
+  const unsubscribeQueries = sources.subscribeEvents((event) => {
+    if (event.type === 'closed') queries.disconnect(event.source)
+  })
+  const hostQueries = queries.open({
+    send: (frame) => { boot.hostSourcePort.postMessage(frame) },
+    close: () => { boot.hostSourcePort.close() },
+  })
+  const hostConnection: SourceConnection = {
+    kind: 'host',
+    send: (frame: WorkerToSourceFrame) => {
+      boot.hostSourcePort.postMessage(frame)
+      if (frame.t === 'source/accepted') hostQueries.accept(frame.sourceId, frame.generation)
+    },
+    close: () => { boot.hostSourcePort.close() },
+  }
+  boot.hostSourcePort.on('message', (value: unknown) => {
+    if (!hostQueries.receive(value)) sources.receive(hostConnection, value)
+  })
+  boot.hostSourcePort.on('close', () => {
+    hostQueries.close()
+    sources.disconnect(hostConnection, 'Host source disconnected')
+  })
+  boot.hostSourcePort.start()
+
+  const endpointOwner = new InspectorEndpoint(
+    boot.config,
+    sources,
+    network,
+    realms,
+    cordisDom,
+    cordisReader,
+    queries,
+  )
+  const endpoint = await endpointOwner.start()
+  let closed: Promise<void> | undefined
+  return {
+    endpoint,
+    close(): Promise<void> {
+      closed ??= (async () => {
+        await endpointOwner.close()
+        network.close()
+        networkStore.dispose()
+        cordisDom.close()
+        realms.close()
+        clientRuntime.close()
+        clientSources.close()
+        hostQueries.close()
+        sources.close()
+        unsubscribeQueries()
+        queries.close()
+        boot.hostSourcePort.close()
+      })()
+      return closed
+    },
+  }
+}

+ 56 - 0
packages/experimental/inspector/tests/built-lib.e2e.ts

@@ -0,0 +1,56 @@
+import { existsSync } from 'node:fs'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { execa } from 'execa'
+import { describe, expect, it } from 'vitest'
+
+const packageDirectory = fileURLToPath(new URL('..', import.meta.url))
+const built = [
+  'lib/index.js',
+  'lib/worker.js',
+  'node_modules/@deepseek-ai/schemastery/lib/index.mjs',
+].every(file => existsSync(join(packageDirectory, file)))
+
+describe.skipIf(!built)('experimental Inspector built artifact', () => {
+  it('starts its sibling Worker and evaluates the Host through plain Node', async () => {
+    const script = `
+      const { startInspector } = await import('@deepseek-ai/dsh-experimental-inspector')
+      const { default: WebSocket } = await import('ws')
+      globalThis.__builtInspectorProbe = 42
+      const inspector = await startInspector({ port: 0, captureFetch: false })
+      const socket = new WebSocket(inspector.endpoint.webSocketDebuggerUrl)
+      await new Promise((resolve, reject) => {
+        socket.once('open', resolve)
+        socket.once('error', reject)
+      })
+      const response = new Promise((resolve, reject) => {
+        const timer = setTimeout(() => reject(new Error('CDP response timeout')), 5000)
+        socket.on('message', data => {
+          const message = JSON.parse(Buffer.from(data).toString('utf8'))
+          if (message.id !== 1) return
+          clearTimeout(timer)
+          resolve(message)
+        })
+      })
+      socket.send(JSON.stringify({
+        id: 1,
+        method: 'Runtime.evaluate',
+        params: { expression: 'globalThis.__builtInspectorProbe', returnByValue: true },
+      }))
+      const message = await response
+      socket.close()
+      await inspector.close()
+      console.log(JSON.stringify(message.result.result))
+    `
+    const result = await execa(process.execPath, ['--input-type=module', '-e', script], {
+      cwd: packageDirectory,
+      stdin: 'ignore',
+      timeout: 20_000,
+      killSignal: 'SIGKILL',
+      reject: false,
+    })
+
+    expect(result.exitCode, `stderr:\n${result.stderr}`).toBe(0)
+    expect(JSON.parse(result.stdout.trim()) as unknown).toEqual({ type: 'number', value: 42, description: '42' })
+  })
+})

+ 257 - 0
packages/experimental/inspector/tests/client-browser.e2e.ts

@@ -0,0 +1,257 @@
+import { existsSync } from 'node:fs'
+import { readFile } from 'node:fs/promises'
+import { createServer, type Server } from 'node:http'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { chromium, type Browser, type Page } from 'playwright'
+import WebSocket, { type RawData } from 'ws'
+import { afterEach, describe, expect, it } from 'vitest'
+import { startInspector, type InspectorHandle } from '../src/host/bridge/controller.ts'
+
+const packageDirectory = fileURLToPath(new URL('..', import.meta.url))
+const clientBundlePath = join(packageDirectory, 'lib/client.js')
+const clientSourceMapPath = join(packageDirectory, 'lib/client.js.map')
+const built = existsSync(clientBundlePath) && existsSync(clientSourceMapPath)
+
+interface CdpMessage {
+  readonly id?: number
+  readonly method?: string
+  readonly params?: Record<string, unknown>
+  readonly result?: Record<string, unknown>
+  readonly error?: { message: string }
+}
+
+class BrowserTestCdpClient {
+  private nextId = 0
+  private readonly pending = new Map<number, (message: CdpMessage) => void>()
+  private readonly events: CdpMessage[] = []
+  private readonly waiters = new Set<() => void>()
+
+  private constructor(private readonly socket: WebSocket) {
+    socket.on('message', (data) => {
+      const message = JSON.parse(rawText(data)) as CdpMessage
+      if (message.id !== undefined) {
+        this.pending.get(message.id)?.(message)
+        return
+      }
+      this.events.push(message)
+      for (const waiter of [...this.waiters]) waiter()
+    })
+  }
+
+  static async connect(url: string): Promise<BrowserTestCdpClient> {
+    const socket = new WebSocket(url)
+    await new Promise<void>((resolve, reject) => {
+      socket.once('open', () => { resolve() })
+      socket.once('error', reject)
+    })
+    return new BrowserTestCdpClient(socket)
+  }
+
+  call(method: string, params: Record<string, unknown> = {}): Promise<CdpMessage> {
+    const id = ++this.nextId
+    return new Promise((resolve, reject) => {
+      const timer = setTimeout(() => {
+        this.pending.delete(id)
+        reject(new Error(`CDP call timed out: ${method}`))
+      }, 5_000)
+      this.pending.set(id, (message) => {
+        clearTimeout(timer)
+        this.pending.delete(id)
+        resolve(message)
+      })
+      this.socket.send(JSON.stringify({ id, method, params }))
+    })
+  }
+
+  waitForEvent(method: string, predicate: (message: CdpMessage) => boolean): Promise<CdpMessage> {
+    const existing = this.events.find(event => event.method === method && predicate(event))
+    if (existing !== undefined) return Promise.resolve(existing)
+    return new Promise((resolve, reject) => {
+      const timer = setTimeout(() => {
+        this.waiters.delete(check)
+        reject(new Error(`CDP event timed out: ${method}`))
+      }, 5_000)
+      const check = (): void => {
+        const event = this.events.find(candidate => candidate.method === method && predicate(candidate))
+        if (event === undefined) return
+        clearTimeout(timer)
+        this.waiters.delete(check)
+        resolve(event)
+      }
+      this.waiters.add(check)
+    })
+  }
+
+  async close(): Promise<void> {
+    if (this.socket.readyState === WebSocket.CLOSED) return
+    const closed = new Promise<void>((resolve) => { this.socket.once('close', () => { resolve() }) })
+    this.socket.close()
+    await closed
+  }
+}
+
+describe.skipIf(!built)('Inspector built Client in Chromium', () => {
+  let inspector: InspectorHandle | undefined
+  let server: Server | undefined
+  let browser: Browser | undefined
+  let page: Page | undefined
+  let cdp: BrowserTestCdpClient | undefined
+
+  afterEach(async () => {
+    await page?.evaluate(() => {
+      const state = Reflect.get(globalThis, '__INSPECTOR_BROWSER_TEST__') as { dispose?: () => void } | undefined
+      state?.dispose?.()
+    }).catch(() => {})
+    await cdp?.close()
+    await browser?.close()
+    await inspector?.close()
+    if (server !== undefined) await new Promise<void>((resolve) => { server!.close(() => { resolve() }) })
+    page = undefined
+    cdp = undefined
+    browser = undefined
+    inspector = undefined
+    server = undefined
+  })
+
+  it('forwards Console values and exposes the built bundle as read-only source', async () => {
+    inspector = await startInspector({ port: 0, captureFetch: false, maxClientSourceBytes: 1_000_000 })
+    const bundle = await readFile(clientBundlePath)
+    const sourceMap = await readFile(clientSourceMapPath)
+    server = createServer((request, response) => {
+      const url = new URL(request.url ?? '/', 'http://127.0.0.1')
+      if (url.pathname === '/client.js') {
+        response.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' })
+        response.end(bundle)
+        return
+      }
+      if (url.pathname === '/client.js.map') {
+        response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
+        response.end(sourceMap)
+        return
+      }
+      response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
+      response.end(browserFixture(inspector!.endpoint.client))
+    })
+    await new Promise<void>((resolve) => { server!.listen(0, '127.0.0.1', () => { resolve() }) })
+    const port = (server.address() as import('node:net').AddressInfo).port
+
+    browser = await chromium.launch()
+    page = await browser.newPage()
+    await page.goto(`http://127.0.0.1:${String(port)}/`)
+    await page.waitForFunction(() => Reflect.get(globalThis, '__INSPECTOR_BROWSER_TEST__') !== undefined)
+
+    cdp = await BrowserTestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
+    await cdp.call('Runtime.enable')
+    const contextEvent = await cdp.waitForEvent('Runtime.executionContextCreated', (event) => {
+      const context = event.params?.context as Record<string, unknown> | undefined
+      return String(context?.name).startsWith('Client —')
+    })
+    const contextId = (contextEvent.params?.context as Record<string, unknown>).id
+    expect(contextId).toBeTypeOf('number')
+
+    await page.evaluate(() => {
+      const value = { browser: true, nested: { ready: true } }
+      Reflect.set(globalThis, '__inspectorBrowserValue', value)
+      console.log(value, 'browser-client-console')
+      setTimeout(() => { throw new Error('browser-client-exception') }, 0)
+    })
+    const consoleEvent = await cdp.waitForEvent('Runtime.consoleAPICalled', event =>
+      event.params?.executionContextId === contextId && hasArgument(event, 'browser-client-console'))
+    const args = consoleEvent.params?.args
+    if (!Array.isArray(args)) throw new Error('Client Console event has no arguments')
+    expect((consoleEvent.params?.stackTrace as { callFrames?: unknown[] } | undefined)?.callFrames?.length)
+      .toBeGreaterThan(0)
+    const objectId = asRecord(args[0]).objectId
+    expect(String(objectId)).toMatch(/^runtime:/u)
+    const properties = await cdp.call('Runtime.getProperties', { objectId, ownProperties: true })
+    expect(propertyValue(properties, 'browser')).toBe(true)
+    const exception = await cdp.waitForEvent('Runtime.exceptionThrown', (event) => {
+      const details = event.params?.exceptionDetails as Record<string, unknown> | undefined
+      return details !== undefined
+        && details.executionContextId === contextId
+        && String((details.exception as Record<string, unknown> | undefined)?.description).includes('browser-client-exception')
+    })
+    const exceptionDetails = exception.params?.exceptionDetails as Record<string, unknown>
+    expect((exceptionDetails.stackTrace as { callFrames?: unknown[] } | undefined)?.callFrames?.length)
+      .toBeGreaterThan(0)
+
+    const enabled = await cdp.call('Debugger.enable')
+    expect(enabled.error).toBeUndefined()
+    expect(enabled.result?.debuggerId).toBeTypeOf('string')
+    const script = await cdp.waitForEvent('Debugger.scriptParsed', event =>
+      String(event.params?.url).includes('/client.js?rev=browser-test'))
+    expect(script.params).toMatchObject({ executionContextId: contextId, buildId: '' })
+    const scriptId = script.params?.scriptId
+    const content = await cdp.call('Debugger.getScriptSource', { scriptId })
+    expect(String(content.result?.scriptSource)).toContain('ClientInspectorSource')
+    expect((await cdp.call('Debugger.setBreakpointByUrl', {
+      url: script.params?.url,
+      lineNumber: 0,
+    })).error?.message).toContain('Client native debugging is unavailable')
+  }, 20_000)
+})
+
+function browserFixture(bootstrap: InspectorHandle['endpoint']['client']): string {
+  const boot = {
+    rev: 'browser-test',
+    entries: [{
+      id: '@deepseek-ai/dsh-experimental-inspector',
+      url: '/client.js?rev=browser-test',
+      rev: 'browser-test',
+    }],
+  }
+  return `<!doctype html>
+<title>Inspector Browser Client</title>
+<script>
+globalThis.__DSH_INSPECTOR__ = ${JSON.stringify(bootstrap)};
+globalThis.__DSH_BOOT__ = ${JSON.stringify(boot)};
+globalThis.__ModuleLoader__ = { load(registration) { globalThis.__INSPECTOR_REGISTRATION__ = registration; } };
+</script>
+<script src="/client.js?rev=browser-test"></script>
+<script>
+const registration = globalThis.__INSPECTOR_REGISTRATION__;
+const disposers = [];
+const root = {
+  __inspectorContext: true,
+  registry: new Map(),
+  events: { _hooks: {} },
+  effect(callback) { const dispose = callback(); disposers.push(dispose); return dispose; },
+  on() { return () => {}; },
+  provide(name, value) { this[name] = value; return () => { delete this[name]; }; },
+};
+root.root = root;
+const cordis = { Context: { is(value) { return value?.__inspectorContext === true; } } };
+const plugin = registration.factory(specifier => {
+  if (specifier === '@deepseek-ai/cordis') return cordis;
+  throw new Error('Unexpected Client bundle dependency ' + specifier);
+});
+plugin.apply(root);
+globalThis.__INSPECTOR_BROWSER_TEST__ = {
+  dispose() { for (const dispose of disposers.reverse()) dispose(); },
+};
+</script>`
+}
+
+function hasArgument(event: CdpMessage, value: unknown): boolean {
+  const args = event.params?.args
+  return Array.isArray(args) && args.some(argument => asRecord(argument).value === value)
+}
+
+function propertyValue(response: CdpMessage, name: string): unknown {
+  const result = response.result?.result
+  if (!Array.isArray(result)) throw new Error('Runtime.getProperties returned no property list')
+  const property = result.map(asRecord).find(candidate => candidate.name === name)
+  return asRecord(property?.value).value
+}
+
+function asRecord(value: unknown): Readonly<Record<string, unknown>> {
+  if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('expected a record')
+  return value as Readonly<Record<string, unknown>>
+}
+
+function rawText(data: RawData): string {
+  if (Array.isArray(data)) return Buffer.concat(data).toString('utf8')
+  if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8')
+  return Buffer.from(data).toString('utf8')
+}

+ 30 - 0
packages/experimental/inspector/tests/client-stack.host.spec.ts

@@ -0,0 +1,30 @@
+import { describe, expect, it } from 'vitest'
+import { inspectorId } from '../src/shared/bridge/ids.ts'
+import { ClientScriptIdentity } from '../src/worker/realms/client/scripts.ts'
+import { clientConsoleEvent } from '../src/worker/realms/client/values.ts'
+
+describe('Worker Client stack projection', () => {
+  it('uses one script key in Client Console and Sources projections', () => {
+    const localKey = inspectorId<'RuntimeScriptKey'>('client-bundle', 'scriptKey')
+    const scripts = new ClientScriptIdentity(-7)
+    const projected = clientConsoleEvent({
+      type: 'console-api',
+      event: {
+        type: 'log',
+        arguments: [],
+        timestamp: 1,
+        stackTrace: {
+          callFrames: [{
+            functionName: 'apply',
+            scriptKey: localKey,
+            url: 'http://client.test/client.js',
+            lineNumber: 1,
+            columnNumber: 2,
+          }],
+        },
+      },
+    }, scriptKey => scripts.toRuntime(scriptKey))
+    if (projected.type !== 'console-api') throw new Error('unexpected exception event')
+    expect(projected.event.stackTrace?.callFrames[0]?.scriptKey).toBe(scripts.toRuntime(localKey))
+  })
+})

+ 198 - 0
packages/experimental/inspector/tests/debugger.e2e.ts

@@ -0,0 +1,198 @@
+import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
+import { fileURLToPath } from 'node:url'
+import WebSocket, { type RawData } from 'ws'
+import { afterEach, describe, expect, it } from 'vitest'
+import { isPlainObject } from '../src/shared/json.ts'
+
+interface CdpMessage {
+  readonly id?: number
+  readonly method?: string
+  readonly params?: Record<string, unknown>
+  readonly result?: Record<string, unknown>
+  readonly error?: { message: string }
+}
+
+class CdpClient {
+  private nextId = 0
+  private readonly pending = new Map<number, (message: CdpMessage) => void>()
+  private readonly events: CdpMessage[] = []
+  private readonly eventWaiters = new Set<() => void>()
+
+  private constructor(private readonly socket: WebSocket) {
+    socket.on('message', (data) => {
+      const message = JSON.parse(rawText(data)) as CdpMessage
+      if (message.id !== undefined) this.pending.get(message.id)?.(message)
+      else {
+        this.events.push(message)
+        for (const wake of [...this.eventWaiters]) wake()
+      }
+    })
+  }
+
+  static async connect(url: string): Promise<CdpClient> {
+    const socket = new WebSocket(url)
+    await new Promise<void>((resolve, reject) => {
+      socket.once('open', () => { resolve() })
+      socket.once('error', reject)
+    })
+    return new CdpClient(socket)
+  }
+
+  call(method: string, params: Record<string, unknown> = {}): Promise<CdpMessage> {
+    const id = ++this.nextId
+    return new Promise((resolve, reject) => {
+      const timer = setTimeout(() => { reject(new Error(`CDP call timed out: ${method}`)) }, 5_000)
+      this.pending.set(id, (message) => {
+        clearTimeout(timer)
+        this.pending.delete(id)
+        resolve(message)
+      })
+      this.socket.send(JSON.stringify({ id, method, params }))
+    })
+  }
+
+  waitForEvent(method: string, predicate: (event: CdpMessage) => boolean = () => true): Promise<CdpMessage> {
+    const found = this.events.find(event => event.method === method && predicate(event))
+    if (found !== undefined) return Promise.resolve(found)
+    return new Promise((resolve, reject) => {
+      const timer = setTimeout(() => {
+        this.eventWaiters.delete(check)
+        reject(new Error(`CDP event timed out: ${method}`))
+      }, 5_000)
+      const check = (): void => {
+        const event = this.events.find(candidate => candidate.method === method && predicate(candidate))
+        if (event === undefined) return
+        clearTimeout(timer)
+        this.eventWaiters.delete(check)
+        resolve(event)
+      }
+      this.eventWaiters.add(check)
+    })
+  }
+
+  async close(): Promise<void> {
+    if (this.socket.readyState === WebSocket.CLOSED) return
+    const closed = new Promise<void>((resolve) => { this.socket.once('close', () => { resolve() }) })
+    this.socket.close()
+    await closed
+  }
+}
+
+function rawText(data: RawData): string {
+  if (Array.isArray(data)) return Buffer.concat(data).toString('utf8')
+  if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8')
+  return Buffer.from(data).toString('utf8')
+}
+
+describe('Host debugger through the Inspector Worker', () => {
+  let child: ChildProcessWithoutNullStreams | undefined
+  let cdp: CdpClient | undefined
+
+  afterEach(async () => {
+    await cdp?.close()
+    cdp = undefined
+    if (child !== undefined && child.exitCode === null) child.kill('SIGKILL')
+    child = undefined
+  })
+
+  it('evaluates a paused Host frame and resumes while the main thread is stopped', async () => {
+    const fixture = fileURLToPath(new URL('./fixtures/debug-host.ts', import.meta.url))
+    const tsx = import.meta.resolve('tsx/esm')
+    child = spawn(process.execPath, ['--import', tsx, fixture], {
+      env: { ...process.env, TSX_TSCONFIG_PATH: fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) },
+      stdio: ['pipe', 'pipe', 'pipe'],
+    })
+    const firstLine = await readLine(child)
+    const endpoint = JSON.parse(firstLine) as { webSocketDebuggerUrl: string }
+    cdp = await CdpClient.connect(endpoint.webSocketDebuggerUrl)
+    expect((await cdp.call('Runtime.enable')).error).toBeUndefined()
+    expect((await cdp.call('Debugger.enable')).error).toBeUndefined()
+    const parsed = await cdp.waitForEvent('Debugger.scriptParsed', event =>
+      String(event.params?.url).endsWith('/debug-host.ts'))
+    const scriptId = parsed.params?.scriptId
+    expect(typeof scriptId).toBe('string')
+    const source = await cdp.call('Debugger.getScriptSource', { scriptId })
+    expect(source.result?.scriptSource).toContain('breakpointProbe')
+    await cdp.call('Runtime.evaluate', { expression: 'console.log("host-console-probe")' })
+    const consoleEvent = await cdp.waitForEvent('Runtime.consoleAPICalled', (event) => {
+      const args = event.params?.args
+      return Array.isArray(args) && args.some(arg => isPlainObject(arg) && arg.value === 'host-console-probe')
+    })
+    expect(consoleEvent.params?.type).toBe('log')
+    const evaluated = await cdp.call('Runtime.evaluate', {
+      expression: 'globalThis.__inspectorBreakpointProbe',
+    })
+    const objectId = (evaluated.result?.result as Record<string, unknown> | undefined)?.objectId
+    expect(typeof objectId).toBe('string')
+    expect((await cdp.call('Debugger.setBreakpointOnFunctionCall', { objectId })).error).toBeUndefined()
+
+    child.stdin.write('run\n')
+    const paused = await cdp.waitForEvent('Debugger.paused')
+    const callFrames = paused.params?.callFrames as Array<Record<string, unknown>>
+    const callFrameId = callFrames[0]?.callFrameId
+    expect(typeof callFrameId).toBe('string')
+    const scopeChain = callFrames[0]?.scopeChain as Array<Record<string, unknown>>
+    const scopeObjectId = (scopeChain[0]?.object as Record<string, unknown> | undefined)?.objectId
+    expect(String(scopeObjectId)).toMatch(/^runtime:/u)
+    expect((await cdp.call('Runtime.getProperties', { objectId: scopeObjectId })).error).toBeUndefined()
+
+    // This Worker-local request must complete while the Host main thread is paused.
+    expect((await cdp.call('DSHInspector.getSources')).result?.sources).toBeDefined()
+    const local = await cdp.call('Debugger.evaluateOnCallFrame', {
+      callFrameId,
+      expression: 'value',
+      returnByValue: true,
+    })
+    expect(local.result?.result).toMatchObject({ type: 'number', value: 41 })
+    const object = await cdp.call('Debugger.evaluateOnCallFrame', {
+      callFrameId,
+      expression: '({ pausedValue: value })',
+      objectGroup: 'backtrace',
+    })
+    const pausedObjectId = (object.result?.result as Record<string, unknown> | undefined)?.objectId
+    expect(String(pausedObjectId)).toMatch(/^runtime:/u)
+    expect((await cdp.call('Runtime.getProperties', { objectId: pausedObjectId })).error).toBeUndefined()
+    expect((await cdp.call('Debugger.resume')).error).toBeUndefined()
+    const completed = await cdp.call('Runtime.evaluate', {
+      expression: 'globalThis.__inspectorBreakpointResult',
+      returnByValue: true,
+    })
+    expect(completed.result?.result).toMatchObject({ type: 'number', value: 42 })
+
+    const exited = new Promise<number | null>((resolve) => { child!.once('exit', resolve) })
+    child.stdin.write('stop\n')
+    expect(await exited).toBe(0)
+    child = undefined
+    cdp = undefined
+  }, 20_000)
+})
+
+function readLine(child: ChildProcessWithoutNullStreams): Promise<string> {
+  return new Promise((resolve, reject) => {
+    let stdout = ''
+    let stderr = ''
+    const onData = (chunk: Buffer): void => {
+      stdout += chunk.toString('utf8')
+      const newline = stdout.indexOf('\n')
+      if (newline === -1) return
+      cleanup()
+      resolve(stdout.slice(0, newline))
+    }
+    const onError = (error: Error): void => { cleanup(); reject(error) }
+    const onExit = (): void => {
+      cleanup()
+      reject(new Error(`debug Host exited before output; stderr:\n${stderr}`))
+    }
+    const onStderr = (chunk: Buffer): void => { stderr += chunk.toString('utf8') }
+    const cleanup = (): void => {
+      child.stdout.off('data', onData)
+      child.stderr.off('data', onStderr)
+      child.off('error', onError)
+      child.off('exit', onExit)
+    }
+    child.stdout.on('data', onData)
+    child.stderr.on('data', onStderr)
+    child.once('error', onError)
+    child.once('exit', onExit)
+  })
+}

+ 114 - 0
packages/experimental/inspector/tests/fixtures/client-source.client.ts

@@ -0,0 +1,114 @@
+/** Client-face process fixture used by Host-side protocol integration tests. */
+
+import { parentPort, workerData } from 'node:worker_threads'
+import { Context } from '@deepseek-ai/cordis'
+import WebSocket from 'ws'
+import { ClientInspectorSource } from '../../src/client/bridge/transport.ts'
+import { ClientSourceCatalog } from '../../src/client/cdp/sources.ts'
+import { publishCordisTree } from '../../src/client/inspection/cordis.ts'
+import { inspectorId } from '../../src/shared/bridge/ids.ts'
+import type { InspectorClientBootstrap } from '../../src/shared/bridge/messages/control.ts'
+import type { InspectorJsonValue } from '../../src/shared/json.ts'
+import { createInspectorService } from '../../src/shared/service.ts'
+
+interface ClientFixtureInput {
+  readonly bootstrap: InspectorClientBootstrap
+  readonly label: string
+  readonly sourceCatalog?: {
+    readonly sourceText: string
+    readonly sourceMap: string
+    readonly sourceUrl: string
+    readonly sourceMapUrl: string
+  }
+}
+
+interface ClientFixtureRequest {
+  readonly id: number
+  readonly op: 'close' | 'disconnect' | 'get-tree' | 'log-cordis' | 'log-value' | 'publish' | 'set-global'
+  readonly name?: string
+  readonly value?: InspectorJsonValue
+  readonly marker?: string
+  readonly topic?: string
+}
+
+const port = parentPort
+if (port === null) throw new Error('Inspector Client fixture requires a Worker parent port')
+const input = workerData as ClientFixtureInput
+globalThis.WebSocket = WebSocket as unknown as typeof globalThis.WebSocket
+console.log = () => {}
+
+const context = new Context()
+const childFiber = context.plugin({ name: 'client-child', apply() {} })
+await childFiber.await()
+Reflect.set(globalThis, '__cordisClientProbe', context)
+Reflect.set(globalThis, '__cordisClientFiberProbe', childFiber)
+
+const sourceCatalog = input.sourceCatalog === undefined
+  ? undefined
+  : new ClientSourceCatalog([{
+    scriptKey: inspectorId<'RuntimeScriptKey'>('bundle', 'scriptKey'),
+    url: input.sourceCatalog.sourceUrl,
+    hash: 'test',
+    sourceMapUrl: input.sourceCatalog.sourceMapUrl,
+    isModule: false,
+    loadSource: async () => input.sourceCatalog!.sourceText,
+    loadSourceMap: async () => input.sourceCatalog!.sourceMap,
+  }])
+const source = new ClientInspectorSource(input.bootstrap, input.label, sourceCatalog)
+const disposeCordis = publishCordisTree(context, source, {
+  maxNodes: input.bootstrap.maxCordisNodes,
+  maxBytes: input.bootstrap.maxFrameBytes - 4_096,
+})
+const service = createInspectorService(source)
+
+port.on('message', (message: ClientFixtureRequest) => {
+  void dispatch(message).then(
+    (value) => {
+      port.postMessage({ type: 'response', id: message.id, ok: true, value })
+      if (message.op === 'close') port.close()
+    },
+    (error: unknown) => {
+      port.postMessage({
+        type: 'response',
+        id: message.id,
+        ok: false,
+        error: error instanceof Error ? error.message : String(error),
+      })
+    },
+  )
+})
+port.postMessage({ type: 'ready', fiberUid: childFiber.uid })
+
+async function dispatch(message: ClientFixtureRequest): Promise<unknown> {
+  switch (message.op) {
+    case 'publish':
+      source.publish(requiredString(message.topic, 'topic'), message.value ?? null)
+      return undefined
+    case 'set-global':
+      Reflect.set(globalThis, requiredString(message.name, 'name'), message.value)
+      return undefined
+    case 'log-value':
+      console.log(message.value, requiredString(message.marker, 'marker'))
+      return undefined
+    case 'log-cordis':
+      console.log(context, childFiber, requiredString(message.marker, 'marker'))
+      return undefined
+    case 'get-tree':
+      return await service.cordis.getTree()
+    case 'disconnect': {
+      const socket = Reflect.get(source, 'socket') as WebSocket | undefined
+      socket?.terminate()
+      return undefined
+    }
+    case 'close':
+      disposeCordis()
+      source.close()
+      await context.fiber.dispose()
+      return undefined
+  }
+}
+
+function requiredString(value: string | undefined, field: string): string {
+  if (value === undefined) throw new Error(`Inspector Client fixture ${field} is required`)
+  return value
+}

+ 141 - 0
packages/experimental/inspector/tests/fixtures/client-source.host.ts

@@ -0,0 +1,141 @@
+/** Host-side controller for the isolated Client test fixture. */
+
+import { Worker } from 'node:worker_threads'
+import type { InspectorClientBootstrap } from '../../src/shared/bridge/messages/control.ts'
+import type { CordisRuntimeTree } from '../../src/shared/cordis/model.ts'
+import type { InspectorJsonValue } from '../../src/shared/json.ts'
+
+/** Optional source artifact exposed by the Client fixture. */
+export interface ClientFixtureSourceCatalog {
+  readonly sourceText: string
+  readonly sourceMap: string
+  readonly sourceUrl: string
+  readonly sourceMapUrl: string
+}
+
+/** Options for one isolated Client fixture. */
+export interface ClientFixtureOptions {
+  readonly label?: string
+  readonly sourceCatalog?: ClientFixtureSourceCatalog
+}
+
+interface FixtureResponse {
+  readonly type: 'response'
+  readonly id: number
+  readonly ok: boolean
+  readonly value?: unknown
+  readonly error?: string
+}
+
+/** A Client producer running outside the Host test realm. */
+export class InspectorClientFixture {
+  private readonly worker: Worker
+  private readonly pending = new Map<number, PromiseWithResolvers<unknown>>()
+  private nextId = 0
+  private closed = false
+  readonly fiberUid: number
+
+  private constructor(worker: Worker, fiberUid: number) {
+    this.worker = worker
+    this.fiberUid = fiberUid
+    worker.on('message', (message: unknown) => { this.receive(message) })
+    worker.on('error', (error) => { this.fail(error) })
+    worker.on('exit', (code) => {
+      if (!this.closed && code !== 0) this.fail(new Error(`Inspector Client fixture exited with code ${String(code)}`))
+    })
+  }
+
+  /** Start one Client fixture and wait for its Cordis tree to be published. */
+  static async start(
+    bootstrap: InspectorClientBootstrap,
+    options: ClientFixtureOptions = {},
+  ): Promise<InspectorClientFixture> {
+    const ready = Promise.withResolvers<number>()
+    const entry = new URL('./client-source.client.ts', import.meta.url)
+    const tsxApi = import.meta.resolve('tsx/esm/api')
+    const source = `import { register } from ${JSON.stringify(tsxApi)}\nregister()\nawait import(${JSON.stringify(entry.href)})`
+    const worker = new Worker(new URL(`data:text/javascript,${encodeURIComponent(source)}`), {
+      execArgv: [],
+      workerData: {
+        bootstrap,
+        label: options.label ?? 'Test Client',
+        ...(options.sourceCatalog === undefined ? {} : { sourceCatalog: options.sourceCatalog }),
+      },
+    })
+    const onMessage = (message: unknown): void => {
+      if (!isRecord(message) || message.type !== 'ready' || typeof message.fiberUid !== 'number') return
+      ready.resolve(message.fiberUid)
+    }
+    worker.on('message', onMessage)
+    worker.once('error', ready.reject)
+    const fiberUid = await ready.promise
+    worker.off('message', onMessage)
+    return new InspectorClientFixture(worker, fiberUid)
+  }
+
+  /** Publish one observation from the Client realm. */
+  async publish(topic: string, value: InspectorJsonValue): Promise<void> {
+    await this.request({ op: 'publish', topic, value })
+  }
+
+  /** Set one JSON-compatible global used by Client Runtime evaluation. */
+  async setGlobal(name: string, value: InspectorJsonValue): Promise<void> {
+    await this.request({ op: 'set-global', name, value })
+  }
+
+  /** Emit one Console event carrying a caller-provided value. */
+  async log(value: InspectorJsonValue, marker: string): Promise<void> {
+    await this.request({ op: 'log-value', value, marker })
+  }
+
+  /** Emit one Console event carrying the fixture's Context and Fiber. */
+  async logCordis(marker: string): Promise<void> {
+    await this.request({ op: 'log-cordis', marker })
+  }
+
+  /** Read the consumer-neutral Cordis tree through the Client service. */
+  async getCordisTree(): Promise<CordisRuntimeTree> {
+    return await this.request({ op: 'get-tree' }) as CordisRuntimeTree
+  }
+
+  /** Break the active ingest socket while preserving the Client source. */
+  async disconnect(): Promise<void> {
+    await this.request({ op: 'disconnect' })
+  }
+
+  /** Dispose the Client source and its Cordis context. */
+  async close(): Promise<void> {
+    if (this.closed) return
+    await this.request({ op: 'close' })
+    this.closed = true
+    await this.worker.terminate()
+  }
+
+  private async request(fields: Record<string, unknown>): Promise<unknown> {
+    if (this.closed) throw new Error('Inspector Client fixture is closed')
+    const id = ++this.nextId
+    const result = Promise.withResolvers<unknown>()
+    this.pending.set(id, result)
+    this.worker.postMessage({ id, ...fields })
+    return await result.promise
+  }
+
+  private receive(message: unknown): void {
+    if (!isRecord(message) || message.type !== 'response' || typeof message.id !== 'number') return
+    const response = message as unknown as FixtureResponse
+    const pending = this.pending.get(response.id)
+    if (pending === undefined) return
+    this.pending.delete(response.id)
+    if (response.ok) pending.resolve(response.value)
+    else pending.reject(new Error(response.error ?? 'Inspector Client fixture request failed'))
+  }
+
+  private fail(error: Error): void {
+    for (const pending of this.pending.values()) pending.reject(error)
+    this.pending.clear()
+  }
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return typeof value === 'object' && value !== null && !Array.isArray(value)
+}

+ 28 - 0
packages/experimental/inspector/tests/fixtures/debug-host.ts

@@ -0,0 +1,28 @@
+/** Child-process fixture whose Host main thread is paused and resumed through the Inspector Worker. */
+
+import { createInterface } from 'node:readline'
+import { startInspector } from '../../src/host/bridge/controller.ts'
+
+const inspector = await startInspector({ port: 0, captureFetch: false })
+
+function breakpointProbe(value: number): number {
+  const local = value
+  return local + 1
+}
+
+Object.defineProperty(globalThis, '__inspectorBreakpointProbe', { value: breakpointProbe, configurable: true })
+process.stdout.write(`${JSON.stringify(inspector.endpoint)}\n`)
+
+const input = createInterface({ input: process.stdin, terminal: false })
+input.on('line', (line) => {
+  if (line === 'run') {
+    Object.defineProperty(globalThis, '__inspectorBreakpointResult', {
+      value: breakpointProbe(41),
+      configurable: true,
+    })
+  }
+  if (line === 'stop') {
+    input.close()
+    void inspector.close().then(() => { process.exit(0) })
+  }
+})

+ 545 - 0
packages/experimental/inspector/tests/integration.host.spec.ts

@@ -0,0 +1,545 @@
+/** Host-driven integration over an isolated Client fixture. */
+
+import { createServer, type Server } from 'node:http'
+import WebSocket, { type RawData } from 'ws'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { startInspector, type InspectorHandle } from '../src/host/bridge/controller.ts'
+import { InspectorClientFixture } from './fixtures/client-source.host.ts'
+
+interface CdpMessage {
+  readonly id?: number
+  readonly method?: string
+  readonly params?: Record<string, unknown>
+  readonly result?: Record<string, unknown>
+  readonly error?: { message: string }
+}
+
+class TestCdpClient {
+  private nextId = 0
+  private readonly pending = new Map<number, (message: CdpMessage) => void>()
+  readonly events: CdpMessage[] = []
+
+  private constructor(private readonly socket: WebSocket) {
+    socket.on('message', (data) => {
+      const message = JSON.parse(rawText(data)) as CdpMessage
+      if (message.id !== undefined) this.pending.get(message.id)?.(message)
+      else this.events.push(message)
+    })
+  }
+
+  static async connect(url: string): Promise<TestCdpClient> {
+    const socket = new WebSocket(url)
+    await new Promise<void>((resolve, reject) => {
+      socket.once('open', () => { resolve() })
+      socket.once('error', reject)
+    })
+    return new TestCdpClient(socket)
+  }
+
+  call(method: string, params: Record<string, unknown> = {}): Promise<CdpMessage> {
+    const id = ++this.nextId
+    return new Promise((resolve, reject) => {
+      const timer = setTimeout(() => {
+        this.pending.delete(id)
+        reject(new Error(`CDP call timed out: ${method}`))
+      }, 5_000)
+      this.pending.set(id, (message) => {
+        clearTimeout(timer)
+        this.pending.delete(id)
+        resolve(message)
+      })
+      this.socket.send(JSON.stringify({ id, method, params }))
+    })
+  }
+
+  async close(): Promise<void> {
+    if (this.socket.readyState === WebSocket.CLOSED) return
+    const closed = new Promise<void>((resolve) => { this.socket.once('close', () => { resolve() }) })
+    this.socket.close()
+    await closed
+  }
+}
+
+describe('experimental Inspector real Worker', () => {
+  let inspector: InspectorHandle | undefined
+  let cdp: TestCdpClient | undefined
+  let secondCdp: TestCdpClient | undefined
+  let client: InspectorClientFixture | undefined
+  let server: Server | undefined
+
+  afterEach(async () => {
+    await client?.close()
+    client = undefined
+    await cdp?.close()
+    cdp = undefined
+    await secondCdp?.close()
+    secondCdp = undefined
+    await inspector?.close()
+    inspector = undefined
+    if (server !== undefined) await new Promise<void>((resolve) => { server!.close(() => { resolve() }) })
+    server = undefined
+  })
+
+  it('switches between Host and Client contexts and routes Client RemoteObjects', async () => {
+    inspector = await startInspector({ port: 0, captureFetch: false, clientReconnectBaseMs: 10, clientReconnectMaxMs: 20 })
+    cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
+    inspector.source.publish('host/probe', { value: 1 })
+    client = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Test Client' })
+    await client.publish('client/probe', { value: 2 })
+
+    await vi.waitFor(async () => {
+      const response = await cdp!.call('DSHInspector.getSources')
+      const sources = response.result?.sources as Array<{ kind: string; topics: Record<string, number> }>
+      expect(sources).toEqual(expect.arrayContaining([
+        expect.objectContaining({ kind: 'host', topics: { 'host/probe': 1 } }),
+        expect.objectContaining({
+          kind: 'client',
+          topics: expect.objectContaining({ 'client/probe': 1 }),
+        }),
+      ]))
+    })
+
+    ;(globalThis as Record<string, unknown>).__inspectorHostProbe = 73
+    expect((await cdp.call('Runtime.enable')).error).toBeUndefined()
+    let clientContextId: number | undefined
+    let clientUniqueContextId: string | undefined
+    await vi.waitFor(() => {
+      expect(runtimeContexts(cdp!).some(context => context.name === 'Host')).toBe(true)
+      const clientContext = cdp!.events
+        .filter(event => event.method === 'Runtime.executionContextCreated')
+        .map(event => event.params?.context as Record<string, unknown> | undefined)
+        .find(context => String(context?.name).startsWith('Client —'))
+      expect(clientContext).toBeDefined()
+      clientContextId = clientContext?.id as number
+      clientUniqueContextId = clientContext?.uniqueId as string
+    })
+    if (clientContextId === undefined || clientUniqueContextId === undefined) {
+      throw new Error('Client execution context was not announced')
+    }
+    const hostEvaluated = await cdp.call('Runtime.evaluate', {
+      expression: 'globalThis.__inspectorHostProbe',
+      returnByValue: true,
+    })
+    expect(hostEvaluated.result?.result).toMatchObject({ type: 'number', value: 73 })
+
+    await client.setGlobal('__inspectorClientProbe', { value: 17, nested: { ready: true } })
+    const clientEvaluated = await cdp.call('Runtime.evaluate', {
+      expression: 'globalThis.__inspectorClientProbe',
+      contextId: clientContextId,
+      objectGroup: 'console',
+      generatePreview: true,
+    })
+    const clientObject = clientEvaluated.result?.result as Record<string, unknown>
+    expect(clientObject).toMatchObject({ type: 'object', className: 'Object' })
+    expect(String(clientObject.objectId)).toMatch(/^runtime:/u)
+
+    const properties = await cdp.call('Runtime.getProperties', {
+      objectId: clientObject.objectId,
+      ownProperties: true,
+    })
+    const propertyRows = recordArray(properties.result?.result)
+    const valueProperty = propertyRows.find(property => property.name === 'value')
+    const nestedProperty = propertyRows.find(property => property.name === 'nested')
+    expect(asRecord(valueProperty?.value)).toMatchObject({ type: 'number', value: 17 })
+    expect(asRecord(nestedProperty?.value).type).toBe('object')
+
+    const called = await cdp.call('Runtime.callFunctionOn', {
+      objectId: clientObject.objectId,
+      functionDeclaration: 'function (increment) { return this.value + increment }',
+      arguments: [{ value: 5 }],
+      returnByValue: true,
+    })
+    expect(called.result?.result).toMatchObject({ type: 'number', value: 22 })
+
+    const hostObject = await cdp.call('Runtime.evaluate', { expression: '({ realm: "host" })' })
+    const hostObjectId = asRecord(hostObject.result?.result).objectId
+    expect((await cdp.call('Runtime.callFunctionOn', {
+      executionContextId: clientContextId,
+      functionDeclaration: 'function (value) { return value }',
+      arguments: [{ objectId: hostObjectId }],
+    })).error?.message).toContain('between realms')
+    expect((await cdp.call('Runtime.callFunctionOn', {
+      objectId: hostObjectId,
+      functionDeclaration: 'function (value) { return value }',
+      arguments: [{ objectId: clientObject.objectId }],
+    })).error?.message).toContain('between realms')
+    expect((await cdp.call('Runtime.queryObjects', {
+      prototypeObjectId: clientObject.objectId,
+    })).error?.message).toContain('Client realm has no native CDP transport')
+
+    const awaited = await cdp.call('Runtime.evaluate', {
+      expression: 'Promise.resolve({ realm: "client" })',
+      contextId: clientContextId,
+      awaitPromise: true,
+      returnByValue: true,
+    })
+    expect(awaited.result?.result).toMatchObject({ type: 'object', value: { realm: 'client' } })
+
+    const uniquelyRouted = await cdp.call('Runtime.evaluate', {
+      expression: '6 * 7',
+      uniqueContextId: clientUniqueContextId,
+      returnByValue: true,
+    })
+    expect(uniquelyRouted.result?.result).toMatchObject({ type: 'number', value: 42 })
+
+    expect((await cdp.call('Runtime.releaseObject', { objectId: clientObject.objectId })).error).toBeUndefined()
+    expect((await cdp.call('Runtime.getProperties', { objectId: clientObject.objectId })).error).toBeDefined()
+
+    const thrown = await cdp.call('Runtime.evaluate', {
+      expression: 'throw new Error("client failure")',
+      contextId: clientContextId,
+    })
+    expect(asRecord(thrown.result?.exceptionDetails)).toMatchObject({
+      text: 'Uncaught',
+      executionContextId: clientContextId,
+    })
+
+    const pendingEvaluation = cdp.call('Runtime.evaluate', {
+      expression: 'new Promise(() => {})',
+      contextId: clientContextId,
+      awaitPromise: true,
+    })
+    await new Promise<void>((resolve) => { setTimeout(resolve, 10) })
+    await client.close()
+    client = undefined
+    expect((await pendingEvaluation).error).toBeDefined()
+    await vi.waitFor(() => {
+      expect(cdp!.events.some(event =>
+        event.method === 'Runtime.executionContextDestroyed'
+        && event.params?.executionContextId === clientContextId)).toBe(true)
+    })
+  })
+
+  it('isolates Client object ids and object groups by DevTools connection', async () => {
+    inspector = await startInspector({ port: 0, captureFetch: false })
+    client = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Shared Client' })
+    cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
+    secondCdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
+    await Promise.all([cdp.call('Runtime.enable'), secondCdp.call('Runtime.enable')])
+
+    const firstContext = await clientContext(cdp)
+    const secondContext = await clientContext(secondCdp)
+    const first = await cdp.call('Runtime.evaluate', {
+      expression: '({ owner: "first" })',
+      contextId: firstContext,
+      objectGroup: 'console',
+    })
+    const second = await secondCdp.call('Runtime.evaluate', {
+      expression: '({ owner: "second" })',
+      contextId: secondContext,
+      objectGroup: 'console',
+    })
+    const firstObjectId = asRecord(first.result?.result).objectId
+    const secondObjectId = asRecord(second.result?.result).objectId
+    expect(firstObjectId).not.toBe(secondObjectId)
+    expect((await secondCdp.call('Runtime.getProperties', { objectId: firstObjectId })).error).toBeDefined()
+
+    await cdp.close()
+    cdp = undefined
+    const secondProperties = await secondCdp.call('Runtime.getProperties', {
+      objectId: secondObjectId,
+      ownProperties: true,
+    })
+    const owner = recordArray(secondProperties.result?.result).find(property => property.name === 'owner')
+    expect(asRecord(owner?.value).value).toBe('second')
+    expect((await secondCdp.call('Runtime.releaseObjectGroup', { objectGroup: 'console' })).error).toBeUndefined()
+    expect((await secondCdp.call('Runtime.getProperties', { objectId: secondObjectId })).error).toBeDefined()
+
+    const beforeDisable = await secondCdp.call('Runtime.evaluate', {
+      expression: '({ retained: true })',
+      contextId: secondContext,
+    })
+    const disabledObjectId = asRecord(beforeDisable.result?.result).objectId
+    expect((await secondCdp.call('Runtime.disable')).error).toBeUndefined()
+    expect((await secondCdp.call('Runtime.enable')).error).toBeUndefined()
+    expect((await secondCdp.call('Runtime.getProperties', { objectId: disabledObjectId })).error).toBeDefined()
+  })
+
+  it('uses the same Runtime value model for Host and Client realms', async () => {
+    inspector = await startInspector({ port: 0, captureFetch: false })
+    client = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Compatibility Client' })
+    cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
+    await cdp.call('Runtime.enable')
+    const clientContextId = await clientContext(cdp)
+
+    for (const [name, contextId] of [['Host', undefined], ['Client', clientContextId]] as const) {
+      const select = contextId === undefined ? {} : { contextId }
+      const nan = await cdp.call('Runtime.evaluate', { expression: 'NaN', ...select })
+      expect(nan.result?.result, name).toMatchObject({ type: 'number', unserializableValue: 'NaN' })
+
+      const array = await cdp.call('Runtime.evaluate', {
+        expression: '[1, 2]',
+        objectGroup: `compat-${name}`,
+        ...select,
+      })
+      const arrayObject = asRecord(array.result?.result)
+      expect(arrayObject, name).toMatchObject({ type: 'object', subtype: 'array', className: 'Array' })
+      const properties = await cdp.call('Runtime.getProperties', {
+        objectId: arrayObject.objectId,
+        ownProperties: true,
+      })
+      const first = recordArray(properties.result?.result).find(property => property.name === '0')
+      expect(first, name).toMatchObject({ configurable: true, enumerable: true, writable: true })
+      expect(asRecord(first?.value), name).toMatchObject({ type: 'number', value: 1 })
+
+      const thrown = await cdp.call('Runtime.evaluate', {
+        expression: 'throw new TypeError("realm-compatibility")',
+        ...select,
+      })
+      expect(thrown.result?.result, name).toMatchObject({ type: 'object', subtype: 'error' })
+      expect(thrown.result?.exceptionDetails, name).toMatchObject({ text: 'Uncaught' })
+
+      expect((await cdp.call('Runtime.releaseObjectGroup', { objectGroup: `compat-${name}` })).error).toBeUndefined()
+      expect((await cdp.call('Runtime.getProperties', { objectId: arrayObject.objectId })).error).toBeDefined()
+    }
+
+    expect((await cdp.call('Runtime.evaluate', {
+      expression: '1 + 1',
+      throwOnSideEffect: true,
+    })).result?.result).toMatchObject({ type: 'number', value: 2 })
+    expect((await cdp.call('Runtime.evaluate', {
+      expression: '1 + 1',
+      contextId: clientContextId,
+      throwOnSideEffect: true,
+    })).error?.message).toContain('does not support throwOnSideEffect')
+    expect((await cdp.call('Runtime.compileScript', {
+      expression: '1 + 1',
+      sourceURL: 'client-eval.js',
+      persistScript: true,
+      executionContextId: clientContextId,
+    })).error?.message).toContain('Client realm has no native CDP transport')
+  })
+
+  it('forwards Client Console objects through isolated realm sessions', async () => {
+    inspector = await startInspector({ port: 0, captureFetch: false })
+    client = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Console Client' })
+    cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
+    secondCdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
+    await Promise.all([cdp.call('Runtime.enable'), secondCdp.call('Runtime.enable')])
+    const firstContext = await clientContext(cdp)
+    const secondContext = await clientContext(secondCdp)
+    const value = { owner: 'client-console' }
+    const marker = 'client-console-event'
+    await client.log(value, marker)
+    let firstEvent: CdpMessage | undefined
+    let secondEvent: CdpMessage | undefined
+    await vi.waitFor(() => {
+      firstEvent = consoleEvent(cdp!, firstContext, marker)
+      secondEvent = consoleEvent(secondCdp!, secondContext, marker)
+      expect(firstEvent).toBeDefined()
+      expect(secondEvent).toBeDefined()
+    })
+    const firstObjectId = asRecord(recordArray(firstEvent!.params?.args)[0]).objectId
+    const secondObjectId = asRecord(recordArray(secondEvent!.params?.args)[0]).objectId
+    expect(firstObjectId).toBeTypeOf('string')
+    expect(secondObjectId).toBeTypeOf('string')
+    expect(firstObjectId).not.toBe(secondObjectId)
+    expect((await secondCdp.call('Runtime.getProperties', { objectId: firstObjectId })).error).toBeDefined()
+
+    const secondProperties = await secondCdp.call('Runtime.getProperties', {
+      objectId: secondObjectId,
+      ownProperties: true,
+    })
+    const owner = recordArray(secondProperties.result?.result).find(property => property.name === 'owner')
+    expect(asRecord(owner?.value).value).toBe('client-console')
+
+    expect((await cdp.call('Runtime.discardConsoleEntries')).error).toBeUndefined()
+    expect((await cdp.call('Runtime.getProperties', { objectId: firstObjectId })).error).toBeDefined()
+    expect((await secondCdp.call('Runtime.getProperties', { objectId: secondObjectId })).error).toBeUndefined()
+  })
+
+  it('projects a chunked Client bundle as read-only Debugger source', async () => {
+    const sourceText = `const clientSourceMarker = 42\n/*${'x'.repeat(150_000)}*/\n`
+    const sourceMap = JSON.stringify({ version: 3, sources: ['client/index.ts'], mappings: 'AAAA' })
+    const sourceUrl = 'http://client.test/plugins/inspector/client.js?rev=test'
+    const sourceMapUrl = 'http://client.test/plugins/inspector/client.js.map?rev=test'
+    inspector = await startInspector({ port: 0, captureFetch: false, maxClientSourceBytes: 1_000_000 })
+    client = await InspectorClientFixture.start(inspector.endpoint.client, {
+      label: 'Source Client',
+      sourceCatalog: { sourceText, sourceMap, sourceUrl, sourceMapUrl },
+    })
+    cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
+    await cdp.call('Runtime.enable')
+    const contextId = await clientContext(cdp)
+    expect((await cdp.call('Debugger.enable')).error).toBeUndefined()
+
+    let script: CdpMessage | undefined
+    await vi.waitFor(() => {
+      script = cdp!.events.find(event => event.method === 'Debugger.scriptParsed'
+        && event.params?.url === sourceUrl)
+      expect(script).toBeDefined()
+    })
+    expect(script?.params).toMatchObject({
+      executionContextId: contextId,
+      sourceMapURL: sourceMapUrl,
+      hash: 'test',
+      isModule: false,
+      length: sourceText.length,
+    })
+    const scriptId = script?.params?.scriptId
+    expect(scriptId).toBeTypeOf('string')
+    await expect(cdp.call('Debugger.getScriptSource', { scriptId })).resolves.toMatchObject({
+      result: { scriptSource: sourceText },
+    })
+    await expect(cdp.call('Debugger.searchInContent', {
+      scriptId,
+      query: 'clientSourceMarker',
+      caseSensitive: true,
+    })).resolves.toMatchObject({
+      result: { result: [{ lineNumber: 0, lineContent: 'const clientSourceMarker = 42' }] },
+    })
+    expect((await cdp.call('Debugger.setBreakpointByUrl', { url: sourceUrl, lineNumber: 0 })).error?.message)
+      .toContain('Client native debugging is unavailable')
+    expect((await cdp.call('Debugger.setBreakpointByUrl', {
+      urlRegex: 'client\\.js',
+      lineNumber: 0,
+    })).error?.message).toContain('Client native debugging is unavailable')
+    expect((await cdp.call('Debugger.setBreakpointByUrl', {
+      scriptHash: 'test',
+      lineNumber: 0,
+    })).error?.message).toContain('Client native debugging is unavailable')
+    expect((await cdp.call('Debugger.evaluateOnCallFrame', {
+      callFrameId: 'client:unsupported-frame',
+      expression: '1',
+    })).error?.message).toContain('Client native debugging is unavailable')
+  })
+
+  it('projects full Host fetch data through the Network domain', async () => {
+    server = createServer((request, response) => {
+      let body = ''
+      request.setEncoding('utf8')
+      request.on('data', (chunk: string) => { body += chunk })
+      request.on('end', () => {
+        response.writeHead(201, { authorization: 'response-secret', 'content-type': 'application/json' })
+        response.end(JSON.stringify({ body }))
+      })
+    })
+    await new Promise<void>((resolve) => { server!.listen(0, '127.0.0.1', () => { resolve() }) })
+    const port = (server.address() as import('node:net').AddressInfo).port
+    inspector = await startInspector({ port: 0 })
+    cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
+    await cdp.call('Network.enable')
+
+    const response = await fetch(`http://127.0.0.1:${String(port)}/capture?secret=query`, {
+      method: 'POST',
+      headers: { authorization: 'Bearer request-secret' },
+      body: 'request-body',
+    })
+    expect(await response.json()).toEqual({ body: 'request-body' })
+
+    let started: CdpMessage | undefined
+    await vi.waitFor(() => {
+      started = cdp!.events.find(event =>
+        event.method === 'Network.requestWillBeSent'
+        && String((event.params?.request as Record<string, unknown> | undefined)?.url).includes('/capture'))
+      expect(started).toBeDefined()
+      expect(cdp!.events.some(event =>
+        event.method === 'Network.loadingFinished'
+        && event.params?.requestId === started!.params?.requestId)).toBe(true)
+    })
+    const request = started!.params?.request as Record<string, unknown>
+    expect(request.url).toBe(`http://127.0.0.1:${String(port)}/capture?secret=query`)
+    expect(request.headers).toMatchObject({ authorization: 'Bearer request-secret' })
+    const requestId = started!.params?.requestId
+    const post = await cdp.call('Network.getRequestPostData', { requestId })
+    expect(post.result?.postData).toBe('request-body')
+    const body = await cdp.call('Network.getResponseBody', { requestId })
+    expect(Buffer.from(String(body.result?.body), 'base64').toString('utf8')).toBe('{"body":"request-body"}')
+  })
+
+  it('streams later Host fetch response chunks to an opted-in CDP connection', async () => {
+    const continueResponse = Promise.withResolvers<true>()
+    const firstChunk = 'data: first\n\n'
+    const laterChunk = 'event: update\nid: 2\ndata: second\ndata: line\n\n'
+    server = createServer((_request, response) => {
+      response.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8' })
+      response.write(firstChunk)
+      void continueResponse.promise.then(() => { response.end(laterChunk) })
+    })
+    await new Promise<void>((resolve) => { server!.listen(0, '127.0.0.1', () => { resolve() }) })
+    const port = (server.address() as import('node:net').AddressInfo).port
+    inspector = await startInspector({ port: 0 })
+    cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
+    await cdp.call('Network.enable')
+
+    try {
+      const response = await fetch(`http://127.0.0.1:${String(port)}/events`)
+      let requestId: string | undefined
+      await vi.waitFor(() => {
+        const received = cdp!.events.find(event =>
+          event.method === 'Network.responseReceived'
+          && (event.params?.response as Record<string, unknown> | undefined)?.mimeType === 'text/event-stream')
+        requestId = received?.params?.requestId as string | undefined
+        expect(requestId).toBeTypeOf('string')
+        expect(cdp!.events.some(event =>
+          event.method === 'Network.dataReceived'
+          && event.params?.requestId === requestId)).toBe(true)
+      })
+      if (requestId === undefined) throw new Error('SSE request was not observed')
+
+      const streaming = await cdp.call('Network.streamResourceContent', { requestId })
+      expect(Buffer.from(String(streaming.result?.bufferedData), 'base64').toString('utf8')).toBe(firstChunk)
+      const laterEventOffset = cdp.events.length
+      continueResponse.resolve(true)
+      expect(await response.text()).toBe(firstChunk + laterChunk)
+
+      await vi.waitFor(() => {
+        expect(cdp!.events.some(event =>
+          event.method === 'Network.loadingFinished'
+          && event.params?.requestId === requestId)).toBe(true)
+        const streamed = cdp!.events.slice(laterEventOffset)
+          .filter(event => event.method === 'Network.dataReceived'
+            && event.params?.requestId === requestId
+            && typeof event.params?.data === 'string')
+          .map(event => Buffer.from(String(event.params!.data), 'base64'))
+        expect(Buffer.concat(streamed).toString('utf8')).toBe(laterChunk)
+      })
+
+      const body = await cdp.call('Network.getResponseBody', { requestId })
+      expect(Buffer.from(String(body.result?.body), 'base64').toString('utf8')).toBe(firstChunk + laterChunk)
+    } finally {
+      continueResponse.resolve(true)
+    }
+  })
+})
+
+async function clientContext(client: TestCdpClient): Promise<number> {
+  let contextId: number | undefined
+  await vi.waitFor(() => {
+    const context = runtimeContexts(client).find(candidate => String(candidate.name).startsWith('Client —'))
+    expect(context).toBeDefined()
+    contextId = context?.id as number
+  })
+  if (contextId === undefined) throw new Error('Client execution context was not announced')
+  return contextId
+}
+
+function runtimeContexts(client: TestCdpClient): Readonly<Record<string, unknown>>[] {
+  return client.events
+    .filter(event => event.method === 'Runtime.executionContextCreated')
+    .map(event => asRecord(event.params?.context))
+}
+
+function consoleEvent(client: TestCdpClient, contextId: number, marker: string): CdpMessage | undefined {
+  return client.events.find((event) => {
+    if (event.method !== 'Runtime.consoleAPICalled' || event.params?.executionContextId !== contextId) return false
+    const args = event.params.args
+    return Array.isArray(args) && args.some(argument => asRecord(argument).value === marker)
+  })
+}
+
+function recordArray(value: unknown): Readonly<Record<string, unknown>>[] {
+  if (!Array.isArray(value)) throw new Error('expected an array of records')
+  return value.map(asRecord)
+}
+
+function asRecord(value: unknown): Readonly<Record<string, unknown>> {
+  if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('expected a record')
+  return value as Readonly<Record<string, unknown>>
+}
+
+function rawText(data: RawData): string {
+  if (Array.isArray(data)) return Buffer.concat(data).toString('utf8')
+  if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8')
+  return Buffer.from(data).toString('utf8')
+}