瀏覽代碼

feat(inspector): expose Cordis trees through CDP DOM

imccyu 2 周之前
父節點
當前提交
28cc3e930b
共有 28 個文件被更改,包括 3126 次插入0 次删除
  1. 25 0
      packages/experimental/inspector/src/client/inspection/cordis.ts
  2. 25 0
      packages/experimental/inspector/src/host/inspection/cordis.ts
  3. 4 0
      packages/experimental/inspector/src/shared/bridge/messages/cordis.ts
  4. 138 0
      packages/experimental/inspector/src/shared/bridge/messages/query/codec.ts
  5. 40 0
      packages/experimental/inspector/src/shared/bridge/messages/query/commands.ts
  6. 30 0
      packages/experimental/inspector/src/shared/bridge/messages/query/frames.ts
  7. 5 0
      packages/experimental/inspector/src/shared/bridge/messages/query/index.ts
  8. 18 0
      packages/experimental/inspector/src/shared/bridge/query-reader.ts
  9. 195 0
      packages/experimental/inspector/src/shared/cordis/collector.ts
  10. 9 0
      packages/experimental/inspector/src/shared/cordis/ids.ts
  11. 161 0
      packages/experimental/inspector/src/shared/cordis/model.ts
  12. 23 0
      packages/experimental/inspector/src/shared/cordis/object-reference.ts
  13. 176 0
      packages/experimental/inspector/src/shared/cordis/object-registry.ts
  14. 46 0
      packages/experimental/inspector/src/shared/cordis/observer.ts
  15. 88 0
      packages/experimental/inspector/src/shared/cordis/projector.ts
  16. 24 0
      packages/experimental/inspector/src/shared/cordis/reader.ts
  17. 111 0
      packages/experimental/inspector/src/shared/cordis/snapshot.ts
  18. 32 0
      packages/experimental/inspector/src/shared/service.ts
  19. 4 0
      packages/experimental/inspector/src/worker/cdp/domains/dom/index.ts
  20. 213 0
      packages/experimental/inspector/src/worker/cdp/domains/dom/model.ts
  21. 361 0
      packages/experimental/inspector/src/worker/cdp/domains/dom/session.ts
  22. 17 0
      packages/experimental/inspector/src/worker/inspection/cordis-query.ts
  23. 248 0
      packages/experimental/inspector/src/worker/inspection/cordis-store.ts
  24. 255 0
      packages/experimental/inspector/src/worker/inspection/query-router.ts
  25. 349 0
      packages/experimental/inspector/tests/cordis-query.host.spec.ts
  26. 454 0
      packages/experimental/inspector/tests/cordis-tree.host.spec.ts
  27. 73 0
      packages/extensions/tool-cordis/src/api-catalog.ts
  28. 2 0
      scripts/gen-cordis-catalog.ts

+ 25 - 0
packages/experimental/inspector/src/client/inspection/cordis.ts

@@ -0,0 +1,25 @@
+/** Browser adapter that publishes shared Cordis snapshots over the Client bridge. */
+
+import type { Context } from '@deepseek-ai/cordis'
+import type { CordisTreeLimits } from '../../shared/cordis/collector.ts'
+import { observeCordisTree } from '../../shared/cordis/observer.ts'
+import { CORDIS_TREE_TOPIC } from '../../shared/bridge/messages/cordis.ts'
+import type { InspectorJsonValue } from '../../shared/json.ts'
+import type { InspectorStatePublisher } from '../../shared/bridge/publisher.ts'
+
+/**
+ * Observe the Client Cordis runtime and retain its latest bridge snapshot.
+ * @param ctx - Client plugin context whose root is inspected.
+ * @param publisher - Active Client bridge publisher.
+ * @param limits - Snapshot node and encoded-byte limits.
+ * @returns A disposer that stops observation and releases retained objects.
+ */
+export function publishCordisTree(
+  ctx: Context,
+  publisher: InspectorStatePublisher,
+  limits: CordisTreeLimits,
+): () => void {
+  return observeCordisTree(ctx, (snapshot) => {
+    publisher.setState(CORDIS_TREE_TOPIC, snapshot as unknown as InspectorJsonValue)
+  }, limits)
+}

+ 25 - 0
packages/experimental/inspector/src/host/inspection/cordis.ts

@@ -0,0 +1,25 @@
+/** Host adapter that publishes shared Cordis snapshots over the Host bridge. */
+
+import type { Context } from '@deepseek-ai/cordis'
+import type { CordisTreeLimits } from '../../shared/cordis/collector.ts'
+import { observeCordisTree } from '../../shared/cordis/observer.ts'
+import { CORDIS_TREE_TOPIC } from '../../shared/bridge/messages/cordis.ts'
+import type { InspectorJsonValue } from '../../shared/json.ts'
+import type { InspectorStatePublisher } from '../../shared/bridge/publisher.ts'
+
+/**
+ * Observe the Host Cordis runtime and retain its latest bridge snapshot.
+ * @param ctx - Host plugin context whose root is inspected.
+ * @param publisher - Active Host bridge publisher.
+ * @param limits - Snapshot node and encoded-byte limits.
+ * @returns A disposer that stops observation and releases retained objects.
+ */
+export function publishCordisTree(
+  ctx: Context,
+  publisher: InspectorStatePublisher,
+  limits: CordisTreeLimits,
+): () => void {
+  return observeCordisTree(ctx, (snapshot) => {
+    publisher.setState(CORDIS_TREE_TOPIC, snapshot as unknown as InspectorJsonValue)
+  }, limits)
+}

+ 4 - 0
packages/experimental/inspector/src/shared/bridge/messages/cordis.ts

@@ -0,0 +1,4 @@
+/** Bridge message metadata for Cordis runtime-tree snapshots. */
+
+/** Observation topic carrying the latest complete Cordis tree. */
+export const CORDIS_TREE_TOPIC = 'cordis/tree'

+ 138 - 0
packages/experimental/inspector/src/shared/bridge/messages/query/codec.ts

@@ -0,0 +1,138 @@
+/** Exact decoders for non-CDP Inspector query frames. */
+
+import { parseCordisRuntimeTree } from '../../../cordis/model.ts'
+import { isPlainObject } from '../../../json.ts'
+import { exactKeys, exactObject, wireId } from '../../../validation.ts'
+import { INSPECTOR_PROTOCOL_VERSION } from '../../version.ts'
+import type { InspectorQuery, InspectorQueryError, InspectorQueryResult } from './commands.ts'
+import type {
+  InspectorQueryRequestFrame,
+  InspectorQueryRequestId,
+  InspectorQueryResponseFrame,
+} from './frames.ts'
+import type { InspectorSourceGeneration, InspectorSourceId } from '../../ids.ts'
+
+/** Correlation fields recoverable before a query body is accepted. */
+export interface InspectorQueryFrameIdentity {
+  readonly sourceId: InspectorSourceId
+  readonly generation: InspectorSourceGeneration
+  readonly requestId: InspectorQueryRequestId
+}
+
+/**
+ * Test whether a decoded carrier value belongs to the query request protocol.
+ * @param value - Decoded carrier value.
+ * @returns Whether the query request decoder owns the value.
+ */
+export function isInspectorQueryRequestEnvelope(value: unknown): boolean {
+  return isPlainObject(value) && value.t === 'query/request'
+}
+
+/**
+ * Test whether a decoded carrier value belongs to the query response protocol.
+ * @param value - Decoded carrier value.
+ * @returns Whether the query response decoder owns the value.
+ */
+export function isInspectorQueryResponseEnvelope(value: unknown): boolean {
+  return isPlainObject(value) && value.t === 'query/response'
+}
+
+/**
+ * Decode one source-to-Worker query request.
+ * @param value - Untrusted decoded carrier value.
+ * @returns The detached, validated request frame.
+ */
+export function parseInspectorQueryRequestFrame(value: unknown): InspectorQueryRequestFrame {
+  const record = exactObject(value, ['v', 't', 'sourceId', 'generation', 'requestId', 'query'], 'query request')
+  if (record.v !== INSPECTOR_PROTOCOL_VERSION || record.t !== 'query/request') {
+    throw new Error('inspector protocol: invalid query request envelope')
+  }
+  return {
+    v: INSPECTOR_PROTOCOL_VERSION,
+    t: 'query/request',
+    sourceId: wireId<'InspectorSourceId'>(record.sourceId, 'sourceId'),
+    generation: wireId<'InspectorSourceGeneration'>(record.generation, 'generation'),
+    requestId: wireId<'InspectorQueryRequestId'>(record.requestId, 'requestId'),
+    query: parseQuery(record.query),
+  }
+}
+
+/**
+ * Decode correlation fields used to reject a malformed request without timing out its caller.
+ * @param value - Candidate query request frame.
+ * @returns Validated source and request identities.
+ */
+export function parseInspectorQueryFrameIdentity(value: unknown): InspectorQueryFrameIdentity {
+  if (!isPlainObject(value) || value.v !== INSPECTOR_PROTOCOL_VERSION || value.t !== 'query/request') {
+    throw new Error('inspector protocol: invalid query request envelope')
+  }
+  return {
+    sourceId: wireId<'InspectorSourceId'>(value.sourceId, 'sourceId'),
+    generation: wireId<'InspectorSourceGeneration'>(value.generation, 'generation'),
+    requestId: wireId<'InspectorQueryRequestId'>(value.requestId, 'requestId'),
+  }
+}
+
+/**
+ * Decode one Worker-to-source query response.
+ * @param value - Untrusted decoded carrier value.
+ * @returns The detached, validated response frame.
+ */
+export function parseInspectorQueryResponseFrame(value: unknown): InspectorQueryResponseFrame {
+  const record = exactObject(value, ['v', 't', 'sourceId', 'generation', 'requestId', 'outcome'], 'query response')
+  if (record.v !== INSPECTOR_PROTOCOL_VERSION || record.t !== 'query/response') {
+    throw new Error('inspector protocol: invalid query response envelope')
+  }
+  return {
+    v: INSPECTOR_PROTOCOL_VERSION,
+    t: 'query/response',
+    sourceId: wireId<'InspectorSourceId'>(record.sourceId, 'sourceId'),
+    generation: wireId<'InspectorSourceGeneration'>(record.generation, 'generation'),
+    requestId: wireId<'InspectorQueryRequestId'>(record.requestId, 'requestId'),
+    outcome: parseOutcome(record.outcome),
+  }
+}
+
+function parseQuery(value: unknown): InspectorQuery {
+  const record = exactObject(value, ['op'], 'Inspector query')
+  if (record.op !== 'cordis-tree/get') {
+    throw new Error(`inspector protocol: unknown query operation ${JSON.stringify(record.op)}`)
+  }
+  return { op: 'cordis-tree/get' }
+}
+
+function parseResult(value: unknown): InspectorQueryResult {
+  if (!isPlainObject(value) || typeof value.op !== 'string') {
+    throw new Error('inspector protocol: query result must have an op')
+  }
+  switch (value.op) {
+    case 'cordis-tree/get':
+      exactKeys(value, ['op', 'tree'], 'Cordis tree query result')
+      return { op: 'cordis-tree/get', tree: parseCordisRuntimeTree(value.tree) }
+    default:
+      throw new Error(`inspector protocol: unknown query result ${JSON.stringify(value.op)}`)
+  }
+}
+
+function parseOutcome(value: unknown): InspectorQueryResponseFrame['outcome'] {
+  if (!isPlainObject(value) || typeof value.ok !== 'boolean') {
+    throw new Error('inspector protocol: invalid query outcome')
+  }
+  if (value.ok) {
+    exactKeys(value, ['ok', 'result'], 'successful query outcome')
+    return { ok: true, result: parseResult(value.result) }
+  }
+  exactKeys(value, ['ok', 'error'], 'failed query outcome')
+  const error = exactObject(value.error, ['code', 'message'], 'query error')
+  if (!QUERY_ERROR_CODES.has(error.code as InspectorQueryError['code']) || typeof error.message !== 'string') {
+    throw new Error('inspector protocol: invalid query error')
+  }
+  return {
+    ok: false,
+    error: { code: error.code as InspectorQueryError['code'], message: error.message },
+  }
+}
+
+const QUERY_ERROR_CODES = new Set<InspectorQueryError['code']>([
+  'invalid-request', 'stale-source', 'result-too-large', 'internal-error',
+])

+ 40 - 0
packages/experimental/inspector/src/shared/bridge/messages/query/commands.ts

@@ -0,0 +1,40 @@
+/** Closed non-CDP Inspector query and result model. */
+
+import type { CordisRuntimeTree } from '../../../cordis/model.ts'
+
+/** Read the latest committed Cordis runtime tree. */
+export interface CordisTreeGetQuery {
+  readonly op: 'cordis-tree/get'
+}
+
+/** Query operations accepted by the Inspector Worker. */
+export type InspectorQuery = CordisTreeGetQuery
+
+/** Result of reading the latest committed Cordis runtime tree. */
+export interface CordisTreeGetResult {
+  readonly op: 'cordis-tree/get'
+  readonly tree: CordisRuntimeTree
+}
+
+/** Results correlated to {@link InspectorQuery} by `op`. */
+export type InspectorQueryResult = CordisTreeGetResult
+
+/** Result member corresponding to one query member. */
+export type InspectorQueryResultFor<Query extends InspectorQuery> = Extract<InspectorQueryResult, { op: Query['op'] }>
+
+/** Stable Worker-side query failure. */
+export interface InspectorQueryError {
+  readonly code: 'invalid-request' | 'stale-source' | 'result-too-large' | 'internal-error'
+  readonly message: string
+}
+
+/** Host/Client interface implemented by the shared correlated-query owner. */
+export interface InspectorQueryRequester {
+  /**
+   * Execute one query against the current connected source generation.
+   * @param query - Closed typed query command.
+   * @returns The result with the same operation discriminant.
+   * @throws When transport or Worker processing cannot settle the request successfully.
+   */
+  request<Query extends InspectorQuery>(query: Query): Promise<InspectorQueryResultFor<Query>>
+}

+ 30 - 0
packages/experimental/inspector/src/shared/bridge/messages/query/frames.ts

@@ -0,0 +1,30 @@
+/** Versioned frames for source-to-Worker non-CDP queries. */
+
+import type { InspectorId, InspectorSourceGeneration, InspectorSourceId } from '../../ids.ts'
+import { INSPECTOR_PROTOCOL_VERSION } from '../../version.ts'
+import type { InspectorQuery, InspectorQueryError, InspectorQueryResult } from './commands.ts'
+
+/** Identity of one in-flight Inspector query. */
+export type InspectorQueryRequestId = InspectorId<'InspectorQueryRequestId'>
+
+/** Source request for one Worker-owned query operation. */
+export interface InspectorQueryRequestFrame {
+  readonly v: typeof INSPECTOR_PROTOCOL_VERSION
+  readonly t: 'query/request'
+  readonly sourceId: InspectorSourceId
+  readonly generation: InspectorSourceGeneration
+  readonly requestId: InspectorQueryRequestId
+  readonly query: InspectorQuery
+}
+
+/** Worker response correlated to one source query request. */
+export interface InspectorQueryResponseFrame {
+  readonly v: typeof INSPECTOR_PROTOCOL_VERSION
+  readonly t: 'query/response'
+  readonly sourceId: InspectorSourceId
+  readonly generation: InspectorSourceGeneration
+  readonly requestId: InspectorQueryRequestId
+  readonly outcome:
+    | { readonly ok: true; readonly result: InspectorQueryResult }
+    | { readonly ok: false; readonly error: InspectorQueryError }
+}

+ 5 - 0
packages/experimental/inspector/src/shared/bridge/messages/query/index.ts

@@ -0,0 +1,5 @@
+/** Public exports for the non-CDP Inspector query protocol. */
+
+export * from './codec.ts'
+export * from './commands.ts'
+export * from './frames.ts'

+ 18 - 0
packages/experimental/inspector/src/shared/bridge/query-reader.ts

@@ -0,0 +1,18 @@
+/** Query-backed adapter for the transport-independent Cordis tree reader. */
+
+import type { CordisRuntimeTreeReader } from '../cordis/reader.ts'
+import type { InspectorQueryRequester } from './messages/query/commands.ts'
+
+/**
+ * Create a reader that obtains the tree through the typed Inspector query protocol.
+ * @param requester - Active Host or Client query connection.
+ * @returns A non-CDP Cordis tree reader.
+ */
+export function createQueryCordisRuntimeTreeReader(requester: InspectorQueryRequester): CordisRuntimeTreeReader {
+  return {
+    async getTree() {
+      const result = await requester.request({ op: 'cordis-tree/get' })
+      return result.tree
+    },
+  }
+}

+ 195 - 0
packages/experimental/inspector/src/shared/cordis/collector.ts

