diff --git a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml
index e6f2fb8718..242b95ac9f 100644
--- a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml
@@ -9,11 +9,11 @@
en: 2ba6c43b20a3d347
zh: 5647179ddaa38ad9
/agent-note-ship-experimental-capabilities-as-optional-bundles/decision:
- en: 1c5a43b3ce2d3056
- zh: b59f549a12cf67d1
+ en: 0d6c40e376fb6e48
+ zh: b0cf2ded4e395942
/agent-note-ship-experimental-capabilities-as-optional-bundles/alternatives-considered:
en: a65d84004e48b3ef
zh: 17221f08e7a8a89b
/agent-note-ship-experimental-capabilities-as-optional-bundles/consequences:
- en: a5c9699904fa72f1
- zh: 80763881150593e2
+ en: d690cf4d49c6af79
+ zh: 1c7a3077c35ae5b8
diff --git a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md
index 9c3de87f91..70bc46df69 100644
--- a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md
+++ b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md
@@ -10,9 +10,9 @@ The Web plugin page offered two optional bundles, Agent Teams and voice input. A
## Decision
-`OPTIONAL_BUNDLES` lists Agent Teams, voice input, and Auto review. Every optional bundle declares `icon` and exports `./locale/*.json` with `meta.title` and `meta.description`, so the Official group renders a localized title, description, and image; `verify-default-product-isolation` rejects an optional bundle without them. The Inspector's `cordis.patch.yml` names the package instead of a built-artifact path, so the same file is the bundle patch and the `--patch` overlay for a built Web launch.
+`OPTIONAL_BUNDLES` lists Agent Teams, voice input, Auto review, and the Inspector profile. Every optional bundle declares `icon` and exports `./locale/*.json` with `meta.title` and `meta.description`, so the Official group renders a localized title, description, and image; `verify-default-product-isolation` rejects an optional bundle without them. The Inspector profile owns the package-named bundle patch. The standalone Inspector's `cordis.patch.yml` mounts its local built entry as an explicit repository overlay; [Session Inspector](../feature/2026-09-24-session-inspector.md) owns the inspection behavior and activation choices.
-An optional bundle is a runtime dependency of the installation, so its dependency graph is downloaded by every `dsh` install. The list therefore admits only packages whose graph is already in the installation's closure; Auto review adds nothing. The Inspector stays available through explicit installation and does not appear in the default plugin list. The browser-use and computer-use providers stay explicit compositions: Playwright MCP, Chrome DevTools MCP, and the native Cua Driver runtime binaries add about 85 MB and 21 packages to every install whether or not the bundle is enabled, and a shipped Cua Driver MCP switch would offer a capability whose executable the installation does not carry. Those provider packages keep locale display metadata for their component rows. Two more packages stay out for other reasons: `ptc-runtime-python` replaces the PTC runtime, and `workflow-ptc` rejects a non-TypeScript runtime at load while the Web presets carry `workflow-ptc` rows a bundle patch cannot reach; `browser-use-stagehand-native` requires a native model name and API key at schema validation with no configuration form on the page.
+An optional bundle is a runtime dependency of the installation, so its dependency graph is downloaded by every `dsh` install. The list weighs the added installation cost: Auto review reuses the installation's closure, while the Inspector profile adds inspection packages, `serve-static`/`open`/`ws`, and 535 mirrored DevTools resources (about 11.95 MiB uncompressed), without another browser binary. The Inspector profile appears in the default plugin list but remains disabled until selected. The browser-use and computer-use providers stay explicit compositions: Playwright MCP, Chrome DevTools MCP, and the native Cua Driver runtime binaries add about 85 MB and 21 packages to every install whether or not the bundle is enabled, and a shipped Cua Driver MCP switch would offer a capability whose executable the installation does not carry. Those provider packages keep locale display metadata for their component rows. Two more packages stay out for other reasons: `ptc-runtime-python` replaces the PTC runtime, and `workflow-ptc` rejects a non-TypeScript runtime at load while the Web presets carry `workflow-ptc` rows a bundle patch cannot reach; `browser-use-stagehand-native` requires a native model name and API key at schema validation with no configuration form on the page.
## Alternatives considered
@@ -22,4 +22,4 @@ An optional bundle is a runtime dependency of the installation, so its dependenc
## Consequences
-The Official group carries three optional-bundle entries, each tagged experimental by its experimental name. The runtime dependency closure of the installation is unchanged. A browser-use or computer-use provider still needs its Service Definition and the provider in a profile patch or composition.
+The Official group carries four optional-bundle entries, each tagged experimental by its experimental name. Their runtime dependencies are installed even while the bundles are disabled. A browser-use or computer-use provider still needs its Service Definition and the provider in a profile patch or composition.
diff --git a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md
index 3198b409c7..d5a9b6aea9 100644
--- a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md
+++ b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md
@@ -10,9 +10,9 @@ Web 插件页只提供两个可选 bundle:Agent Teams 与语音输入。Auto r
## 决策
-`OPTIONAL_BUNDLES` 列出 Agent Teams、语音输入与 Auto review。每个可选 bundle 都声明 `icon` 并导出带 `meta.title` 与 `meta.description` 的 `./locale/*.json`,官方分组因此能渲染本地化标题、描述和图片;`verify-default-product-isolation` 会拒绝缺少这些声明的可选 bundle。Inspector 的 `cordis.patch.yml` 直接写包名而非构建产物路径,所以同一文件既是 bundle patch,也是已构建 Web 启动的 `--patch` overlay。
+`OPTIONAL_BUNDLES` 列出 Agent Teams、语音输入、Auto review 与 Inspector profile。每个可选 bundle 都声明 `icon` 并导出带 `meta.title` 与 `meta.description` 的 `./locale/*.json`,官方分组因此能渲染本地化标题、描述和图片;`verify-default-product-isolation` 会拒绝缺少这些声明的可选 bundle。Inspector profile 管理按包名挂载的 bundle patch。独立 Inspector 的 `cordis.patch.yml` 通过本地构建入口提供显式仓库 overlay;[Session Inspector](../feature/2026-09-24-session-inspector.zh.md) 管理检查行为与启用选择。
-可选 bundle 是安装的运行时依赖,其依赖图会随每次 `dsh` 安装一起下载。因此名单只接纳依赖图已在安装闭包内的包;Auto review 不增加任何依赖。Inspector 保留显式安装方式,不在默认插件列表中显示。浏览器操作与电脑操作提供方仍保持显式组合:Playwright MCP、Chrome DevTools MCP 与原生 Cua Driver 运行时二进制会让每次安装多出约 85 MB、21 个包,无论 bundle 是否启用;而随附的 Cua Driver MCP 开关会提供一个安装本身并不携带其可执行文件的能力。这些提供方包保留 locale 显示元数据,供组件行使用。另有两个包因其他原因不纳入:`ptc-runtime-python` 会替换 PTC 运行时,而 `workflow-ptc` 在加载时拒绝非 TypeScript 运行时,且 Web preset 内含 bundle patch 无法触及的 `workflow-ptc` 行;`browser-use-stagehand-native` 在 schema 校验时就要求原生模型名称与 API 密钥,而插件页没有对应的配置表单。
+可选 bundle 是安装的运行时依赖,其依赖图会随每次 `dsh` 安装一起下载。名单权衡新增安装成本:Auto review 复用安装闭包,Inspector profile 增加检查包、`serve-static`/`open`/`ws` 和 535 个镜像 DevTools 资源(未压缩约 11.95 MiB),但不携带另一个浏览器二进制。Inspector profile 在默认插件列表显示,选中前保持关闭。浏览器操作与电脑操作提供方仍保持显式组合:Playwright MCP、Chrome DevTools MCP 与原生 Cua Driver 运行时二进制会让每次安装多出约 85 MB、21 个包,无论 bundle 是否启用;而随附的 Cua Driver MCP 开关会提供一个安装本身并不携带其可执行文件的能力。这些提供方包保留 locale 显示元数据,供组件行使用。另有两个包因其他原因不纳入:`ptc-runtime-python` 会替换 PTC 运行时,而 `workflow-ptc` 在加载时拒绝非 TypeScript 运行时,且 Web preset 内含 bundle patch 无法触及的 `workflow-ptc` 行;`browser-use-stagehand-native` 在 schema 校验时就要求原生模型名称与 API 密钥,而插件页没有对应的配置表单。
## 考虑过的替代方案
@@ -22,4 +22,4 @@ Web 插件页只提供两个可选 bundle:Agent Teams 与语音输入。Auto r
## 后果
-官方分组包含三个可选 bundle 条目,每项都因实验包名而带实验性标签。安装的运行时依赖闭包保持不变。浏览器操作或电脑操作提供方仍需在 profile patch 或组合中同时挂载其 Service Definition 与提供方。
+官方分组包含四个可选 bundle 条目,每项都因实验包名而带实验性标签。即使组合包关闭,其运行时依赖也会安装。浏览器操作或电脑操作提供方仍需在 profile patch 或组合中同时挂载其 Service Definition 与提供方。
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index b863bd7258..ac0cb99d37 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -121,6 +121,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
| [`readable-stream`](https://github.com/nodejs/readable-stream) | MIT |
| [`resolve.exports`](https://github.com/lukeed/resolve.exports) | MIT |
| [`semver`](https://github.com/npm/node-semver) | ISC |
+| [`serve-static`](https://github.com/expressjs/serve-static) | MIT |
| [`sharp`](https://github.com/lovell/sharp) | Apache-2.0 |
| [`sherpa-onnx-node`](https://github.com/csukuangfj/sherpa-onnx) | Apache-2.0 |
| [`shiki`](https://github.com/shikijs/shiki) | MIT |
@@ -147,6 +148,11 @@ pnpm applies local patches to the following packages at install time, so shipped
- `exceljs@4.4.0` — [`patches/exceljs@4.4.0.patch`](patches/exceljs@4.4.0.patch)
- `node-pty@1.2.0-beta.15` — [`patches/node-pty@1.2.0-beta.15.patch`](patches/node-pty@1.2.0-beta.15.patch)
+## Chrome DevTools frontend
+
+The optional experimental Inspector distributes a local copy of [Chrome 150.0.7871.186's frontend](https://chrome-devtools-frontend.appspot.com/serve_rev/@ec97cf3bbeea2cb623fbf97c4e3f22f5acb4d568/devtools_app.html). The Chromium [BSD-3-Clause license](packages/experimental/inspector/assets/devtools/LICENSE) is included with the frontend. Copied JavaScript retains its embedded license comments; the resource downloader does not collect separate upstream third-party license files. The Chromium root license does not replace those dependencies' licenses.
+
+
## Official Claude Code platform payloads
The project owner authorizes distribution of every version of the official `@anthropic-ai/claude-agent-sdk` package and the official Claude Code CLI/platform payloads that each version declares through `optionalDependencies`. This identity-scoped authorization does not classify their declared terms as permissive and does not cover any unrelated runtime package; version, declared-license, and payload-set changes still require the ordinary dependency, lockfile, compatibility, terms, and notices review.
@@ -205,6 +211,7 @@ External packages **directly declared** for development, tests, types, or toolin
| [`@types/react-dom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/readable-stream`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/semver`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
+| [`@types/serve-static`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/spdx-expression-parse`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/turndown`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/use-sync-external-store`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
diff --git a/apps/cli/package.json b/apps/cli/package.json
index 81494aea52..e98a7c41ce 100644
--- a/apps/cli/package.json
+++ b/apps/cli/package.json
@@ -39,6 +39,7 @@
"@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:*",
"@deepseek-ai/dsh-cordis-client-runner": "workspace:*",
"@deepseek-ai/dsh-experimental-agent-team-profile": "workspace:*",
+ "@deepseek-ai/dsh-experimental-inspector-profile": "workspace:*",
"@deepseek-ai/dsh-fs-local": "workspace:*",
"@deepseek-ai/dsh-goal": "workspace:*",
"@deepseek-ai/dsh-goal-round-driver": "workspace:*",
diff --git a/apps/web/tests/devtools-frontend.e2e.ts b/apps/web/tests/devtools-frontend.e2e.ts
new file mode 100644
index 0000000000..0ca535ab4e
--- /dev/null
+++ b/apps/web/tests/devtools-frontend.e2e.ts
@@ -0,0 +1,118 @@
+/** Prebuilt Inspector frontend assets through the authenticated Web Host subpath. */
+import { fileURLToPath } from 'node:url'
+import { mkdir } from 'node:fs/promises'
+import { chromium, type Browser } from 'playwright'
+import { expect, it, onTestFinished } from 'vitest'
+import { launchWebScaffold, type WebScaffold } from './scaffold.ts'
+
+it('serves the trimmed DevTools frontend under its own path without rebuilding DevTools', async () => {
+ const resources: { scaffold?: WebScaffold; browser?: Browser } = {}
+ onTestFinished(async () => {
+ try { await resources.browser?.close() } finally { await resources.scaffold?.close() }
+ })
+ const scaffold = await launchWebScaffold({
+ profile: { packages: [{
+ dir: fileURLToPath(new URL('../../../packages/experimental/inspector-profile', import.meta.url)), enabled: true,
+ }], hmr: false },
+ extraOverlayPath: [
+ fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
+ fileURLToPath(new URL('./fixtures/shared-inspector.patch.yml', import.meta.url)),
+ ],
+ })
+ resources.scaffold = scaffold
+ const browser = await chromium.launch()
+ resources.browser = browser
+ const page = await browser.newPage({ locale: 'zh-CN' })
+ const root = new URL('inspector/devtools/', scaffold.authenticatedUrl).href
+ expect((await page.request.get(`${root}devtools_app.html`)).status()).toBe(401)
+ await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+
+ const redirect = await page.request.get(`${root.slice(0, -1)}?probe=retained`, { maxRedirects: 0 })
+ expect(redirect.status()).toBe(302)
+ expect(redirect.headers()['location']).toBe('devtools/devtools_app.html?probe=retained')
+ const html = await page.request.get(`${root}devtools_app.html`)
+ expect(html.status()).toBe(200)
+ expect(html.headers()['content-type']).toContain('text/html')
+ expect(html.headers()['x-content-type-options']).toBe('nosniff')
+ const markup = await html.text()
+ const importMap = markup.match(/',
+ new URL('devtools_app.html', root))).toEqual(['main.js', 'tokens.css'])
+ expect(paths('@import "./more.css"; body { background:url(../Images/icon.svg); src:url(data:font/woff2;base64,AAAA) }',
+ new URL('styles/main.css', root))).toEqual(['Images/icon.svg', 'styles/more.css'])
+})
+
+it('discovers assets in emitted CSS modules but skips their diagnostic source URL', () => {
+ expect(paths('const cssContent = "body { background:url(../../Images/icon.svg) }"; import.meta.resolve("./widget.css");',
+ new URL('ui/example/widget.css.js', root))).toEqual(['Images/icon.svg'])
+})
+
+it('rejects module dependencies outside the pinned revision', () => {
+ expect(() => paths('import "../../../other-revision/module.js"')).toThrow('escapes the pinned revision')
+ expect(() => paths('import "https://other.example/module.js"')).toThrow('escapes the pinned revision')
+})
+
+it('enumerates every locale supplied to the upstream I18n constructor before any pruning', () => {
+ expect(paths('const available = ["en-US", "zh", "fr"]; const i18n = new Library.I18n.I18n(available, "en-US");',
+ new URL('core/i18n/i18n.js', root))).toEqual([
+ 'core/i18n/locales/en-US.json', 'core/i18n/locales/fr.json', 'core/i18n/locales/zh.json',
+ ])
+})
+
+it('records bare and variable imports instead of inventing relative CDN paths for them', () => {
+ const result = findDevtoolsDependencies('import("puppeteer"); import("node:fs"); import(workerEntry);', file, root)
+ expect(result.urls).toEqual([])
+ expect(result.externalModules).toEqual(['puppeteer', 'node:fs'])
+ expect(result.dynamicImports).toEqual(['import(workerEntry)'])
+})
+
+it('follows the Lighthouse locale table with its upstream source-catalog exclusion', () => {
+ expect(paths('const locales = ["en-US.json", "en-US.ctc.json", "fr.json"].filter(name => !name.endsWith(".ctc.json"));',
+ new URL('third_party/lighthouse/lighthouse-dt-bundle.js', root))).toEqual([
+ 'third_party/lighthouse/locales/en-US.json', 'third_party/lighthouse/locales/fr.json',
+ ])
+})
diff --git a/packages/experimental/inspector/tests/devtools-locales.host.spec.ts b/packages/experimental/inspector/tests/devtools-locales.host.spec.ts
new file mode 100644
index 0000000000..897dc3a58d
--- /dev/null
+++ b/packages/experimental/inspector/tests/devtools-locales.host.spec.ts
@@ -0,0 +1,22 @@
+/** Locale pruning changes the upstream menu source and dependency graph together. */
+import { expect, it } from 'vitest'
+import { configureDevtoolsLocales } from '../scripts/build-devtools.ts'
+import { findDevtoolsDependencies } from '../scripts/download-devtools.ts'
+
+it('keeps Chinese and English in both the I18n instance and its resource dependencies', () => {
+ const source = 'const locales=["fr","en-US","zh","de"]; const bundled=["en-US","zh"]; new Lib.I18n.I18n(locales,"en-US");'
+ const result = configureDevtoolsLocales(source)
+ expect(result).toBe('const locales=["en-US","zh"]; const bundled=["en-US","zh"]; new Lib.I18n.I18n(locales,"en-US");')
+ const root = new URL('https://cdn.example/serve_rev/@revision/')
+ expect(findDevtoolsDependencies(result, new URL('core/i18n/i18n.js', root), root).urls.map(url => url.href)).toEqual([
+ new URL('core/i18n/locales/en-US.json', root).href,
+ new URL('core/i18n/locales/zh.json', root).href,
+ ])
+})
+
+it('rejects an upstream locale declaration that no longer matches the distribution', () => {
+ expect(() => configureDevtoolsLocales('const locales=["en-US"]; new Lib.I18n.I18n(locales,"en-US");'))
+ .toThrow('every distributed locale')
+ expect(() => configureDevtoolsLocales('new Lib.I18n.I18n(loadLocales(),"en-US");'))
+ .toThrow('one supported-locale array')
+})
diff --git a/packages/experimental/inspector/tests/devtools-selection.host.spec.ts b/packages/experimental/inspector/tests/devtools-selection.host.spec.ts
new file mode 100644
index 0000000000..215224bb20
--- /dev/null
+++ b/packages/experimental/inspector/tests/devtools-selection.host.spec.ts
@@ -0,0 +1,44 @@
+/** Per-connection Client selection in the frontend bootstrap and upgrade URL. */
+import { readFileSync } from 'node:fs'
+import { runInNewContext } from 'node:vm'
+import { expect, it } from 'vitest'
+import { INSPECTOR_CLIENT_QUERY, readInspectorClientSelection } from '../src/shared/web.ts'
+
+const bootstrap = readFileSync(new URL('../assets/devtools/connect.js', import.meta.url), 'utf8')
+
+it('keeps an absent selector global and decodes one selected Client', () => {
+ expect(readInspectorClientSelection(new URL('http://inspector/cdp'))).toBeUndefined()
+ const url = new URL('http://inspector/cdp')
+ url.searchParams.set(INSPECTOR_CLIENT_QUERY, 'client/page?x=1&y=2')
+ expect(readInspectorClientSelection(url)).toBe('client/page?x=1&y=2')
+})
+
+it.each(['clientSourceId=', 'clientSourceId=a&clientSourceId=b', `clientSourceId=${'x'.repeat(257)}`])(
+ 'rejects malformed selections instead of opening a global connection: %s', (query) => {
+ expect(() => readInspectorClientSelection(new URL(`http://inspector/cdp?${query}`))).toThrow()
+ },
+)
+
+it.each(['http', 'https'])('preserves the embedding Client selection and deployment prefix over %s', (protocol) => {
+ const location = new URL(`${protocol}://inspector/app/inspector/devtools/devtools_app.html?disableLocaleInfoBar=true`)
+ location.searchParams.set(INSPECTOR_CLIENT_QUERY, 'client/page?x=1&y=2')
+ let result: URL | undefined
+ const preferences = new Map()
+ runInNewContext(bootstrap, { URL, window: {
+ location, localStorage: { setItem: (key: string, value: string) => preferences.set(key, value) },
+ history: { state: null, replaceState: (_state: null, _title: string, url: URL) => { result = url } },
+ } })
+ const selected = new URL(`${protocol === 'https' ? 'wss' : 'ws'}://${result!.searchParams.get(protocol === 'https' ? 'wss' : 'ws')}`)
+ expect(selected.pathname).toBe('/app/inspector/devtools/cdp')
+ expect(readInspectorClientSelection(selected)).toBe('client/page?x=1&y=2')
+ expect(preferences.get('disable-locale-info-bar')).toBe('true')
+})
+
+it('keeps an explicit debugging endpoint unchanged', () => {
+ const location = new URL('http://inspector/devtools_app.html?ws=remote:9230/devtools/page/target&clientSourceId=client-page')
+ let result: URL | undefined
+ runInNewContext(bootstrap, { URL, window: {
+ location, history: { state: null, replaceState: (_state: null, _title: string, url: URL) => { result = url } },
+ } })
+ expect(result!.searchParams.get('ws')).toBe('remote:9230/devtools/page/target')
+})
diff --git a/packages/experimental/inspector/tests/dispose.host.spec.ts b/packages/experimental/inspector/tests/dispose.host.spec.ts
new file mode 100644
index 0000000000..0584bee935
--- /dev/null
+++ b/packages/experimental/inspector/tests/dispose.host.spec.ts
@@ -0,0 +1,27 @@
+/** Host-run checks for ordered async Inspector cleanup. */
+
+import { expect, it, vi } from 'vitest'
+import { disposeInspectorResources } from '../src/shared/dispose.ts'
+
+it('waits for each registration before releasing its predecessor and source', async () => {
+ const released = Promise.withResolvers()
+ const order: string[] = []
+ const disposed = disposeInspectorResources([
+ () => { order.push('first') },
+ async () => { order.push('second'); await released.promise },
+ ], () => { order.push('source') }, 'failed')
+ try {
+ expect(order).toEqual(['second'])
+ } finally { released.resolve(undefined) }
+ await disposed
+ expect(order).toEqual(['second', 'first', 'source'])
+})
+
+it('collects both registration and transport failures after attempting every action', async () => {
+ const earlier = new Error('registration')
+ const later = new Error('transport')
+ const retained = vi.fn()
+ const disposed = disposeInspectorResources([retained, async () => { throw earlier }], () => { throw later }, 'cleanup failed')
+ await expect(disposed).rejects.toMatchObject({ message: 'cleanup failed', errors: [earlier, later] })
+ expect(retained).toHaveBeenCalledOnce()
+})
diff --git a/packages/experimental/inspector/tests/integration.host.spec.ts b/packages/experimental/inspector/tests/integration.host.spec.ts
index 40f518359b..383c4e364f 100644
--- a/packages/experimental/inspector/tests/integration.host.spec.ts
+++ b/packages/experimental/inspector/tests/integration.host.spec.ts
@@ -81,6 +81,86 @@ describe('experimental Inspector real Worker', () => {
server = undefined
})
+ it('limits an embedded connection to its Client while direct connections retain every Client', async (test) => {
+ inspector = await startInspector({ port: 0, captureFetch: false, clientReconnectBaseMs: 10, clientReconnectMaxMs: 20 })
+ const sourceCatalog = (name: string) => ({
+ sourceText: `globalThis.clientName = ${JSON.stringify(name)}`,
+ sourceMap: JSON.stringify({ version: 3, sources: [`${name}.ts`], mappings: 'AAAA' }),
+ sourceUrl: `http://client.test/${name}.js`, sourceMapUrl: `http://client.test/${name}.js.map`,
+ })
+ client = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Embedded', sourceCatalog: sourceCatalog('embedded') })
+ cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
+ let sourceId: string | undefined
+ await vi.waitFor(async () => {
+ const sources = recordArray((await cdp!.call('DSHInspector.getSources')).result?.sources)
+ const selected = sources.find(source => source.label === 'Embedded')
+ expect(typeof selected?.sourceId).toBe('string')
+ sourceId = String(selected!.sourceId)
+ })
+ const endpoint = new URL(inspector.endpoint.webSocketDebuggerUrl)
+ endpoint.searchParams.set('clientSourceId', sourceId!)
+ secondCdp = await TestCdpClient.connect(endpoint.href)
+ await Promise.all([cdp.call('Runtime.enable'), secondCdp.call('Runtime.enable')])
+ await Promise.all([cdp.call('Debugger.enable'), secondCdp.call('Debugger.enable')])
+ await secondCdp.call('DSHInspector.enable')
+ const other = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Other', sourceCatalog: sourceCatalog('other') })
+ test.onTestFinished(() => other.close())
+ await vi.waitFor(() => {
+ expect(runtimeContexts(cdp!).map(context => context.name).sort()).toEqual(['Client — Embedded', 'Client — Other', 'Host'])
+ expect(runtimeContexts(secondCdp!).map(context => context.name).sort()).toEqual(['Client — Embedded', 'Host'])
+ expect(cdp!.events.some(event => event.method === 'Debugger.scriptParsed' && event.params?.url === 'http://client.test/other.js')).toBe(true)
+ })
+ expect(secondCdp.events.some(event => event.method === 'Debugger.scriptParsed' && event.params?.url === 'http://client.test/other.js')).toBe(false)
+ const otherContext = runtimeContexts(cdp).find(context => context.name === 'Client — Other')!
+ const selectedContext = runtimeContexts(secondCdp).find(context => context.name === 'Client — Embedded')!
+ expect((await secondCdp.call('Runtime.evaluate', { expression: '1', contextId: otherContext.id })).error).toBeDefined()
+ await other.log('other-value', 'other-console')
+ await client.log('own-value', 'own-console')
+ await vi.waitFor(() => {
+ expect(consoleEvent(cdp!, Number(otherContext.id), 'other-console')).toBeDefined()
+ expect(consoleEvent(secondCdp!, Number(selectedContext.id), 'own-console')).toBeDefined()
+ })
+ expect(consoleEvent(secondCdp, Number(otherContext.id), 'other-console')).toBeUndefined()
+ const filteredSources = recordArray((await secondCdp.call('DSHInspector.getSources')).result?.sources)
+ expect(filteredSources.filter(source => source.kind === 'client').map(source => source.label)).toEqual(['Embedded'])
+ for (const event of secondCdp.events.filter(event => event.method === 'DSHInspector.sourcesChanged')) {
+ expect(recordArray(event.params?.sources).filter(source => source.kind === 'client').map(source => source.label)).toEqual(['Embedded'])
+ }
+ const scopedTree = asRecord((await secondCdp.call('DSHInspector.getCordisTree')).result?.tree)
+ const fullTree = asRecord((await cdp.call('DSHInspector.getCordisTree')).result?.tree)
+ expect(recordArray(scopedTree.clients).map(realm => asRecord(realm.source).label)).toEqual(['Embedded'])
+ expect(recordArray(fullTree.clients)).toHaveLength(2)
+ const root = asRecord((await secondCdp.call('DOM.getDocument', { depth: 3 })).result?.root)
+ const clients = recordArray(root.children).find(node => node.localName === 'clients')!
+ expect(recordArray(clients.children)).toHaveLength(1)
+ await client.disconnect()
+ await vi.waitFor(() => {
+ expect(secondCdp!.events.some(event => event.method === 'Runtime.executionContextDestroyed'
+ && event.params?.executionContextId === selectedContext.id)).toBe(true)
+ const reconnected = runtimeContexts(secondCdp!).filter(context => context.name === 'Client — Embedded')
+ expect(reconnected).toHaveLength(2)
+ expect(reconnected[1]?.id).not.toBe(selectedContext.id)
+ })
+ expect(runtimeContexts(secondCdp).some(context => context.name === 'Client — Other')).toBe(false)
+ expect(recordArray((await cdp.call('DSHInspector.getSources')).result?.sources).filter(source => source.kind === 'client')).toHaveLength(2)
+ endpoint.searchParams.set('clientSourceId', 'absent-client')
+ const absent = await TestCdpClient.connect(endpoint.href)
+ test.onTestFinished(() => absent.close())
+ await absent.call('Runtime.enable')
+ expect(runtimeContexts(absent).map(context => context.name)).toEqual(['Host'])
+ expect(recordArray((await absent.call('DSHInspector.getSources')).result?.sources).every(source => source.kind === 'host')).toBe(true)
+ })
+
+ it('rejects malformed Client selectors before admitting a CDP connection', async () => {
+ inspector = await startInspector({ port: 0, captureFetch: false })
+ for (const query of ['clientSourceId=', 'clientSourceId=a&clientSourceId=b']) {
+ await expect(TestCdpClient.connect(`${inspector.endpoint.webSocketDebuggerUrl}?${query}`))
+ .rejects.toThrow('400')
+ }
+ cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
+ expect((await cdp.call('Runtime.enable')).error).toBeUndefined()
+ })
+
it('switches between Host and Client contexts and routes Client RemoteObjects', async () => {
inspector = await startInspector({ port: 0, captureFetch: false, clientReconnectBaseMs: 10, clientReconnectMaxMs: 20 })
cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
@@ -100,7 +180,7 @@ describe('experimental Inspector real Worker', () => {
let clientContextId: number | undefined
let clientUniqueContextId: string | undefined
await vi.waitFor(() => {
- expect(runtimeContexts(cdp!).some(context => context.name === 'Host')).toBe(true)
+ expect(runtimeContexts(cdp!).map(context => context.name).sort()).toEqual(['Client — Test Client', 'Host'])
const clientContext = cdp!.events
.filter(event => event.method === 'Runtime.executionContextCreated')
.map(event => event.params?.context as Record | undefined)
@@ -271,7 +351,7 @@ describe('experimental Inspector real Worker', () => {
})).result?.result).toMatchObject({ type: 'number', value: 42 })
})
- it('preserves native Host execution-context selectors', async () => {
+ it('advertises only the default Host context and preserves its numeric and unique selectors', async () => {
inspector = await startInspector({ port: 0, captureFetch: false })
cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
await cdp.call('Runtime.enable')
@@ -281,30 +361,31 @@ describe('experimental Inspector real Worker', () => {
let contextId: number | undefined
let uniqueContextId: string | undefined
await vi.waitFor(() => {
- const created = runtimeContexts(cdp!).find(candidate => candidate.name === 'Inspector VM Context')
+ const created = runtimeContexts(cdp!).find(candidate => candidate.name === 'Host')
contextId = created?.id as number | undefined
uniqueContextId = created?.uniqueId as string | undefined
expect(contextId).toBeTypeOf('number')
expect(uniqueContextId).toBeTypeOf('string')
})
const evaluated = await cdp.call('Runtime.evaluate', {
- expression: 'globalThis.vmMarker',
+ expression: 'process.pid',
contextId,
returnByValue: true,
})
- expect(evaluated.result?.result).toMatchObject({ type: 'string', value: 'selected-vm' })
+ expect(evaluated.result?.result).toMatchObject({ type: 'number', value: process.pid })
expect((await cdp.call('Runtime.evaluate', {
- expression: 'globalThis.vmMarker',
+ expression: 'process.pid',
uniqueContextId,
returnByValue: true,
- })).result?.result).toMatchObject({ type: 'string', value: 'selected-vm' })
+ })).result?.result).toMatchObject({ type: 'number', value: process.pid })
expect((await cdp.call('Runtime.callFunctionOn', {
executionContextId: contextId,
- functionDeclaration: 'function () { return globalThis.vmMarker }',
+ functionDeclaration: 'function () { return process.pid }',
returnByValue: true,
- })).result?.result).toMatchObject({ type: 'string', value: 'selected-vm' })
+ })).result?.result).toMatchObject({ type: 'number', value: process.pid })
expect((await cdp.call('Runtime.globalLexicalScopeNames', { executionContextId: contextId })).result?.names)
- .toContain('vmLexicalMarker')
+ .not.toContain('vmLexicalMarker')
+ expect(runtimeContexts(cdp).map(candidate => candidate.name)).toEqual(['Host'])
})
it('uses the same Runtime value model for Host and Client realms', async () => {
diff --git a/packages/experimental/inspector/tests/layout.host.spec.ts b/packages/experimental/inspector/tests/layout.host.spec.ts
index 39b8cf7bf4..2ef6c4be16 100644
--- a/packages/experimental/inspector/tests/layout.host.spec.ts
+++ b/packages/experimental/inspector/tests/layout.host.spec.ts
@@ -10,8 +10,11 @@ const packageRoot = fileURLToPath(new URL('../', import.meta.url))
const testsRoot = fileURLToPath(new URL('./', import.meta.url))
describe('Inspector execution layout', () => {
- it('keeps Client and Host implementation paths mirrored', async () => {
- expect(await sourceFiles('client')).toEqual(await sourceFiles('host'))
+ it('keeps Client and Host adapters mirrored apart from Client dictionaries and bottom controls', async () => {
+ const clientOnly = ['bridge/locales.ts', 'bottom/locales.ts', 'bottom/keyboard.ts']
+ const client = await sourceFiles('client')
+ expect(clientOnly.every(file => client.includes(file))).toBe(true)
+ expect(client.filter(file => !clientOnly.includes(file))).toEqual(await sourceFiles('host'))
})
it('keeps Worker Client and Host backend paths mirrored', async () => {
diff --git a/packages/experimental/inspector/tests/loader-composition.host.spec.ts b/packages/experimental/inspector/tests/loader-composition.host.spec.ts
index 387482d6e0..462a5b146a 100644
--- a/packages/experimental/inspector/tests/loader-composition.host.spec.ts
+++ b/packages/experimental/inspector/tests/loader-composition.host.spec.ts
@@ -7,10 +7,16 @@ import { fileURLToPath, pathToFileURL } from 'node:url'
import { Context } from '@deepseek-ai/cordis'
import Include from '@deepseek-ai/cordis-plugin-include'
import Loader from '@deepseek-ai/cordis-plugin-loader'
-import { loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
import WebServer from '@deepseek-ai/dsh-host-webserver'
+import { HostConnectionService } from '@deepseek-ai/dsh-client-connection'
+import { composeEntries, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
+import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser-auth.ts'
+import open from 'open'
import { afterEach, describe, expect, it, vi } from 'vitest'
import * as Inspector from '../src/index.ts'
+import { INSPECTOR_BOOTSTRAP_PATH } from '../src/shared/web.ts'
+
+vi.mock('open', () => ({ default: vi.fn(async () => undefined), apps: { chrome: 'chrome' } }))
let root: string | undefined
let context: Context | undefined
@@ -20,27 +26,24 @@ afterEach(async () => {
context = undefined
if (root !== undefined) await rm(root, { recursive: true, force: true })
root = undefined
+ vi.clearAllMocks()
})
describe('experimental Inspector through a real Loader composition', () => {
- it('mounts the bundle patch over a Web server row and releases its endpoint', async () => {
+ it.each([false, true])('starts inspection with --inspect=%s and opens Chrome only when requested', async (flag) => {
root = await mkdtemp(join(tmpdir(), 'dsh-inspector-loader-'))
const configPath = join(root, 'cordis.yml')
- await writeFile(configPath, [
- "- name: '@deepseek-ai/dsh-host-webserver'",
- ' config:',
- " host: '127.0.0.1'",
- ' port: 0',
- '',
- ].join('\n'))
- // The shipped bundle patch inserts the row by package name under a stable id; a later
- // profile patch configures that id, as the Plugins page and a `--patch` overlay do.
- const bundlePatches = loadOverlayPatches('test', fileURLToPath(new URL('../cordis.patch.yml', import.meta.url)))
- expect(bundlePatches.flatMap(patch => patch.insert ?? [])).toEqual([
- { id: 'experimental-inspector', name: '@deepseek-ai/dsh-experimental-inspector' },
- ])
+ const entries = composeEntries([loadOverlayPatches('dsh', fileURLToPath(
+ new URL('../../inspector-profile/cordis.patch.yml', import.meta.url),
+ ))])
+ const inspector = entries.find(entry => entry.id === 'experimental-inspector')!
+ await writeFile(configPath, JSON.stringify([
+ { name: '@deepseek-ai/dsh-host-webserver', config: { host: '127.0.0.1', port: 0 } },
+ { name: 'fixture:connection' },
+ ]))
context = new Context()
+ context.provide('cmdlineArgs', { get: () => flag ? ['--inspect'] : [] })
context.baseUrl = pathToFileURL(root).href + '/'
await context.plugin(Loader)
expect('default' in Inspector).toBe(false)
@@ -54,6 +57,7 @@ describe('experimental Inspector through a real Loader composition', () => {
context.loader.builtins.include = Include
const modules = new Map([
['@deepseek-ai/dsh-host-webserver', WebServer],
+ ['fixture:connection', (ctx: Context) => { new HostConnectionService(ctx, [], {} as BrowserAuth) }],
['@deepseek-ai/dsh-experimental-inspector', Inspector],
])
context.loader.internal = {
@@ -67,7 +71,7 @@ describe('experimental Inspector through a real Loader composition', () => {
name: 'cordis:include',
config: {
path: pathToFileURL(configPath).href,
- patches: [...bundlePatches, { id: 'experimental-inspector', config: { port: 0, captureFetch: false } }],
+ patches: [{ insert: [inspector] }, { id: 'experimental-inspector', config: { port: 0, captureFetch: false } }],
},
})
await context.loader.await()
@@ -75,13 +79,18 @@ describe('experimental Inspector through a real Loader composition', () => {
expect([...context.loader.entries()]
.filter(entry => entry.fiber === undefined && !entry.disabled))
.toEqual([])
+ const inspectorEntry = [...context.loader.entries()]
+ .find(entry => entry.options.name === '@deepseek-ai/dsh-experimental-inspector')
+ expect(inspectorEntry?.disabled).toBe(false)
+ expect(open).toHaveBeenCalledTimes(flag ? 1 : 0)
+ const api = (context.connection as HostConnectionService).createSharedFetchHandler('/api')
+ expect(inspectorEntry?.fiber).toBeDefined()
await vi.waitFor(async () => {
expect((await context!.inspector.cordis.getTree()).host?.source.kind).toBe('host')
})
+ const bootstrap = await api.fetch(new Request(`http://localhost${INSPECTOR_BOOTSTRAP_PATH}`))
+ expect(await bootstrap.json()).toHaveProperty('endpoint', expect.stringContaining('/ingest'))
- const inspectorEntry = [...context.loader.entries()]
- .find(entry => entry.options.name === '@deepseek-ai/dsh-experimental-inspector')
- expect(inspectorEntry?.fiber).toBeDefined()
await inspectorEntry!.fiber!.dispose()
expect(context.get('inspector')).toBeUndefined()
})
diff --git a/packages/experimental/inspector/tests/plugin.client.spec.ts b/packages/experimental/inspector/tests/plugin.client.spec.ts
index 7d518e976f..f5160e1cc7 100644
--- a/packages/experimental/inspector/tests/plugin.client.spec.ts
+++ b/packages/experimental/inspector/tests/plugin.client.spec.ts
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { apply } from '../src/client/index.ts'
import { ClientRealmSource } from '../src/client/inspection/realm.ts'
import type { InspectorClientBootstrap } from '../src/shared/bridge/messages/control.ts'
+import { INSPECTOR_BOOTSTRAP_ROUTE } from '../src/shared/web.ts'
class FakeWebSocket extends EventTarget {
static readonly CONNECTING = 0
@@ -68,6 +69,7 @@ describe('experimental Inspector Client plugin', () => {
afterEach(() => {
vi.restoreAllMocks()
+ vi.unstubAllGlobals()
FakeWebSocket.sockets.length = 0
globalThis.WebSocket = nativeWebSocket
globalThis.fetch = nativeFetch
@@ -76,6 +78,34 @@ describe('experimental Inspector Client plugin', () => {
Reflect.deleteProperty(globalThis, '__DSH_BOOT__')
})
+ it('removes the source on pagehide and reconnects once when the cached page resumes', async () => {
+ vi.stubGlobal('WebSocket', FakeWebSocket)
+ globalThis.__DSH_INSPECTOR__ = bootstrap
+ const ctx = new Context()
+ try {
+ await ctx.plugin({ apply }).await()
+ const first = FakeWebSocket.sockets[0]!
+ first.open()
+ const opened = JSON.parse(first.sent[0]!) as { source: { sourceId: string; generation: string } }
+ first.receive({ v: 0, t: 'source/accepted', sourceId: opened.source.sourceId, generation: opened.source.generation })
+ window.dispatchEvent(new Event('visibilitychange'))
+ expect(first.readyState).toBe(FakeWebSocket.OPEN)
+ window.dispatchEvent(new Event('pagehide'))
+ expect(first.readyState).toBe(FakeWebSocket.CLOSED)
+ expect(first.sent.map(value => JSON.parse(value) as { t: string }).at(-1)?.t).toBe('source/close')
+ window.dispatchEvent(new Event('pageshow'))
+ window.dispatchEvent(new Event('pageshow'))
+ expect(FakeWebSocket.sockets).toHaveLength(2)
+ const second = FakeWebSocket.sockets[1]!
+ second.open()
+ const resumed = JSON.parse(second.sent[0]!) as { source: { sourceId: string; generation: string } }
+ expect(resumed.source.sourceId).toBe(opened.source.sourceId)
+ expect(resumed.source.generation).not.toBe(opened.source.generation)
+ } finally { await ctx.fiber.dispose() }
+ window.dispatchEvent(new Event('pageshow'))
+ expect(FakeWebSocket.sockets).toHaveLength(2)
+ })
+
it('provides ctx.inspector and sends observations after the Worker accepts the source', async () => {
globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket
globalThis.__DSH_INSPECTOR__ = bootstrap
@@ -366,11 +396,11 @@ describe('experimental Inspector Client plugin', () => {
rev: 'graph',
entries: [{
id: '@deepseek-ai/dsh-experimental-inspector',
- url: 'plugins/@deepseek-ai/dsh-experimental-inspector/client.js?rev=bundle-rev',
+ url: 'plugins/??@deepseek-ai/dsh-experimental-inspector/client.js&rev=bundle-rev',
rev: 'bundle-rev',
}],
})
- const source = 'const clientBundleMarker = "你好"\n'
+ const source = 'const clientBundleMarker = "你好"\n//# sourceMappingURL=??@deepseek-ai/dsh-experimental-inspector/client.js.map&rev=bundle-rev\n'
const sourceMap = '{"version":3,"sources":["client/index.ts"]}'
globalThis.fetch = vi.fn(async (input: string | URL | Request) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
@@ -409,9 +439,9 @@ describe('experimental Inspector Client plugin', () => {
outcome?: { result?: { scripts?: Array<{ scriptKey: string; url: string; sourceMapUrl: string }> } }
}).find(frame => frame.requestId === 'source-request-1')
const script = response?.outcome?.result?.scripts?.[0]
- expect(script?.url).toContain('/plugins/@deepseek-ai/dsh-experimental-inspector/client.js?rev=bundle-rev')
+ expect(script?.url).toContain('/plugins/??@deepseek-ai/dsh-experimental-inspector/client.js&rev=bundle-rev')
expect(script?.sourceMapUrl)
- .toContain('/plugins/@deepseek-ai/dsh-experimental-inspector/client.js.map?rev=bundle-rev')
+ .toContain('/plugins/??@deepseek-ai/dsh-experimental-inspector/client.js.map&rev=bundle-rev')
scriptKey = script?.scriptKey
})
socket.receive({
@@ -436,17 +466,77 @@ describe('experimental Inspector Client plugin', () => {
await fiber.dispose()
})
- it('fails loud when the Host did not inject a bootstrap', async () => {
- globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket
+ it('connects without index injection and reuses the source after a connection reset', async () => {
+ vi.stubGlobal('WebSocket', FakeWebSocket)
+ const fetch = vi.fn(async (url: string) => {
+ expect(url).toBe(INSPECTOR_BOOTSTRAP_ROUTE)
+ expect(new URL(url, 'https://host.example/tools/dsh/').pathname)
+ .toBe('/tools/dsh/api/experimental-inspector/bootstrap')
+ return Response.json(bootstrap)
+ })
+ vi.stubGlobal('fetch', fetch)
const ctx = new Context()
- const fiber = ctx.plugin({ apply })
- await expect(fiber).rejects.toThrow('Host bootstrap is missing; reload the page after enabling the Inspector')
- await fiber.dispose()
+ try {
+ const fiber = ctx.plugin({ apply })
+ await fiber.await()
+ expect(ctx.get('inspector')).toBeDefined()
+ expect(FakeWebSocket.sockets).toHaveLength(1)
+ ctx.emit('connection/reset')
+ await vi.waitFor(() => { expect(fetch).toHaveBeenCalledTimes(2) })
+ expect(FakeWebSocket.sockets).toHaveLength(1)
+ await fiber.dispose()
+ expect(FakeWebSocket.sockets[0]!.readyState).toBe(FakeWebSocket.CLOSED)
+ } finally { await ctx.fiber.dispose() }
})
- it('closes the Client source when a later plugin registration fails', async () => {
- globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket
+ it('retries a missing bootstrap and replaces a source after the Host generation changes', async () => {
+ vi.stubGlobal('WebSocket', FakeWebSocket)
+ let response = new Response(null, { status: 503 })
+ vi.stubGlobal('fetch', vi.fn(async () => response))
+ const ctx = new Context()
+ try {
+ await ctx.plugin({ apply }).await()
+ expect(ctx.get('inspector')).toBeUndefined()
+ response = Response.json(bootstrap)
+ ctx.emit('connection/reset')
+ await vi.waitFor(() => { expect(FakeWebSocket.sockets).toHaveLength(1) })
+ const first = FakeWebSocket.sockets[0]!
+ response = Response.json({ ...bootstrap, protocol: 'dsh-inspector-v0-new-worker' })
+ ctx.emit('connection/reset')
+ await vi.waitFor(() => { expect(FakeWebSocket.sockets).toHaveLength(2) })
+ expect(first.readyState).toBe(FakeWebSocket.CLOSED)
+ expect(FakeWebSocket.sockets[1]!.protocol).toBe('dsh-inspector-v0-new-worker')
+ } finally { await ctx.fiber.dispose() }
+ })
+
+ it('does not install a late bootstrap after disposal begins', async () => {
+ vi.stubGlobal('WebSocket', FakeWebSocket)
globalThis.__DSH_INSPECTOR__ = bootstrap
+ let respond!: (response: Response) => void
+ const pending = new Promise((resolve) => { respond = resolve })
+ const fetch = vi.fn(() => pending)
+ vi.stubGlobal('fetch', fetch)
+ const ctx = new Context()
+ try {
+ const fiber = ctx.plugin({ apply })
+ await fiber.await()
+ ctx.emit('connection/reset')
+ await vi.waitFor(() => { expect(fetch).toHaveBeenCalledOnce() })
+ const disposed = fiber.dispose()
+ respond(Response.json({ ...bootstrap, protocol: 'dsh-inspector-v0-late-worker' }))
+ await disposed
+ expect(FakeWebSocket.sockets).toHaveLength(1)
+ expect(FakeWebSocket.sockets[0]!.readyState).toBe(FakeWebSocket.CLOSED)
+ } finally {
+ respond(Response.json(bootstrap))
+ await ctx.fiber.dispose()
+ }
+ })
+
+ it.each([true, false])('closes the Client source when registration fails (injected=%s)', async (injected) => {
+ globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket
+ if (injected) globalThis.__DSH_INSPECTOR__ = bootstrap
+ else vi.stubGlobal('fetch', vi.fn(async () => Response.json(bootstrap)))
const ctx = new Context()
ctx.provide('inspector', {
publish: () => undefined,
@@ -459,4 +549,105 @@ describe('experimental Inspector Client plugin', () => {
expect(FakeWebSocket.sockets[0]?.readyState).toBe(FakeWebSocket.CLOSED)
await fiber.dispose()
})
+
+ it.each([true, false])('retains reconnect recovery after transport setup fails (injected=%s)', async (injected) => {
+ vi.stubGlobal('WebSocket', FakeWebSocket)
+ vi.stubGlobal('fetch', vi.fn(async () => Response.json(bootstrap)))
+ if (injected) globalThis.__DSH_INSPECTOR__ = bootstrap
+ vi.spyOn(ClientRealmSource, 'claim').mockRejectedValueOnce(new Error('identity temporarily unavailable'))
+ const ctx = new Context()
+ const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
+ try {
+ await ctx.plugin({ apply }).await()
+ expect(warn).toHaveBeenCalledOnce()
+ expect(ctx.get('inspector')).toBeUndefined()
+ ctx.emit('connection/reset')
+ await vi.waitFor(() => { expect(ctx.get('inspector')).toBeDefined() })
+ expect(FakeWebSocket.sockets).toHaveLength(1)
+ } finally { await ctx.fiber.dispose() }
+ expect(FakeWebSocket.sockets[0]?.readyState).toBe(FakeWebSocket.CLOSED)
+ expect(ctx.get('inspector')).toBeUndefined()
+ })
+
+ it.each([true, false])('rejects invalid bootstrap data during startup (injected=%s)', async (injected) => {
+ vi.stubGlobal('WebSocket', FakeWebSocket)
+ if (injected) globalThis.__DSH_INSPECTOR__ = null
+ else vi.stubGlobal('fetch', vi.fn(async () => Response.json(null)))
+ const ctx = new Context()
+ try {
+ await expect(ctx.plugin({ apply }).await()).rejects.toThrow()
+ expect(FakeWebSocket.sockets).toHaveLength(0)
+ expect(ctx.get('inspector')).toBeUndefined()
+ } finally { await ctx.fiber.dispose() }
+ })
+
+ it('retains recovery after a bootstrap network failure', async () => {
+ vi.stubGlobal('WebSocket', FakeWebSocket)
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValueOnce(new TypeError('network unavailable')).mockResolvedValue(Response.json(bootstrap)))
+ const ctx = new Context()
+ try {
+ await ctx.plugin({ apply }).await()
+ expect(ctx.get('inspector')).toBeUndefined()
+ ctx.emit('connection/reset')
+ await vi.waitFor(() => { expect(ctx.get('inspector')).toBeDefined() })
+ } finally { await ctx.fiber.dispose() }
+ })
+
+ it('settles an aborted bootstrap refresh without warning after disposal', async () => {
+ vi.stubGlobal('WebSocket', FakeWebSocket)
+ globalThis.__DSH_INSPECTOR__ = bootstrap
+ const pending = Promise.withResolvers()
+ const aborted = Promise.withResolvers()
+ const fetch = vi.fn((_url: string, init: RequestInit) => {
+ init.signal?.addEventListener('abort', () => { aborted.resolve(undefined) }, { once: true })
+ return pending.promise
+ })
+ vi.stubGlobal('fetch', fetch)
+ const ctx = new Context()
+ const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
+ const fiber = ctx.plugin({ apply })
+ try {
+ await fiber.await()
+ ctx.emit('connection/reset')
+ await vi.waitFor(() => { expect(fetch).toHaveBeenCalledOnce() })
+ const disposed = fiber.dispose()
+ await aborted.promise
+ pending.reject(new Error('aborted request'))
+ await disposed
+ expect(warn).not.toHaveBeenCalled()
+ } finally {
+ pending.resolve(Response.json(bootstrap))
+ await ctx.fiber.dispose()
+ }
+ })
+
+ it('joins service-consumer cleanup before claiming a replacement source', async () => {
+ vi.stubGlobal('WebSocket', FakeWebSocket)
+ globalThis.__DSH_INSPECTOR__ = bootstrap
+ vi.stubGlobal('fetch', vi.fn(async () => Response.json({ ...bootstrap, protocol: 'replacement-worker' })))
+ const claimed = vi.spyOn(ClientRealmSource, 'claim')
+ const started = Promise.withResolvers()
+ const released = Promise.withResolvers()
+ const ctx = new Context()
+ try {
+ await ctx.plugin({ apply }).await()
+ await ctx.plugin({ inject: ['inspector'], apply(child) {
+ child.effect(() => async () => {
+ started.resolve(undefined)
+ await released.promise
+ })
+ } }).await()
+ ctx.emit('connection/reset')
+ await started.promise
+ expect(claimed).toHaveBeenCalledTimes(1)
+ expect(ctx.get('inspector')).toBeUndefined()
+ released.resolve(undefined)
+ await vi.waitFor(() => { expect(FakeWebSocket.sockets).toHaveLength(2) })
+ expect(FakeWebSocket.sockets[0]?.readyState).toBe(FakeWebSocket.CLOSED)
+ expect(ctx.get('inspector')).toBeDefined()
+ } finally {
+ released.resolve(undefined)
+ await ctx.fiber.dispose()
+ }
+ })
})
diff --git a/packages/experimental/inspector/tests/plugin.host.spec.ts b/packages/experimental/inspector/tests/plugin.host.spec.ts
index 905c655d96..0fe950bfed 100644
--- a/packages/experimental/inspector/tests/plugin.host.spec.ts
+++ b/packages/experimental/inspector/tests/plugin.host.spec.ts
@@ -1,11 +1,18 @@
-import { createServer } from 'node:http'
+import { createServer, type IncomingMessage } from 'node:http'
+import { PassThrough } from 'node:stream'
import type { AddressInfo } from 'node:net'
import { Context } from '@deepseek-ai/cordis'
-import type { IndexInjection, WebServer } from '@deepseek-ai/dsh-host-webserver'
+import type { IndexInjection, WebServer, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
+import { HostConnectionService } from '@deepseek-ai/dsh-client-connection'
+import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser-auth.ts'
+import open from 'open'
import WebSocket, { type RawData } from 'ws'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { apply, Config, inject, name, startInspector } from '../src/index.ts'
import { isPlainObject } from '../src/shared/json.ts'
+import { INSPECTOR_BOOTSTRAP_PATH } from '../src/shared/web.ts'
+
+vi.mock('open', () => ({ default: vi.fn(async () => undefined), apps: { chrome: 'chrome' } }))
interface CdpResponse {
readonly id: number
@@ -19,12 +26,21 @@ describe('experimental Inspector Host plugin', () => {
await context?.fiber.dispose()
context = undefined
vi.restoreAllMocks()
+ vi.clearAllMocks()
})
it('starts the Worker, provides ctx.inspector, injects Client bootstrap, and disposes', async () => {
context = new Context()
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined)
- context.provide('webServer', {} as WebServer)
+ const releaseRoute = vi.fn()
+ const registerRoute = vi.fn(() => releaseRoute)
+ const releaseUpgrade = vi.fn()
+ const registerUpgrade = vi.fn<(_route: WebUpgradeRoute) => () => void>(() => releaseUpgrade)
+ const routes: Pick = { register: registerRoute, registerUpgrade }
+ let authenticated = false
+ const auth: Pick = { isAuthenticated: () => authenticated }
+ context.provide('webServer', routes as WebServer)
+ const connection = new HostConnectionService(context, [], auth as BrowserAuth)
const fiber = context.plugin(
{ name, inject: [...inject], Config, apply },
{ port: 0, captureFetch: false },
@@ -37,6 +53,22 @@ describe('experimental Inspector Host plugin', () => {
expect(bootstrap).toMatchObject({ kind: 'global', name: '__DSH_INSPECTOR__' })
expect(log).toHaveBeenCalledWith(expect.stringMatching(/^dsh inspector: devtools:\/\//u))
expect(context.inspector).toBeDefined()
+ expect(registerRoute).toHaveBeenCalledWith(expect.objectContaining({ kind: 'prefix', path: '/inspector/devtools' }))
+ expect(registerUpgrade).toHaveBeenCalledWith(expect.objectContaining({ path: '/inspector/devtools/cdp' }))
+ const upgrade = registerUpgrade.mock.calls[0]![0]
+ for (const [origin, status] of [['http://127.0.0.1', 401], ['http://untrusted.invalid', 403]] as const) {
+ const socket = new PassThrough()
+ await upgrade.handler({ headers: { host: '127.0.0.1', origin } } as IncomingMessage, socket, Buffer.alloc(0))
+ expect(String(socket.read())).toContain(`HTTP/1.1 ${status}`)
+ socket.destroy()
+ }
+ authenticated = true
+ for (const query of ['clientSourceId=', 'clientSourceId=a&clientSourceId=b']) {
+ const socket = new PassThrough()
+ await upgrade.handler({ url: `/inspector/devtools/cdp?${query}`, headers: { host: '127.0.0.1', origin: 'http://127.0.0.1' } } as IncomingMessage, socket, Buffer.alloc(0))
+ expect(String(socket.read())).toContain('HTTP/1.1 400')
+ socket.destroy()
+ }
await vi.waitFor(async () => {
const tree = await context!.inspector.cordis.getTree()
expect(tree.host?.source.kind).toBe('host')
@@ -46,6 +78,11 @@ describe('experimental Inspector Host plugin', () => {
context.inspector.publish('host/plugin-probe', { ready: true })
const value = bootstrap?.kind === 'global' ? bootstrap.value : undefined
+ const api = connection.createSharedFetchHandler('/api')
+ const fetched = await api.fetch(new Request(`http://localhost${INSPECTOR_BOOTSTRAP_PATH}`))
+ expect(fetched.headers.get('cache-control')).toBe('no-store')
+ expect(await fetched.json()).toEqual(value)
+ expect(open).not.toHaveBeenCalled()
const endpoint = value as { endpoint: string; protocol: string }
const authority = new URL(endpoint.endpoint)
const targets: unknown = await fetch(`http://${authority.host}/json`).then(response => response.json())
@@ -72,16 +109,21 @@ describe('experimental Inspector Host plugin', () => {
await new Promise((resolve) => { socket.once('close', () => { resolve() }) })
await fiber.dispose()
+ expect(releaseRoute).toHaveBeenCalledOnce()
+ expect(releaseUpgrade).toHaveBeenCalledOnce()
expect(rows).toHaveLength(1)
const afterDispose: IndexInjection[] = []
context.emit('webserver/index-inject', afterDispose)
expect(afterDispose).toEqual([])
+ expect((await api.fetch(new Request(`http://localhost${INSPECTOR_BOOTSTRAP_PATH}`))).status).toBe(404)
})
it('closes the started Worker when a later plugin registration fails', async () => {
const port = await availablePort()
context = new Context()
- context.provide('webServer', {} as WebServer)
+ const routes: Pick = { register: () => () => {}, registerUpgrade: () => () => {} }
+ context.provide('webServer', routes as WebServer)
+ new HostConnectionService(context, [], {} as BrowserAuth)
context.provide('inspector', {
publish: () => undefined,
cordis: { getTree: () => Promise.reject(new Error('unused test service')) },
@@ -98,6 +140,26 @@ describe('experimental Inspector Host plugin', () => {
await replacement.close()
})
+ it('keeps inspection available when --inspect cannot open Chrome', async () => {
+ context = new Context()
+ const routes: Pick = { register: () => () => {}, registerUpgrade: () => () => {} }
+ context.provide('webServer', routes as WebServer)
+ context.provide('cmdlineArgs', { get: () => ['--inspect'] })
+ const connection = new HostConnectionService(context, [], {} as BrowserAuth)
+ vi.mocked(open).mockRejectedValueOnce(new Error('Chrome unavailable'))
+ const fiber = context.plugin({ name, inject: [...inject], Config, apply }, { port: 0, captureFetch: false })
+ await fiber.await()
+ expect(context.get('inspector')).toBeDefined()
+ expect(open).toHaveBeenCalledOnce()
+ expect(open).toHaveBeenCalledWith(expect.stringMatching(/^devtools:\/\//u), {
+ app: { name: 'chrome' }, wait: false,
+ })
+ const api = connection.createSharedFetchHandler('/api')
+ const response = await api.fetch(new Request(`http://localhost${INSPECTOR_BOOTSTRAP_PATH}`))
+ expect(response.status).toBe(200)
+ expect(await response.json()).toHaveProperty('endpoint', expect.stringContaining('/ingest'))
+ })
+
it('closes the Worker when fetch capture installation fails', async () => {
const port = await availablePort()
const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'fetch')
diff --git a/packages/experimental/inspector/tests/prune-lighthouse.host.spec.ts b/packages/experimental/inspector/tests/prune-lighthouse.host.spec.ts
new file mode 100644
index 0000000000..1bc38d51b3
--- /dev/null
+++ b/packages/experimental/inspector/tests/prune-lighthouse.host.spec.ts
@@ -0,0 +1,145 @@
+/** Pure Lighthouse pruning fixtures retain adjacent panels, AI capabilities, and image registrations. */
+import ts from 'typescript'
+import { describe, expect, it } from 'vitest'
+import { pruneLighthouse } from '../scripts/prune-lighthouse.ts'
+
+const entryPath = 'entrypoints/devtools_app/devtools_app.js'
+const panelPath = 'panels/ai_assistance/ai_assistance.js'
+const modelPath = 'models/ai_assistance/ai_assistance.js'
+const entry = `
+import * as Lighthouse from "../../panels/lighthouse/lighthouse.js";
+import * as Views from "../../ui/legacy/legacy.js";
+const strings = { title: "Lighthouse" }, messages = Locale.registerUIStrings("panels/lighthouse/lighthouse-meta.ts", strings), cached = undefined;
+async function loadLighthouse() { return cached || await import("../../panels/lighthouse/lighthouse.js"); }
+Views.registerViewExtension({ id: "elements", title: "Elements" });
+Views.registerViewExtension({ id: "lighthouse", async loadView() { return (await loadLighthouse()).LighthousePanel; } });
+Core.registerRevealer({ contextTypes() { return [Lighthouse.LighthousePanel.ActiveLighthouseReport]; }, async loadRevealer() { return (await loadLighthouse()).ReportRevealer; } });
+Views.registerViewExtension({ id: "timeline", title: "Performance" });
+`
+
+const panel = `
+import * as Reports from "../lighthouse/lighthouse.js";
+import * as Rendering from "../lighthouse/lighthouse.js";
+import * as AI from "../../models/ai_assistance/ai_assistance.js";
+function reportContext(report) { return report ? new AI.AccessibilityAgent.AccessibilityContext(report.report) : null; }
+async function renderReport(widget) { return Rendering.LighthouseReportRenderer.renderLighthouseScores(widget.report); }
+async function renderWidget(widget) { let rendered = null; switch (widget.name) {
+case "LIGHTHOUSE_REPORT": rendered = await renderReport(widget); break;
+case "OTHER": rendered = "other-widget"; break;
+} return rendered; }
+function icon(context) { return context instanceof AI.AccessibilityAgent.AccessibilityContext ? "Lighthouse" : "other-icon"; }
+class Panel {
+ #report = null;
+ #onReport = event => { this.#report = reportContext(event.data); };
+ async #record(params) { return await Reports.LighthousePanel.executeLighthouseRecording(params); }
+ wasShown() { before(), this.#report = reportContext(Context.flavor(Reports.LighthousePanel.ActiveLighthouseReport)), Context.addFlavorChangeListener(Reports.LighthousePanel.ActiveLighthouseReport, this.#onReport), after(); }
+ willHide() { before(), Context.removeFlavorChangeListener(Reports.LighthousePanel.ActiveLighthouseReport, this.#onReport), after(); }
+ first() { return new AI.AiConversation.AiConversation({ lighthouseRecording: this.#record.bind(this), keep: "first" }); }
+ second() { return new AI.AiConversation.AiConversation({ keep: "second", lighthouseRecording: this.#record.bind(this), other: true }); }
+ third() { return new AI.AiConversation.AiConversation({ keep: "third", lighthouseRecording: this.#record.bind(this) }); }
+ context(type) { switch (type) { case "accessibility": return this.#report; case "styling": return "styling-context"; } }
+ setContext = value => value instanceof AI.AccessibilityAgent.AccessibilityContext ? this.#report = value : useOtherContext(value);
+ view() { let elements = Views.isViewVisible("elements"), lighthouse = Views.isViewVisible("lighthouse"), storage = Views.isViewVisible("resources");
+ return elements ? "styling" : lighthouse && config.devToolsAiAssistanceAccessibilityAgent?.enabled ? "accessibility" : storage ? "storage" : "none";
+ }
+}
+class Unrelated { #report = "unrelated"; read() { return this.#report; } }
+`
+
+const model = `
+class SelectionAgent { constructor() {
+ this.declareFunction("listNetworkRequests", {}), this.declareFunction("runLighthouseAudits", {}), this.declareFunction("inspectDom", {});
+} }
+class AccessibilityAgent { configure() {
+ this.declareFunction("executeJavaScript", {}), this.declareFunction("runAccessibilityAudits", {}), this.declareFunction("getLighthouseAudits", {}), this.declareFunction("getStyles", {});
+} }
+`
+
+function imports(source: string): string[] {
+ const file = ts.createSourceFile('fixture.js', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS)
+ const result: string[] = []
+ const visit = (node: ts.Node): void => {
+ if (ts.isImportDeclaration(node) && ts.isStringLiteralLike(node.moduleSpecifier)) result.push(node.moduleSpecifier.text)
+ if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword
+ && node.arguments[0] !== undefined && ts.isStringLiteralLike(node.arguments[0])) result.push(node.arguments[0].text)
+ ts.forEachChild(node, visit)
+ }
+ visit(file)
+ return result
+}
+
+describe('pruneLighthouse', () => {
+ it('removes the panel, revealer, lazy loader and private metadata while retaining adjacent panels', () => {
+ const result = pruneLighthouse(entryPath, entry)
+ expect(imports(result)).toEqual(['../../ui/legacy/legacy.js'])
+ expect(result).toContain('Views.registerViewExtension({ id: "elements", title: "Elements" });')
+ expect(result).toContain('Views.registerViewExtension({ id: "timeline", title: "Performance" });')
+ expect(result).not.toMatch(/Lighthouse|loadLighthouse|registerRevealer|registerUIStrings|cached/)
+ })
+
+ it('removes only report context, recording and rendering from the AI panel', () => {
+ const result = pruneLighthouse(panelPath, panel)
+ expect(imports(result)).toEqual(['../../models/ai_assistance/ai_assistance.js'])
+ expect(result).not.toMatch(/Reports|Rendering|renderReport|reportContext|lighthouseRecording|#record|#onReport/)
+ expect(result).toContain('wasShown() { before(), after(); }')
+ expect(result).toContain('willHide() { before(), after(); }')
+ expect(result).toContain('case"LIGHTHOUSE_REPORT":break;')
+ expect(result).toContain('case "OTHER": rendered = "other-widget"; break;')
+ expect(result).toContain('case"accessibility":return null;')
+ expect(result).toContain('case "styling": return "styling-context";')
+ expect(result).toContain('setContext = value => useOtherContext(value);')
+ expect(result).toContain('return "other-icon";')
+ expect(result).toContain('{ keep: "first" }')
+ expect(result).toContain('{ keep: "second", other: true }')
+ expect(result).toContain('{ keep: "third" }')
+ expect(result).toContain('storage ? "storage" : "none"')
+ expect(result).toContain('class Unrelated { #report = "unrelated"; read() { return this.#report; } }')
+ })
+
+ it('removes Lighthouse model tool declarations without dropping other agent tools', () => {
+ const result = pruneLighthouse(modelPath, model)
+ expect(result).not.toMatch(/runLighthouseAudits|runAccessibilityAudits|getLighthouseAudits/)
+ for (const tool of ['listNetworkRequests', 'inspectDom', 'executeJavaScript', 'getStyles']) {
+ expect(result).toContain(`this.declareFunction("${tool}", {})`)
+ }
+ expect(result).toContain('class SelectionAgent')
+ expect(result).toContain('class AccessibilityAgent')
+ })
+
+ it.each(['./lighthouse_logo.svg', 'lighthouse_logo.svg'])('replaces only the %s image call and preserves the comma chain', (url) => {
+ const source = `style.setProperty("--before", "before.svg"),style.setProperty("--image-file-lighthouse_logo", new URL(new URL("${url}", import.meta.url).href)),style.setProperty("--after", "after.svg");`
+ expect(pruneLighthouse('Images/Images.js', source))
+ .toBe('style.setProperty("--before", "before.svg"),void 0,style.setProperty("--after", "after.svg");')
+ })
+
+ it('keeps unowned modules and i18n strings byte-identical', () => {
+ const source = '{"showLighthouse":"Show Lighthouse","language":"all locales retained"}\n'
+ expect(pruneLighthouse('core/i18n/locales/en-US.json', source)).toBe(source)
+ expect(pruneLighthouse('panels/elements/elements.js', 'const Lighthouse = "unrelated";')).toBe('const Lighthouse = "unrelated";')
+ expect(pruneLighthouse(entryPath.replaceAll('/', '\\'), entry)).toBe(pruneLighthouse(entryPath, entry))
+ })
+
+ it('rejects missing or duplicate pinned contributions', () => {
+ expect(() => pruneLighthouse(entryPath, entry.replace('id: "lighthouse"', 'id: "renamed"'))).toThrow('expected 1 panel registrations, found 0')
+ expect(() => pruneLighthouse(modelPath, model.replace('"runLighthouseAudits"', '"renamed"'))).toThrow('expected 1 runLighthouseAudits, found 0')
+ const logo = 'style.setProperty("--image-file-lighthouse_logo", new URL("./lighthouse_logo.svg", import.meta.url));'
+ expect(() => pruneLighthouse('Images/Images.js', logo + logo)).toThrow('expected 1 Lighthouse logo registrations, found 2')
+ expect(() => pruneLighthouse(panelPath, panel.replace('async #record', 'async record'))).toThrow('private Lighthouse recording method')
+ })
+
+ it('rejects extra references instead of removing their unrelated consumers', () => {
+ expect(() => pruneLighthouse(entryPath, entry + '\nuseElsewhere(Lighthouse);')).toThrow('removed binding Lighthouse remains referenced')
+ expect(() => pruneLighthouse(entryPath, entry + '\nuseElsewhere(loadLighthouse);')).toThrow('removed binding loadLighthouse remains referenced')
+ expect(() => pruneLighthouse(panelPath, panel.replace('first() {', 'extra() { return this.#report; } first() {')))
+ .toThrow('removed private member #report remains referenced')
+ expect(() => pruneLighthouse(panelPath, panel + '\nimport("../lighthouse/lighthouse.js");'))
+ .toThrow('Lighthouse import remains after pruning')
+ })
+
+ it('rejects unexpected import forms, syntax, and effect-bearing tool expressions', () => {
+ expect(() => pruneLighthouse(entryPath, entry.replace('import * as Lighthouse', 'import Lighthouse'))).toThrow('Lighthouse import must use a namespace')
+ expect(() => pruneLighthouse(entryPath, 'function {')).toThrow('pruneLighthouse:')
+ expect(() => pruneLighthouse(modelPath, model.replace('this.declareFunction("runLighthouseAudits", {})', 'wrap(this.declareFunction("runLighthouseAudits", {}))')))
+ .toThrow('runLighthouseAudits is not an independent expression')
+ })
+})
diff --git a/packages/experimental/inspector/tests/workspace-prefetch.host.spec.ts b/packages/experimental/inspector/tests/workspace-prefetch.host.spec.ts
new file mode 100644
index 0000000000..2f925f7406
--- /dev/null
+++ b/packages/experimental/inspector/tests/workspace-prefetch.host.spec.ts
@@ -0,0 +1,31 @@
+/** DevTools prefetch belongs to contributor setup, not published package installation. */
+import { readFileSync } from 'node:fs'
+import { expect, it } from 'vitest'
+
+interface Manifest {
+ scripts?: Record
+ files?: string[]
+}
+
+function manifest(path: string): Manifest {
+ return JSON.parse(readFileSync(new URL(path, import.meta.url), 'utf8')) as Manifest
+}
+
+it('prefetches DevTools from the root workspace after installing its existing Git hooks', () => {
+ const root = manifest('../../../../package.json')
+ expect(root.scripts?.postinstall).toBe('node scripts/install-lefthook.mjs && pnpm run prefetch:devtools')
+ expect(root.scripts?.['prefetch:devtools']).toBe('tsx packages/experimental/inspector/scripts/download-devtools.ts')
+})
+
+it.each(['../../../../apps/cli/package.json', '../../inspector-profile/package.json', '../package.json'])(
+ 'keeps download lifecycle hooks out of the published package %s', (path) => {
+ const { scripts = {} } = manifest(path)
+ for (const hook of ['preinstall', 'install', 'postinstall', 'prepare']) expect(scripts[hook]).toBeUndefined()
+ },
+)
+
+it('publishes the built frontend without its download tooling or cache', () => {
+ const { files } = manifest('../package.json')
+ expect(files).toContain('lib/devtools/**')
+ expect(files?.some(path => /^(?:scripts|\.cache)(?:\/|$)/u.test(path))).toBe(false)
+})
diff --git a/packages/experimental/inspector/tsconfig.client.json b/packages/experimental/inspector/tsconfig.client.json
index b24fdcb91f..3f0511fed2 100644
--- a/packages/experimental/inspector/tsconfig.client.json
+++ b/packages/experimental/inspector/tsconfig.client.json
@@ -6,7 +6,13 @@
"tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo"
},
"files": [
+ "src/css-modules.d.ts",
+ "src/client/bottom/page.tsx",
+ "src/client/bottom/resize.tsx",
+ "src/client/bottom/locales.ts",
+ "src/client/bottom/keyboard.ts",
"src/client/bridge/controller.ts",
+ "src/client/bridge/locales.ts",
"src/client/bridge/dispatcher.ts",
"src/client/bridge/lifecycle.ts",
"src/client/bridge/publisher.ts",
@@ -76,15 +82,25 @@
"src/shared/cordis/projector.ts",
"src/shared/cordis/reader.ts",
"src/shared/cordis/snapshot.ts",
+ "src/shared/dispose.ts",
"src/shared/identity.ts",
"src/shared/index.ts",
"src/shared/json.ts",
"src/shared/network/event-source.ts",
"src/shared/network/observation.ts",
"src/shared/service.ts",
- "src/shared/validation.ts"
+ "src/shared/validation.ts",
+ "src/shared/web.ts"
],
"references": [
+ { "path": "../../client/ui-renderer" },
+ { "path": "../../client/ui-layout" },
+ { "path": "../../client/shortcuts/tsconfig.client.json" },
+ { "path": "../../client/store" },
+ { "path": "../../client/ui-slots" },
+ { "path": "../../client/ui-primitives" },
+ { "path": "../../client/locale/tsconfig.client.json" },
+ { "path": "../../client/connection/tsconfig.client.json" },
{
"path": "../../util/brand"
},
diff --git a/packages/experimental/inspector/tsconfig.host.json b/packages/experimental/inspector/tsconfig.host.json
index f96992b4aa..3a1272922a 100644
--- a/packages/experimental/inspector/tsconfig.host.json
+++ b/packages/experimental/inspector/tsconfig.host.json
@@ -77,6 +77,7 @@
"src/shared/cordis/projector.ts",
"src/shared/cordis/reader.ts",
"src/shared/cordis/snapshot.ts",
+ "src/shared/dispose.ts",
"src/shared/identity.ts",
"src/shared/index.ts",
"src/shared/json.ts",
@@ -84,6 +85,7 @@
"src/shared/network/observation.ts",
"src/shared/service.ts",
"src/shared/validation.ts",
+ "src/shared/web.ts",
"src/worker/bridge/endpoint.ts",
"src/worker/bridge/hub.ts",
"src/worker/bridge/runtime-rpc.ts",
@@ -134,6 +136,10 @@
"src/worker/server.ts"
],
"references": [
+ { "path": "../../client/connection/tsconfig.host.json" },
+ {
+ "path": "../../boot/cmdline"
+ },
{
"path": "../../util/brand"
},
diff --git a/packages/experimental/inspector/tsdown.config.ts b/packages/experimental/inspector/tsdown.config.ts
index 53917a1ea2..95b3266d38 100644
--- a/packages/experimental/inspector/tsdown.config.ts
+++ b/packages/experimental/inspector/tsdown.config.ts
@@ -1,6 +1,7 @@
import type { UserConfig } from 'tsdown'
import { clientBundle } from '../../client/tsdown.client.ts'
import { profileWorkerBanner } from '../../tsdown.worker.ts'
+import { buildDevtools } from './scripts/build-devtools.ts'
const worker: UserConfig = {
entry: { worker: 'lib/types/worker/entry.js' },
@@ -22,5 +23,13 @@ const worker: UserConfig = {
export default clientBundle(
'@deepseek-ai/dsh-experimental-inspector',
['lib/types/index.js'],
- { hostPhase: true, companions: [worker] },
+ {
+ hostPhase: true,
+ companions: [worker],
+ lib: {
+ hooks: {
+ 'build:done': async () => { await buildDevtools() },
+ },
+ },
+ },
)
diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
index 34a751030e..bfd0e0b6f7 100644
--- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
+++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
@@ -2781,6 +2781,35 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.trigger\', () => ctx.slots.register(\n { name: \'settings.trigger\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-settings/src/client/contract/slots.ts:27',
},
+ {
+ key: 'shell.bottom',
+ kind: 'single',
+ scope: 'root',
+ summary: 'Full-width bottom content below all three columns.',
+ doc: 'Full-width bottom content below all three columns. Its rendered height\nreduces the columns\' available height; empty content reserves no space.\nThe occupant owns its height, visibility, and controls. It receives no\nSession binding and remains mounted across main-panel navigation.',
+ registerOptions: [],
+ ownerProps: [],
+ ownerPropsReferences: [],
+ standardProps: [
+ 'useResource: UseResource',
+ 'useWorkspaces: SnapshotSelectorHook',
+ 'usePanelInfo: UsePanelInfo',
+ 'useSessions: UseSessions',
+ 'useSessionStatus: UseSessionStatus',
+ 'useSessionRetainInfo: UseSessionRetainInfo',
+ 'useWorkspaces: SnapshotSelectorHook',
+ ],
+ keyDomain: '',
+ hookContext: '',
+ slotInject: '',
+ declaredBy: 'an entry in \'root\' (client-ui-layout), so it exists while that entry is mounted',
+ occupants: [
+ 'experimental-inspector InspectorPage',
+ ],
+ replaceRisk: 'shadows-shipped-ui',
+ example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'shell.bottom\', () => ctx.slots.register(\n { name: \'shell.bottom\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
+ source: 'packages/client/ui-layout/src/client/index.ts:94',
+ },
{
key: 'shell.leading',
kind: 'single',
@@ -2808,7 +2837,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'shell.leading\', () => ctx.slots.register(\n { name: \'shell.leading\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
- source: 'packages/client/ui-layout/src/client/index.ts:112',
+ source: 'packages/client/ui-layout/src/client/index.ts:119',
},
{
key: 'shell.overlay',
@@ -2865,7 +2894,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'shell.overlay\', () => ctx.slots.register(\n { name: \'shell.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
- source: 'packages/client/ui-layout/src/client/index.ts:98',
+ source: 'packages/client/ui-layout/src/client/index.ts:105',
},
{
key: 'shell.quota-notice',
@@ -3185,6 +3214,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
'client-ui-sidebar-right GuideBody',
'client-ui-sidebar-terminal LazyTerminalBody',
'client-ui-subagent SidebarChatTab',
+ 'experimental-session-inspector SessionInspectorView',
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.pane.tab\', () => ctx.slots.register(\n { name: \'sidebar.right.pane.tab\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
diff --git a/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md
index 9910286ca0..ca87b357aa 100644
--- a/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md
+++ b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md
@@ -190,6 +190,7 @@ Every package below exports a Cordis plugin that a bundle patch can name in a Lo
| `@deepseek-ai/dsh-experimental-computer-use-cua-driver-native` | no | Experimental computer-use provider embedding the Cua Driver native npm SDK |
| `@deepseek-ai/dsh-experimental-inspector` | yes | Experimental cross-realm CDP hub for Host debugging and Client Runtime inspection |
| `@deepseek-ai/dsh-experimental-ptc-runtime-python` | yes | CPython subprocess implementation of the DeepSeek Harness PTC execution seam |
+| `@deepseek-ai/dsh-experimental-session-inspector` | no | Experimental virtualized Session log and live Chat group/node inspectors |
| `@deepseek-ai/dsh-experimental-speech-to-text` | yes | Experimental speech recognition with independently selectable providers |
| `@deepseek-ai/dsh-experimental-speech-to-text-sensevoice` | yes | Local SenseVoice ONNX transcription with a managed sherpa-onnx process |
| `@deepseek-ai/dsh-experimental-tool-agent-team` | yes | Scoped model-facing Agent Teams tools over ctx.agentTeams |
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7b5c2234db..247444ca1e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -247,6 +247,9 @@ importers:
'@deepseek-ai/dsh-experimental-auto-review':
specifier: workspace:*
version: link:../../packages/experimental/auto-review
+ '@deepseek-ai/dsh-experimental-inspector-profile':
+ specifier: workspace:*
+ version: link:../../packages/experimental/inspector-profile
'@deepseek-ai/dsh-experimental-voice-input-bundle':
specifier: workspace:*
version: link:../../packages/experimental/voice-input-bundle
@@ -7921,6 +7924,12 @@ importers:
'@deepseek-ai/schemastery':
specifier: link:../../../vendor/schemastery
version: link:../../../vendor/schemastery
+ open:
+ specifier: ^11.0.0
+ version: 11.0.4
+ serve-static:
+ specifier: ^2.2.1
+ version: 2.2.1(supports-color@9.4.0)
ws:
specifier: ^8.21.0
version: 8.21.0
@@ -7934,19 +7943,80 @@ importers:
'@deepseek-ai/cordis-plugin-loader':
specifier: workspace:~
version: link:../../../vendor/loader
+ '@deepseek-ai/dsh-client-connection':
+ specifier: workspace:*
+ version: link:../../client/connection
+ '@deepseek-ai/dsh-client-locale':
+ specifier: workspace:*
+ version: link:../../client/locale
+ '@deepseek-ai/dsh-client-shortcuts':
+ specifier: workspace:*
+ version: link:../../client/shortcuts
+ '@deepseek-ai/dsh-client-store':
+ specifier: workspace:*
+ version: link:../../client/store
+ '@deepseek-ai/dsh-client-test-runtime':
+ specifier: workspace:*
+ version: link:../../test-support/client-runtime
+ '@deepseek-ai/dsh-client-ui-layout':
+ specifier: workspace:*
+ version: link:../../client/ui-layout
+ '@deepseek-ai/dsh-client-ui-primitives':
+ specifier: workspace:*
+ version: link:../../client/ui-primitives
+ '@deepseek-ai/dsh-client-ui-renderer':
+ specifier: workspace:*
+ version: link:../../client/ui-renderer
+ '@deepseek-ai/dsh-client-ui-slots':
+ specifier: workspace:*
+ version: link:../../client/ui-slots
+ '@deepseek-ai/dsh-cmdline':
+ specifier: workspace:*
+ version: link:../../boot/cmdline
'@deepseek-ai/dsh-host-webserver':
specifier: workspace:*
version: link:../../host/webserver
+ '@testing-library/react':
+ specifier: ^16.1.0
+ version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@types/react':
+ specifier: ~18.3.1
+ version: 18.3.31
+ '@types/serve-static':
+ specifier: ^2.2.0
+ version: 2.2.0
'@types/ws':
specifier: ^8.18.1
version: 8.18.1
playwright:
specifier: ^1.49.0
version: 1.61.1
+ react:
+ specifier: ^18.2.0
+ version: 18.3.1
tsx:
specifier: ^4.19.2
version: 4.22.4
+ packages/experimental/inspector-profile:
+ dependencies:
+ '@deepseek-ai/dsh-experimental-inspector':
+ specifier: workspace:*
+ version: link:../inspector
+ '@deepseek-ai/dsh-experimental-session-inspector':
+ specifier: workspace:*
+ version: link:../session-inspector
+ devDependencies:
+ '@deepseek-ai/cordis':
+ specifier: workspace:~
+ version: link:../../../vendor/cordis
+ '@deepseek-ai/cordis-plugin-include':
+ specifier: workspace:~
+ version: link:../../../vendor/include
+ js-yaml:
+ specifier: ^4.2.0
+ version: 4.3.1
+
packages/experimental/ptc-runtime-python:
dependencies:
'@deepseek-ai/schemastery':
@@ -7966,6 +8036,69 @@ importers:
specifier: workspace:*
version: link:../../util/values
+ packages/experimental/session-inspector:
+ devDependencies:
+ '@deepseek-ai/cordis':
+ specifier: workspace:~
+ version: link:../../../vendor/cordis
+ '@deepseek-ai/dsh-api-session-controller':
+ specifier: workspace:*
+ version: link:../../api/session-controller
+ '@deepseek-ai/dsh-client-locale':
+ specifier: workspace:*
+ version: link:../../client/locale
+ '@deepseek-ai/dsh-client-store':
+ specifier: workspace:*
+ version: link:../../client/store
+ '@deepseek-ai/dsh-client-test-runtime':
+ specifier: workspace:*
+ version: link:../../test-support/client-runtime
+ '@deepseek-ai/dsh-client-ui-chat':
+ specifier: workspace:*
+ version: link:../../client/ui-chat
+ '@deepseek-ai/dsh-client-ui-conversation':
+ specifier: workspace:*
+ version: link:../../client/ui-conversation
+ '@deepseek-ai/dsh-client-ui-primitives':
+ specifier: workspace:*
+ version: link:../../client/ui-primitives
+ '@deepseek-ai/dsh-client-ui-renderer':
+ specifier: workspace:*
+ version: link:../../client/ui-renderer
+ '@deepseek-ai/dsh-client-ui-session':
+ specifier: workspace:*
+ version: link:../../client/ui-session
+ '@deepseek-ai/dsh-client-ui-sidebar-right':
+ specifier: workspace:*
+ version: link:../../client/ui-sidebar-right
+ '@deepseek-ai/dsh-client-ui-slots':
+ specifier: workspace:*
+ version: link:../../client/ui-slots
+ '@deepseek-ai/dsh-llm':
+ specifier: workspace:*
+ version: link:../../llm/llm
+ '@deepseek-ai/dsh-session':
+ specifier: workspace:*
+ version: link:../../core/session
+ '@tanstack/react-virtual':
+ specifier: ^3.14.9
+ version: 3.14.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@testing-library/react':
+ specifier: ^16.1.0
+ version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@types/react':
+ specifier: ~18.3.1
+ version: 18.3.31
+ '@types/react-dom':
+ specifier: ~18.3.0
+ version: 18.3.7(@types/react@18.3.31)
+ react:
+ specifier: ^18.2.0
+ version: 18.3.1
+ react-dom:
+ specifier: ^18.2.0
+ version: 18.3.1(react@18.3.1)
+
packages/experimental/speech-to-text:
dependencies:
'@deepseek-ai/dsh-brand':
@@ -26462,8 +26595,8 @@ snapshots:
qs: 6.15.3
range-parser: 1.3.0
router: 2.2.0
- send: 1.2.1
- serve-static: 2.2.1
+ send: 1.2.1(supports-color@9.4.0)
+ serve-static: 2.2.1(supports-color@9.4.0)
statuses: 2.0.2
type-is: 2.1.0
vary: 1.1.2
@@ -28722,7 +28855,7 @@ snapshots:
semver@7.8.5: {}
- send@1.2.1:
+ send@1.2.1(supports-color@9.4.0):
dependencies:
debug: 4.4.3(supports-color@9.4.0)
encodeurl: 2.0.0
@@ -28743,12 +28876,12 @@ snapshots:
type-fest: 0.13.1
optional: true
- serve-static@2.2.1:
+ serve-static@2.2.1(supports-color@9.4.0):
dependencies:
encodeurl: 2.0.0
escape-html: 1.0.3
parseurl: 1.3.3
- send: 1.2.1
+ send: 1.2.1(supports-color@9.4.0)
transitivePeerDependencies:
- supports-color
diff --git a/scripts/check-workspace-constraints.spec.ts b/scripts/check-workspace-constraints.spec.ts
index 3307aa3956..c225c1a59e 100644
--- a/scripts/check-workspace-constraints.spec.ts
+++ b/scripts/check-workspace-constraints.spec.ts
@@ -388,6 +388,16 @@ it('requires the local speech worker and locked runtime in the published payload
}
})
+it('requires the Inspector Worker, Client chunks, and mirrored DevTools resources in the published payload', () => {
+ const dir = 'packages/experimental/inspector'
+ const manifest = JSON.parse(readFileSync(new URL(`../${dir}/package.json`, import.meta.url), 'utf8')) as WorkspaceManifest['manifest']
+ expect(checkWorkspaceManifest({ dir, manifest })).toEqual([])
+ for (const omitted of ['lib/client.*.js', 'lib/worker.js', 'lib/devtools/**']) {
+ expect(checkWorkspaceManifest({ dir, manifest: { ...manifest, files: manifest.files!.filter(file => file !== omitted) } }))
+ .toEqual([expect.stringContaining('package.json files must be')])
+ }
+})
+
it('requires the standalone shortcut protocol and rejects unrelated runtime files', () => {
const dir = 'packages/client/shortcuts'
const manifest = JSON.parse(readFileSync(new URL(`../${dir}/package.json`, import.meta.url), 'utf8')) as WorkspaceManifest['manifest']
diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts
index 13deaa24d9..02068b242f 100644
--- a/scripts/check-workspace-constraints.ts
+++ b/scripts/check-workspace-constraints.ts
@@ -188,8 +188,8 @@ const packageFileExtras: Readonly> = {
'@deepseek-ai/dsh-experimental-speech-to-text-sensevoice': ['runtime/assets.json'],
// The isolated Node bootstrap is a separately launched bundle.
'@deepseek-ai/dsh-ptc-runtime-node': ['lib/process.js'],
- // The Host entry starts its sibling Worker by URL rather than a package export.
- '@deepseek-ai/dsh-experimental-inspector': ['lib/worker.js'],
+ // The Inspector owns a Worker and a mirrored frontend outside package export paths.
+ '@deepseek-ai/dsh-experimental-inspector': ['lib/client.*.js', 'lib/worker.js', 'lib/devtools/**'],
// Creator's composition guidance travels with the declaration package.
'@deepseek-ai/dsh-agent-preset': ['skills'],
// The Web Host mounts the default-off settings owner independently of each
diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts
index 6167190367..e872aac220 100644
--- a/scripts/gen-third-party-notices.ts
+++ b/scripts/gen-third-party-notices.ts
@@ -15,6 +15,7 @@ import { parse as parseToml, type TomlTableWithoutBigInt, type TomlValueWithoutB
import parseSpdx from 'spdx-expression-parse'
import primaryRuntimeLock from './primary-runtime/lock.json' with { type: 'json' }
import { browserBundledExternals } from './browser-bundled-externals.ts'
+import { DEVTOOLS_CDN, DEVTOOLS_CHROMIUM_VERSION } from '../packages/experimental/inspector/scripts/download-devtools.ts'
const root = resolve(import.meta.dirname, '..')
const OUT = 'THIRD_PARTY_NOTICES.md'
@@ -793,6 +794,11 @@ ${renderNpmTable(runtimeDeps)}
pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification:
${patchedLines.join('\n')}
+
+## Chrome DevTools frontend
+
+The optional experimental Inspector distributes a local copy of [Chrome ${DEVTOOLS_CHROMIUM_VERSION}'s frontend](${DEVTOOLS_CDN.href}devtools_app.html). The Chromium [BSD-3-Clause license](packages/experimental/inspector/assets/devtools/LICENSE) is included with the frontend. Copied JavaScript retains its embedded license comments; the resource downloader does not collect separate upstream third-party license files. The Chromium root license does not replace those dependencies' licenses.
+
${renderClaudeDistribution(claudeDistribution)}
${kitRuntime ? `
## LibreOffice conversion kit
diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json
index d43d1be214..c8940cfd06 100644
--- a/scripts/no-unknown-casts.baseline.json
+++ b/scripts/no-unknown-casts.baseline.json
@@ -1180,7 +1180,7 @@
},
"packages/experimental/inspector/tests/plugin.client.spec.ts": {
"148509a0193d8d3c16a606a15f5145770f454c2ebc70dd0ccaa51fc1a0f43e99": 1,
- "2066ad746457f15b6737d62124c4ec81f2140fc3fa425187eac8e3b1c3b6ab0b": 9
+ "2066ad746457f15b6737d62124c4ec81f2140fc3fa425187eac8e3b1c3b6ab0b": 8
},
"packages/experimental/ptc-runtime-python/src/index.ts": {
"b121cee14635b00f7891217013e4682493cf96f676710be60722fdf8b862aea3": 1
diff --git a/scripts/release/families.spec.ts b/scripts/release/families.spec.ts
index a54d7c3fb2..0cbba9d3c8 100644
--- a/scripts/release/families.spec.ts
+++ b/scripts/release/families.spec.ts
@@ -60,8 +60,10 @@ describe('release families', () => {
'@deepseek-ai/dsh-experimental-client-ui-voice-input',
'@deepseek-ai/dsh-experimental-computer-use-cua-driver-mcp',
'@deepseek-ai/dsh-experimental-computer-use-cua-driver-native',
+ '@deepseek-ai/dsh-experimental-inspector-profile',
'@deepseek-ai/dsh-experimental-inspector',
'@deepseek-ai/dsh-experimental-ptc-runtime-python',
+ '@deepseek-ai/dsh-experimental-session-inspector',
'@deepseek-ai/dsh-experimental-speech-to-text-sensevoice',
'@deepseek-ai/dsh-experimental-speech-to-text',
'@deepseek-ai/dsh-experimental-tool-agent-team',
diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts
index 735aaa2fc2..4db748696f 100644
--- a/scripts/verify-package-readme-model-experience.ts
+++ b/scripts/verify-package-readme-model-experience.ts
@@ -100,6 +100,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = {
'packages/experimental/webworker-runtime': { kind: 'none', reason: 'Browser-side host runtime and Node-compatibility layer; the plugins it boots own every model-facing registration.' },
'packages/experimental/webworker-packer': { kind: 'none', reason: 'Build-time image writer; its output reaches a model only through the tree the worker then boots.' },
'packages/experimental/inspector': { kind: 'none', reason: 'Developer diagnostics transport; it observes runtime activity without changing model requests.' },
+ 'packages/experimental/inspector-profile': { kind: 'none', reason: 'The bundle mounts developer inspection plugins without contributing model context.' },
+ 'packages/experimental/session-inspector': { kind: 'none', reason: 'The conversation view entries add no model-facing input.' },
'packages/client/ui-slots': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
'packages/client/ui-attachment': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
'packages/client/ui-primitives': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
diff --git a/snapshots/web/session-inspector/array-details.expected.md b/snapshots/web/session-inspector/array-details.expected.md
new file mode 100644
index 0000000000..f047571639
--- /dev/null
+++ b/snapshots/web/session-inspector/array-details.expected.md
@@ -0,0 +1,9 @@
+- group:
+ - text: "blocks: Array(2)"
+ - list:
+ - listitem:
+ - group: "Other properties: Object"
+ - listitem:
+ - group: "0: Object"
+ - listitem:
+ - group: "1: Object"
diff --git a/snapshots/web/session-inspector/groups.expected.md b/snapshots/web/session-inspector/groups.expected.md
new file mode 100644
index 0000000000..4565e7b623
--- /dev/null
+++ b/snapshots/web/session-inspector/groups.expected.md
@@ -0,0 +1,11 @@
+- system-prompt
+- user
+- context
+- context
+- turn-process
+- group
+- assistant-step
+- tool-call
+- assistant-step
+- assistant-step
+- turn-tail
diff --git a/snapshots/web/session-inspector/log.expected.md b/snapshots/web/session-inspector/log.expected.md
new file mode 100644
index 0000000000..6f82049736
--- /dev/null
+++ b/snapshots/web/session-inspector/log.expected.md
@@ -0,0 +1,53 @@
+- table "Raw Log":
+ - rowgroup:
+ - row "Index Type Filter types T/S Time (UTC) Data":
+ - columnheader "Index"
+ - columnheader "Type Filter types":
+ - text: Type
+ - button "Filter types" [pressed]
+ - columnheader "T/S"
+ - columnheader "Time (UTC)"
+ - columnheader "Data"
+ - rowgroup:
+ - row "4 Collapse children turn/start 1 {{time}} 1":
+ - cell "4"
+ - cell "Collapse children turn/start":
+ - button "Collapse children" [expanded]: ▾
+ - button "turn/start"
+ - cell "1"
+ - cell "{{time}}"
+ - cell "1":
+ - button "1"
+ - 'row "6 Collapse children step/start 1/1 {{time}} turn: 1 · step: 1"':
+ - cell "6"
+ - cell "Collapse children step/start":
+ - button "Collapse children" [expanded]: ▾
+ - button "step/start"
+ - cell "1/1"
+ - cell "{{time}}"
+ - 'cell "turn: 1 · step: 1"':
+ - 'button "turn: 1 · step: 1"'
+ - 'row "8 user/message {{time}} data: content: [1] · source: {…} · role: user · id: {{uuid}} · surfaceOp: append"':
+ - cell "8"
+ - cell "user/message":
+ - button "user/message"
+ - cell
+ - cell "{{time}}"
+ - 'cell "data: content: [1] · source: {…} · role: user · id: {{uuid}} · surfaceOp: append"':
+ - 'button "data: content: [1] · source: {…} · role: user · id: {{uuid}} · surfaceOp: append"'
+ - 'row "9 user/message {{time}} data: content: [1] · source: {…} · role: user · id: {{uuid}} · surfaceOp: append"':
+ - cell "9"
+ - cell "user/message":
+ - button "user/message"
+ - cell
+ - cell "{{time}}"
+ - 'cell "data: content: [1] · source: {…} · role: user · id: {{uuid}} · surfaceOp: append"':
+ - 'button "data: content: [1] · source: {…} · role: user · id: {{uuid}} · surfaceOp: append"'
+ - 'row "10 user/message {{time}} data: content: [1] · source: {…} · role: user · id: {{uuid}} · surfaceOp: append"':
+ - cell "10"
+ - cell "user/message":
+ - button "user/message"
+ - cell
+ - cell "{{time}}"
+ - 'cell "data: content: [1] · source: {…} · role: user · id: {{uuid}} · surfaceOp: append"':
+ - 'button "data: content: [1] · source: {…} · role: user · id: {{uuid}} · surfaceOp: append"'
diff --git a/snapshots/web/session-inspector/navigation.expected.md b/snapshots/web/session-inspector/navigation.expected.md
new file mode 100644
index 0000000000..1bef36afd5
--- /dev/null
+++ b/snapshots/web/session-inspector/navigation.expected.md
@@ -0,0 +1,5 @@
+- selected: tool/call
+- unavailable: tool-call
+- highlighted: assistant-step/reasoning
+- visible: true
+- bounds match highlighted node: true
diff --git a/snapshots/web/session-inspector/snapshot.yml b/snapshots/web/session-inspector/snapshot.yml
new file mode 100644
index 0000000000..6ebb822ab1
--- /dev/null
+++ b/snapshots/web/session-inspector/snapshot.yml
@@ -0,0 +1,9 @@
+version: 1
+scenario: session-inspector
+profile: web
+composition: web-default
+recording: authored
+header:
+ class: web-default
+session:
+ source: ../fresh-round-trip/session.v4.jsonl
diff --git a/tsconfig.base.json b/tsconfig.base.json
index c1c658ba1e..b5f649378a 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -323,6 +323,9 @@
"@deepseek-ai/dsh-experimental-webworker-runtime": ["./packages/experimental/webworker-runtime/src"],
"@deepseek-ai/dsh-experimental-webworker-packer": ["./packages/experimental/webworker-packer/src"],
"@deepseek-ai/dsh-experimental-inspector": ["./packages/experimental/inspector/src"],
+ "@deepseek-ai/dsh-experimental-inspector-profile": ["./packages/experimental/inspector-profile/src"],
+ "@deepseek-ai/dsh-experimental-session-inspector": ["./packages/experimental/session-inspector/src"],
+ "@deepseek-ai/dsh-experimental-session-inspector/client": ["./packages/experimental/session-inspector/src/client/index.ts"],
"@deepseek-ai/dsh-experimental-inspector/client": ["./packages/experimental/inspector/src/client/index.ts"],
"@deepseek-ai/dsh-experimental-ptc-runtime-python": ["./packages/experimental/ptc-runtime-python/src"],
"@deepseek-ai/dsh-util-crypto": ["./packages/util/crypto/src"],
diff --git a/tsconfig.client.json b/tsconfig.client.json
index 738ed0c5b4..97f4f1b2a6 100644
--- a/tsconfig.client.json
+++ b/tsconfig.client.json
@@ -74,6 +74,7 @@
{ "path": "./packages/client/ui-primitives" },
{ "path": "./packages/client/modules" },
{ "path": "./packages/experimental/inspector/tsconfig.client.json" },
+ { "path": "./packages/experimental/session-inspector" },
{ "path": "./packages/client/hmr" },
{ "path": "./packages/client/connection/tsconfig.client.json" },
{ "path": "./packages/client/file-upload/tsconfig.client.json" },
diff --git a/tsconfig.host.json b/tsconfig.host.json
index ee2d3eaa98..31586f034a 100644
--- a/tsconfig.host.json
+++ b/tsconfig.host.json
@@ -63,6 +63,11 @@
"apps/web/tests/plugin-config.e2e.ts",
"apps/web/tests/input-plugin-extensions.e2e.ts",
"apps/web/tests/plugin-manager.e2e.ts",
+ "apps/web/tests/session-inspector.e2e.ts",
+ "apps/web/tests/shared-inspector.e2e.ts",
+ "apps/web/tests/layout-bottom.e2e.ts",
+ "apps/web/tests/devtools-frontend.e2e.ts",
+ "apps/web/tests/inspector-panels.e2e.ts",
"apps/web/tests/plugin-install-cancel.e2e.ts",
"apps/web/tests/plugin-install-approve.e2e.ts",
"apps/web/tests/plugin-install-registry.e2e.ts",
@@ -179,6 +184,7 @@
"apps/desktop-host/tests/**/*.ts",
"benchmarks/**/*.ts",
"packages/*/*/tests/**/*.ts",
+ "packages/experimental/inspector/scripts/**/*.ts",
"scripts/**/*.ts",
"website/**/*.ts",
"website/.vitepress/**/*.ts"
@@ -440,6 +446,7 @@
{ "path": "./packages/jobs/tool-jobs" },
{ "path": "./packages/experimental/agent-team" },
{ "path": "./packages/experimental/agent-team-profile" },
+ { "path": "./packages/experimental/inspector-profile" },
{ "path": "./packages/experimental/tool-agent-team" },
{ "path": "./packages/workflow/workflow" },
{ "path": "./packages/workflow/workflow-ptc" },