dsh-xray 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,6 +6,8 @@
6
6
  <a href="https://www.npmjs.com/package/dsh-xray"><img src="https://img.shields.io/npm/v/dsh-xray?style=flat-square&color=00d4aa" alt="npm"></a>
7
7
  <a href="https://github.com/alloevil/dsh-xray/actions/workflows/check.yml"><img src="https://img.shields.io/github/actions/workflow/status/alloevil/dsh-xray/check.yml?style=flat-square&label=CI" alt="CI"></a>
8
8
  <a href="./LICENSE"><img src="https://img.shields.io/npm/l/dsh-xray?style=flat-square" alt="license"></a>
9
+ <a href="https://scorecard.dev/viewer/?uri=github.com/alloevil/dsh-xray"><img src="https://api.securityscorecards.dev/projects/github.com/alloevil/dsh-xray/badge?style=flat-square" alt="OpenSSF Scorecard"></a>
10
+ <a href="https://codecov.io/gh/alloevil/dsh-xray"><img src="https://img.shields.io/codecov/c/github/alloevil/dsh-xray?style=flat-square" alt="coverage"></a>
9
11
  <img src="https://img.shields.io/badge/language-JavaScript-f7df1e?style=flat-square&logo=javascript&logoColor=black" alt="JavaScript">
10
12
  </p>
11
13
 
@@ -27,7 +29,7 @@
27
29
 
28
30
  **dsh-xray does.**
29
31
 
30
- > **Status: 0.4.x — static + runtime imaging with context-cost attribution.** Static commands work even when dsh cannot boot; `deps`/`health`/`cost`/`shadow` and the agent tool need the plugin mounted.
32
+ > Static commands work even when dsh cannot boot; `deps`/`health`/`cost`/`shadow` and the agent tool need the plugin mounted.
31
33
 
32
34
  ---
33
35
 
@@ -79,6 +81,13 @@ Per-plugin fiber lifecycle state, startup failures, transition history.
79
81
  ### 🤖 Agent Self-Introspection
80
82
  The `xray_composition` tool lets agents inspect their own capability set.
81
83
 
84
+ ### 🖥️ Web Panel
85
+ Mounted in `dsh web`, the plugin serves a zero-dependency panel at **`/xray`** — summary, health, deps (with the disable-cascade table), cost, and shadow views, live from the running composition. JSON endpoints under `/xray/api/*` serve the same data.
86
+
87
+ The package also ships a client half (`dsh.client` declaration + `exports["./client"]`): the host discovers it automatically and mounts an **X-ray tab beside Chat / Trajectory** in every session, rendering the same five views natively in the GUI with the host's design tokens. The standalone `/xray` page stays available as the degradation path — it only needs the web server, not the client-module pipeline it helps diagnose.
88
+
89
+ ![The /xray panel: deps view with the disable-cascade table](./assets/panel-deps.webp)
90
+
82
91
  What every request actually carries — prompt sections observed at assembly, blended with tool schemas:
83
92
 
84
93
  ```console
@@ -131,10 +140,31 @@ Mounted in the tree, dsh-xray registers an `xray_composition` tool (`view: summa
131
140
  <img src="./assets/section-install.svg" width="100%" alt="Install">
132
141
  </p>
133
142
 
143
+ Two ways to use it — they're independent:
144
+
145
+ **1. Static CLI only** (no install into dsh; works even when dsh cannot boot):
146
+
147
+ ```sh
148
+ npx dsh-xray attribute # requires Node >= 22
149
+ ```
150
+
151
+ **2. Mount the plugin** (adds the runtime commands, the `/xray` panel, and the agent tool):
152
+
134
153
  ```sh
135
154
  dsh plugin --profile web add dsh-xray
155
+ # bundle plugins take effect on the next start — restart dsh web
136
156
  ```
137
157
 
158
+ Verify it took:
159
+
160
+ ```sh
161
+ dsh --profile web --dump-config | grep dsh-xray # row present in the composed tree
162
+ npx dsh-xray health # reads the runtime snapshot
163
+ # then open http://localhost:3080/xray for the live panel
164
+ ```
165
+
166
+ Uninstall: `dsh plugin --profile web remove dsh-xray`.
167
+
138
168
  All commands take `--profile <name>` (default `web`) and `--json`.
139
169
 
140
170
  | Command | Behavior |