@@ -0,0 +1,195 @@
+/** Shared Host/Client projection from live Cordis objects to a bounded semantic tree. */
+
+import { Context, type Fiber } from '@deepseek-ai/cordis'
+import { jsonByteLength, type InspectorJsonValue } from '../json.ts'
+import {
+  CORDIS_TREE_SCHEMA_VERSION,
+  type CordisContextTreeNode,
+  type CordisFiberTreeNode,
+  type CordisTreeSnapshot,
+} from './snapshot.ts'
+import type { InspectorObjectHandle } from './ids.ts'
+import { RealmObjectRegistry } from './object-registry.ts'
+
+const SHADOW = Symbol.for('cordis.shadow')
+
+/** Bounds applied before one snapshot enters a source frame. */
+export interface CordisTreeLimits {
+  readonly maxNodes: number
+  readonly maxBytes: number
+}
+
+interface ContextInfo {
+  readonly value: Context
+  readonly children: ContextInfo[]
+  readonly fiber: Fiber | undefined
+}
+
+interface MutableContextNode extends Omit<CordisContextTreeNode, 'children'> {
+  readonly children: MutableTreeNode[]
+}
+
+interface MutableFiberNode extends Omit<CordisFiberTreeNode, 'children'> {
+  readonly children: [MutableContextNode]
+}
+
+type MutableTreeNode = MutableContextNode | MutableFiberNode
+
+/** Realm-local collector with a current live-object table. */
+export class CordisTreeCollector {
+  /** Live-object table replaced atomically with each emitted snapshot. */
+  readonly objects = new RealmObjectRegistry()
+  private revision = 0
+
+  constructor(private readonly root: Context, private readonly limits: CordisTreeLimits) {}
+
+  /**
+   * Capture the current reachable Context/Fiber tree.
+   * @returns A detached JSON snapshot whose retained objects replace the prior generation atomically.
+   */
+  snapshot(): CordisTreeSnapshot {
+    const tree = collectContexts(this.root)
+    const objects = this.objects.begin()
+    let nodeCount = 0
+    let truncated = false
+
+    const contextNode = (info: ContextInfo): MutableContextNode | undefined => {
+      if (nodeCount >= this.limits.maxNodes) {
+        truncated = true
+        return undefined
+      }
+      nodeCount++
+      const node: MutableContextNode = {
+        kind: 'context',
+        objectHandle: objects.retain(info.value).handle,
+        children: [],
+      }
+      for (const child of info.children) {
+        if (child.fiber !== undefined && child.fiber.ctx === child.value) {
+          const projected = fiberNode(child.fiber, child)
+          if (projected !== undefined) node.children.push(projected)
+        } else {
+          const projected = contextNode(child)
+          if (projected !== undefined) node.children.push(projected)
+        }
+      }
+      return node
+    }
+    const fiberNode = (fiber: Fiber, owned: ContextInfo): MutableFiberNode | undefined => {
+      if (fiber.uid === null) return undefined
+      if (nodeCount + 2 > this.limits.maxNodes) {
+        truncated = true
+        return undefined
+      }
+      nodeCount++
+      const context = contextNode(owned)
+      if (context === undefined) throw new Error('inspector: reserved Fiber Context was not collected')
+      return {
+        kind: 'fiber',
+        objectHandle: objects.retain(fiber).handle,
+        uid: fiber.uid,
+        children: [context],
+      }
+    }
+
+    const root = contextNode(tree)
+    if (root === undefined) throw new Error('inspector: maxNodes cannot retain the root Context')
+    let snapshot: CordisTreeSnapshot = {
+      schemaVersion: CORDIS_TREE_SCHEMA_VERSION,
+      revision: ++this.revision,
+      objectRegistryId: this.objects.id,
+      root,
+      truncated,
+    }
+    while (jsonByteLength(snapshot as unknown as InspectorJsonValue) > this.limits.maxBytes) {
+      const removed = pruneLast(root)
+      if (removed.length === 0) break
+      for (const handle of removed) objects.release(handle)
+      snapshot = { ...snapshot, truncated: true }
+    }
+    if (jsonByteLength(snapshot as unknown as InspectorJsonValue) > this.limits.maxBytes) {
+      throw new Error('inspector: Cordis root exceeds the source-frame byte limit')
+    }
+    objects.commit()
+    return snapshot
+  }
+
+  /** Release the realm-global resolver and every retained object. */
+  close(): void {
+    this.objects.close()
+  }
+}
+
+function collectContexts(root: Context): ContextInfo {
+  const contexts = new Map<Context, ContextInfo>()
+  const ensure = (candidate: unknown, depth = 0): ContextInfo | undefined => {
+    if (depth > 100) return undefined
+    const value = unwrapContext(candidate)
+    if (!Context.is(value)) return undefined
+    const existing = contexts.get(value)
+    if (existing !== undefined) return existing
+    if (value === root) {
+      const info = describeContext(value)
+      contexts.set(value, info)
+      return info
+    }
+    const prototype = unwrapContext(Object.getPrototypeOf(value) as unknown)
+    const parent = ensure(prototype, depth + 1)
+    if (parent === undefined) return undefined
+    const info = describeContext(value)
+    contexts.set(value, info)
+    parent.children.push(info)
+    return info
+  }
+
+  const rootInfo = ensure(root)
+  if (rootInfo === undefined) throw new Error('inspector: Cordis root context is not reachable')
+  for (const runtime of root.registry.values()) {
+    for (const fiber of runtime.fibers) {
+      if (fiber.uid === null) continue
+      ensure(fiber.parent)
+      ensure(fiber.ctx)
+    }
+  }
+  for (const key of Reflect.ownKeys(root.events._hooks)) {
+    for (const hook of root.events._hooks[key] ?? []) ensure(hook.ctx)
+  }
+  const order = (info: ContextInfo): number => info.fiber?.uid ?? Number.MAX_SAFE_INTEGER
+  for (const info of contexts.values()) {
+    info.children.sort((left, right) => order(left) - order(right))
+  }
+  return rootInfo
+}
+
+function describeContext(value: Context): ContextInfo {
+  const fiber = ownValue(value, 'fiber') as Fiber | undefined
+  return { value, children: [], fiber }
+}
+
+function ownValue(value: object, key: PropertyKey): unknown {
+  return Reflect.getOwnPropertyDescriptor(value, key)?.value
+}
+
+function unwrapContext(value: unknown): unknown {
+  let current = value
+  while (typeof current === 'object' && current !== null && Object.hasOwn(current, SHADOW)) {
+    current = Object.getPrototypeOf(current)
+  }
+  return current
+}
+
+function pruneLast(context: MutableContextNode): InspectorObjectHandle[] {
+  const child = context.children.at(-1)
+  if (child === undefined) return []
+  if (child.kind === 'context') {
+    const nested = pruneLast(child)
+    if (nested.length > 0) return nested
+    context.children.pop()
+    return [child.objectHandle]
+  }
+  const owned = child.children[0]
+  const nested = pruneLast(owned)
+  if (nested.length > 0) return nested
+  context.children.pop()
+  return [child.objectHandle, owned.objectHandle]
+}

+ 9 - 0
packages/experimental/inspector/src/shared/cordis/ids.ts

@@ -0,0 +1,9 @@
+/** Opaque identifiers owned by a realm-local Cordis object registry. */
+
+import type { InspectorId } from '../identity.ts'
+
+/** Identity of one realm-local table that retains objects named in a snapshot. */
+export type InspectorObjectRegistryId = InspectorId<'InspectorObjectRegistryId'>
+
+/** Opaque reference to one object retained by a realm-local registry. */
+export type InspectorObjectHandle = InspectorId<'InspectorObjectHandle'>

+ 161 - 0
packages/experimental/inspector/src/shared/cordis/model.ts

@@ -0,0 +1,161 @@
+/** Consumer-neutral Cordis runtime tree shared by non-CDP readers. */
+
+import { CORDIS_TREE_MAX_DEPTH } from './snapshot.ts'
+import { inspectorId, type InspectorId } from '../identity.ts'
+import { isPlainObject } from '../json.ts'
+import { exactKeys, exactObject, wireId } from '../validation.ts'
+
+/** Current consumer-neutral Cordis tree version. */
+export const CORDIS_RUNTIME_TREE_SCHEMA_VERSION = 0 as const
+
+/** Consumer-visible identity of one inspected Cordis runtime. */
+export type CordisRuntimeSourceId = InspectorId<'CordisRuntimeSourceId'>
+
+/** Execution environment represented by one consumer-visible Cordis runtime. */
+export type CordisRuntimeSourceKind = 'host' | 'client'
+
+/** Availability of the realm represented by a retained tree. */
+export type CordisRuntimeConnection =
+  | { readonly state: 'connected' }
+  | { readonly state: 'disconnected'; readonly reason: string }
+
+/** Consumer-visible identity of one Cordis realm. */
+export interface CordisRuntimeSource {
+  readonly sourceId: CordisRuntimeSourceId
+  readonly kind: CordisRuntimeSourceKind
+  readonly label: string
+}
+
+/** One Context in a consumer-neutral Cordis tree. */
+export interface CordisRuntimeContext {
+  readonly kind: 'context'
+  readonly children: readonly CordisRuntimeNode[]
+}
+
+/** One Fiber and its owned Context in a consumer-neutral Cordis tree. */
+export interface CordisRuntimeFiber {
+  readonly kind: 'fiber'
+  readonly uid: number
+  readonly children: readonly [CordisRuntimeContext]
+}
+
+/** One semantic Cordis runtime node. */
+export type CordisRuntimeNode = CordisRuntimeContext | CordisRuntimeFiber
+
+/** Latest retained topology and availability of one Cordis realm. */
+export interface CordisRuntimeRealm {
+  readonly source: CordisRuntimeSource
+  readonly connection: CordisRuntimeConnection
+  readonly revision: number
+  readonly truncated: boolean
+  readonly root: CordisRuntimeContext
+}
+
+/** Latest Host and Client Cordis topology without routing or CDP identifiers. */
+export interface CordisRuntimeTree {
+  readonly schemaVersion: typeof CORDIS_RUNTIME_TREE_SCHEMA_VERSION
+  readonly host: CordisRuntimeRealm | null
+  readonly clients: readonly CordisRuntimeRealm[]
+}
+
+/**
+ * Decode a consumer-neutral tree received across an Inspector transport.
+ * @param value - Untrusted query result value.
+ * @returns A detached tree containing only public semantic fields.
+ */
+export function parseCordisRuntimeTree(value: unknown): CordisRuntimeTree {
+  const record = exactObject(value, ['schemaVersion', 'host', 'clients'], 'Cordis runtime tree')
+  if (record.schemaVersion !== CORDIS_RUNTIME_TREE_SCHEMA_VERSION || !Array.isArray(record.clients)) {
+    throw new Error('inspector protocol: invalid Cordis runtime tree')
+  }
+  const host = record.host === null ? null : parseRealm(record.host, 'host')
+  const clients = record.clients.map(client => parseRealm(client, 'client'))
+  const sourceIds = new Set<CordisRuntimeSourceId>()
+  for (const realm of host === null ? clients : [host, ...clients]) {
+    if (sourceIds.has(realm.source.sourceId)) {
+      throw new Error('inspector protocol: Cordis runtime tree repeats a sourceId')
+    }
+    sourceIds.add(realm.source.sourceId)
+  }
+  return {
+    schemaVersion: CORDIS_RUNTIME_TREE_SCHEMA_VERSION,
+    host,
+    clients,
+  }
+}
+
+function parseRealm(value: unknown, kind: CordisRuntimeSourceKind): CordisRuntimeRealm {
+  const record = exactObject(value, ['source', 'connection', 'revision', 'truncated', 'root'], 'Cordis runtime realm')
+  const source = exactObject(record.source, ['sourceId', 'kind', 'label'], 'Cordis runtime source')
+  if (source.kind !== kind || typeof source.label !== 'string' || source.label.length === 0 || source.label.length > 256) {
+    throw new Error(`inspector protocol: invalid ${kind} Cordis runtime source`)
+  }
+  if (!Number.isSafeInteger(record.revision) || (record.revision as number) < 1 || typeof record.truncated !== 'boolean') {
+    throw new Error('inspector protocol: invalid Cordis runtime realm header')
+  }
+  const root = parseNode(record.root, { fiberUids: new Set() }, 0)
+  if (root.kind !== 'context') throw new Error('inspector protocol: Cordis runtime root must be a Context')
+  return {
+    source: {
+      sourceId: wireId<'CordisRuntimeSourceId'>(source.sourceId, 'sourceId'),
+      kind,
+      label: source.label,
+    },
+    connection: parseConnection(record.connection),
+    revision: record.revision as number,
+    truncated: record.truncated,
+    root,
+  }
+}
+
+/**
+ * Project an inspected source id into the consumer-visible Cordis identity namespace.
+ * @param value - Stable source id carried by the current runtime observation.
+ * @returns The corresponding Cordis runtime source id.
+ */
+export function cordisRuntimeSourceId(value: string): CordisRuntimeSourceId {
+  return inspectorId<'CordisRuntimeSourceId'>(value, 'sourceId')
+}
+
+function parseConnection(value: unknown): CordisRuntimeConnection {
+  if (!isPlainObject(value)) throw new Error('inspector protocol: Cordis runtime connection must be an object')
+  if (value.state === 'connected') {
+    exactKeys(value, ['state'], 'connected Cordis runtime connection')
+    return { state: 'connected' }
+  }
+  if (value.state === 'disconnected' && typeof value.reason === 'string') {
+    exactKeys(value, ['state', 'reason'], 'disconnected Cordis runtime connection')
+    return { state: 'disconnected', reason: value.reason }
+  }
+  throw new Error('inspector protocol: invalid Cordis runtime connection')
+}
+
+interface ParseState {
+  readonly fiberUids: Set<number>
+}
+
+function parseNode(value: unknown, state: ParseState, depth: number): CordisRuntimeNode {
+  if (depth > CORDIS_TREE_MAX_DEPTH) throw new Error('inspector protocol: Cordis runtime tree exceeds the depth limit')
+  if (!isPlainObject(value) || (value.kind !== 'context' && value.kind !== 'fiber')) {
+    throw new Error('inspector protocol: Cordis runtime node must have a known kind')
+  }
+  const record = exactObject(value, value.kind === 'fiber'
+    ? ['kind', 'uid', 'children']
+    : ['kind', 'children'], 'Cordis runtime node')
+  if (!Array.isArray(record.children)) throw new Error('inspector protocol: Cordis runtime node children must be an array')
+  if (record.kind === 'context') {
+    return { kind: 'context', children: record.children.map(child => parseNode(child, state, depth + 1)) }
+  }
+  if (record.kind !== 'fiber'
+    || !Number.isSafeInteger(record.uid)
+    || (record.uid as number) < 1
+    || record.children.length !== 1) {
+    throw new Error('inspector protocol: invalid Cordis runtime Fiber')
+  }
+  const uid = record.uid as number
+  if (state.fiberUids.has(uid)) throw new Error('inspector protocol: Cordis runtime tree repeats a Fiber uid')
+  state.fiberUids.add(uid)
+  const context = parseNode(record.children[0], state, depth + 1)
+  if (context.kind !== 'context') throw new Error('inspector protocol: Cordis runtime Fiber child must be a Context')
+  return { kind: 'fiber', uid, children: [context] }
+}

+ 23 - 0
packages/experimental/inspector/src/shared/cordis/object-reference.ts

@@ -0,0 +1,23 @@
+/** Opaque references to live objects retained inside an observation source realm. */
+
+import type { InspectorObjectHandle, InspectorObjectRegistryId } from './ids.ts'
+import { exactObject, wireId } from '../validation.ts'
+
+/** Wire-safe identity of one live object; the source generation supplies the realm identity. */
+export interface InspectorObjectReference {
+  readonly registryId: InspectorObjectRegistryId
+  readonly handle: InspectorObjectHandle
+}
+
+/**
+ * Decode one source-local live-object reference.
+ * @param value - Untrusted wire value.
+ * @returns The validated opaque reference.
+ */
+export function parseInspectorObjectReference(value: unknown): InspectorObjectReference {
+  const record = exactObject(value, ['registryId', 'handle'], 'object reference')
+  return {
+    registryId: wireId<'InspectorObjectRegistryId'>(record.registryId, 'registryId'),
+    handle: wireId<'InspectorObjectHandle'>(record.handle, 'handle'),
+  }
+}

+ 176 - 0
packages/experimental/inspector/src/shared/cordis/object-registry.ts

@@ -0,0 +1,176 @@
+/** Realm-local retention and identity for live objects referenced by Inspector snapshots. */
+
+import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
+import { inspectorId } from '../identity.ts'
+import {
+  type InspectorObjectHandle,
+  type InspectorObjectRegistryId,
+} from './ids.ts'
+import type { InspectorObjectReference } from './object-reference.ts'
+
+const REGISTRIES_SYMBOL = 'dsh.inspector.realm-object-registries'
+const MAX_FIBER_WRAPPER_DEPTH = 8
+
+/** Self-contained function sent through CDP to identify its `this` object in the inspected realm. */
+export const IDENTIFY_REALM_OBJECT_FUNCTION = `function () {
+  const table = globalThis[Symbol.for(${JSON.stringify(REGISTRIES_SYMBOL)})]
+  if (!(table instanceof Map)) return undefined
+  for (const registry of table.values()) {
+    const reference = registry.identify(this)
+    if (reference !== undefined) return reference
+  }
+  return undefined
+}`
+
+/** One realm's bounded table of objects retained by its latest semantic snapshot. */
+export class RealmObjectRegistry {
+  /** Realm-unique id carried by every reference from this registry. */
+  readonly id = inspectorId<'InspectorObjectRegistryId'>(randomUUID(), 'registryId')
+  private readonly known = new WeakMap<object, InspectorObjectHandle>()
+  private retained = new Map<InspectorObjectHandle, object>()
+  private nextHandle = 1
+  private disposed = false
+
+  constructor() {
+    registries().set(this.id, this)
+  }
+
+  /**
+   * Start one replacement generation.
+   * @returns A collector that atomically installs exactly the retained objects on commit.
+   */
+  begin(): RealmObjectGeneration {
+    if (this.disposed) throw new Error('inspector: realm object registry is disposed')
+    return new RealmObjectGeneration(this)
+  }
+
+  /**
+   * Resolve one current opaque handle.
+   * @param handle - Handle from the latest committed snapshot.
+   * @returns The live object, when it remains retained.
+   */
+  resolve(handle: InspectorObjectHandle): object | undefined {
+    return this.retained.get(handle)
+  }
+
+  /**
+   * Identify one object retained by the latest snapshot. Cordis plugin calls may return nested thenable facades;
+   * only objects whose prototype path consists exclusively of those `then` wrappers resolve to the retained Fiber.
+   * @param value - Candidate live value.
+   * @returns Its wire reference, when present in this registry.
+   */
+  identify(value: unknown): InspectorObjectReference | undefined {
+    if ((typeof value !== 'object' || value === null) && typeof value !== 'function') return undefined
+    let candidate: object | null = value
+    for (let depth = 0; candidate !== null && depth <= MAX_FIBER_WRAPPER_DEPTH; depth++) {
+      const handle = this.known.get(candidate)
+      if (handle !== undefined && this.retained.get(handle) === candidate) return { registryId: this.id, handle }
+      try {
+        const keys = Reflect.ownKeys(candidate)
+        if (keys.length !== 1 || keys[0] !== 'then') return undefined
+        candidate = Object.getPrototypeOf(candidate) as object | null
+      } catch {
+        // A hostile proxy cannot prevent later registries from checking the original value.
+        return undefined
+      }
+    }
+    return undefined
+  }
+
+  /** Remove this registry from the realm and release all strong references. */
+  close(): void {
+    if (this.disposed) return
+    this.disposed = true
+    registries().delete(this.id)
+    this.retained.clear()
+  }
+
+  /**
+   * Assign a stable handle and retain a value in one pending generation.
+   * @param value - Object represented by the pending snapshot.
+   * @param next - Pending generation's strong-reference table.
+   * @returns The registry id and stable object handle.
+   */
+  retain(value: object, next: Map<InspectorObjectHandle, object>): InspectorObjectReference {
+    let handle = this.known.get(value)
+    if (handle === undefined) {
+      handle = inspectorId<'InspectorObjectHandle'>(`object-${String(this.nextHandle++)}`, 'objectHandle')
+      this.known.set(value, handle)
+    }
+    next.set(handle, value)
+    return { registryId: this.id, handle }
+  }
+
+  /**
+   * Replace the current strong-reference set with one completed generation.
+   * @param next - Complete object table for the committed snapshot.
+   */
+  commit(next: Map<InspectorObjectHandle, object>): void {
+    this.retained = next
+  }
+}
+
+/** Mutable object set assembled before one snapshot becomes visible. */
+export class RealmObjectGeneration {
+  private readonly retained = new Map<InspectorObjectHandle, object>()
+  private committed = false
+
+  constructor(private readonly owner: RealmObjectRegistry) {}
+
+  /**
+   * Retain one object and obtain its stable opaque reference.
+   * @param value - Context or Fiber represented in the snapshot.
+   * @returns Source-local wire reference.
+   */
+  retain(value: object): InspectorObjectReference {
+    if (this.committed) throw new Error('inspector: realm object generation is already committed')
+    return this.owner.retain(value, this.retained)
+  }
+
+  /**
+   * Stop retaining an object omitted while bounding the pending snapshot.
+   * @param handle - Opaque handle removed from this pending generation.
+   */
+  release(handle: InspectorObjectHandle): void {
+    if (this.committed) throw new Error('inspector: realm object generation is already committed')
+    this.retained.delete(handle)
+  }
+
+  /** Atomically replace the registry's retained set. */
+  commit(): void {
+    if (this.committed) return
+    this.committed = true
+    this.owner.commit(this.retained)
+  }
+}
+
+/**
+ * Build an expression that resolves one reference inside its owning realm.
+ * @param reference - Validated source-local object reference.
+ * @returns Side-effect-free JavaScript expression for Runtime evaluation.
+ */
+export function realmObjectExpression(reference: InspectorObjectReference): string {
+  return `globalThis[Symbol.for(${JSON.stringify(REGISTRIES_SYMBOL)})]?.get(${JSON.stringify(reference.registryId)})?.resolve(${JSON.stringify(reference.handle)})`
+}
+
+/**
+ * Identify a retained object across all Inspector collectors in this realm.
+ * @param value - Runtime value returned to a debugger.
+ * @returns Its source-local reference, when the value is a visible entity.
+ */
+export function identifyRealmObject(value: unknown): InspectorObjectReference | undefined {
+  for (const registry of registries().values()) {
+    const reference = registry.identify(value)
+    if (reference !== undefined) return reference
+  }
+  return undefined
+}
+
+function registries(): Map<InspectorObjectRegistryId, RealmObjectRegistry> {
+  const key = Symbol.for(REGISTRIES_SYMBOL)
+  const existing = Reflect.get(globalThis, key) as unknown
+  if (existing instanceof Map) return existing as Map<InspectorObjectRegistryId, RealmObjectRegistry>
+  const value = new Map<InspectorObjectRegistryId, RealmObjectRegistry>()
+  Reflect.set(globalThis, key, value)
+  return value
+}

+ 46 - 0
packages/experimental/inspector/src/shared/cordis/observer.ts

@@ -0,0 +1,46 @@
+/** Lifecycle-driven Cordis tree publication shared by Host and Client plugin faces. */
+
+import type { Context } from '@deepseek-ai/cordis'
+import type { CordisTreeSnapshot } from './snapshot.ts'
+import { CordisTreeCollector, type CordisTreeLimits } from './collector.ts'
+
+/** Receives one complete semantic snapshot after a coalesced Cordis mutation. */
+export type CordisTreeSnapshotListener = (snapshot: CordisTreeSnapshot) => void
+
+/**
+ * Observe one Cordis realm and publish immutable tree replacements.
+ * @param ctx - Plugin context whose root is inspected and whose effects own listeners.
+ * @param listener - Consumer of complete snapshots in the inspected realm.
+ * @param limits - Snapshot node and encoded-byte limits.
+ * @returns A disposer that unregisters listeners and releases retained objects.
+ */
+export function observeCordisTree(
+  ctx: Context,
+  listener: CordisTreeSnapshotListener,
+  limits: CordisTreeLimits,
+): () => void {
+  const collector = new CordisTreeCollector(ctx.root, limits)
+  let scheduled = false
+  let closed = false
+  const publish = (): void => {
+    scheduled = false
+    if (closed) return
+    listener(collector.snapshot())
+  }
+  const schedule = (): void => {
+    if (scheduled || closed) return
+    scheduled = true
+    queueMicrotask(publish)
+  }
+  const disposers = [
+    ctx.on('internal/plugin', schedule, { global: true }),
+    ctx.on('internal/status', schedule, { global: true }),
+  ]
+  publish()
+  return () => {
+    if (closed) return
+    closed = true
+    for (const dispose of disposers) dispose()
+    collector.close()
+  }
+}

+ 88 - 0
packages/experimental/inspector/src/shared/cordis/projector.ts

@@ -0,0 +1,88 @@
+/** Pure projection from routed Cordis snapshots to the consumer-neutral tree. */
+
+import type { CordisTreeNode, CordisTreeSnapshot } from './snapshot.ts'
+import {
+  CORDIS_RUNTIME_TREE_SCHEMA_VERSION,
+  cordisRuntimeSourceId,
+  type CordisRuntimeContext,
+  type CordisRuntimeNode,
+  type CordisRuntimeSourceKind,
+  type CordisRuntimeTree,
+} from './model.ts'
+
+/** Whether a retained routed snapshot still has a live source generation. */
+export type CordisTreeSourceConnection =
+  | { readonly state: 'connected' }
+  | { readonly state: 'disconnected'; readonly reason: string }
+
+/** One source generation and its latest routed Cordis snapshot. */
+export interface CordisTreeSource {
+  readonly sourceId: string
+  readonly kind: CordisRuntimeSourceKind
+  readonly label: string
+}
+
+/** One source generation and its latest routed Cordis snapshot. */
+export interface CordisTreeSourceSnapshot<Source extends CordisTreeSource = CordisTreeSource> {
+  readonly source: Source
+  readonly snapshot: CordisTreeSnapshot
+  readonly connection: CordisTreeSourceConnection
+}
+
+/** Routed Host and Client snapshots before consumer-neutral projection. */
+export interface CordisInspectionTree<Source extends CordisTreeSource = CordisTreeSource> {
+  readonly host: CordisTreeSourceSnapshot<Source> | null
+  readonly clients: readonly CordisTreeSourceSnapshot<Source>[]
+}
+
+/**
+ * Strip transport and live-object routing fields from retained Cordis snapshots.
+ * @param tree - Worker-owned routed snapshots.
+ * @returns A detached semantic tree safe for non-CDP consumers.
+ */
+export function projectCordisRuntimeTree<Source extends CordisTreeSource>(tree: CordisInspectionTree<Source>): CordisRuntimeTree {
+  return {
+    schemaVersion: CORDIS_RUNTIME_TREE_SCHEMA_VERSION,
+    host: tree.host === null ? null : projectRealm(tree.host),
+    clients: tree.clients.map(projectRealm),
+  }
+}
+
+function projectRealm(realm: CordisTreeSourceSnapshot): CordisRuntimeTree['clients'][number] {
+  return {
+    source: {
+      sourceId: cordisRuntimeSourceId(realm.source.sourceId),
+      kind: realm.source.kind,
+      label: realm.source.label,
+    },
+    connection: realm.connection.state === 'connected'
+      ? { state: 'connected' }
+      : { state: 'disconnected', reason: realm.connection.reason },
+    revision: realm.snapshot.revision,
+    truncated: realm.snapshot.truncated,
+    root: projectContext(realm.snapshot.root),
+  }
+}
+
+function projectContext(node: Extract<CordisTreeNode, { kind: 'context' }>): CordisRuntimeContext {
+  return { kind: 'context', children: node.children.map(projectNode) }
+}
+
+function projectNode(node: CordisTreeNode): CordisRuntimeNode {
+  switch (node.kind) {
+    case 'context':
+      return projectContext(node)
+    case 'fiber':
+      return {
+        kind: 'fiber',
+        uid: node.uid,
+        children: [projectContext(node.children[0])],
+      }
+    default:
+      return assertNever(node)
+  }
+}
+
+function assertNever(value: never): never {
+  throw new Error(`Unexpected Cordis tree node: ${JSON.stringify(value)}`)
+}

+ 24 - 0
packages/experimental/inspector/src/shared/cordis/reader.ts

@@ -0,0 +1,24 @@
+/** Environment-independent Cordis runtime tree reader. */
+
+import type { CordisRuntimeTree } from './model.ts'
+
+/** Read-only access to the latest committed consumer-neutral Cordis tree. */
+export interface CordisRuntimeTreeReader {
+  /**
+   * Read the latest Worker snapshot without activating CDP domains.
+   * @returns A detached Host and Client Cordis tree.
+   * @throws When the source transport is unavailable, closes, times out, or rejects the query.
+   */
+  getTree(): Promise<CordisRuntimeTree>
+}
+
+/**
+ * Create a reader around a local committed-tree projection.
+ * @param read - Synchronous or asynchronous latest-tree read.
+ * @returns A reader suitable for query and CDP adapters.
+ */
+export function createCordisRuntimeTreeReader(
+  read: () => CordisRuntimeTree | Promise<CordisRuntimeTree>,
+): CordisRuntimeTreeReader {
+  return { getTree: async () => await read() }
+}

+ 111 - 0
packages/experimental/inspector/src/shared/cordis/snapshot.ts

@@ -0,0 +1,111 @@
+/** CDP-independent snapshot model for a Cordis Context and Fiber tree. */
+
+import {
+  type InspectorObjectHandle,
+  type InspectorObjectRegistryId,
+} from './ids.ts'
+import { isPlainObject } from '../json.ts'
+import { exactKeys, exactObject, wireId } from '../validation.ts'
+
+/** Current serialized Cordis tree model version. */
+export const CORDIS_TREE_SCHEMA_VERSION = 0 as const
+
+/** Maximum nesting accepted from one realm snapshot. */
+export const CORDIS_TREE_MAX_DEPTH = 256
+
+interface CordisTreeNodeBase {
+  readonly objectHandle: InspectorObjectHandle
+}
+
+/** One Context entity in a Cordis tree snapshot. */
+export interface CordisContextTreeNode extends CordisTreeNodeBase {
+  readonly kind: 'context'
+  readonly children: readonly CordisTreeNode[]
+}
+
+/** One Fiber entity in a Cordis tree snapshot. */
+export interface CordisFiberTreeNode extends CordisTreeNodeBase {
+  readonly kind: 'fiber'
+  readonly uid: number
+  readonly children: readonly [CordisContextTreeNode]
+}
+
+/** One semantic entity node in preorder. */
+export type CordisTreeNode = CordisContextTreeNode | CordisFiberTreeNode
+
+/** Immutable, serializable state of one realm's reachable Cordis tree. */
+export interface CordisTreeSnapshot {
+  readonly schemaVersion: typeof CORDIS_TREE_SCHEMA_VERSION
+  readonly revision: number
+  readonly objectRegistryId: InspectorObjectRegistryId
+  readonly root: CordisContextTreeNode
+  readonly truncated: boolean
+}
+
+/**
+ * Decode and validate one complete Cordis tree replacement.
+ * @param value - Untrusted observation payload.
+ * @param maxNodes - Maximum nodes admitted from one source.
+ * @returns A detached, validated snapshot.
+ */
+export function parseCordisTreeSnapshot(value: unknown, maxNodes: number): CordisTreeSnapshot {
+  const record = exactObject(value, [
+    'schemaVersion', 'revision', 'objectRegistryId', 'root', 'truncated',
+  ], 'Cordis tree')
+  if (record.schemaVersion !== CORDIS_TREE_SCHEMA_VERSION
+    || !Number.isSafeInteger(record.revision) || (record.revision as number) < 1
+    || typeof record.truncated !== 'boolean') {
+    throw new Error('inspector protocol: invalid Cordis tree header')
+  }
+  const state: ParseState = { count: 0, handles: new Set(), fiberUids: new Set() }
+  const root = parseNode(record.root, state, maxNodes, 0)
+  if (root.kind !== 'context') throw new Error('inspector protocol: Cordis tree root must be a Context')
+  return {
+    schemaVersion: CORDIS_TREE_SCHEMA_VERSION,
+    revision: record.revision as number,
+    objectRegistryId: wireId<'InspectorObjectRegistryId'>(record.objectRegistryId, 'objectRegistryId'),
+    root,
+    truncated: record.truncated,
+  }
+}
+
+interface ParseState {
+  count: number
+  readonly handles: Set<InspectorObjectHandle>
+  readonly fiberUids: Set<number>
+}
+
+function parseNode(value: unknown, state: ParseState, maxNodes: number, depth: number): CordisTreeNode {
+  if (depth > CORDIS_TREE_MAX_DEPTH) throw new Error('inspector protocol: Cordis tree exceeds the depth limit')
+  if (++state.count > maxNodes) throw new Error(`inspector protocol: Cordis tree exceeds ${String(maxNodes)} nodes`)
+  if (!isPlainObject(value) || (value.kind !== 'context' && value.kind !== 'fiber')) {
+    throw new Error('inspector protocol: Cordis tree node must have a known kind')
+  }
+  const objectHandle = wireId<'InspectorObjectHandle'>(value.objectHandle, 'objectHandle')
+  if (state.handles.has(objectHandle)) throw new Error('inspector protocol: Cordis tree repeats an object handle')
+  state.handles.add(objectHandle)
+  if (!Array.isArray(value.children)) throw new Error('inspector protocol: Cordis tree node children must be an array')
+  if (value.kind === 'context') {
+    exactKeys(value, ['kind', 'objectHandle', 'children'], 'Context tree node')
+    return {
+      kind: 'context',
+      objectHandle,
+      children: value.children.map(child => parseNode(child, state, maxNodes, depth + 1)),
+    }
+  }
+  exactKeys(value, ['kind', 'objectHandle', 'uid', 'children'], 'Fiber tree node')
+  if (!Number.isSafeInteger(value.uid) || (value.uid as number) < 1) {
+    throw new Error('inspector protocol: Cordis Fiber uid must be a positive safe integer')
+  }
+  if (state.fiberUids.has(value.uid as number)) throw new Error('inspector protocol: Cordis tree repeats a Fiber uid')
+  state.fiberUids.add(value.uid as number)
+  if (value.children.length !== 1) throw new Error('inspector protocol: Cordis Fiber must own exactly one Context')
+  const context = parseNode(value.children[0], state, maxNodes, depth + 1)
+  if (context.kind !== 'context') throw new Error('inspector protocol: Cordis Fiber child must be a Context')
+  return {
+    kind: 'fiber',
+    objectHandle,
+    uid: value.uid as number,
+    children: [context],
+  }
+}

+ 32 - 0
packages/experimental/inspector/src/shared/service.ts

@@ -0,0 +1,32 @@
+/** Cordis service API shared by the Host and Client plugin faces. */
+
+import type { CordisRuntimeTreeReader } from './cordis/reader.ts'
+import { createQueryCordisRuntimeTreeReader } from './bridge/query-reader.ts'
+import type { InspectorJsonValue } from './json.ts'
+import type { InspectorConnection } from './bridge/publisher.ts'
+
+/** Shared Host/Client service façade over the realm's source publisher. */
+export interface InspectorService {
+  /**
+   * Publish one JSON observation without waiting for Worker delivery.
+   * @param topic - Domain-owned topic name.
+   * @param payload - JSON value validated before it reaches the carrier.
+   * @param monotonicMs - Source-clock timestamp; defaults to `performance.now()`.
+   */
+  publish(topic: string, payload: InspectorJsonValue, monotonicMs?: number): void
+
+  /** Read-only Cordis topology queries independent of CDP sessions. */
+  readonly cordis: CordisRuntimeTreeReader
+}
+
+/**
+ * Create the shared service façade without exposing the carrier implementation.
+ * @param connection - Realm-local observation and query transport.
+ * @returns The Cordis service value.
+ */
+export function createInspectorService(connection: InspectorConnection): InspectorService {
+  return {
+    publish: (topic, payload, monotonicMs) => { connection.publish(topic, payload, monotonicMs) },
+    cordis: createQueryCordisRuntimeTreeReader(connection),
+  }
+}