package/README.zh.md CHANGED
@@ -1,16 +1,26 @@
1
- # dsh-xray
2
-
3
- [![npm](https://img.shields.io/npm/v/dsh-xray)](https://www.npmjs.com/package/dsh-xray)
4
- [![CI](https://github.com/alloevil/dsh-xray/actions/workflows/check.yml/badge.svg)](https://github.com/alloevil/dsh-xray/actions/workflows/check.yml)
5
- [![license](https://img.shields.io/npm/l/dsh-xray)](./LICENSE)
6
-
7
- [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 拍 X 光——看清到底加载了什么、为什么在那、以及它悄悄花掉了你什么。
1
+ <p align="center">
2
+ <img src="./assets/hero.svg" width="100%" alt="dsh-xray — 给你的 DeepSeek Harness 拍 X 光">
3
+ </p>
4
+
5
+ <p align="center">
6
+ <a href="https://www.npmjs.com/package/dsh-xray"><img src="https://img.shields.io/npm/v/dsh-xray?style=flat-square&color=00d4aa" alt="npm"></a>
7
+ <a href="https://github.com/alloevil/dsh-xray/actions/workflows/check.yml"><img src="https://img.shields.io/github/actions/workflow/status/alloevil/dsh-xray/check.yml?style=flat-square&label=CI" alt="CI"></a>
8
+ <a href="./LICENSE"><img src="https://img.shields.io/npm/l/dsh-xray?style=flat-square" alt="license"></a>
9
+ <a href="https://scorecard.dev/viewer/?uri=github.com/alloevil/dsh-xray"><img src="https://api.securityscorecards.dev/projects/github.com/alloevil/dsh-xray/badge?style=flat-square" alt="OpenSSF Scorecard"></a>
10
+ <a href="https://codecov.io/gh/alloevil/dsh-xray"><img src="https://img.shields.io/codecov/c/github/alloevil/dsh-xray?style=flat-square" alt="coverage"></a>
11
+ </p>
12
+
13
+ <p align="center">
14
+ <strong>给 <a href="https://github.com/deepseek-ai/deepseek-harness">DeepSeek Harness</a> 拍 X 光</strong>——看清到底加载了什么、为什么在那、以及它悄悄花掉了你什么。
15
+ </p>
16
+
17
+ <p align="center">
18
+ <a href="./README.md">English</a>
19
+ </p>
8
20
 
9
21
  ![dsh-xray 演示](./docs/demo.svg)
10
22
 
11
- [English](./README.md)
12
-
13
- > **状态:0.4.x — 静态 + 运行时成像,含上下文成本归因。** 静态命令在 dsh 起不来时照样能用;`deps`/`health`/`cost`/`shadow` 和 agent 工具需要插件已挂载。
23
+ > 静态命令在 dsh 起不来时照样能用;`deps`/`health`/`cost`/`shadow` 和 agent 工具需要插件已挂载。
14
24
 
15
25
  `dsh --dump-config` 只给你原始组合树,插件面板只给你平铺列表。它们都不回答:这个插件*为什么*在这、停用它会*连带瘫掉什么*、它在*悄悄消耗什么*。dsh-xray 回答这些。
16
26
 
@@ -91,6 +101,14 @@ orphan overrides (silently skipped) (1)
91
101
 
92
102
  挂载进树后,dsh-xray 注册 `xray_composition` 工具(`view: summary | deps | health | cost | shadow`),agent 可以自答"我有哪些能力 / 哪个插件提供 X / 为什么 Y 不可用"。
93
103
 
104
+ ## Web 面板
105
+
106
+ 在 `dsh web` 中挂载后,插件在 **`/xray`** 提供零依赖面板——summary、health、deps(含停用级联表)、cost、shadow 五个视图,数据实时来自运行中的组合树;`/xray/api/*` 提供同源 JSON。
107
+
108
+ 包同时携带 client 半(`dsh.client` 声明 + `exports["./client"]`):宿主自动发现并在每个会话中挂载 **与 Chat / Trajectory 并列的 X-ray 标签页**,用宿主设计变量原生渲染同样五个视图。独立的 `/xray` 页面保留为降级通道——它只依赖 web 服务器,不依赖它所诊断的 client-module 加载链路。
109
+
110
+ ![/xray 面板:deps 视图与停用级联表](./assets/panel-deps.webp)
111
+
94
112
  ## 安全立场
95
113
 
96
114
  dsh-xray 只读不执行。patch 文件里的 loader `!!js` 表达式解析为不透明标记、绝不求值;CLI 从不执行插件代码(`audit` 是对源码文本的模式扫描);挂载的插件只写 `$DSH_HOME/xray/` 目录。详见 [SECURITY.md](./SECURITY.md)。
@@ -99,26 +117,47 @@ dsh-xray 只读不执行。patch 文件里的 loader `!!js` 表达式解析为
99
117
 
100
118
  对运行中组合树的诊断成像——与 [dsh-doctor](https://www.npmjs.com/package/dsh-doctor)(救援与恢复)互补。
101
119
 
102
- 0.4.x 已交付:
120
+ 已交付:
103
121
 
104
122
  - **来源归因** — 每个活跃插件来自哪一层:内核 bundle / profile 依赖 / `cordis.patch.yml` insert / repository 源
105
123
  - **声明 vs 实际 diff** — 装了但没生效、卸了但残留 patch 行
106
124
  - **冲突检测** — 多个插件 patch 同一配置行时,谁静默赢了
107
- - **组合快照** — 把当前生效组合导出为 lockfile,异地一键复现
125
+ - **组合快照** — 把当前生效组合导出为 lockfile;`--against` 对比漂移
108
126
  - **服务依赖图** — 每个服务谁提供、谁消费;停用 X 会级联影响什么(`deps`)
109
127
  - **运行时健康** — 每个插件的 fiber 生命周期状态、启动失败、状态迁移史(`health`)
110
128
  - **Agent 自省** — `xray_composition` 工具让 agent 检视自己的能力集
111
-
112
129
  - **能力审计** — 对 out-of-tree 插件的启发式静态扫描:网络外发、shell、文件系统、环境变量、动态求值(`audit`)
113
130
  - **服务重名检测** — 被多个插件同时提供的服务,及每插件工具/命令注册数(`shadow`)
114
131
  - **上下文成本** — prompt sections(观测自 system-prompt/assemble)+ 工具 schema 的估算 token 占用(`cost`)
132
+ - **Web 面板** — `/xray` 五视图,零依赖零构建
115
133
 
116
134
  ## 安装
117
135
 
136
+ 两条使用路径,互相独立:
137
+
138
+ **1. 只用静态 CLI**(不装进 dsh;dsh 起不来时也能用):
139
+
140
+ ```sh
141
+ npx dsh-xray attribute # 需要 Node >= 22
142
+ ```
143
+
144
+ **2. 挂载插件**(解锁运行时命令、`/xray` 面板和 agent 工具):
145
+
118
146
  ```sh
119
147
  dsh plugin --profile web add dsh-xray
148
+ # bundle 插件重启后生效——重启 dsh web
120
149
  ```
121
150
 
151
+ 验证装好了:
152
+
153
+ ```sh
154
+ dsh --profile web --dump-config | grep dsh-xray # 组合树里有这一行
155
+ npx dsh-xray health # 能读到运行时快照
156
+ # 然后打开 http://localhost:3080/xray 看实时面板
157
+ ```
158
+
159
+ 卸载:`dsh plugin --profile web remove dsh-xray`。
160
+
122
161
  ## 许可证
123
162
 
124
163
  MIT
package/assets/hero.svg CHANGED
@@ -117,7 +117,7 @@
117
117
  <!-- Status badge -->
118
118
  <g transform="translate(450, 250)">
119
119
  <rect x="-100" y="-12" width="200" height="24" rx="12" fill="#00d4aa" opacity="0.1" stroke="#00d4aa" stroke-width="0.5" stroke-opacity="0.3"/>
120
- <text x="0" y="4" text-anchor="middle" fill="#00d4aa" font-family="'SF Mono', monospace" font-size="11">v0.2.x · static + runtime</text>
120
+ <text x="0" y="4" text-anchor="middle" fill="#00d4aa" font-family="'SF Mono', monospace" font-size="11">static + runtime imaging</text>
121
121
  </g>
122
122
 
123
123
  <!-- Feature pills -->
Binary file
Binary file
package/bin/xray.js CHANGED
@@ -172,6 +172,10 @@ function readRuntimeSnapshot() {
172
172
  return JSON.parse(fs.readFileSync(file, 'utf8'));
173
173
  }
174
174
 
175
+ function panelHint(args) {
176
+ if (!args.json) console.log('\nlive panel: http://localhost:3080/xray');
177
+ }
178
+
175
179
  function cmdDeps(args) {
176
180
  const snap = readRuntimeSnapshot();
177
181
  const result = model.serviceGraph(snap);
@@ -196,6 +200,7 @@ function cmdDeps(args) {
196
200
  console.log(`\n! ${result.unsatisfied.length} unsatisfied inject(s):`);
197
201
  for (const u of result.unsatisfied) console.log(` ${u.plugin} wants ${u.service}`);
198
202
  }
203
+ panelHint(args);
199
204
  }
200
205
 
201
206
  function cmdHealth(args) {
@@ -214,6 +219,7 @@ function cmdHealth(args) {
214
219
  }
215
220
  }
216
221
  if (result.unhealthy.length) process.exitCode = 1;
222
+ panelHint(args);
217
223
  }
218
224
 
219
225
  function cmdCost(args) {
@@ -241,6 +247,7 @@ function cmdCost(args) {
241
247
  `${pad(t.name, 32)} ${pad(`~${t.tokens}`, 8)} ${pad(`${t.share}%`, 7)} ${bar(t.share)}`,
242
248
  );
243
249
  }
250
+ panelHint(args);
244
251
  }
245
252
 
246
253
  function cmdShadow(args) {
@@ -258,6 +265,7 @@ function cmdShadow(args) {
258
265
  }
259
266
  }
260
267
  if (result.services.length) process.exitCode = 1;
268
+ panelHint(args);
261
269
  }
262
270
 
263
271
  function cmdAudit(args) {
@@ -0,0 +1,88 @@
1
+ # dsh-xray — 给你的 Harness 拍 X 光:组合树归因、依赖级联、上下文成本
2
+
3
+ (草稿 — 建议发到 deepseek-ai/deepseek-harness 的 Discussions · Show and tell 分类。
4
+ 发布前把口吻改成你自己的;英文版附在下半部分,可以只发中文或双语。)
5
+
6
+ ---
7
+
8
+ 大家好,我做了一个诊断类插件 **dsh-xray**,想解决我自己折腾 profile 时反复遇到的三个问题:
9
+
10
+ 1. `--dump-config` 打出 130 行,但**哪一行是谁引入的**?patch 改了不生效时,是 id 写错被静默跳过,还是被后面的层覆盖了?
11
+ 2. 想停用一个插件,**会连带瘫掉什么**?
12
+ 3. 装了一堆插件后,**每次请求的上下文里到底塞了多少东西**?
13
+
14
+ ## 它长什么样
15
+
16
+ ```console
17
+ $ npx dsh-xray attribute
18
+ # 130 rows in profile "web"
19
+ hmr @deepseek-ai/dsh-base ← patched by dsh-web-app [disabled]
20
+ session-query @deepseek-ai/dsh-base ← patched by dsh-web-app
21
+
22
+ $ npx dsh-xray deps
23
+ # disable-cascade (transitive consumers of each provider):
24
+ SessionStore → AgentLoop, ApiProxyService, JsonlSessionPersistence, … (11 plugins)
25
+ ToolRuntime → AgentLoop, ApiProxyService, DynamicCordisRunnerService, PlanModeController
26
+
27
+ $ npx dsh-xray cost
28
+ ~1625 tokens: 1 tool schema(s) ~121 + 19 prompt section(s) ~1504
29
+ app:web-surface ~248 15.3% ████████
30
+ tool:goal ~184 11.3% ██████
31
+ ```
32
+
33
+ ## 全部命令
34
+
35
+ | 命令 | 回答的问题 |
36
+ |---|---|
37
+ | `attribute` | 每一行由哪层引入(bundle / profile patch / home patch / repository 源),之后被谁改过 |
38
+ | `diff` | 声明 vs 实际:抓出被 dsh 静默跳过的 orphan patch 行、装了没生效的插件 |
39
+ | `conflicts` | 同一字段多个写者,谁赢了 |
40
+ | `deps` / `health` | 服务依赖图 + 停用级联;每个插件的 fiber 生命周期状态 |
41
+ | `cost` | prompt sections(观测自 system-prompt/assemble)+ tool schemas 的 token 估算 |
42
+ | `shadow` / `audit` | 多提供者服务;out-of-tree 插件的敏感触点静态扫描(网络/shell/fs/env/eval) |
43
+ | `snapshot --against` | 组合树 lockfile 导出与漂移对比 |
44
+
45
+ 另外:
46
+
47
+ - **`/xray` Web 面板**(dsh web 里直接开,零依赖零构建)
48
+ - **`xray_composition` agent 工具** — agent 可以自查"我有哪些能力/为什么 Y 不可用"
49
+ - 静态命令(attribute/conflicts/diff/snapshot/audit)**在 dsh 起不来时照样能跑**
50
+ - 与恢复类工具(dsh-doctor 等)互补:它们负责"救回来",xray 负责"看清楚"
51
+
52
+ ## 安全立场
53
+
54
+ 只读不执行:patch 里的 `!!js` 解析为不透明标记、绝不求值;audit 是源码文本扫描,不运行插件代码;挂载后只写 `$DSH_HOME/xray/`。
55
+
56
+ ## 安装
57
+
58
+ ```sh
59
+ dsh plugin --profile web add dsh-xray # 挂载(面板 + agent 工具 + 运行时快照)
60
+ npx dsh-xray attribute # 或者不挂载,直接用静态 CLI
61
+ ```
62
+
63
+ - GitHub: https://github.com/alloevil/dsh-xray (MIT, CI on Node 22/24, 39 tests)
64
+ - npm: https://www.npmjs.com/package/dsh-xray (发布带 GitHub Actions provenance)
65
+
66
+ 欢迎 issue / PR,特别想听:你们排查 profile 问题时还缺什么视角?
67
+
68
+ ---
69
+
70
+ ## English version
71
+
72
+ Hi all — I built **dsh-xray**, a diagnostics plugin for three questions I kept hitting while hacking on profiles:
73
+
74
+ 1. `--dump-config` prints 130 rows, but **which layer introduced each one**? When a patch "doesn't take", is the id wrong (silently skipped) or overridden by a later layer?
75
+ 2. If I disable plugin X, **what breaks transitively**?
76
+ 3. After installing a pile of plugins, **what does every request actually carry** in context?
77
+
78
+ Commands: `attribute` (per-row layer attribution), `diff` (declared vs actual — catches orphan patch rows), `conflicts` (last-writer-wins chains), `deps`/`health` (service graph with disable-cascade, fiber lifecycle), `cost` (prompt sections + tool schemas, estimated tokens), `shadow`/`audit` (multi-provider services, capability scan), `snapshot --against` (composition lockfile + drift).
79
+
80
+ Plus a zero-dependency web panel at `/xray`, and an `xray_composition` tool so agents can introspect their own capability set. Static commands work even when dsh cannot boot. Read-only by design: `!!js` never evaluated, `audit` never executes plugin code.
81
+
82
+ ```sh
83
+ dsh plugin --profile web add dsh-xray
84
+ ```
85
+
86
+ GitHub: https://github.com/alloevil/dsh-xray · npm: https://www.npmjs.com/package/dsh-xray
87
+
88
+ Feedback welcome — what else do you wish you could see when a profile misbehaves?
package/lib/client.js ADDED
@@ -0,0 +1,340 @@
1
+ // dsh-xray browser half. React panel registered as one conversation view tab
2
+ // (`conversation.view` list slot, beside Chat / Trajectory); data comes from
3
+ // the same-origin /xray/api/* endpoints the node half already serves, so this
4
+ // layer stays a thin renderer — every computation lives in lib/model.js.
5
+ //
6
+ // Skeleton note: authored directly in ModuleLoader factory form (plain
7
+ // React.createElement, no build step) to prove the wiring; migrate to
8
+ // src/client/*.tsx + tsdown once the surface stabilizes.
9
+ window.__ModuleLoader__.load({
10
+ id: 'dsh-xray',
11
+ factory: (require) => {
12
+ const module = { exports: {} };
13
+ const exports = module.exports;
14
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
15
+ const react = require('react');
16
+ const h = react.createElement;
17
+
18
+ //#region styles (host design tokens; auto-claimed by client-modules)
19
+ const css = [
20
+ '.xray-panel{max-width:920px;margin:0 auto;padding:16px 20px;font-size:13px;color:var(--dsw-alias-label-primary)}',
21
+ '.xray-sub{color:var(--dsw-alias-label-tertiary);margin:0 0 14px;font-size:12px}',
22
+ '.xray-nav{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px}',
23
+ '.xray-nav button{font:inherit;font-size:12px;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:4px 10px;cursor:pointer}',
24
+ '.xray-nav button.active{color:#fff;background:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary)}',
25
+ '.xray-table{border-collapse:collapse;width:100%;margin-top:6px}',
26
+ '.xray-table th,.xray-table td{text-align:left;padding:4px 10px;border-bottom:1px solid var(--dsw-alias-border-l2);vertical-align:top}',
27
+ '.xray-table th{color:var(--dsw-alias-label-tertiary);font-weight:normal}',
28
+ '.xray-num{text-align:right}',
29
+ '.xray-warn{color:var(--dsw-alias-label-error)}',
30
+ '.xray-ok{color:var(--dsw-alias-label-success,#7ee787)}',
31
+ '.xray-muted{color:var(--dsw-alias-label-tertiary)}',
32
+ '.xray-bar{background:var(--dsw-alias-brand-primary);height:10px;border-radius:2px;display:inline-block}',
33
+ '.xray-h3{font-size:13px;font-weight:600;margin:14px 0 4px}',
34
+ ].join('\n');
35
+ if (
36
+ typeof document !== 'undefined' &&
37
+ document.querySelector('style[data-plugin-css="dsh-xray/panel"]') === null
38
+ ) {
39
+ const tag = document.createElement('style');
40
+ tag.dataset.plugin = 'dsh-xray';
41
+ tag.dataset.pluginCss = 'dsh-xray/panel';
42
+ tag.textContent = css;
43
+ document.head.appendChild(tag);
44
+ }
45
+ //#endregion
46
+
47
+ //#region tiny view primitives
48
+ const VIEWS = ['summary', 'health', 'deps', 'cost', 'shadow'];
49
+ function Table({ headers, rows }) {
50
+ return h(
51
+ 'table',
52
+ { className: 'xray-table' },
53
+ h(
54
+ 'thead',
55
+ null,
56
+ h(
57
+ 'tr',
58
+ null,
59
+ headers.map((head, i) => h('th', { key: i }, head)),
60
+ ),
61
+ ),
62
+ h('tbody', null, rows),
63
+ );
64
+ }
65
+ function Bar({ share }) {
66
+ return h('span', { className: 'xray-bar', style: { width: `${Math.max(2, share * 2)}px` } });
67
+ }
68
+ function Row(key, cells) {
69
+ return h(
70
+ 'tr',
71
+ { key },
72
+ cells.map((cell, i) =>
73
+ h(
74
+ 'td',
75
+ { key: i, className: cell?.cls ? cell.cls : void 0 },
76
+ cell && cell.cls !== void 0 ? cell.text : cell,
77
+ ),
78
+ ),
79
+ );
80
+ }
81
+ //#endregion
82
+
83
+ //#region per-view renderers (mirror lib/panel.js, as components)
84
+ const renderers = {
85
+ summary: (d) =>
86
+ h(
87
+ react.Fragment,
88
+ null,
89
+ h(Table, {
90
+ headers: ['metric', 'value'],
91
+ rows: [
92
+ Row('p', ['plugins mounted', { cls: 'xray-num', text: String(d.plugins) }]),
93
+ Row('u', [
94
+ 'unhealthy',
95
+ {
96
+ cls: `xray-num ${d.unhealthy ? 'xray-warn' : 'xray-ok'}`,
97
+ text: String(d.unhealthy),
98
+ },
99
+ ]),
100
+ Row('s', ['services', { cls: 'xray-num', text: String(d.services) }]),
101
+ Row('t', [
102
+ 'context tokens (tools + sections)',
103
+ { cls: 'xray-num', text: `~${d.toolSchemaTokens}` },
104
+ ]),
105
+ ],
106
+ }),
107
+ h('p', { className: 'xray-muted', style: { marginTop: 12 } }, `captured ${d.capturedAt}`),
108
+ ),
109
+
110
+ health: (d) =>
111
+ h(
112
+ react.Fragment,
113
+ null,
114
+ h(
115
+ 'p',
116
+ null,
117
+ h('span', { className: 'xray-ok' }, `${d.healthy.length} healthy`),
118
+ d.waiting.length ? ` · ${d.waiting.length} waiting` : null,
119
+ d.unhealthy.length
120
+ ? h(
121
+ react.Fragment,
122
+ null,
123
+ ' · ',
124
+ h('span', { className: 'xray-warn' }, `${d.unhealthy.length} unhealthy`),
125
+ )
126
+ : null,
127
+ ),
128
+ d.unhealthy.length
129
+ ? h(Table, {
130
+ headers: ['plugin', 'fiber', 'state', 'error'],
131
+ rows: d.unhealthy.flatMap((p) =>
132
+ p.fibers.map((f) =>
133
+ Row(`${p.name}/${f.uid}`, [
134
+ p.name,
135
+ { cls: 'xray-num', text: String(f.uid) },
136
+ { cls: 'xray-warn', text: f.state },
137
+ f.error ?? '',
138
+ ]),
139
+ ),
140
+ ),
141
+ })
142
+ : null,
143
+ d.waiting.length
144
+ ? h(Table, {
145
+ headers: ['waiting plugin', 'wants'],
146
+ rows: d.waiting.map((p) => Row(p.name, [p.name, p.inject.join(', ')])),
147
+ })
148
+ : null,
149
+ ),
150
+
151
+ deps: (d) =>
152
+ h(
153
+ react.Fragment,
154
+ null,
155
+ d.unsatisfied.length
156
+ ? h('p', { className: 'xray-warn' }, `${d.unsatisfied.length} unsatisfied inject(s)`)
157
+ : null,
158
+ Object.keys(d.cascade).length
159
+ ? h(
160
+ react.Fragment,
161
+ null,
162
+ h('div', { className: 'xray-h3' }, 'disable-cascade'),
163
+ h(Table, {
164
+ headers: ['provider', 'affects'],
165
+ rows: Object.entries(d.cascade).map(([provider, affected]) =>
166
+ Row(provider, [provider, affected.join(', ')]),
167
+ ),
168
+ }),
169
+ )
170
+ : null,
171
+ h('div', { className: 'xray-h3' }, 'services'),
172
+ h(Table, {
173
+ headers: ['service', 'provided by', 'consumed by'],
174
+ rows: Object.entries(d.services).map(([name, node]) =>
175
+ Row(name, [name, node.providers.join(', ') || '—', node.consumers.join(', ') || '—']),
176
+ ),
177
+ }),
178
+ ),
179
+
180
+ cost: (d) =>
181
+ h(
182
+ react.Fragment,
183
+ null,
184
+ h(
185
+ 'p',
186
+ null,
187
+ `~${d.totalTokens} tokens: ${d.toolCount} tool schema(s) ~${d.toolTokens} + ${d.sectionCount} prompt section(s) ~${d.sectionTokens}`,
188
+ ),
189
+ d.sections.length
190
+ ? h(
191
+ react.Fragment,
192
+ null,
193
+ h('div', { className: 'xray-h3' }, 'prompt sections'),
194
+ h(Table, {
195
+ headers: ['section', 'tokens', 'share', ''],
196
+ rows: d.sections.map((s) =>
197
+ Row(s.name, [
198
+ s.name,
199
+ { cls: 'xray-num', text: `~${s.tokens}` },
200
+ { cls: 'xray-num', text: `${s.share}%` },
201
+ h(Bar, { share: s.share }),
202
+ ]),
203
+ ),
204
+ }),
205
+ )
206
+ : h(
207
+ 'p',
208
+ { className: 'xray-muted' },
209
+ 'no prompt assembly observed yet — send one agent message first',
210
+ ),
211
+ h('div', { className: 'xray-h3' }, 'tool schemas'),
212
+ h(Table, {
213
+ headers: ['tool', 'tokens', 'share', ''],
214
+ rows: d.tools.map((t) =>
215
+ Row(t.name, [
216
+ t.name,
217
+ { cls: 'xray-num', text: `~${t.tokens}` },
218
+ { cls: 'xray-num', text: `${t.share}%` },
219
+ h(Bar, { share: t.share }),
220
+ ]),
221
+ ),
222
+ }),
223
+ ),
224
+
225
+ shadow: (d) =>
226
+ h(
227
+ react.Fragment,
228
+ null,
229
+ d.services.length
230
+ ? h(Table, {
231
+ headers: ['service', 'providers'],
232
+ rows: d.services.map((s) =>
233
+ Row(s.service, [
234
+ s.service,
235
+ { cls: 'xray-warn', text: s.providers.join(' AND ') },
236
+ ]),
237
+ ),
238
+ })
239
+ : h('p', { className: 'xray-ok' }, 'no service is provided by more than one plugin'),
240
+ d.registrars.length
241
+ ? h(
242
+ react.Fragment,
243
+ null,
244
+ h('div', { className: 'xray-h3' }, 'registrars'),
245
+ h(Table, {
246
+ headers: ['plugin', 'registrations'],
247
+ rows: d.registrars.map((r) =>
248
+ Row(r.plugin, [r.plugin, { cls: 'xray-num', text: String(r.registrations) }]),
249
+ ),
250
+ }),
251
+ )
252
+ : null,
253
+ ),
254
+ };
255
+ //#endregion
256
+
257
+ //#region panel (one conversation.view tab)
258
+ function XrayPanel() {
259
+ const [view, setView] = react.useState('summary');
260
+ // `for` stamps which view the payload belongs to: a click flips `view`
261
+ // synchronously while `state` still carries the previous view's data —
262
+ // rendering that mismatch with the new renderer throws and unmounts
263
+ // the whole tab. Mismatch renders as loading instead.
264
+ const [state, setState] = react.useState({ phase: 'loading', for: 'summary' });
265
+ react.useEffect(() => {
266
+ let alive = true;
267
+ setState({ phase: 'loading', for: view });
268
+ fetch(`/xray/api/${view}`)
269
+ .then(async (res) => {
270
+ if (!res.ok) throw new Error(await res.text());
271
+ return res.json();
272
+ })
273
+ .then((data) => {
274
+ if (alive) setState({ phase: 'ready', for: view, data });
275
+ })
276
+ .catch((err) => {
277
+ if (alive)
278
+ setState({
279
+ phase: 'error',
280
+ for: view,
281
+ message: err instanceof Error ? err.message : String(err),
282
+ });
283
+ });
284
+ return () => {
285
+ alive = false;
286
+ };
287
+ }, [view]);
288
+ let body;
289
+ if (state.for !== view || state.phase === 'loading')
290
+ body = h('p', { className: 'xray-muted' }, `loading ${view}…`);
291
+ else if (state.phase === 'error') body = h('p', { className: 'xray-warn' }, state.message);
292
+ else {
293
+ // A diagnostic surface must never take itself down on one bad
294
+ // payload: render the failure, keep the tab and its nav alive.
295
+ try {
296
+ body = renderers[view](state.data);
297
+ } catch (err) {
298
+ body = h(
299
+ 'p',
300
+ { className: 'xray-warn' },
301
+ `render failed: ${err instanceof Error ? err.message : String(err)}`,
302
+ );
303
+ }
304
+ }
305
+ return h(
306
+ 'div',
307
+ { className: 'xray-panel' },
308
+ h('p', { className: 'xray-sub' }, 'composition X-ray — live from this harness'),
309
+ h(
310
+ 'nav',
311
+ { className: 'xray-nav' },
312
+ VIEWS.map((name) =>
313
+ h(
314
+ 'button',
315
+ { key: name, className: name === view ? 'active' : '', onClick: () => setView(name) },
316
+ name,
317
+ ),
318
+ ),
319
+ ),
320
+ body,
321
+ );
322
+ }
323
+ //#endregion
324
+
325
+ const inject = ['slots'];
326
+ /** Mount the X-ray tab into the conversation view ring (beside Chat / Trajectory). */
327
+ function apply(ctx) {
328
+ ctx.slots.inject('conversation.view', () =>
329
+ ctx.slots.register(
330
+ { name: 'conversation.view', id: 'xray', order: 20, label: 'X-ray' },
331
+ XrayPanel,
332
+ ),
333
+ );
334
+ }
335
+
336
+ exports.apply = apply;
337
+ exports.inject = inject;
338
+ return module.exports;
339
+ },
340
+ });
package/lib/index.js CHANGED
@@ -91,6 +91,43 @@ function apply(ctx) {
91
91
  ];
92
92
  }, 'xray-runtime-snapshot');
93
93
 
94
+ // Web panel: mounts when the profile composes a webServer (dsh web).
95
+ // Headless profiles simply never activate this subplugin.
96
+ ctx.plugin({
97
+ name: 'dsh-xray-panel',
98
+ inject: ['webServer'],
99
+ apply: (wctx) => {
100
+ const { mountPanel } = require('./panel.js');
101
+ const freshSnap = () => {
102
+ const snap = snapshotRegistry(ctx);
103
+ snap.transitions = Object.fromEntries(transitions);
104
+ snap.promptAssembly = lastAssembly;
105
+ return snap;
106
+ };
107
+ wctx.effect(
108
+ () =>
109
+ mountPanel(wctx.webServer, {
110
+ summary: () => {
111
+ const snap = freshSnap();
112
+ return {
113
+ plugins: snap.plugins.length,
114
+ unhealthy: health(snap).unhealthy.length,
115
+ services: Object.keys(serviceGraph(snap).services).length,
116
+ toolSchemaTokens: contextCost(snap).totalTokens,
117
+ capturedAt: snap.capturedAt,
118
+ };
119
+ },
120
+ deps: () => serviceGraph(freshSnap()),
121
+ health: () => health(freshSnap()),
122
+ cost: () => contextCost(freshSnap()),
123
+ shadow: () => shadowing(freshSnap()),
124
+ }),
125
+ 'xray-panel-routes',
126
+ );
127
+ logger.info('xray panel mounted at /xray');
128
+ },
129
+ });
130
+
94
131
  // Agent self-introspection tool: only when a tool registry exists
95
132
  // (headless/web both have one; keep it optional so xray mounts anywhere).
96
133
  ctx.plugin({
@@ -119,7 +156,8 @@ function apply(ctx) {
119
156
  'Introspect the live plugin composition of this harness: every mounted plugin, ' +
120
157
  'the services it requires (inject) and provides, its lifecycle state, and ' +
121
158
  'unhealthy plugins with their last transitions. Use to answer "what capabilities ' +
122
- 'do I have / what plugin provides X / why is Y unavailable".',
159
+ 'do I have / what plugin provides X / why is Y unavailable". A human-browsable ' +
160
+ 'panel with the same data is served at /xray on this harness.',
123
161
  parameters: {
124
162
  view: {
125
163
  type: 'string',
package/lib/panel.js ADDED
@@ -0,0 +1,213 @@
1
+ // Self-contained web panel: one HTML page + JSON endpoints, mounted on the
2
+ // host webServer. No client-module bundle, no React, no build step — the
3
+ // model layer already computes everything; this only renders it.
4
+
5
+ const PAGE = `<!doctype html>
6
+ <html lang="en">
7
+ <head>
8
+ <meta charset="utf-8">
9
+ <meta name="viewport" content="width=device-width, initial-scale=1">
10
+ <title>dsh-xray</title>
11
+ <style>
12
+ :root { color-scheme: dark; }
13
+ * { box-sizing: border-box; margin: 0; }
14
+ body { font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
15
+ background: #0d1117; color: #e6edf3; padding: 24px; }
16
+ h1 { font-size: 16px; margin-bottom: 4px; }
17
+ .sub { color: #8b949e; margin-bottom: 20px; }
18
+ nav { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
19
+ nav button { background: #21262d; color: #e6edf3; border: 1px solid #30363d;
20
+ border-radius: 6px; padding: 5px 12px; cursor: pointer; font: inherit; }
21
+ nav button.active { background: #1f6feb; border-color: #1f6feb; }
22
+ table { border-collapse: collapse; width: 100%; margin-top: 8px; }
23
+ th, td { text-align: left; padding: 4px 10px; border-bottom: 1px solid #21262d; }
24
+ th { color: #8b949e; font-weight: normal; }
25
+ .num { text-align: right; }
26
+ .bar { background: #1f6feb; height: 10px; border-radius: 2px; display: inline-block; }
27
+ .warn { color: #f85149; }
28
+ .ok { color: #7ee787; }
29
+ .muted { color: #8b949e; }
30
+ #status { margin: 12px 0; color: #8b949e; }
31
+ .tag { background: #21262d; border-radius: 4px; padding: 1px 6px; margin-left: 6px; font-size: 11px; }
32
+ </style>
33
+ </head>
34
+ <body>
35
+ <h1>dsh-xray</h1>
36
+ <div class="sub">composition X-ray — live from this harness</div>
37
+ <nav id="nav"></nav>
38
+ <div id="status"></div>
39
+ <div id="content"></div>
40
+ <script>
41
+ const views = ['summary', 'health', 'deps', 'cost', 'shadow'];
42
+ const esc = (s) => String(s ?? '').replace(/[&<>]/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
43
+ const nav = document.getElementById('nav');
44
+ const content = document.getElementById('content');
45
+ const status = document.getElementById('status');
46
+ let active = 'summary';
47
+
48
+ for (const v of views) {
49
+ const b = document.createElement('button');
50
+ b.textContent = v;
51
+ b.onclick = () => { active = v; render(); };
52
+ b.id = 'nav-' + v;
53
+ nav.appendChild(b);
54
+ }
55
+
56
+ function table(headers, rows) {
57
+ return '<table><tr>' + headers.map((h) => '<th>' + esc(h) + '</th>').join('') + '</tr>'
58
+ + rows.join('') + '</table>';
59
+ }
60
+ function bar(share) {
61
+ return '<span class="bar" style="width:' + Math.max(2, share * 2) + 'px"></span>';
62
+ }
63
+
64
+ const renderers = {
65
+ summary(d) {
66
+ return table(['metric', 'value'], [
67
+ '<tr><td>plugins mounted</td><td class="num">' + d.plugins + '</td></tr>',
68
+ '<tr><td>unhealthy</td><td class="num ' + (d.unhealthy ? 'warn' : 'ok') + '">' + d.unhealthy + '</td></tr>',
69
+ '<tr><td>services</td><td class="num">' + d.services + '</td></tr>',
70
+ '<tr><td>context tokens (tools + sections)</td><td class="num">~' + d.toolSchemaTokens + '</td></tr>',
71
+ ]) + '<p class="muted" style="margin-top:12px">captured ' + esc(d.capturedAt) + '</p>';
72
+ },
73
+ health(d) {
74
+ let html = '<p><span class="ok">' + d.healthy.length + ' healthy</span>'
75
+ + (d.waiting.length ? ' · ' + d.waiting.length + ' waiting' : '')
76
+ + (d.unhealthy.length ? ' · <span class="warn">' + d.unhealthy.length + ' unhealthy</span>' : '') + '</p>';
77
+ if (d.unhealthy.length) {
78
+ html += table(['plugin', 'fiber', 'state', 'error'], d.unhealthy.flatMap((p) =>
79
+ p.fibers.map((f) => '<tr><td>' + esc(p.name) + '</td><td class="num">' + f.uid
80
+ + '</td><td class="warn">' + esc(f.state) + '</td><td>' + esc(f.error ?? '') + '</td></tr>')));
81
+ }
82
+ if (d.waiting.length) {
83
+ html += table(['waiting plugin', 'wants'], d.waiting.map((p) =>
84
+ '<tr><td>' + esc(p.name) + '</td><td>' + esc(p.inject.join(', ')) + '</td></tr>'));
85
+ }
86
+ return html;
87
+ },
88
+ deps(d) {
89
+ const services = Object.entries(d.services).map(([name, node]) =>
90
+ '<tr><td>' + esc(name) + '</td><td>' + esc(node.providers.join(', ') || '—')
91
+ + '</td><td>' + esc(node.consumers.join(', ') || '—') + '</td></tr>');
92
+ let html = table(['service', 'provided by', 'consumed by'], services);
93
+ const cascade = Object.entries(d.cascade);
94
+ if (cascade.length) {
95
+ html = '<h3 style="margin:8px 0">disable-cascade</h3>'
96
+ + table(['provider', 'affects'], cascade.map(([p, a]) =>
97
+ '<tr><td>' + esc(p) + '</td><td>' + esc(a.join(', ')) + '</td></tr>'))
98
+ + '<h3 style="margin:16px 0 8px">services</h3>' + html;
99
+ }
100
+ if (d.unsatisfied.length) {
101
+ html = '<p class="warn">' + d.unsatisfied.length + ' unsatisfied inject(s)</p>' + html;
102
+ }
103
+ return html;
104
+ },
105
+ cost(d) {
106
+ let html = '<p>~' + d.totalTokens + ' tokens: ' + d.toolCount + ' tool schema(s) ~' + d.toolTokens
107
+ + ' + ' + d.sectionCount + ' prompt section(s) ~' + d.sectionTokens + '</p>';
108
+ if (d.sections.length) {
109
+ html += '<h3 style="margin:12px 0 4px">prompt sections</h3>'
110
+ + table(['section', 'tokens', 'share', ''], d.sections.map((s) =>
111
+ '<tr><td>' + esc(s.name) + '</td><td class="num">~' + s.tokens + '</td><td class="num">'
112
+ + s.share + '%</td><td>' + bar(s.share) + '</td></tr>'));
113
+ } else {
114
+ html += '<p class="muted">no prompt assembly observed yet — send one agent message first</p>';
115
+ }
116
+ html += '<h3 style="margin:12px 0 4px">tool schemas</h3>'
117
+ + table(['tool', 'tokens', 'share', ''], d.tools.map((t) =>
118
+ '<tr><td>' + esc(t.name) + '</td><td class="num">~' + t.tokens + '</td><td class="num">'
119
+ + t.share + '%</td><td>' + bar(t.share) + '</td></tr>'));
120
+ return html;
121
+ },
122
+ shadow(d) {
123
+ let html = d.services.length
124
+ ? table(['service', 'providers'], d.services.map((s) =>
125
+ '<tr><td>' + esc(s.service) + '</td><td class="warn">' + esc(s.providers.join(' AND ')) + '</td></tr>'))
126
+ : '<p class="ok">no service is provided by more than one plugin</p>';
127
+ if (d.registrars.length) {
128
+ html += '<h3 style="margin:12px 0 4px">registrars</h3>'
129
+ + table(['plugin', 'registrations'], d.registrars.map((r) =>
130
+ '<tr><td>' + esc(r.plugin) + '</td><td class="num">' + r.registrations + '</td></tr>'));
131
+ }
132
+ return html;
133
+ },
134
+ };
135
+
136
+ async function render() {
137
+ for (const v of views) document.getElementById('nav-' + v).className = v === active ? 'active' : '';
138
+ status.textContent = 'loading ' + active + '…';
139
+ try {
140
+ const res = await fetch('/xray/api/' + active);
141
+ if (!res.ok) throw new Error(await res.text());
142
+ const data = await res.json();
143
+ content.innerHTML = renderers[active](data);
144
+ status.textContent = '';
145
+ } catch (err) {
146
+ status.innerHTML = '<span class="warn">' + esc(err.message) + '</span>';
147
+ content.innerHTML = '';
148
+ }
149
+ }
150
+ render();
151
+ setInterval(() => { if (active === 'health' || active === 'summary') render(); }, 5000);
152
+ </script>
153
+ </body>
154
+ </html>`;
155
+
156
+ function sendJson(response, code, value) {
157
+ const body = JSON.stringify(value);
158
+ response.writeHead(code, {
159
+ 'content-type': 'application/json; charset=utf-8',
160
+ 'cache-control': 'no-store',
161
+ });
162
+ response.end(body);
163
+ }
164
+
165
+ /**
166
+ * Mount the panel routes. `views` supplies fresh data per request:
167
+ * { summary, deps, health, cost, shadow } — each a () => object.
168
+ * Returns the disposers webServer.register produced.
169
+ */
170
+ function mountPanel(webServer, views) {
171
+ const disposers = [];
172
+ disposers.push(
173
+ webServer.register({
174
+ kind: 'exact',
175
+ path: '/xray',
176
+ handler: (request, response) => {
177
+ if (request.method !== 'GET') {
178
+ response.writeHead(405, { allow: 'GET' });
179
+ response.end();
180
+ return;
181
+ }
182
+ response.writeHead(200, {
183
+ 'content-type': 'text/html; charset=utf-8',
184
+ 'cache-control': 'no-store',
185
+ });
186
+ response.end(PAGE);
187
+ },
188
+ }),
189
+ );
190
+ for (const [name, compute] of Object.entries(views)) {
191
+ disposers.push(
192
+ webServer.register({
193
+ kind: 'exact',
194
+ path: `/xray/api/${name}`,
195
+ handler: (request, response) => {
196
+ if (request.method !== 'GET') {
197
+ response.writeHead(405, { allow: 'GET' });
198
+ response.end();
199
+ return;
200
+ }
201
+ try {
202
+ sendJson(response, 200, compute());
203
+ } catch (err) {
204
+ sendJson(response, 500, { error: err.message });
205
+ }
206
+ },
207
+ }),
208
+ );
209
+ }
210
+ return disposers;
211
+ }
212
+
213
+ module.exports = { mountPanel, PAGE };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-xray",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "X-ray for your DeepSeek Harness — see what's actually loaded, why, and what it costs you.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -15,6 +15,7 @@
15
15
  },
16
16
  "exports": {
17
17
  ".": "./lib/index.js",
18
+ "./client": "./lib/client.js",
18
19
  "./package.json": "./package.json",
19
20
  "./cordis.patch.yml": "./cordis.patch.yml"
20
21
  },
@@ -56,6 +57,14 @@
56
57
  "dsh": {
57
58
  "bundle": {
58
59
  "patch": "./cordis.patch.yml"
60
+ },
61
+ "client": {
62
+ "platform": "web",
63
+ "inject": [
64
+ "@deepseek-ai/dsh-client-runtime",
65
+ "@deepseek-ai/dsh-client-ui-slots",
66
+ "@deepseek-ai/dsh-client-ui-conversation"
67
+ ]
59
68
  }
60
69
  },
61
70
  "peerDependencies": {