+ 4 - 0
packages/experimental/inspector/src/worker/cdp/domains/dom/index.ts

@@ -0,0 +1,4 @@
+/** Cordis semantic DOM domain exports. */
+
+export { CordisDomBackend, type CordisDomChange } from './model.ts'
+export { CordisDomSession } from './session.ts'

+ 213 - 0
packages/experimental/inspector/src/worker/cdp/domains/dom/model.ts

@@ -0,0 +1,213 @@
+/** Worker projection from Cordis snapshots to a connection-neutral semantic DOM. */
+
+import type { CordisTreeNode } from '../../../../shared/cordis/snapshot.ts'
+import type { InspectorSourceDescriptor } from '../../../../shared/bridge/messages/observation.ts'
+import type { InspectorObjectReference } from '../../../../shared/cordis/object-reference.ts'
+import type { InspectorRealmDescriptor } from '../../../inspection/realm.ts'
+import { cdpNumericId, type CdpBackendNodeId } from '../../ids.ts'
+import type {
+  CordisTreeObjectRoute,
+  CordisTreeSourceSnapshot,
+  CordisTreeStore,
+  CordisTreeStoreEvent,
+} from '../../../inspection/cordis-store.ts'
+
+/** One Worker-global backend node independent of any DevTools connection. */
+export interface CordisDomNode {
+  readonly backendNodeId: CdpBackendNodeId
+  readonly key: string
+  readonly name: string
+  readonly attributes: readonly (readonly [string, string])[]
+  readonly description: string
+  readonly object?: CordisTreeObjectRoute
+  readonly children: readonly CordisDomNode[]
+}
+
+/** Immutable document revision shared by all current DevTools sessions. */
+export interface CordisDomDocument {
+  readonly revision: number
+  readonly root: CordisDomNode
+  readonly byBackendId: ReadonlyMap<CdpBackendNodeId, CordisDomNode>
+  readonly parentByBackendId: ReadonlyMap<CdpBackendNodeId, CdpBackendNodeId>
+}
+
+/** A full tree replacement or an in-place source availability change. */
+export type CordisDomChange =
+  | { readonly type: 'document-updated' }
+  | { readonly type: 'source-disconnected'; readonly source: InspectorSourceDescriptor }
+
+/** Assigns durable backend ids and projects the latest source snapshots. */
+export class CordisDomBackend {
+  private readonly backendIdByKey = new Map<string, CdpBackendNodeId>()
+  private readonly listeners = new Set<(event: CordisDomChange) => void>()
+  private documentValue: CordisDomDocument
+  private nextBackendNodeId = 1
+  private nextRevision = 1
+  private readonly unsubscribe: () => void
+  private readonly nodeByObject = new Map<string, CordisDomNode>()
+
+  constructor(private readonly trees: CordisTreeStore) {
+    this.documentValue = this.build()
+    this.unsubscribe = trees.subscribe((event) => {
+      const previous = this.documentValue
+      this.documentValue = this.build()
+      const change = this.change(event, previous)
+      for (const listener of [...this.listeners]) {
+        try {
+          listener(change)
+        } catch {
+          // One closed CDP connection cannot prevent sibling sessions from receiving the new document.
+        }
+      }
+    })
+  }
+
+  /**
+   * Read the latest connection-neutral semantic document.
+   * @returns The current immutable document revision.
+   */
+  document(): CordisDomDocument {
+    return this.documentValue
+  }
+
+  /**
+   * Subscribe to full document replacements and in-place realm state changes.
+   * @param listener - Called after a new backend revision is installed.
+   * @returns A disposer removing the listener.
+   */
+  subscribe(listener: (event: CordisDomChange) => void): () => void {
+    this.listeners.add(listener)
+    return () => { this.listeners.delete(listener) }
+  }
+
+  /** Release repository subscriptions at Worker shutdown. */
+  close(): void {
+    this.unsubscribe()
+    this.listeners.clear()
+  }
+
+  /**
+   * Resolve one source-local object reference to its current projected node.
+   * @param source - Connected source generation that owns the reference.
+   * @param reference - Realm-local registry and object handle.
+   * @returns The current projected node, when present.
+   */
+  nodeForObject(source: InspectorSourceDescriptor, reference: InspectorObjectReference): CordisDomNode | undefined {
+    return this.nodeByObject.get(objectKey(source, reference))
+  }
+
+  /**
+   * Resolve a reference when a Runtime route identifies only Host or Client ownership.
+   * @param kind - Host or Client ownership inferred by the Runtime adapter.
+   * @param reference - Realm-local registry and object handle.
+   * @returns The current projected node, when present.
+   */
+  nodeForObjectKind(kind: InspectorSourceDescriptor['kind'], reference: InspectorObjectReference): CordisDomNode | undefined {
+    const route = this.trees.resolveObjectInKind(kind, reference)
+    return route === undefined ? undefined : this.nodeForObject(route.source, reference)
+  }
+
+  /**
+   * Resolve one realm-neutral Runtime reference to its current projected node.
+   * @param realm - Realm that exposed the Runtime object.
+   * @param reference - Realm-local registry and object handle.
+   * @returns The current projected node, when present.
+   */
+  nodeForRealm(realm: InspectorRealmDescriptor, reference: InspectorObjectReference): CordisDomNode | undefined {
+    if (realm.kind === 'host') return this.nodeForObjectKind('host', reference)
+    const route = this.trees.resolveObjectIdentity(realm.sourceId, realm.generation, reference)
+    return route === undefined ? undefined : this.nodeForObject(route.source, reference)
+  }
+
+  private build(): CordisDomDocument {
+    const byBackendId = new Map<CdpBackendNodeId, CordisDomNode>()
+    const parentByBackendId = new Map<CdpBackendNodeId, CdpBackendNodeId>()
+    this.nodeByObject.clear()
+    const tree = this.trees.tree()
+    const root = this.node('document', '#document', [], '#document')
+    const host = this.node('host', 'host', [], '<host>')
+    if (tree.host !== null) host.children.push(this.entity(tree.host, tree.host.snapshot.root))
+    const clients = this.node('clients', 'clients', [], '<clients>')
+    for (const clientTree of tree.clients) {
+      const client = this.node(`client:${clientTree.source.sourceId}`, 'client', [], '<client>')
+      client.children.push(this.entity(clientTree, clientTree.snapshot.root))
+      clients.children.push(client)
+    }
+    root.children.push(host, clients)
+    const retainedKeys = new Set<string>()
+    const freeze = (node: MutableDomNode, parent?: MutableDomNode): CordisDomNode => {
+      const value: CordisDomNode = { ...node, children: node.children.map(child => freeze(child, node)) }
+      retainedKeys.add(value.key)
+      byBackendId.set(value.backendNodeId, value)
+      if (parent !== undefined) parentByBackendId.set(value.backendNodeId, parent.backendNodeId)
+      if (value.object?.connection.state === 'connected') this.nodeByObject.set(objectKey(value.object.source, {
+        registryId: value.object.snapshot.objectRegistryId,
+        handle: value.object.node.objectHandle,
+      }), value)
+      return value
+    }
+    const frozenRoot = freeze(root)
+    for (const key of this.backendIdByKey.keys()) {
+      if (!retainedKeys.has(key)) this.backendIdByKey.delete(key)
+    }
+    return { revision: this.nextRevision++, root: frozenRoot, byBackendId, parentByBackendId }
+  }
+
+  private entity(
+    tree: CordisTreeSourceSnapshot,
+    node: CordisTreeNode,
+  ): MutableDomNode {
+    const { source, snapshot } = tree
+    const key = `entity:${objectKey(source, { registryId: snapshot.objectRegistryId, handle: node.objectHandle })}`
+    const object = { ...tree, node }
+    const attributes: readonly (readonly [string, string])[] = node.kind === 'fiber'
+      ? [['uid', String(node.uid)]]
+      : []
+    const projected = this.node(key, node.kind, attributes, elementDescription(node.kind, attributes), object)
+    projected.children.push(...node.children.map(child => this.entity(tree, child)))
+    return projected
+  }
+
+  private node(
+    key: string,
+    name: string,
+    attributes: readonly (readonly [string, string])[],
+    description: string,
+    object?: CordisTreeObjectRoute,
+  ): MutableDomNode {
+    let backendNodeId = this.backendIdByKey.get(key)
+    if (backendNodeId === undefined) {
+      backendNodeId = cdpNumericId<'CdpBackendNodeId'>(this.nextBackendNodeId++, 'backendNodeId')
+      this.backendIdByKey.set(key, backendNodeId)
+    }
+    return { backendNodeId, key, name, attributes, description, ...(object === undefined ? {} : { object }), children: [] }
+  }
+
+  private change(event: CordisTreeStoreEvent, previous: CordisDomDocument): CordisDomChange {
+    if (event.type === 'source-disconnected' && sameNodeSet(previous, this.documentValue)) {
+      return { type: 'source-disconnected', source: event.source }
+    }
+    return { type: 'document-updated' }
+  }
+}
+
+interface MutableDomNode extends Omit<CordisDomNode, 'children'> {
+  readonly children: MutableDomNode[]
+}
+
+function elementDescription(name: string, attributes: readonly (readonly [string, string])[]): string {
+  const rendered = attributes.map(([key, value]) => value === '' ? key : `${key}=${JSON.stringify(value)}`).join(' ')
+  return `<${name}${rendered === '' ? '' : ` ${rendered}`}>`
+}
+
+function objectKey(source: InspectorSourceDescriptor, reference: InspectorObjectReference): string {
+  return `${source.sourceId}\0${source.generation}\0${reference.registryId}\0${reference.handle}`
+}
+
+function sameNodeSet(left: CordisDomDocument, right: CordisDomDocument): boolean {
+  if (left.byBackendId.size !== right.byBackendId.size) return false
+  for (const backendNodeId of left.byBackendId.keys()) {
+    if (!right.byBackendId.has(backendNodeId)) return false
+  }
+  return true
+}

+ 361 - 0
packages/experimental/inspector/src/worker/cdp/domains/dom/session.ts

@@ -0,0 +1,361 @@
+/** Per-DevTools-session read-only DOM projection over Cordis tree snapshots. */
+
+import { realmObjectExpression } from '../../../../shared/cordis/object-registry.ts'
+import type { InspectorSourceDescriptor } from '../../../../shared/bridge/messages/observation.ts'
+import type { InspectorObjectReference } from '../../../../shared/cordis/object-reference.ts'
+import { respondToCdpRequest, type CdpRequest, type CdpTransport } from '../../protocol.ts'
+import type { InspectorRealmDescriptor } from '../../../inspection/realm.ts'
+import type { RuntimeDomainSession } from '../runtime/index.ts'
+import type { RuntimeObjectPresentation } from '../runtime/object-table.ts'
+import type { CordisDomBackend, CordisDomChange, CordisDomNode } from './model.ts'
+import {
+  cdpNumericId,
+  cdpStringId,
+  type CdpBackendNodeId,
+  type CdpNodeId,
+  type CdpRemoteObjectId,
+} from '../../ids.ts'
+
+const READ_ONLY_METHODS = new Set([
+  'DOM.setAttributeValue', 'DOM.setAttributesAsText', 'DOM.setNodeName', 'DOM.setNodeValue',
+  'DOM.setOuterHTML', 'DOM.removeNode', 'DOM.moveTo', 'DOM.copyTo',
+])
+
+interface BoundDomObject {
+  readonly backendNodeId: CdpBackendNodeId
+  readonly sourceId: string
+  readonly generation: string
+}
+
+/** Connection-local NodeId, search, and RemoteObject mapping owner. */
+export class CordisDomSession {
+  private readonly nodeIdByBackend = new Map<CdpBackendNodeId, CdpNodeId>()
+  private readonly backendByNodeId = new Map<CdpNodeId, CdpBackendNodeId>()
+  private readonly backendByObjectId = new Map<CdpRemoteObjectId, BoundDomObject>()
+  private readonly objectIdsByGroup = new Map<string, Set<CdpRemoteObjectId>>()
+  private readonly searches = new Map<string, CdpNodeId[]>()
+  private readonly unsubscribe: () => void
+  private nextNodeId = 1
+  private nextSearchId = 1
+  private enabled = false
+
+  constructor(
+    private readonly transport: CdpTransport,
+    private readonly backend: CordisDomBackend,
+    private readonly runtime: RuntimeDomainSession,
+  ) {
+    this.unsubscribe = backend.subscribe((event) => { this.updateDocument(event) })
+  }
+
+  /**
+   * Handle one DOM command.
+   * @param request - Parsed CDP request.
+   * @returns Whether this adapter owns the method.
+   */
+  handle(request: CdpRequest): boolean {
+    if (!request.method.startsWith('DOM.')) return false
+    this.respond(request, async () => this.execute(request.method, request.params))
+    return true
+  }
+
+  /**
+   * Forget a Runtime object mapping before its owner releases the object.
+   * @param objectId - Connection-local Runtime object id.
+   */
+  releaseObject(objectId: unknown): void {
+    if (typeof objectId !== 'string') return
+    const id = cdpStringId<'CdpRemoteObjectId'>(objectId, 'objectId')
+    this.backendByObjectId.delete(id)
+    for (const ids of this.objectIdsByGroup.values()) ids.delete(id)
+  }
+
+  /**
+   * Recognize a Runtime object from any realm as one current Cordis node.
+   * @param objectId - Connection-local CDP object id.
+   * @param realm - Realm that exposed the object.
+   * @param reference - Realm-local semantic object identity.
+   * @param group - Runtime object group retaining the id.
+   * @returns Node presentation fields, when the object remains in the current tree.
+   */
+  bindObject(
+    objectId: CdpRemoteObjectId,
+    realm: InspectorRealmDescriptor,
+    reference: InspectorObjectReference,
+    group: string | undefined,
+  ): RuntimeObjectPresentation | undefined {
+    const node = this.backend.nodeForRealm(realm, reference)
+    if (node === undefined) return undefined
+    this.bindObjectId(objectId, node, group)
+    return presentation(node)
+  }
+
+  /**
+   * Forget every DOM mapping retained under one Runtime object group.
+   * @param group - Runtime object-group name.
+   */
+  releaseObjectGroup(group: unknown): void {
+    if (typeof group !== 'string') return
+    for (const objectId of this.objectIdsByGroup.get(group) ?? []) this.backendByObjectId.delete(objectId)
+    this.objectIdsByGroup.delete(group)
+  }
+
+  /** Release connection-owned ids and subscriptions. */
+  close(): void {
+    this.unsubscribe()
+    this.resetDocument()
+    this.searches.clear()
+  }
+
+  private async execute(method: string, params: Readonly<Record<string, unknown>>): Promise<object> {
+    if (READ_ONLY_METHODS.has(method)) throw new Error('Cordis DOM projection is read-only')
+    switch (method) {
+      case 'DOM.enable':
+        this.enabled = true
+        return {}
+      case 'DOM.disable':
+        this.enabled = false
+        this.resetDocument()
+        return {}
+      case 'DOM.getDocument':
+        this.enabled = true
+        return { root: this.serialize(this.backend.document().root, 0, true) }
+      case 'DOM.requestChildNodes': {
+        const node = this.fromNodeId(params.nodeId)
+        this.transport.send({
+          method: 'DOM.setChildNodes',
+          params: {
+            parentId: numberParam(params.nodeId, 'nodeId'),
+            nodes: node.children.map(child => this.serialize(child, this.nodeId(node), true)),
+          },
+        })
+        return {}
+      }
+      case 'DOM.describeNode': {
+        const node = this.selectNode(params)
+        return { node: this.serialize(node, this.parentNodeId(node), true) }
+      }
+      case 'DOM.getAttributes':
+        return { attributes: this.fromNodeId(params.nodeId).attributes.flat() }
+      case 'DOM.getOuterHTML':
+        return { outerHTML: outerHtml(this.selectNode(params)) }
+      case 'DOM.pushNodesByBackendIdsToFrontend': {
+        if (!Array.isArray(params.backendNodeIds)) throw new Error('backendNodeIds must be an array')
+        return {
+          nodeIds: params.backendNodeIds.map((value) => {
+            if (!Number.isSafeInteger(value) || (value as number) < 1) return 0
+            const node = this.backend.document().byBackendId.get(cdpBackendNodeId(value, 'backendNodeId'))
+            return node === undefined ? 0 : this.nodeId(node)
+          }),
+        }
+      }
+      case 'DOM.resolveNode':
+        return { object: await this.resolveNode(this.selectNode(params), optionalString(params.objectGroup)) }
+      case 'DOM.requestNode': {
+        const objectId = cdpStringId<'CdpRemoteObjectId'>(stringParam(params.objectId, 'objectId'), 'objectId')
+        const binding = this.backendByObjectId.get(objectId)
+        if (binding === undefined) throw new Error('RemoteObject is not a current Cordis node')
+        const node = this.backend.document().byBackendId.get(binding.backendNodeId)
+        if (node === undefined) throw new Error('Cordis node is no longer available')
+        return { nodeId: this.nodeId(node) }
+      }
+      case 'DOM.performSearch': {
+        const query = stringParam(params.query, 'query').toLowerCase()
+        const nodes = [...this.backend.document().byBackendId.values()]
+          .filter(node => node.name !== '#document' && searchable(node).includes(query))
+          .map(node => this.nodeId(node))
+        const searchId = `cordis-search-${String(this.nextSearchId++)}`
+        this.searches.set(searchId, nodes)
+        return { searchId, resultCount: nodes.length }
+      }
+      case 'DOM.getSearchResults': {
+        const ids = this.searches.get(stringParam(params.searchId, 'searchId')) ?? []
+        return {
+          nodeIds: ids.slice(nonNegativeInteger(params.fromIndex, 'fromIndex'), nonNegativeInteger(params.toIndex, 'toIndex')),
+        }
+      }
+      case 'DOM.discardSearchResults':
+        this.searches.delete(stringParam(params.searchId, 'searchId'))
+        return {}
+      case 'DOM.setInspectedNode':
+        this.fromNodeId(params.nodeId)
+        return {}
+      case 'DOM.getBoxModel':
+      case 'DOM.getNodeForLocation':
+        throw new Error('Cordis semantic nodes do not have browser layout geometry')
+      default:
+        throw new Error(`Method not found: ${method}`)
+    }
+  }
+
+  private async resolveNode(node: CordisDomNode, objectGroup: string | undefined): Promise<Readonly<Record<string, unknown>>> {
+    const route = node.object
+    if (route === undefined) throw new Error('Structural Cordis node has no live Runtime object')
+    if (route.connection.state === 'disconnected') throw new Error('Cordis realm is disconnected')
+    const expression = realmObjectExpression({
+      registryId: route.snapshot.objectRegistryId,
+      handle: route.node.objectHandle,
+    })
+    const remote = await this.runtime.resolveObject(route.source, expression, objectGroup)
+    const rawObjectId = remote.objectId
+    if (typeof rawObjectId !== 'string') throw new Error('Cordis object lookup returned no RemoteObjectId')
+    const objectId = cdpStringId<'CdpRemoteObjectId'>(rawObjectId, 'objectId')
+    this.bindObjectId(objectId, node, objectGroup)
+    return {
+      ...remote,
+      ...presentation(node),
+    }
+  }
+
+  private bindObjectId(objectId: CdpRemoteObjectId, node: CordisDomNode, group: string | undefined): void {
+    const source = node.object?.source
+    if (source === undefined) throw new Error('Structural Cordis node cannot bind a Runtime object')
+    this.backendByObjectId.set(objectId, {
+      backendNodeId: node.backendNodeId,
+      sourceId: source.sourceId,
+      generation: source.generation,
+    })
+    if (group === undefined) return
+    let ids = this.objectIdsByGroup.get(group)
+    if (ids === undefined) this.objectIdsByGroup.set(group, ids = new Set())
+    ids.add(objectId)
+  }
+
+  private selectNode(params: Readonly<Record<string, unknown>>): CordisDomNode {
+    if (params.nodeId !== undefined) return this.fromNodeId(params.nodeId)
+    if (params.backendNodeId !== undefined) {
+      const id = cdpBackendNodeId(params.backendNodeId, 'backendNodeId')
+      const node = this.backend.document().byBackendId.get(id)
+      if (node !== undefined) return node
+    }
+    if (typeof params.objectId === 'string') {
+      const binding = this.backendByObjectId.get(cdpStringId<'CdpRemoteObjectId'>(params.objectId, 'objectId'))
+      const node = binding === undefined
+        ? undefined
+        : this.backend.document().byBackendId.get(binding.backendNodeId)
+      if (node !== undefined) return node
+    }
+    throw new Error('Cordis node is not available')
+  }
+
+  private fromNodeId(value: unknown): CordisDomNode {
+    const backendId = this.backendByNodeId.get(cdpNodeId(value, 'nodeId'))
+    const node = backendId === undefined ? undefined : this.backend.document().byBackendId.get(backendId)
+    if (node === undefined) throw new Error('Cordis NodeId is not available in this document')
+    return node
+  }
+
+  private serialize(node: CordisDomNode, parentId: CdpNodeId | 0, children: boolean): object {
+    const nodeId = this.nodeId(node)
+    const document = node.name === '#document'
+    return {
+      nodeId,
+      backendNodeId: node.backendNodeId,
+      nodeType: document ? 9 : 1,
+      nodeName: document ? '#document' : node.name.toUpperCase(),
+      localName: document ? '' : node.name,
+      nodeValue: '',
+      ...(parentId === 0 ? {} : { parentId }),
+      ...(document ? { documentURL: 'dsh://cordis', baseURL: 'dsh://cordis' } : {}),
+      childNodeCount: node.children.length,
+      ...(children ? { children: node.children.map(child => this.serialize(child, nodeId, true)) } : {}),
+      attributes: node.attributes.flat(),
+    }
+  }
+
+  private nodeId(node: CordisDomNode): CdpNodeId {
+    let nodeId = this.nodeIdByBackend.get(node.backendNodeId)
+    if (nodeId === undefined) {
+      nodeId = cdpNumericId<'CdpNodeId'>(this.nextNodeId++, 'nodeId')
+      this.nodeIdByBackend.set(node.backendNodeId, nodeId)
+      this.backendByNodeId.set(nodeId, node.backendNodeId)
+    }
+    return nodeId
+  }
+
+  private parentNodeId(node: CordisDomNode): CdpNodeId | 0 {
+    const parent = this.backend.document().parentByBackendId.get(node.backendNodeId)
+    if (parent === undefined) return 0
+    const nodeValue = this.backend.document().byBackendId.get(parent)
+    return nodeValue === undefined ? 0 : this.nodeId(nodeValue)
+  }
+
+  private resetDocument(): void {
+    this.nodeIdByBackend.clear()
+    this.backendByNodeId.clear()
+    this.backendByObjectId.clear()
+    this.objectIdsByGroup.clear()
+    this.searches.clear()
+  }
+
+  private updateDocument(event: CordisDomChange): void {
+    if (event.type === 'source-disconnected') {
+      this.releaseSourceObjects(event.source)
+      return
+    }
+    this.resetDocument()
+    if (this.enabled) this.transport.send({ method: 'DOM.documentUpdated', params: {} })
+  }
+
+  private releaseSourceObjects(source: InspectorSourceDescriptor): void {
+    for (const [objectId, binding] of this.backendByObjectId) {
+      if (binding.sourceId !== source.sourceId || binding.generation !== source.generation) continue
+      this.backendByObjectId.delete(objectId)
+      for (const [group, objectIds] of this.objectIdsByGroup) {
+        objectIds.delete(objectId)
+        if (objectIds.size === 0) this.objectIdsByGroup.delete(group)
+      }
+    }
+  }
+
+  private respond(request: CdpRequest, operation: () => Promise<object>): void {
+    respondToCdpRequest(this.transport, request, operation)
+  }
+}
+
+function outerHtml(node: CordisDomNode, indent = ''): string {
+  const attributes = node.attributes.map(([name, value]) => ` ${name}=${JSON.stringify(value)}`).join('')
+  if (node.children.length === 0) return `${indent}<${node.name}${attributes} />`
+  const children = node.children.map(child => outerHtml(child, `${indent}  `)).join('\n')
+  return `${indent}<${node.name}${attributes}>\n${children}\n${indent}</${node.name}>`
+}
+
+function searchable(node: CordisDomNode): string {
+  return `${node.name} ${node.description} ${node.attributes.flat().join(' ')}`.toLowerCase()
+}
+
+function numberParam(value: unknown, name: string): number {
+  if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error(`${name} must be a non-negative integer`)
+  return value as number
+}
+
+function cdpNodeId(value: unknown, name: string): CdpNodeId {
+  if (!Number.isSafeInteger(value)) throw new Error(`${name} must be an integer`)
+  return cdpNumericId<'CdpNodeId'>(value as number, name)
+}
+
+function cdpBackendNodeId(value: unknown, name: string): CdpBackendNodeId {
+  if (!Number.isSafeInteger(value)) throw new Error(`${name} must be an integer`)
+  return cdpNumericId<'CdpBackendNodeId'>(value as number, name)
+}
+
+function nonNegativeInteger(value: unknown, name: string): number {
+  return numberParam(value, name)
+}
+
+function stringParam(value: unknown, name: string): string {
+  if (typeof value !== 'string') throw new Error(`${name} must be a string`)
+  return value
+}
+
+function optionalString(value: unknown): string | undefined {
+  if (value === undefined) return undefined
+  return stringParam(value, 'objectGroup')
+}
+
+function presentation(node: CordisDomNode): RuntimeObjectPresentation {
+  return {
+    subtype: 'node',
+    className: node.object?.node.kind === 'fiber' ? 'Fiber' : 'Context',
+    description: node.description,
+  }
+}

+ 17 - 0
packages/experimental/inspector/src/worker/inspection/cordis-query.ts

@@ -0,0 +1,17 @@
+/** Cordis tree query execution independent of its source carrier. */
+
+import type { CordisRuntimeTreeReader } from '../../shared/cordis/reader.ts'
+import type { InspectorQuery, InspectorQueryResult } from '../../shared/bridge/messages/query/commands.ts'
+
+/**
+ * Execute one closed Inspector query against the shared semantic reader.
+ * @param reader - Latest committed Cordis tree reader.
+ * @param query - Validated query command.
+ * @returns The result corresponding to the query operation.
+ */
+export async function executeInspectorQuery(
+  reader: CordisRuntimeTreeReader,
+  query: InspectorQuery,
+): Promise<InspectorQueryResult> {
+  return { op: query.op, tree: await reader.getTree() }
+}

+ 248 - 0
packages/experimental/inspector/src/worker/inspection/cordis-store.ts

@@ -0,0 +1,248 @@
+/** Worker-owned repository of CDP-independent Cordis tree snapshots. */
+
+import {
+  parseCordisTreeSnapshot,
+  type CordisTreeNode,
+  type CordisTreeSnapshot,
+} from '../../shared/cordis/snapshot.ts'
+import { CORDIS_TREE_TOPIC } from '../../shared/bridge/messages/cordis.ts'
+import type { InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts'
+import type { InspectorSourceGeneration, InspectorSourceId } from '../../shared/bridge/ids.ts'
+import type { InspectorObjectReference } from '../../shared/cordis/object-reference.ts'
+import {
+  projectCordisRuntimeTree,
+  type CordisInspectionTree as SharedCordisInspectionTree,
+  type CordisTreeSourceSnapshot as SharedCordisTreeSourceSnapshot,
+} from '../../shared/cordis/projector.ts'
+import type { CordisRuntimeTree } from '../../shared/cordis/model.ts'
+import type { IngestedInspectorRecord, InspectorRecordConsumer } from '../bridge/hub.ts'
+
+/** Routed Worker snapshot retaining its complete source-generation descriptor. */
+export type CordisTreeSourceSnapshot = SharedCordisTreeSourceSnapshot<InspectorSourceDescriptor>
+
+/** Routed Host and Client snapshots retained by the Worker. */
+export type CordisInspectionTree = SharedCordisInspectionTree<InspectorSourceDescriptor>
+
+export type { CordisTreeSourceConnection } from '../../shared/cordis/projector.ts'
+
+/** One object-backed tree node with its owning source generation. */
+export interface CordisTreeObjectRoute extends CordisTreeSourceSnapshot {
+  readonly node: CordisTreeNode
+}
+
+/** Store mutation consumed by presentation adapters. */
+export type CordisTreeStoreEvent =
+  | { readonly type: 'snapshot-changed'; readonly source: InspectorSourceDescriptor }
+  | { readonly type: 'source-disconnected'; readonly source: InspectorSourceDescriptor }
+
+/** Independent bounds for live tree size and retained disconnected snapshots. */
+export interface CordisTreeStoreOptions {
+  readonly maxNodes: number
+  readonly maxDisconnectedTrees: number
+}
+
+interface StoredTree extends CordisTreeSourceSnapshot {
+  readonly nodesByObject: ReadonlyMap<string, CordisTreeNode>
+}
+
+/** Validated latest-value store consumed independently by CDP and future query adapters. */
+export class CordisTreeStore implements InspectorRecordConsumer {
+  readonly topics = new Set([CORDIS_TREE_TOPIC])
+  private readonly trees = new Map<string, StoredTree>()
+  private readonly disconnected = new Set<string>()
+  private readonly listeners = new Set<(event: CordisTreeStoreEvent) => void>()
+
+  constructor(private readonly options: CordisTreeStoreOptions) {}
+
+  /** Replace all retained state for one source generation. */
+  replace(source: InspectorSourceDescriptor, records: readonly IngestedInspectorRecord[]): void {
+    const next = this.latest(source, records)
+    const changed = next === undefined
+      ? this.remove(source.sourceId)
+      : this.install(source, next)
+    if (changed) this.emit({ type: 'snapshot-changed', source })
+  }
+
+  /** Apply later state replacements, ignoring unrelated observation topics. */
+  append(source: InspectorSourceDescriptor, records: readonly IngestedInspectorRecord[]): void {
+    const next = this.latest(source, records)
+    if (next !== undefined && this.install(source, next)) this.emit({ type: 'snapshot-changed', source })
+  }
+
+  /** Freeze a closed source generation's last tree and invalidate its object routes. */
+  close(source: InspectorSourceDescriptor, reason: string): void {
+    const current = this.trees.get(source.sourceId)
+    if (current?.source.generation !== source.generation || current.connection.state === 'disconnected') return
+    this.trees.set(source.sourceId, {
+      ...current,
+      connection: { state: 'disconnected', reason },
+    })
+    this.disconnected.delete(source.sourceId)
+    this.disconnected.add(source.sourceId)
+    while (this.disconnected.size > this.options.maxDisconnectedTrees) {
+      const oldest = this.disconnected.values().next().value
+      if (oldest === undefined) break
+      this.remove(oldest)
+    }
+    this.emit({ type: 'source-disconnected', source })
+  }
+
+  /**
+   * Read all current realm snapshots without CDP identifiers.
+   * @returns Snapshots in source admission order.
+   */
+  snapshots(): CordisTreeSourceSnapshot[] {
+    return [...this.trees.values()].map(({ source, snapshot, connection }) => ({ source, snapshot, connection }))
+  }
+
+  /**
+   * Compose the common realm model into Host and Client slots.
+   * @returns A detached view whose Host and Client entries share one type.
+   */
+  tree(): CordisInspectionTree {
+    const snapshots = this.snapshots()
+    return {
+      host: snapshots.find(tree => tree.source.kind === 'host') ?? null,
+      clients: snapshots.filter(tree => tree.source.kind === 'client'),
+    }
+  }
+
+  /**
+   * Read a detached semantic tree without object-routing or CDP identifiers.
+   * @returns The latest retained Host and Client topology.
+   */
+  readTree(): CordisRuntimeTree {
+    return projectCordisRuntimeTree(this.tree())
+  }
+
+  /**
+   * Resolve a source-local object reference to its semantic tree node.
+   * @param source - Active source generation.
+   * @param reference - Realm-local registry and object handle.
+   * @returns The matching node while its source remains connected.
+   */
+  resolveObject(source: InspectorSourceDescriptor, reference: InspectorObjectReference): CordisTreeObjectRoute | undefined {
+    const tree = this.trees.get(source.sourceId)
+    if (tree === undefined
+      || tree.source.generation !== source.generation
+      || tree.connection.state === 'disconnected') return undefined
+    const node = tree.nodesByObject.get(objectKey(reference))
+    return node === undefined ? undefined : this.route(tree, node)
+  }
+
+  /**
+   * Resolve a source-local object without requiring the source's presentation fields.
+   * @param sourceId - Logical source identity.
+   * @param generation - Active source generation.
+   * @param reference - Realm-local object reference.
+   * @returns The matching live tree node.
+   */
+  resolveObjectIdentity(
+    sourceId: InspectorSourceId,
+    generation: InspectorSourceGeneration,
+    reference: InspectorObjectReference,
+  ): CordisTreeObjectRoute | undefined {
+    const tree = this.trees.get(sourceId)
+    if (tree === undefined || tree.source.generation !== generation || tree.connection.state === 'disconnected') {
+      return undefined
+    }
+    const node = tree.nodesByObject.get(objectKey(reference))
+    return node === undefined ? undefined : this.route(tree, node)
+  }
+
+  /**
+   * Resolve a live reference when only its source realm kind is known.
+   * @param kind - Host or Client ownership inferred by the Runtime adapter.
+   * @param reference - Realm-local registry and object handle.
+   * @returns The matching connected node, when present.
+   */
+  resolveObjectInKind(kind: InspectorSourceDescriptor['kind'], reference: InspectorObjectReference): CordisTreeObjectRoute | undefined {
+    for (const tree of this.trees.values()) {
+      if (tree.source.kind !== kind || tree.connection.state === 'disconnected') continue
+      const node = tree.nodesByObject.get(objectKey(reference))
+      if (node !== undefined) return this.route(tree, node)
+    }
+    return undefined
+  }
+
+  /**
+   * Subscribe to accepted tree replacements and source availability changes.
+   * @param listener - Repository observer.
+   * @returns A disposer removing the observer.
+   */
+  subscribe(listener: (event: CordisTreeStoreEvent) => void): () => void {
+    this.listeners.add(listener)
+    return () => { this.listeners.delete(listener) }
+  }
+
+  private latest(
+    source: InspectorSourceDescriptor,
+    records: readonly IngestedInspectorRecord[],
+  ): CordisTreeSnapshot | undefined {
+    let snapshot: CordisTreeSnapshot | undefined
+    for (const record of records) {
+      if (record.topic !== CORDIS_TREE_TOPIC) continue
+      const candidate = parseCordisTreeSnapshot(record.payload, this.options.maxNodes)
+      if (snapshot === undefined || candidate.revision > snapshot.revision) snapshot = candidate
+    }
+    if (snapshot === undefined) return undefined
+    const current = this.trees.get(source.sourceId)
+    if (current?.source.generation === source.generation && current.snapshot.revision >= snapshot.revision) {
+      return current.snapshot
+    }
+    return snapshot
+  }
+
+  private install(source: InspectorSourceDescriptor, snapshot: CordisTreeSnapshot): boolean {
+    const current = this.trees.get(source.sourceId)
+    if (current?.source.generation === source.generation
+      && current.snapshot === snapshot
+      && current.connection.state === 'connected') return false
+    this.disconnected.delete(source.sourceId)
+    this.trees.set(source.sourceId, {
+      source,
+      snapshot,
+      connection: { state: 'connected' },
+      nodesByObject: new Map(treeNodes(snapshot.root).map(node => [objectKey({
+        registryId: snapshot.objectRegistryId,
+        handle: node.objectHandle,
+      }), node])),
+    })
+    return true
+  }
+
+  private remove(sourceId: string): boolean {
+    this.disconnected.delete(sourceId)
+    return this.trees.delete(sourceId)
+  }
+
+  private route(tree: StoredTree, node: CordisTreeNode): CordisTreeObjectRoute {
+    return { source: tree.source, snapshot: tree.snapshot, connection: tree.connection, node }
+  }
+
+  private emit(event: CordisTreeStoreEvent): void {
+    for (const listener of [...this.listeners]) {
+      try {
+        listener(event)
+      } catch {
+        // One query adapter cannot prevent later repository observers from updating.
+      }
+    }
+  }
+}
+
+function objectKey(reference: InspectorObjectReference): string {
+  return `${reference.registryId}\0${reference.handle}`
+}
+
+function treeNodes(root: CordisTreeNode): CordisTreeNode[] {
+  const nodes: CordisTreeNode[] = []
+  const pending: CordisTreeNode[] = [root]
+  while (pending.length > 0) {
+    const node = pending.pop()
+    if (node === undefined) break
+    nodes.push(node)
+    pending.push(...node.children.toReversed())
+  }
+  return nodes
+}

+ 255 - 0
packages/experimental/inspector/src/worker/inspection/query-router.ts

@@ -0,0 +1,255 @@
+/** Worker-side admission, execution, and bounded settlement of non-CDP queries. */
+
+import type { CordisRuntimeTreeReader } from '../../shared/cordis/reader.ts'
+import type { InspectorSourceGeneration, InspectorSourceId } from '../../shared/bridge/ids.ts'
+import { jsonByteLength, type InspectorJsonValue } from '../../shared/json.ts'
+import type { InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts'
+import type { InspectorQueryError } from '../../shared/bridge/messages/query/commands.ts'
+import {
+  isInspectorQueryRequestEnvelope,
+  parseInspectorQueryFrameIdentity,
+  parseInspectorQueryRequestFrame,
+} from '../../shared/bridge/messages/query/codec.ts'
+import type {
+  InspectorQueryRequestFrame,
+  InspectorQueryRequestId,
+  InspectorQueryResponseFrame,
+} from '../../shared/bridge/messages/query/frames.ts'
+import { INSPECTOR_PROTOCOL_VERSION } from '../../shared/bridge/version.ts'
+import { executeInspectorQuery } from './cordis-query.ts'
+
+/** Carrier operations owned by one Worker query peer. */
+export interface InspectorQueryPeerTransport {
+  /** Send one bounded Worker response. */
+  send(frame: InspectorQueryResponseFrame): void
+  /** Reject a malformed peer whose request cannot be correlated safely. */
+  close(code: number, reason: string): void
+}
+
+interface AcceptedGeneration {
+  readonly sourceId: InspectorSourceId
+  readonly generation: InspectorSourceGeneration
+}
+
+/** Creates isolated query peers over one shared semantic reader. */
+export class InspectorQueryRouter {
+  private readonly peers = new Set<InspectorQueryPeer>()
+  private readonly activeBySource = new Map<InspectorSourceId, {
+    readonly generation: InspectorSourceGeneration
+    readonly peer: InspectorQueryPeer
+  }>()
+
+  constructor(
+    private readonly reader: CordisRuntimeTreeReader,
+    private readonly maxFrameBytes: number,
+  ) {}
+
+  /**
+   * Create query state for one Host MessagePort or Client WebSocket.
+   * @param transport - Carrier response and rejection operations.
+   * @returns The peer that receives frames from this carrier only.
+  */
+  open(transport: InspectorQueryPeerTransport): InspectorQueryPeer {
+    const peer: InspectorQueryPeer = new InspectorQueryPeer(
+      this.reader,
+      this.maxFrameBytes,
+      transport,
+      (accepted) => {
+        for (const [sourceId, active] of this.activeBySource) {
+          if (active.peer === peer) this.activeBySource.delete(sourceId)
+        }
+        this.activeBySource.set(accepted.sourceId, { ...accepted, peer })
+      },
+      (accepted): boolean => this.activeBySource.get(accepted.sourceId)?.peer === peer
+        && this.activeBySource.get(accepted.sourceId)?.generation === accepted.generation,
+      () => {
+        this.peers.delete(peer)
+        for (const [sourceId, active] of this.activeBySource) {
+          if (active.peer === peer) this.activeBySource.delete(sourceId)
+        }
+      },
+    )
+    this.peers.add(peer)
+    return peer
+  }
+
+  /**
+   * Revoke query access when the source registry closes one generation.
+   * @param source - Closed source generation.
+   */
+  disconnect(source: InspectorSourceDescriptor): void {
+    const active = this.activeBySource.get(source.sourceId)
+    if (active?.generation !== source.generation) return
+    this.activeBySource.delete(source.sourceId)
+    active.peer.revoke(source.sourceId, source.generation)
+  }
+
+  /** Revoke every peer during Worker shutdown. */
+  close(): void {
+    for (const peer of [...this.peers]) peer.close()
+    this.activeBySource.clear()
+  }
+}
+
+/** Query protocol state associated with exactly one source carrier. */
+export class InspectorQueryPeer {
+  private accepted: AcceptedGeneration | undefined
+  private readonly inFlight = new Map<InspectorQueryRequestId, AcceptedGeneration>()
+  private closed = false
+
+  constructor(
+    private readonly reader: CordisRuntimeTreeReader,
+    private readonly maxFrameBytes: number,
+    private readonly transport: InspectorQueryPeerTransport,
+    private readonly register: (accepted: AcceptedGeneration) => void,
+    private readonly isRegistered: (accepted: AcceptedGeneration) => boolean,
+    private readonly unregister: () => void,
+  ) {}
+
+  /**
+   * Admit the source generation after the source registry accepts it.
+   * @param sourceId - Stable source identity.
+   * @param generation - Active carrier generation.
+   */
+  accept(sourceId: InspectorSourceId, generation: InspectorSourceGeneration): void {
+    if (this.closed) return
+    this.accepted = { sourceId, generation }
+    this.inFlight.clear()
+    this.register(this.accepted)
+  }
+
+  /**
+   * Revoke one generation while leaving its carrier available for a later source/open.
+   * @param sourceId - Stable source identity.
+   * @param generation - Generation being removed by the source registry.
+   */
+  revoke(sourceId: InspectorSourceId, generation: InspectorSourceGeneration): void {
+    if (this.accepted?.sourceId !== sourceId || this.accepted.generation !== generation) return
+    this.accepted = undefined
+    this.inFlight.clear()
+  }
+
+  /**
+   * Consume a decoded carrier value when it belongs to the query protocol.
+   * @param value - Untrusted source-to-Worker value.
+   * @returns Whether this peer owned the value.
+   */
+  receive(value: unknown): boolean {
+    if (!isInspectorQueryRequestEnvelope(value)) return false
+    let frame: InspectorQueryRequestFrame
+    try {
+      frame = parseInspectorQueryRequestFrame(value)
+      if (jsonByteLength(frame as unknown as InspectorJsonValue) > this.maxFrameBytes) {
+        throw new Error(`inspector protocol: query request exceeds ${String(this.maxFrameBytes)} bytes`)
+      }
+    } catch (error) {
+      this.rejectMalformed(value, renderError(error))
+      return true
+    }
+    const accepted = this.accepted
+    if (this.closed || accepted === undefined || !this.isRegistered(accepted)
+      || accepted.sourceId !== frame.sourceId
+      || accepted.generation !== frame.generation) {
+      this.sendFailure(frame, 'stale-source', 'Inspector query does not belong to the accepted source generation')
+      return true
+    }
+    if (this.inFlight.has(frame.requestId)) {
+      this.sendFailure(frame, 'invalid-request', 'Inspector query requestId is already in flight')
+      return true
+    }
+    this.inFlight.set(frame.requestId, accepted)
+    void this.execute(frame, accepted)
+    return true
+  }
+
+  /** Stop this peer and suppress completion from in-flight readers. */
+  close(): void {
+    if (this.closed) return
+    this.closed = true
+    this.accepted = undefined
+    this.inFlight.clear()
+    this.unregister()
+  }
+
+  private async execute(frame: InspectorQueryRequestFrame, accepted: AcceptedGeneration): Promise<void> {
+    try {
+      const result = await executeInspectorQuery(this.reader, frame.query)
+      if (!this.canReply(frame, accepted)) return
+      const response: InspectorQueryResponseFrame = {
+        v: INSPECTOR_PROTOCOL_VERSION,
+        t: 'query/response',
+        sourceId: frame.sourceId,
+        generation: frame.generation,
+        requestId: frame.requestId,
+        outcome: { ok: true, result },
+      }
+      if (jsonByteLength(response as unknown as InspectorJsonValue) > this.maxFrameBytes) {
+        this.sendFailure(frame, 'result-too-large', `Inspector query result exceeds ${String(this.maxFrameBytes)} bytes`)
+        return
+      }
+      this.deliver(response)
+    } catch (error) {
+      if (this.canReply(frame, accepted)) this.sendFailure(frame, 'internal-error', renderError(error).message)
+    } finally {
+      if (this.inFlight.get(frame.requestId) === accepted) this.inFlight.delete(frame.requestId)
+    }
+  }
+
+  private rejectMalformed(value: unknown, error: Error): void {
+    try {
+      const identity = parseInspectorQueryFrameIdentity(value)
+      this.sendFailure(identity, 'invalid-request', error.message)
+    } catch {
+      this.rejectTransport(1008, error.message)
+    }
+  }
+
+  private sendFailure(
+    frame: Pick<InspectorQueryRequestFrame, 'sourceId' | 'generation' | 'requestId'>,
+    code: InspectorQueryError['code'],
+    message: string,
+  ): void {
+    if (this.closed) return
+    const response: InspectorQueryResponseFrame = {
+      v: INSPECTOR_PROTOCOL_VERSION,
+      t: 'query/response',
+      sourceId: frame.sourceId,
+      generation: frame.generation,
+      requestId: frame.requestId,
+      outcome: { ok: false, error: { code, message } },
+    }
+    if (jsonByteLength(response as unknown as InspectorJsonValue) > this.maxFrameBytes) {
+      this.rejectTransport(1009, 'Inspector query error exceeds the frame limit')
+      return
+    }
+    this.deliver(response)
+  }
+
+  private canReply(frame: InspectorQueryRequestFrame, accepted: AcceptedGeneration): boolean {
+    return !this.closed
+      && this.accepted === accepted
+      && this.isRegistered(accepted)
+      && this.inFlight.get(frame.requestId) === accepted
+  }
+
+  private deliver(frame: InspectorQueryResponseFrame): void {
+    try {
+      this.transport.send(frame)
+    } catch (error) {
+      this.rejectTransport(1011, renderError(error).message)
+    }
+  }
+
+  private rejectTransport(code: number, reason: string): void {
+    this.close()
+    try {
+      this.transport.close(code, reason.slice(0, 123))
+    } catch {
+      // The carrier is already unusable; query state has reached quiescence.
+    }
+  }
+}
+
+function renderError(error: unknown): Error {
+  return error instanceof Error ? error : new Error(String(error))
+}

+ 349 - 0
packages/experimental/inspector/tests/cordis-query.host.spec.ts

@@ -0,0 +1,349 @@
+/** Host-driven Cordis query integration. */
+
+import { Context } from '@deepseek-ai/cordis'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { createCordisRuntimeTreeReader } from '../src/shared/cordis/reader.ts'
+import {
+  cordisRuntimeSourceId,
+  type CordisRuntimeContext,
+  type CordisRuntimeTree,
+} from '../src/shared/cordis/model.ts'
+import { startInspector, type InspectorHandle } from '../src/host/bridge/controller.ts'
+import { publishCordisTree as publishHostCordisTree } from '../src/host/inspection/cordis.ts'
+import { inspectorId } from '../src/shared/bridge/ids.ts'
+import type { InspectorJsonValue } from '../src/shared/json.ts'
+import { InspectorQueryConnection } from '../src/shared/bridge/rpc.ts'
+import { parseInspectorQueryRequestFrame, parseInspectorQueryResponseFrame } from '../src/shared/bridge/messages/query/codec.ts'
+import type { InspectorQueryRequestFrame, InspectorQueryResponseFrame } from '../src/shared/bridge/messages/query/frames.ts'
+import type { InspectorSourceDescriptor } from '../src/shared/bridge/messages/observation.ts'
+import { createInspectorService } from '../src/shared/service.ts'
+import { CordisTreeStore } from '../src/worker/inspection/cordis-store.ts'
+import { InspectorQueryRouter } from '../src/worker/inspection/query-router.ts'
+import { InspectorClientFixture } from './fixtures/client-source.host.ts'
+
+describe('consumer-neutral Cordis tree', () => {
+  it('projects a detached recursive tree without routing identifiers', () => {
+    const store = new CordisTreeStore({ maxNodes: 10, maxDisconnectedTrees: 1 })
+    const source = sourceDescriptor('host-1', 'generation-1', 'host')
+    store.replace(source, [{
+      sequence: 1,
+      monotonicMs: 1,
+      topic: 'cordis/tree',
+      payload: asJson({
+        schemaVersion: 0,
+        revision: 3,
+        objectRegistryId: 'registry-1',
+        truncated: false,
+        root: {
+          kind: 'context',
+          objectHandle: 'context-1',
+          children: [{
+            kind: 'fiber',
+            uid: 12,
+            objectHandle: 'fiber-1',
+            children: [{ kind: 'context', objectHandle: 'context-2', children: [] }],
+          }],
+        },
+      }),
+    }])
+
+    const tree = store.readTree()
+    expect(tree).toEqual({
+      schemaVersion: 0,
+      host: {
+        source: { sourceId: 'host-1', kind: 'host', label: 'host-1' },
+        connection: { state: 'connected' },
+        revision: 3,
+        truncated: false,
+        root: {
+          kind: 'context',
+          children: [{ kind: 'fiber', uid: 12, children: [{ kind: 'context', children: [] }] }],
+        },
+      },
+      clients: [],
+    })
+    expect(tree.host?.root).not.toBe(store.tree().host?.snapshot.root)
+    expect(forbiddenKeys(tree)).toEqual([])
+
+    store.close(source, 'transport closed')
+    expect(store.readTree().host?.connection).toEqual({ state: 'disconnected', reason: 'transport closed' })
+
+    const reconnected = sourceDescriptor('host-1', 'generation-2', 'host')
+    store.replace(reconnected, [{
+      sequence: 1,
+      monotonicMs: 2,
+      topic: 'cordis/tree',
+      payload: asJson({
+        schemaVersion: 0,
+        revision: 4,
+        objectRegistryId: 'registry-2',
+        truncated: false,
+        root: { kind: 'context', objectHandle: 'context-3', children: [] },
+      }),
+    }])
+    expect(store.readTree().host).toMatchObject({
+      connection: { state: 'connected' },
+      revision: 4,
+      root: { kind: 'context', children: [] },
+    })
+    expect(forbiddenKeys(store.readTree())).toEqual([])
+  })
+})
+
+describe('Inspector query protocol', () => {
+  afterEach(() => { vi.useRealTimers() })
+
+  it('uses exact request and response codecs', () => {
+    const hiddenTree = runtimeTree()
+    if (hiddenTree.host === null) throw new Error('test tree requires a Host realm')
+    expect(parseInspectorQueryRequestFrame({
+      v: 0,
+      t: 'query/request',
+      sourceId: 'host-1',
+      generation: 'generation-1',
+      requestId: 'query-1',
+      query: { op: 'cordis-tree/get' },
+    })).toMatchObject({ query: { op: 'cordis-tree/get' } })
+    expect(() => parseInspectorQueryRequestFrame({
+      v: 0,
+      t: 'query/request',
+      sourceId: 'host-1',
+      generation: 'generation-1',
+      requestId: 'query-1',
+      query: { op: 'cordis-tree/get', extension: true },
+    })).toThrow('unknown field')
+    expect(() => parseInspectorQueryResponseFrame({
+      ...successResponse('query-1', runtimeTree()),
+      outcome: {
+        ok: true,
+        result: {
+          op: 'cordis-tree/get',
+          tree: {
+            ...hiddenTree,
+            host: {
+              ...hiddenTree.host,
+              root: { kind: 'context', objectHandle: 'private', children: [] },
+            },
+          },
+        },
+      },
+    })).toThrow('unknown field')
+  })
+
+  it('correlates results and clears stale, malformed, timed-out, and closed requests', async () => {
+    const sent: InspectorQueryRequestFrame[] = []
+    const connection = new InspectorQueryConnection({ timeoutMs: 20, maxFrameBytes: 16_384 })
+    connection.connect(sourceId('host-1'), generation('generation-1'), {
+      send: (frame) => { sent.push(frame) },
+    })
+
+    const first = connection.request({ op: 'cordis-tree/get' })
+    const firstFrame = sent.at(-1)!
+    expect(connection.receive(successResponse(firstFrame.requestId, runtimeTree()))).toBe(true)
+    await expect(first).resolves.toEqual({ op: 'cordis-tree/get', tree: runtimeTree() })
+
+    const stale = connection.request({ op: 'cordis-tree/get' })
+    const staleFrame = sent.at(-1)!
+    expect(connection.receive({
+      ...successResponse(staleFrame.requestId, runtimeTree()),
+      generation: generation('generation-old'),
+    })).toBe(true)
+    await expect(stale).rejects.toThrow('source generation does not match')
+
+    const malformed = connection.request({ op: 'cordis-tree/get' })
+    const malformedFrame = sent.at(-1)!
+    const malformedRejection = expect(malformed).rejects.toThrow('Invalid Inspector query response')
+    expect(() => connection.receive({
+      ...successResponse(malformedFrame.requestId, runtimeTree()),
+      extension: true,
+    })).toThrow('unknown field')
+    await malformedRejection
+
+    connection.connect(sourceId('host-1'), generation('generation-2'), {
+      send: (frame) => { sent.push(frame) },
+    })
+    vi.useFakeTimers()
+    const timedOut = connection.request({ op: 'cordis-tree/get' })
+    const timeoutRejection = expect(timedOut).rejects.toThrow('timed out')
+    await vi.advanceTimersByTimeAsync(21)
+    await timeoutRejection
+    vi.useRealTimers()
+
+    const closed = connection.request({ op: 'cordis-tree/get' })
+    connection.close()
+    await expect(closed).rejects.toThrow('closed')
+  })
+
+  it('rejects malformed, stale, and oversized Worker requests with bounded outcomes', async () => {
+    const responses: InspectorQueryResponseFrame[] = []
+    const close = vi.fn()
+    const largeTree = runtimeTree({
+      kind: 'context',
+      children: Array.from({ length: 100 }, () => ({ kind: 'context', children: [] } as const)),
+    })
+    const router = new InspectorQueryRouter(createCordisRuntimeTreeReader(() => largeTree), 512)
+    const peer = router.open({ send: (frame) => { responses.push(frame) }, close })
+    peer.accept(sourceId('host-1'), generation('generation-1'))
+
+    expect(peer.receive(requestFrame('query-stale', 'generation-old'))).toBe(true)
+    expect(responses.at(-1)?.outcome).toMatchObject({ ok: false, error: { code: 'stale-source' } })
+
+    expect(peer.receive({ ...requestFrame('query-malformed'), extension: true })).toBe(true)
+    expect(responses.at(-1)?.outcome).toMatchObject({ ok: false, error: { code: 'invalid-request' } })
+
+    expect(peer.receive(requestFrame('query-large'))).toBe(true)
+    await vi.waitFor(() => {
+      expect(responses.at(-1)?.outcome).toMatchObject({ ok: false, error: { code: 'result-too-large' } })
+    })
+    expect(close).not.toHaveBeenCalled()
+
+    const requester = new InspectorQueryConnection({ timeoutMs: 100, maxFrameBytes: 512 })
+    const pairedPeer = router.open({
+      send: (frame) => { requester.receive(frame) },
+      close: vi.fn(),
+    })
+    pairedPeer.accept(sourceId('client-2'), generation('generation-1'))
+    requester.connect(sourceId('client-2'), generation('generation-1'), {
+      send: (frame) => { pairedPeer.receive(frame) },
+    })
+    await expect(requester.request({ op: 'cordis-tree/get' })).rejects.toMatchObject({ code: 'result-too-large' })
+    requester.close()
+  })
+
+  it('revokes an older carrier when the same source opens a new generation', () => {
+    const firstResponses: InspectorQueryResponseFrame[] = []
+    const router = new InspectorQueryRouter(createCordisRuntimeTreeReader(() => runtimeTree()), 16_384)
+    const first = router.open({ send: (frame) => { firstResponses.push(frame) }, close: vi.fn() })
+    const second = router.open({ send: vi.fn(), close: vi.fn() })
+    first.accept(sourceId('client-1'), generation('generation-1'))
+    second.accept(sourceId('client-1'), generation('generation-2'))
+
+    expect(first.receive({
+      ...requestFrame('query-old', 'generation-1'),
+      sourceId: sourceId('client-1'),
+    })).toBe(true)
+    expect(firstResponses.at(-1)?.outcome).toMatchObject({ ok: false, error: { code: 'stale-source' } })
+  })
+})
+
+describe('Cordis query service integration', () => {
+  let inspector: InspectorHandle | undefined
+  let clientSource: InspectorClientFixture | undefined
+  const observers: Array<() => void> = []
+
+  afterEach(async () => {
+    for (const dispose of observers.splice(0).reverse()) dispose()
+    await clientSource?.close()
+    clientSource = undefined
+    await inspector?.close()
+    inspector = undefined
+  })
+
+  it('returns the same Worker snapshot to Host and Client services without a CDP connection', async () => {
+    inspector = await startInspector({
+      port: 0,
+      captureFetch: false,
+      queryTimeoutMs: 1_000,
+      maxCordisNodes: 100,
+    })
+    const hostContext = new Context()
+    observers.push(publishHostCordisTree(hostContext, inspector.source, { maxNodes: 100, maxBytes: 64 * 1_024 }))
+    const hostService = createInspectorService(inspector.source)
+
+    clientSource = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Query Client' })
+
+    await vi.waitFor(async () => {
+      const [hostTree, clientTree] = await Promise.all([
+        hostService.cordis.getTree(),
+        clientSource!.getCordisTree(),
+      ])
+      expect(hostTree).toEqual(clientTree)
+      expect(hostTree.host?.source.kind).toBe('host')
+      expect(hostTree.clients).toHaveLength(1)
+      expect(forbiddenKeys(hostTree)).toEqual([])
+    })
+
+    await clientSource.close()
+    clientSource = undefined
+    await vi.waitFor(async () => {
+      const tree = await hostService.cordis.getTree()
+      expect(tree.clients[0]?.connection.state).toBe('disconnected')
+    })
+  })
+})
+
+function sourceDescriptor(
+  id: string,
+  sourceGeneration: string,
+  kind: InspectorSourceDescriptor['kind'],
+): InspectorSourceDescriptor {
+  return {
+    sourceId: sourceId(id),
+    generation: generation(sourceGeneration),
+    kind,
+    label: id,
+    timeOriginMs: 0,
+    capabilities: [],
+  }
+}
+
+function sourceId(value: string): InspectorSourceDescriptor['sourceId'] {
+  return inspectorId<'InspectorSourceId'>(value, 'sourceId')
+}
+
+function generation(value: string): InspectorSourceDescriptor['generation'] {
+  return inspectorId<'InspectorSourceGeneration'>(value, 'generation')
+}
+
+function runtimeTree(root: CordisRuntimeContext = { kind: 'context', children: [] }): CordisRuntimeTree {
+  return {
+    schemaVersion: 0,
+    host: {
+      source: { sourceId: cordisRuntimeSourceId('host-1'), kind: 'host', label: 'Host' },
+      connection: { state: 'connected' },
+      revision: 1,
+      truncated: false,
+      root,
+    },
+    clients: [],
+  }
+}
+
+function requestFrame(requestId: string, sourceGeneration = 'generation-1'): InspectorQueryRequestFrame {
+  return {
+    v: 0,
+    t: 'query/request',
+    sourceId: sourceId('host-1'),
+    generation: generation(sourceGeneration),
+    requestId: inspectorId<'InspectorQueryRequestId'>(requestId, 'requestId'),
+    query: { op: 'cordis-tree/get' },
+  }
+}
+
+function successResponse(requestId: string, tree: CordisRuntimeTree): InspectorQueryResponseFrame {
+  return {
+    v: 0,
+    t: 'query/response',
+    sourceId: sourceId('host-1'),
+    generation: generation('generation-1'),
+    requestId: inspectorId<'InspectorQueryRequestId'>(requestId, 'requestId'),
+    outcome: { ok: true, result: { op: 'cordis-tree/get', tree } },
+  }
+}
+
+function forbiddenKeys(value: unknown): string[] {
+  if (value === null || typeof value !== 'object') return []
+  if (Array.isArray(value)) return value.flatMap(forbiddenKeys)
+  const forbidden = new Set([
+    'objectHandle', 'objectRegistryId', 'registryId', 'generation', 'executionContextId',
+    'scriptId', 'nodeId', 'backendNodeId', 'objectId', 'remoteObjectId',
+  ])
+  return Reflect.ownKeys(value).flatMap((key) => {
+    if (typeof key !== 'string') return []
+    return [...(forbidden.has(key) ? [key] : []), ...forbiddenKeys(Reflect.get(value, key))]
+  })
+}
+
+function asJson(value: object): InspectorJsonValue {
+  return value as unknown as InspectorJsonValue
+}

+ 454 - 0
packages/experimental/inspector/tests/cordis-tree.host.spec.ts

@@ -0,0 +1,454 @@
+/** Host-driven Cordis tree integration. */
+
+import { Context } from '@deepseek-ai/cordis'
+import WebSocket, { type RawData } from 'ws'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { CordisTreeCollector } from '../src/shared/cordis/collector.ts'
+import { startInspector, type InspectorHandle } from '../src/host/bridge/controller.ts'
+import { publishCordisTree as publishHostCordisTree } from '../src/host/inspection/cordis.ts'
+import { parseCordisTreeSnapshot, type CordisTreeNode } from '../src/shared/cordis/snapshot.ts'
+import { inspectorId } from '../src/shared/bridge/ids.ts'
+import type { InspectorJsonValue } from '../src/shared/json.ts'
+import type { InspectorSourceDescriptor } from '../src/shared/bridge/messages/observation.ts'
+import { CordisTreeStore } from '../src/worker/inspection/cordis-store.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 }
+}
+
+interface CdpNode {
+  readonly nodeId: number
+  readonly backendNodeId: number
+  readonly localName: string
+  readonly attributes?: string[]
+  readonly children?: CdpNode[]
+}
+
+class CdpClient {
+  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<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 }))
+    })
+  }
+
+  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('Cordis tree inspection', () => {
+  let inspector: InspectorHandle | undefined
+  let cdp: CdpClient | undefined
+  let secondCdp: CdpClient | undefined
+  let clientSource: InspectorClientFixture | undefined
+  const observers: Array<() => void> = []
+  const fibers: Array<{ dispose(): Promise<void> }> = []
+
+  afterEach(async () => {
+    for (const dispose of observers.splice(0).reverse()) dispose()
+    for (const fiber of fibers.splice(0).reverse()) await fiber.dispose()
+    await clientSource?.close()
+    clientSource = undefined
+    await cdp?.close()
+    cdp = undefined
+    await secondCdp?.close()
+    secondCdp = undefined
+    await inspector?.close()
+    inspector = undefined
+    Reflect.deleteProperty(globalThis, '__cordisHostProbe')
+  })
+
+  it('preserves separate Fiber and Context identities in one shared snapshot model', async () => {
+    const root = new Context()
+    const parent = root.isolate('probe')
+    const fiber = parent.plugin({ name: 'child', apply() {} })
+    await fiber.await()
+    const collector = new CordisTreeCollector(root, { maxNodes: 100, maxBytes: 64 * 1_024 })
+
+    const snapshot = collector.snapshot()
+    expect(parseCordisTreeSnapshot(snapshot, 100)).toEqual(snapshot)
+    const nodes = treeNodes(snapshot.root)
+    const fiberNode = nodes.find(node => node.kind === 'fiber' && node.uid === fiber.uid)
+    if (fiberNode === undefined) throw new Error('expected child Fiber node')
+    expect(nodes.every(node => !('id' in node) && !('parentId' in node))).toBe(true)
+    expect(() => parseCordisTreeSnapshot({
+      ...snapshot,
+      root: { ...snapshot.root, children: [{ ...fiberNode, children: [] }] },
+    }, 100)).toThrow('exactly one Context')
+    const contextNode = fiberNode.children[0]
+    const isolateNode = nodes.find(node => node.kind === 'context'
+      && collector.objects.resolve(node.objectHandle) === parent)
+
+    expect(snapshot.root.kind).toBe('context')
+    expect(nodes.some(node => node.kind === 'fiber' && node.uid === 0)).toBe(false)
+    expect(isolateNode?.children).toContain(fiberNode)
+    const retainedFiber = collector.objects.resolve(fiberNode.objectHandle)
+    expect(Reflect.get(retainedFiber ?? {}, 'uid')).toBe(fiber.uid)
+    expect(Reflect.get(retainedFiber ?? {}, 'ctx') === fiber.ctx).toBe(true)
+    expect(collector.objects.resolve(contextNode.objectHandle) === fiber.ctx).toBe(true)
+    const identifiedFiber = collector.objects.identify(fiber)
+    expect(identifiedFiber).toEqual({
+      registryId: snapshot.objectRegistryId,
+      handle: fiberNode.objectHandle,
+    })
+    expect(collector.objects.identify(Object.create(parent) as object)).toBeUndefined()
+
+    collector.close()
+    await fiber.dispose()
+  })
+
+  it('freezes a disconnected snapshot and replaces it with the reconnect generation', () => {
+    const root = new Context()
+    const collector = new CordisTreeCollector(root, { maxNodes: 100, maxBytes: 64 * 1_024 })
+    const snapshot = collector.snapshot()
+    const store = new CordisTreeStore({ maxNodes: 100, maxDisconnectedTrees: 1 })
+    const first = source('client-a', 'generation-1')
+    store.replace(first, [{ sequence: 1, monotonicMs: 1, topic: 'cordis/tree', payload: asJson(snapshot) }])
+
+    const object = snapshot.root
+    expect(store.resolveObject(first, {
+      registryId: snapshot.objectRegistryId,
+      handle: object.objectHandle,
+    })).toBeDefined()
+    store.close(first, 'transport closed')
+    expect(store.snapshots()[0]?.connection).toEqual({ state: 'disconnected', reason: 'transport closed' })
+    expect(store.resolveObject(first, {
+      registryId: snapshot.objectRegistryId,
+      handle: object.objectHandle,
+    })).toBeUndefined()
+
+    const reconnected = source('client-a', 'generation-2')
+    store.replace(reconnected, [{
+      sequence: 1,
+      monotonicMs: 2,
+      topic: 'cordis/tree',
+      payload: asJson({ ...snapshot, revision: snapshot.revision + 1 }),
+    }])
+    expect(store.snapshots()).toEqual([
+      expect.objectContaining({ source: reconnected, connection: { state: 'connected' } }),
+    ])
+
+    store.close(reconnected, 'transport closed again')
+    const other = source('client-b', 'generation-1')
+    store.replace(other, [{ sequence: 1, monotonicMs: 3, topic: 'cordis/tree', payload: asJson(snapshot) }])
+    store.close(other, 'other transport closed')
+    const retained = store.snapshots()
+    expect(retained).toHaveLength(1)
+    expect(retained[0]?.source).toEqual(other)
+    expect(retained[0]?.connection.state).toBe('disconnected')
+    collector.close()
+  })
+
+  it('projects Host and Client trees and resolves both node kinds to RemoteObjects', async () => {
+    inspector = await startInspector({ port: 0, captureFetch: false, maxCordisNodes: 100 })
+    const host = new Context()
+    const hostFiber = host.plugin({ name: 'host-child', apply() {} })
+    fibers.push(hostFiber)
+    await hostFiber.await()
+    Reflect.set(globalThis, '__cordisHostProbe', host)
+    observers.push(publishHostCordisTree(host, inspector.source, { maxNodes: 100, maxBytes: 64 * 1_024 }))
+
+    clientSource = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Tree Client' })
+    cdp = await CdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
+    await cdp.call('Runtime.enable')
+
+    let document: CdpNode | undefined
+    await vi.waitFor(async () => {
+      const response = await cdp!.call('DOM.getDocument')
+      expect(response.error).toBeUndefined()
+      document = response.result?.root as CdpNode
+      expect(hostContainer(document)).toBeDefined()
+      expect(clientContainers(document)).toHaveLength(1)
+    })
+    if (document === undefined) throw new Error('DOM.getDocument returned no root')
+    expect(document.children?.map(node => node.localName)).toEqual(['host', 'clients'])
+    expect(document.children?.every(node => (node.attributes ?? []).length === 0)).toBe(true)
+
+    const stored = await cdp.call('DSHInspector.getCordisTree')
+    const model = stored.result?.tree as {
+      host: { root: Record<string, unknown> } | null
+      clients: Array<{ root: Record<string, unknown> }>
+    }
+    expect(model.host?.root).toMatchObject({ kind: 'context' })
+    expect(model.clients).toHaveLength(1)
+    expect(model.clients[0]?.root).toMatchObject({ kind: 'context' })
+    expect(model.host?.root).not.toHaveProperty('nodeId')
+    expect(model.host?.root).not.toHaveProperty('backendNodeId')
+
+    const realms = [
+      ['host', hostContainer(document)],
+      ['client', clientContainers(document)[0]],
+    ] as const
+    for (const [realmKind, realm] of realms) {
+      expect(realm?.attributes ?? []).toEqual([])
+      const rootContext = realm?.children?.[0]
+      expect(rootContext?.localName).toBe('context')
+      expect(rootContext?.children?.[0]?.localName).toBe('fiber')
+      expect(rootContext?.children?.[0]?.children?.[0]?.localName).toBe('context')
+      for (const entityKind of ['context', 'fiber']) {
+        const node = realm === undefined ? undefined : walk(realm).find(item => item.localName === entityKind)
+        if (node === undefined) throw new Error(`missing ${realmKind} ${entityKind} node`)
+        expect(node.attributes ?? []).toEqual(entityKind === 'fiber'
+          ? ['uid', expect.stringMatching(/^\d+$/u)]
+          : [])
+        expect(node.nodeId).toBeGreaterThan(0)
+        expect(node.backendNodeId).toBeGreaterThan(0)
+        const objectGroup = `tree-${realmKind}-${entityKind}`
+        const resolved = await cdp.call('DOM.resolveNode', { nodeId: node.nodeId, objectGroup })
+        expect(resolved.error).toBeUndefined()
+        const remote = resolved.result?.object as Record<string, unknown>
+        expect(remote).toMatchObject({
+          type: 'object',
+          subtype: 'node',
+          className: entityKind === 'fiber' ? 'Fiber' : 'Context',
+        })
+        expect(typeof remote.objectId).toBe('string')
+        const properties = await cdp.call('Runtime.getProperties', { objectId: remote.objectId, ownProperties: true })
+        expect(properties.error).toBeUndefined()
+        await expect(cdp.call('DOM.requestNode', { objectId: remote.objectId })).resolves.toMatchObject({
+          result: { nodeId: node.nodeId },
+        })
+        await cdp.call('Runtime.releaseObjectGroup', { objectGroup })
+      }
+    }
+
+    const hostNode = walk(hostContainer(document)!).find(item => item.localName === 'context')!
+    const hostEvaluated = await cdp.call('Runtime.evaluate', { expression: 'globalThis.__cordisHostProbe' })
+    expect(hostEvaluated.result?.result).toMatchObject({ type: 'object', subtype: 'node', className: 'Context' })
+    await expect(cdp.call('DOM.requestNode', {
+      objectId: (hostEvaluated.result?.result as Record<string, unknown>).objectId,
+    })).resolves.toMatchObject({ result: { nodeId: hostNode.nodeId } })
+    const hostThrown = await cdp.call('Runtime.evaluate', { expression: 'throw globalThis.__cordisHostProbe' })
+    const hostException = hostThrown.result?.exceptionDetails as Record<string, unknown>
+    const hostExceptionObject = hostException.exception as Record<string, unknown>
+    expect(hostExceptionObject).toMatchObject({ subtype: 'node', className: 'Context' })
+    await expect(cdp.call('DOM.requestNode', { objectId: hostExceptionObject.objectId }))
+      .resolves.toMatchObject({ result: { nodeId: hostNode.nodeId } })
+
+    let clientContextId: number | undefined
+    await vi.waitFor(() => {
+      const event = cdp!.events.find(item => item.method === 'Runtime.executionContextCreated'
+        && String((item.params?.context as { name?: string } | undefined)?.name).startsWith('Client'))
+      clientContextId = (event?.params?.context as { id?: number } | undefined)?.id
+      expect(clientContextId).toBeTypeOf('number')
+    })
+    const clientNode = walk(clientContainers(document)[0]!).find(item => item.localName === 'context')!
+    const clientEvaluated = await cdp.call('Runtime.evaluate', {
+      expression: 'globalThis.__cordisClientProbe',
+      contextId: clientContextId,
+    })
+    expect(clientEvaluated.result?.result).toMatchObject({ type: 'object', subtype: 'node', className: 'Context' })
+    await expect(cdp.call('DOM.requestNode', {
+      objectId: (clientEvaluated.result?.result as Record<string, unknown>).objectId,
+    })).resolves.toMatchObject({ result: { nodeId: clientNode.nodeId } })
+    const clientThrown = await cdp.call('Runtime.evaluate', {
+      expression: 'throw globalThis.__cordisClientProbe',
+      contextId: clientContextId,
+    })
+    const clientException = clientThrown.result?.exceptionDetails as Record<string, unknown>
+    const clientExceptionObject = clientException.exception as Record<string, unknown>
+    expect(clientExceptionObject).toMatchObject({ subtype: 'node', className: 'Context' })
+    await expect(cdp.call('DOM.requestNode', { objectId: clientExceptionObject.objectId }))
+      .resolves.toMatchObject({ result: { nodeId: clientNode.nodeId } })
+
+    const consoleOffset = cdp.events.length
+    await clientSource.logCordis('cordis-client-console')
+    let consoleObject: Record<string, unknown> | undefined
+    let consoleFiber: Record<string, unknown> | undefined
+    await vi.waitFor(() => {
+      const event = cdp!.events.slice(consoleOffset).find((candidate) => {
+        const params = candidate.params
+        if (params === undefined
+          || candidate.method !== 'Runtime.consoleAPICalled'
+          || params.executionContextId !== clientContextId
+          || !Array.isArray(params.args)) return false
+        return params.args.some(argument => (argument as { value?: unknown }).value === 'cordis-client-console')
+      })
+      const args = event?.params?.args
+      consoleObject = Array.isArray(args) ? args[0] as Record<string, unknown> | undefined : undefined
+      consoleFiber = Array.isArray(args) ? args[1] as Record<string, unknown> | undefined : undefined
+      expect(consoleObject).toMatchObject({ type: 'object', subtype: 'node', className: 'Context' })
+      expect(consoleFiber).toMatchObject({ type: 'object', subtype: 'node', className: 'Fiber' })
+    })
+    await expect(cdp.call('DOM.requestNode', { objectId: consoleObject!.objectId }))
+      .resolves.toMatchObject({ result: { nodeId: clientNode.nodeId } })
+    const requestedFiber = await cdp.call('DOM.requestNode', { objectId: consoleFiber!.objectId })
+    const requestedFiberId = (requestedFiber.result as { nodeId?: number } | undefined)?.nodeId
+    const clientFiberNode = walk(clientContainers(document)[0]!).find(node => node.nodeId === requestedFiberId)
+    expect(clientFiberNode).toMatchObject({
+      localName: 'fiber',
+      attributes: ['uid', String(clientSource.fiberUid)],
+    })
+
+    const firstResolved = await cdp.call('DOM.resolveNode', { backendNodeId: clientNode.backendNodeId })
+    const firstObjectId = (firstResolved.result?.object as Record<string, unknown>).objectId
+    secondCdp = await CdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
+    const secondDocument = (await secondCdp.call('DOM.getDocument')).result?.root as CdpNode
+    const secondNode = walk(secondDocument).find(node => node.backendNodeId === clientNode.backendNodeId)
+    expect(secondNode).toBeDefined()
+    const secondResolved = await secondCdp.call('DOM.resolveNode', { backendNodeId: clientNode.backendNodeId })
+    const secondObjectId = (secondResolved.result?.object as Record<string, unknown>).objectId
+    expect(secondObjectId).not.toBe(firstObjectId)
+    expect((await secondCdp.call('DOM.requestNode', { objectId: firstObjectId })).error).toBeDefined()
+
+    const eventOffset = cdp.events.length
+    await clientSource.close()
+    clientSource = undefined
+    await vi.waitFor(() => {
+      const events = cdp!.events.slice(eventOffset)
+      expect(events.some(event => event.method === 'Runtime.executionContextDestroyed'
+        && event.params?.executionContextId === clientContextId)).toBe(true)
+      expect(events.some(event => event.method === 'DOM.documentUpdated')).toBe(false)
+    })
+
+    const disconnectedDocument = (await cdp.call('DOM.getDocument')).result?.root as CdpNode
+    const disconnectedClient = clientContainers(disconnectedDocument)[0]
+    expect(disconnectedClient).toBeDefined()
+    expect(walk(disconnectedClient!).find(node => node.backendNodeId === clientNode.backendNodeId)?.nodeId)
+      .toBe(clientNode.nodeId)
+    expect((await cdp.call('DOM.resolveNode', { nodeId: clientNode.nodeId })).error?.message)
+      .toContain('Cordis realm is disconnected')
+    expect((await cdp.call('DOM.requestNode', {
+      objectId: (clientEvaluated.result?.result as Record<string, unknown>).objectId,
+    })).error).toBeDefined()
+    const disconnectedTree = (await cdp.call('DSHInspector.getCordisTree')).result?.tree as {
+      clients: Array<{ connection: { state: string } }>
+    }
+    expect(disconnectedTree.clients[0]?.connection.state).toBe('disconnected')
+  })
+
+  it('restores a disconnected Client tree from a new transport generation', async () => {
+    inspector = await startInspector({
+      port: 0,
+      captureFetch: false,
+      maxCordisNodes: 100,
+      clientReconnectBaseMs: 10,
+      clientReconnectMaxMs: 20,
+    })
+    clientSource = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Reconnect Client' })
+    cdp = await CdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
+    await cdp.call('Runtime.enable')
+
+    let document: CdpNode | undefined
+    let contextId: number | undefined
+    await vi.waitFor(async () => {
+      document = (await cdp!.call('DOM.getDocument')).result?.root as CdpNode
+      expect(clientContainers(document)).toHaveLength(1)
+      const created = cdp!.events.find(event => event.method === 'Runtime.executionContextCreated'
+        && String((event.params?.context as { name?: string } | undefined)?.name).startsWith('Client'))
+      contextId = (created?.params?.context as { id?: number } | undefined)?.id
+      expect(contextId).toBeTypeOf('number')
+    })
+    const initialTree = (await cdp.call('DSHInspector.getCordisTree')).result?.tree as {
+      clients: Array<{ source: { sourceId: string } }>
+    }
+    const sourceId = initialTree.clients[0]?.source.sourceId
+    const eventOffset = cdp.events.length
+    await clientSource.disconnect()
+
+    await vi.waitFor(() => {
+      const events = cdp!.events.slice(eventOffset)
+      const destroyed = events.findIndex(event => event.method === 'Runtime.executionContextDestroyed'
+        && event.params?.executionContextId === contextId)
+      const created = events.findIndex((event) => {
+        if (event.method !== 'Runtime.executionContextCreated') return false
+        const context = event.params?.context as { id?: number } | undefined
+        return typeof context?.id === 'number' && context.id !== contextId
+      })
+      const refreshed = events.findIndex(event => event.method === 'DOM.documentUpdated')
+      expect(destroyed).toBeGreaterThanOrEqual(0)
+      expect(created).toBeGreaterThan(destroyed)
+      expect(refreshed).toBeGreaterThan(created)
+      expect(events.slice(0, created).some(event => event.method?.startsWith('DOM.'))).toBe(false)
+    })
+
+    await vi.waitFor(async () => {
+      const current = (await cdp!.call('DOM.getDocument')).result?.root as CdpNode
+      expect(clientContainers(current)).toHaveLength(1)
+      expect(clientContainers(current)[0]?.children?.[0]?.localName).toBe('context')
+      const tree = (await cdp!.call('DSHInspector.getCordisTree')).result?.tree as {
+        clients: Array<{
+          source: { sourceId: string }
+          connection: { state: string }
+        }>
+      }
+      expect(tree.clients).toHaveLength(1)
+      expect(tree.clients[0]?.source.sourceId).toBe(sourceId)
+      expect(tree.clients[0]?.connection.state).toBe('connected')
+    })
+  })
+})
+
+function source(sourceId: string, generation: string): InspectorSourceDescriptor {
+  return {
+    sourceId: inspectorId<'InspectorSourceId'>(sourceId, 'sourceId'),
+    generation: inspectorId<'InspectorSourceGeneration'>(generation, 'generation'),
+    kind: 'client',
+    label: sourceId,
+    timeOriginMs: 0,
+    capabilities: [],
+  }
+}
+
+function hostContainer(root: CdpNode | undefined): CdpNode | undefined {
+  return root?.children?.find(node => node.localName === 'host')
+}
+
+function clientContainers(root: CdpNode | undefined): CdpNode[] {
+  return root?.children?.find(node => node.localName === 'clients')?.children
+    ?.filter(node => node.localName === 'client') ?? []
+}
+
+function walk(root: CdpNode): CdpNode[] {
+  return [root, ...(root.children ?? []).flatMap(walk)]
+}
+
+function treeNodes(root: CordisTreeNode): CordisTreeNode[] {
+  return [root, ...root.children.flatMap(treeNodes)]
+}
+
+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')
+}
+
+function asJson(value: object): InspectorJsonValue {
+  return value as unknown as InspectorJsonValue
+}

+ 73 - 0
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -1015,6 +1015,23 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       },
     ],
   },
+  {
+    key: 'inspector',
+    summary: 'Shared Host/Client service façade over the realm\'s source publisher.',
+    description: 'Shared Host/Client service façade over the realm\'s source publisher.',
+    methods: [
+      {
+        signature: 'publish(topic: string, payload: InspectorJsonValue, monotonicMs?: number): void',
+        description: 'Publish one JSON observation without waiting for Worker delivery.',
+        parameters: [{ name: 'topic', description: 'Domain-owned topic name.' }, { name: 'payload', description: 'JSON value validated before it reaches the carrier.' }, { name: 'monotonicMs', description: 'Source-clock timestamp; defaults to `performance.now()`.' }],
+      },
+      {
+        signature: 'readonly cordis: CordisRuntimeTreeReader',
+        description: 'Read-only Cordis topology queries independent of CDP sessions.',
+        parameters: [],
+      },
+    ],
+  },
   {
     key: 'invariants',
     summary: 'Package-owned invariant registry with global and regex-based selection.',
@@ -3678,6 +3695,46 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'CordisInspectRequestId',
     declaration: 'export type CordisInspectRequestId = Branded<\'CordisInspectRequestId\'>;',
   },
+  {
+    name: 'CordisRuntimeConnection',
+    declaration: 'export type CordisRuntimeConnection = {\n    readonly state: \'connected\';\n} | {\n    readonly state: \'disconnected\';\n    readonly reason: string;\n};',
+  },
+  {
+    name: 'CordisRuntimeContext',
+    declaration: 'export interface CordisRuntimeContext {\n    readonly kind: \'context\';\n    readonly children: readonly CordisRuntimeNode[];\n}',
+  },
+  {
+    name: 'CordisRuntimeFiber',
+    declaration: 'export interface CordisRuntimeFiber {\n    readonly kind: \'fiber\';\n    readonly uid: number;\n    readonly children: readonly [\n        CordisRuntimeContext\n    ];\n}',
+  },
+  {
+    name: 'CordisRuntimeNode',
+    declaration: 'export type CordisRuntimeNode = CordisRuntimeContext | CordisRuntimeFiber;',
+  },
+  {
+    name: 'CordisRuntimeRealm',
+    declaration: 'export interface CordisRuntimeRealm {\n    readonly source: CordisRuntimeSource;\n    readonly connection: CordisRuntimeConnection;\n    readonly revision: number;\n    readonly truncated: boolean;\n    readonly root: CordisRuntimeContext;\n}',
+  },
+  {
+    name: 'CordisRuntimeSource',
+    declaration: 'export interface CordisRuntimeSource {\n    readonly sourceId: CordisRuntimeSourceId;\n    readonly kind: CordisRuntimeSourceKind;\n    readonly label: string;\n}',
+  },
+  {
+    name: 'CordisRuntimeSourceId',
+    declaration: 'export type CordisRuntimeSourceId = InspectorId<\'CordisRuntimeSourceId\'>;',
+  },
+  {
+    name: 'CordisRuntimeSourceKind',
+    declaration: 'export type CordisRuntimeSourceKind = \'host\' | \'client\';',
+  },
+  {
+    name: 'CordisRuntimeTree',
+    declaration: 'export interface CordisRuntimeTree {\n    readonly schemaVersion: typeof CORDIS_RUNTIME_TREE_SCHEMA_VERSION;\n    readonly host: CordisRuntimeRealm | null;\n    readonly clients: readonly CordisRuntimeRealm[];\n}',
+  },
+  {
+    name: 'CordisRuntimeTreeReader',
+    declaration: 'export interface CordisRuntimeTreeReader {\n    getTree(): Promise<CordisRuntimeTree>;\n}',
+  },
   {
     name: 'CreateAgentOptions',
     declaration: 'export interface CreateAgentOptions {\n    readonly sessionId: SessionId;\n    readonly meta?: {\n        readonly cwd?: string;\n        readonly parentSession?: SessionId;\n        readonly seedLength?: number;\n        readonly origin?: \'subagent\';\n        readonly delegationDepth?: number;\n        readonly agentPreset?: string;\n    };\n    readonly seed?: readonly SessionEvent[];\n    readonly agentOptions?: AgentOptions;\n    readonly signal?: AbortSignal;\n    readonly setup?: AgentSetup;\n}',
@@ -4006,6 +4063,22 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'IndexInjectionPlacement',
     declaration: 'export type IndexInjectionPlacement = \'head\' | \'body\';',
   },
+  {
+    name: 'InspectorId',
+    declaration: 'export type InspectorId<Role extends string> = Branded<Role>;',
+  },
+  {
+    name: 'InspectorJsonObject',
+    declaration: 'export interface InspectorJsonObject {\n    readonly [key: string]: InspectorJsonValue;\n}',
+  },
+  {
+    name: 'InspectorJsonPrimitive',
+    declaration: 'export type InspectorJsonPrimitive = null | boolean | number | string;',
+  },
+  {
+    name: 'InspectorJsonValue',
+    declaration: 'export type InspectorJsonValue = InspectorJsonPrimitive | readonly InspectorJsonValue[] | InspectorJsonObject;',
+  },
   {
     name: 'InvariantFailure',
     declaration: 'export type InvariantFailure = (message: string) => never;',

+ 2 - 0
scripts/gen-cordis-catalog.ts

@@ -79,6 +79,7 @@ export const SERVICE_PAGE: Record<string, string> = {
   fileReferences: 'session-reference.md',
   fs: 'filesystem.md',
   goals: 'goal.md',
+  inspector: 'extensions.md',
   webServer: 'web-server.md',
   invariants: 'invariants.md',
   llm: 'llm-streaming.md',
@@ -249,6 +250,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
   GenerateOptions: 'llm-streaming.md',
   InboxItem: 'core.md',
   InboxPlacement: 'core.md',
+  InspectorJsonValue: 'extensions.md',
   MessageId: 'llm-streaming.md',
   ResumeAgentOptions: 'core.md',
   SettleReason: 'core.md',