dsh-xray 0.1.2 → 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 alloevil
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -4,7 +4,7 @@ X-ray for your [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harnes
4
4
 
5
5
  [中文](./README.zh.md)
6
6
 
7
- > **Status: 0.1.x — static imaging.** The CLI works today, even when dsh cannot boot. Runtime imaging (dependency graph, health, context cost) lands in 0.2.0.
7
+ > **Status: 0.2.x — static + runtime imaging.** Static commands work even when dsh cannot boot; `deps`/`health` and the agent tool need the plugin mounted.
8
8
 
9
9
  `dsh --dump-config` shows you the composed tree. The plugin panel shows you a flat list. Neither tells you *why* a plugin is there, *what breaks* if you disable it, or *what it silently costs you*. dsh-xray does.
10
10
 
@@ -15,29 +15,35 @@ npx dsh-xray attribute # which layer introduced each row, and who patched it s
15
15
  npx dsh-xray conflicts # rows whose fields have multiple writers, and who wins
16
16
  npx dsh-xray diff # declared (static layers) vs actual (dump-config) tree
17
17
  npx dsh-xray snapshot # content-addressed lockfile of the effective composition
18
+ npx dsh-xray deps [svc] # service dependency graph: providers, consumers, disable-cascade
19
+ npx dsh-xray health # plugin lifecycle health: failed fibers, pending injects, transitions
18
20
  ```
19
21
 
20
- All commands take `--profile <name>` (default `web`) and `--json`. `diff` exits `1` when the trees disagree orphan patch rows silently skipped by dsh, installed-but-inactive plugins, disabled-state mismatches. `attribute`, `conflicts`, and `snapshot` are fully static: they work even when dsh cannot start.
22
+ All commands take `--profile <name>` (default `web`) and `--json`. `diff` exits `1` when the trees disagree; `health` exits `1` when any plugin is unhealthy. `attribute`, `conflicts`, and `snapshot` are fully static: they work even when dsh cannot start. `deps` and `health` read the runtime snapshot the mounted plugin maintains at `$DSH_HOME/xray/runtime.json`.
23
+
24
+ ## Agent tool
25
+
26
+ Mounted in the tree, dsh-xray registers an `xray_composition` tool (`view: summary | deps | health`), so an agent can answer "what capabilities do I have / what plugin provides X / why is Y unavailable" about itself.
21
27
 
22
28
  ## Capabilities
23
29
 
24
30
  Diagnostic imaging for a running composition — complementary to [dsh-doctor](https://www.npmjs.com/package/dsh-doctor) (rescue & recovery).
25
31
 
26
- Shipped in 0.1.x:
32
+ Shipped in 0.2.x:
27
33
 
28
34
  - **Layer attribution** — which layer introduced each active plugin: kernel bundle, profile dependency, `cordis.patch.yml` insert, or repository source
29
35
  - **Declared vs. actual diff** — installed-but-inactive, uninstalled-but-lingering patch rows
30
36
  - **Conflict detection** — plugins patching the same config row, and which one silently wins
31
37
  - **Composition snapshot** — export the effective composition as a lockfile; reproduce it elsewhere
38
+ - **Service dependency graph** — who provides and consumes each service; what cascades if you disable X (`deps`)
39
+ - **Runtime health** — per-plugin fiber lifecycle state, startup failures, transition history (`health`)
40
+ - **Agent self-introspection** — the `xray_composition` tool lets agents inspect their own capability set
32
41
 
33
42
  Planned:
34
43
 
35
- - **Service dependency graph** — who `inject`s whose service; what cascades if you disable X
36
- - **Runtime health** — per-plugin scope state: activation failures, stacks, load time, HMR reloads
37
44
  - **Capability audit** — what installed plugins actually touch: network egress, shell, filesystem, env; permission diff across updates
38
45
  - **Command/tool shadowing** — plugins registering the same command/tool, and which one silently wins
39
46
  - **Context cost** — tokens each plugin injects into agent context: tool schemas, prompt sections, skills
40
- - **Agent self-introspection** — expose the composition as a tool so agents know what capabilities they have
41
47
 
42
48
  ## Install
43
49
 
package/README.zh.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  [English](./README.md)
6
6
 
7
- > **状态:0.1.x — 静态成像。** CLI 现在就能用,即使 dsh 起不来。运行时成像(依赖图、健康状态、上下文成本)在 0.2.0 落地。
7
+ > **状态:0.2.x — 静态 + 运行时成像。** 静态命令在 dsh 起不来时照样能用;`deps`/`health` agent 工具需要插件已挂载。
8
8
 
9
9
  `dsh --dump-config` 只给你原始组合树,插件面板只给你平铺列表。它们都不回答:这个插件*为什么*在这、停用它会*连带瘫掉什么*、它在*悄悄消耗什么*。dsh-xray 回答这些。
10
10
 
@@ -15,29 +15,35 @@ npx dsh-xray attribute # 每一行由哪层引入、之后被谁 patch 过
15
15
  npx dsh-xray conflicts # 哪些行的字段有多个写者、最终谁赢
16
16
  npx dsh-xray diff # 声明(静态层)vs 实际(dump-config)组合树
17
17
  npx dsh-xray snapshot # 当前生效组合的内容寻址 lockfile
18
+ npx dsh-xray deps [svc] # 服务依赖图:提供者、消费者、停用级联
19
+ npx dsh-xray health # 插件生命周期健康:失败 fiber、等待中的注入、状态迁移史
18
20
  ```
19
21
 
20
- 所有命令支持 `--profile <name>`(默认 `web`)和 `--json`。`diff` 在两棵树不一致时以退出码 `1` 结束——被 dsh 静默跳过的 orphan patch 行、装了但未生效的插件、disabled 状态不匹配。`attribute`、`conflicts`、`snapshot` 是纯静态的:dsh 起不来时照样能跑。
22
+ 所有命令支持 `--profile <name>`(默认 `web`)和 `--json`。`diff` 在两棵树不一致时退出码 `1`;`health` 在有插件不健康时退出码 `1`。`attribute`、`conflicts`、`snapshot` 是纯静态的:dsh 起不来时照样能跑。`deps` 和 `health` 读取已挂载插件维护在 `$DSH_HOME/xray/runtime.json` 的运行时快照。
23
+
24
+ ## Agent 工具
25
+
26
+ 挂载进树后,dsh-xray 注册 `xray_composition` 工具(`view: summary | deps | health`),agent 可以自答"我有哪些能力 / 哪个插件提供 X / 为什么 Y 不可用"。
21
27
 
22
28
  ## 能力
23
29
 
24
30
  对运行中组合树的诊断成像——与 [dsh-doctor](https://www.npmjs.com/package/dsh-doctor)(救援与恢复)互补。
25
31
 
26
- 0.1.x 已交付:
32
+ 0.2.x 已交付:
27
33
 
28
34
  - **来源归因** — 每个活跃插件来自哪一层:内核 bundle / profile 依赖 / `cordis.patch.yml` insert / repository 源
29
35
  - **声明 vs 实际 diff** — 装了但没生效、卸了但残留 patch 行
30
36
  - **冲突检测** — 多个插件 patch 同一配置行时,谁静默赢了
31
37
  - **组合快照** — 把当前生效组合导出为 lockfile,异地一键复现
38
+ - **服务依赖图** — 每个服务谁提供、谁消费;停用 X 会级联影响什么(`deps`)
39
+ - **运行时健康** — 每个插件的 fiber 生命周期状态、启动失败、状态迁移史(`health`)
40
+ - **Agent 自省** — `xray_composition` 工具让 agent 检视自己的能力集
32
41
 
33
42
  规划中:
34
43
 
35
- - **服务依赖图** — 谁 `inject` 了谁的服务;停用 X 会级联影响什么
36
- - **运行时健康** — 每个插件的 scope 状态:激活失败与堆栈、加载耗时、HMR 重载次数
37
44
  - **能力审计** — 已装插件实际触碰什么:网络外发、shell、文件系统、环境变量;更新前后权限 diff
38
45
  - **命令/工具重名检测** — 注册同名命令/工具时,谁静默覆盖谁
39
46
  - **上下文成本** — 每个插件往 agent 上下文注入多少 token:工具 schema、prompt 片段、skill 文档
40
- - **Agent 自省** — 把组合信息暴露为 tool,让 agent 知道自己有哪些能力
41
47
 
42
48
  ## 安装
43
49
 
package/bin/xray.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- 'use strict';
2
+
3
3
  // dsh-xray CLI. Static analysis works even when dsh cannot boot;
4
4
  // commands needing the composed tree degrade with a clear notice.
5
5
 
@@ -22,7 +22,10 @@ function tryDump(profile) {
22
22
  try {
23
23
  return { dump: collectDump(profile), error: null };
24
24
  } catch (err) {
25
- return { dump: null, error: `dump-config unavailable (${err.message.split('\n')[0]}); static-only mode` };
25
+ return {
26
+ dump: null,
27
+ error: `dump-config unavailable (${err.message.split('\n')[0]}); static-only mode`,
28
+ };
26
29
  }
27
30
  }
28
31
 
@@ -42,7 +45,9 @@ function cmdAttribute(args) {
42
45
  console.log(`${pad(row.id, 28)} ${pad(row.origin?.layer, 32)}${over}${flags}`);
43
46
  }
44
47
  if (result.orphans.length) {
45
- console.log(`\n! ${result.orphans.length} orphan override(s) targeting nonexistent rows (silently skipped by dsh):`);
48
+ console.log(
49
+ `\n! ${result.orphans.length} orphan override(s) targeting nonexistent rows (silently skipped by dsh):`,
50
+ );
46
51
  for (const o of result.orphans) console.log(` ${o.id} in ${o.file}`);
47
52
  }
48
53
  for (const w of result.warnings) console.log(`! ${w}`);
@@ -56,7 +61,9 @@ function cmdConflicts(args) {
56
61
  for (const c of result) {
57
62
  console.log(`${c.id}`);
58
63
  for (const f of c.fields) {
59
- console.log(` .${f.field}: ${f.writers.map((w) => w.layer).join(' → ')} (winner: ${f.winner})`);
64
+ console.log(
65
+ ` .${f.field}: ${f.writers.map((w) => w.layer).join(' → ')} (winner: ${f.winner})`,
66
+ );
60
67
  }
61
68
  }
62
69
  }
@@ -64,7 +71,11 @@ function cmdConflicts(args) {
64
71
  function cmdDiff(args) {
65
72
  const data = collectStatic(args.profile);
66
73
  const { dump, error } = tryDump(args.profile);
67
- if (error) { console.error(`! ${error}`); process.exitCode = 1; return; }
74
+ if (error) {
75
+ console.error(`! ${error}`);
76
+ process.exitCode = 1;
77
+ return;
78
+ }
68
79
  const result = model.diff(data, dump);
69
80
  if (args.json) return console.log(JSON.stringify(result, null, 2));
70
81
 
@@ -74,12 +85,32 @@ function cmdDiff(args) {
74
85
  for (const it of items) console.log(` ${fmt(it)}`);
75
86
  };
76
87
  section('declared but not in boot tree', result.missingFromActual, (r) => `${r.id} (${r.name})`);
77
- section('in boot tree but undeclared', result.missingFromDeclared, (r) => `${r.id} (${r.name}) — dump says: ${r.provenance}`);
78
- section('disabled-state mismatch', result.disabledMismatch, (r) => `${r.id}: declared=${r.declared} actual=${r.actual}`);
79
- section('orphan overrides (silently skipped)', result.orphanOverrides, (r) => `${r.id} in ${r.file}`);
80
- section('installed but inactive packages', result.inactivePackages, (r) => `${r.name}@${r.version}`);
81
- const total = result.missingFromActual.length + result.missingFromDeclared.length
82
- + result.disabledMismatch.length + result.orphanOverrides.length + result.inactivePackages.length;
88
+ section(
89
+ 'in boot tree but undeclared',
90
+ result.missingFromDeclared,
91
+ (r) => `${r.id} (${r.name}) — dump says: ${r.provenance}`,
92
+ );
93
+ section(
94
+ 'disabled-state mismatch',
95
+ result.disabledMismatch,
96
+ (r) => `${r.id}: declared=${r.declared} actual=${r.actual}`,
97
+ );
98
+ section(
99
+ 'orphan overrides (silently skipped)',
100
+ result.orphanOverrides,
101
+ (r) => `${r.id} in ${r.file}`,
102
+ );
103
+ section(
104
+ 'installed but inactive packages',
105
+ result.inactivePackages,
106
+ (r) => `${r.name}@${r.version}`,
107
+ );
108
+ const total =
109
+ result.missingFromActual.length +
110
+ result.missingFromDeclared.length +
111
+ result.disabledMismatch.length +
112
+ result.orphanOverrides.length +
113
+ result.inactivePackages.length;
83
114
  if (total === 0) console.log('declared and actual trees agree');
84
115
  else process.exitCode = 1;
85
116
  }
@@ -90,11 +121,70 @@ function cmdSnapshot(args) {
90
121
  console.log(JSON.stringify(model.snapshot(data, dump), null, 2));
91
122
  }
92
123
 
124
+ function readRuntimeSnapshot() {
125
+ const fs = require('node:fs');
126
+ const path = require('node:path');
127
+ const { xrayDir } = require('../lib/index.js');
128
+ const file = path.join(xrayDir(), 'runtime.json');
129
+ if (!fs.existsSync(file)) {
130
+ throw new Error(
131
+ `no runtime snapshot at ${file} — mount the plugin first: dsh plugin --profile web add dsh-xray`,
132
+ );
133
+ }
134
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
135
+ }
136
+
137
+ function cmdDeps(args) {
138
+ const snap = readRuntimeSnapshot();
139
+ const result = model.serviceGraph(snap);
140
+ if (args.json) return console.log(JSON.stringify(result, null, 2));
141
+
142
+ const filter = args._[1];
143
+ for (const [service, node] of Object.entries(result.services)) {
144
+ if (filter && service !== filter) continue;
145
+ console.log(`${service}`);
146
+ console.log(` provided by: ${node.providers.join(', ') || '(nobody)'}`);
147
+ if (node.consumers.length) console.log(` consumed by: ${node.consumers.join(', ')}`);
148
+ }
149
+ if (!filter && Object.keys(result.cascade).length) {
150
+ console.log('\n# disable-cascade (transitive consumers of each provider):');
151
+ for (const [plugin, affected] of Object.entries(result.cascade)) {
152
+ console.log(
153
+ ` ${plugin} → ${affected.length} plugin(s): ${affected.slice(0, 6).join(', ')}${affected.length > 6 ? ', …' : ''}`,
154
+ );
155
+ }
156
+ }
157
+ if (result.unsatisfied.length) {
158
+ console.log(`\n! ${result.unsatisfied.length} unsatisfied inject(s):`);
159
+ for (const u of result.unsatisfied) console.log(` ${u.plugin} wants ${u.service}`);
160
+ }
161
+ }
162
+
163
+ function cmdHealth(args) {
164
+ const snap = readRuntimeSnapshot();
165
+ const result = model.health(snap);
166
+ if (args.json) return console.log(JSON.stringify(result, null, 2));
167
+ console.log(
168
+ `captured ${result.capturedAt}: ${result.healthy.length} healthy, ${result.unhealthy.length} unhealthy`,
169
+ );
170
+ for (const p of result.unhealthy) {
171
+ console.log(`\n${p.name}`);
172
+ for (const f of p.fibers)
173
+ console.log(` fiber ${f.uid}: ${f.state}${f.error ? ` (${f.error})` : ''}`);
174
+ for (const t of p.transitions.slice(-5)) {
175
+ console.log(` ${new Date(t.at).toISOString()} ${t.state}`);
176
+ }
177
+ }
178
+ if (result.unhealthy.length) process.exitCode = 1;
179
+ }
180
+
93
181
  const commands = {
94
182
  attribute: cmdAttribute,
95
183
  conflicts: cmdConflicts,
96
184
  diff: cmdDiff,
97
185
  snapshot: cmdSnapshot,
186
+ deps: cmdDeps,
187
+ health: cmdHealth,
98
188
  };
99
189
 
100
190
  const args = parseArgs(process.argv.slice(2));
@@ -108,7 +198,11 @@ Commands:
108
198
  attribute which layer introduced each row, and who patched it since
109
199
  conflicts rows whose fields have multiple writers, and who wins
110
200
  diff declared (static layers) vs actual (dump-config) tree
111
- snapshot content-addressed lockfile of the effective composition`);
201
+ snapshot content-addressed lockfile of the effective composition
202
+ deps service dependency graph from the live runtime snapshot
203
+ health plugin lifecycle health from the live runtime snapshot
204
+
205
+ deps/health need the plugin mounted: dsh plugin --profile web add dsh-xray`);
112
206
  process.exit(args._[0] ? 2 : 0);
113
207
  }
114
208
  try {
@@ -1,4 +1,3 @@
1
- 'use strict';
2
1
  // Dump collector: runs `dsh --profile <p> --dump-config` and parses the
3
2
  // composed tree, preserving dsh's own provenance comments (`# == <layer>`),
4
3
  // which annotate the row group that follows them.
@@ -0,0 +1,106 @@
1
+ // Runtime collector: snapshots the live Cordis registry from inside a
2
+ // mounted plugin. Output is plain JSON, written for the CLI to read.
3
+
4
+ /** Normalize an inject declaration (array | object | null) to a name list. */
5
+ function injectNames(inject) {
6
+ if (!inject) return [];
7
+ if (Array.isArray(inject)) return [...inject];
8
+ // Object form may carry a prototype chain (class-inherited); walk own+proto keys.
9
+ const names = new Set();
10
+ for (let o = inject; o && o !== Object.prototype; o = Object.getPrototypeOf(o)) {
11
+ for (const k of Object.keys(o)) names.add(k);
12
+ }
13
+ return [...names];
14
+ }
15
+
16
+ function provideNames(provide) {
17
+ if (!provide) return [];
18
+ return Array.isArray(provide) ? [...provide] : [provide];
19
+ }
20
+
21
+ // Cordis Fiber.State enum (vendor/cordis/src/fiber.ts): numeric values.
22
+ const STATE_NAMES = ['PENDING', 'LOADING', 'ACTIVE', 'FAILED', 'DISPOSED', 'UNLOADING'];
23
+
24
+ function stateName(state) {
25
+ return STATE_NAMES[state] ?? String(state);
26
+ }
27
+
28
+ /** Serialize one fiber defensively: never let a bad fiber break the snapshot. */
29
+ function fiberInfo(fiber) {
30
+ const info = { uid: null, state: null, error: null, effects: [] };
31
+ try {
32
+ info.uid = fiber.uid;
33
+ info.state = stateName(fiber.state);
34
+ } catch (err) {
35
+ info.error = `state unreadable: ${err.message}`;
36
+ }
37
+ try {
38
+ const effects = fiber.getEffects?.();
39
+ if (Array.isArray(effects)) {
40
+ info.effects = effects.map((e) => summarizeEffect(e)).filter(Boolean);
41
+ }
42
+ } catch (err) {
43
+ info.error = info.error ?? `effects unreadable: ${err.message}`;
44
+ }
45
+ return info;
46
+ }
47
+
48
+ function summarizeEffect(meta, depth = 0) {
49
+ if (!meta || depth > 3) return null;
50
+ const out = { label: meta.label ?? null };
51
+ const children = Array.isArray(meta.children)
52
+ ? meta.children.map((c) => summarizeEffect(c, depth + 1)).filter(Boolean)
53
+ : [];
54
+ if (children.length) out.children = children;
55
+ return out;
56
+ }
57
+
58
+ /**
59
+ * Snapshot every registered plugin runtime plus the service store.
60
+ * @param ctx a live Cordis context (any fiber's ctx reaches the shared registry)
61
+ */
62
+ function snapshotRegistry(ctx) {
63
+ const plugins = [];
64
+ for (const runtime of ctx.registry.values()) {
65
+ const cb = runtime.callback;
66
+ plugins.push({
67
+ name: runtime.name ?? cb?.name ?? null,
68
+ inject: injectNames(cb?.inject),
69
+ provide: provideNames(cb?.provide),
70
+ fibers: [...runtime.fibers].map(fiberInfo),
71
+ });
72
+ }
73
+
74
+ // Service implementations live in the root reflect store (Impl records),
75
+ // not on plugin callbacks: Service subclasses provide via ctx.provide().
76
+ // The store is keyed by isolation-label symbols; values are Impl records.
77
+ const services = [];
78
+ try {
79
+ const store = ctx.root?.reflect?.store ?? ctx.reflect?.store;
80
+ if (store) {
81
+ for (const key of [...Object.getOwnPropertySymbols(store), ...Object.keys(store)]) {
82
+ const impl = store[key];
83
+ if (!impl?.name) continue;
84
+ let provider = null;
85
+ try {
86
+ provider = impl.fiber?.name ?? null;
87
+ } catch {
88
+ /* disposed fiber */
89
+ }
90
+ services.push({ name: impl.name, provider });
91
+ }
92
+ }
93
+ } catch {
94
+ /* reflect layout changed; plugins alone still work */
95
+ }
96
+
97
+ return {
98
+ schema: 'dsh-xray/runtime@1',
99
+ capturedAt: new Date().toISOString(),
100
+ registrySize: ctx.registry.size,
101
+ plugins,
102
+ services,
103
+ };
104
+ }
105
+
106
+ module.exports = { snapshotRegistry, injectNames, provideNames, stateName };
@@ -1,4 +1,3 @@
1
- 'use strict';
2
1
  // Static collector: reads the layer stack from DSH_HOME without running dsh.
3
2
  // Layers, in application order: each profile bundle's patch, the profile's
4
3
  // cordis.patch.yml, the home-level cordis.patch.yml.
@@ -100,7 +99,14 @@ function collectStatic(profileName) {
100
99
  const text = fs.readFileSync(file, 'utf8');
101
100
  const { value, error } = parseYaml(text, file);
102
101
  if (error) warnings.push(error);
103
- layers.push({ kind, name: kind, version: null, file, entries: Array.isArray(value) ? value : [], text });
102
+ layers.push({
103
+ kind,
104
+ name: kind,
105
+ version: null,
106
+ file,
107
+ entries: Array.isArray(value) ? value : [],
108
+ text,
109
+ });
104
110
  }
105
111
 
106
112
  // Out-of-tree plugins: profile dependencies carrying a `dsh` field.
package/lib/index.js CHANGED
@@ -1,18 +1,130 @@
1
- 'use strict';
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+ const os = require('node:os');
4
+ const { snapshotRegistry, stateName } = require('./collect/runtime.js');
5
+ const { serviceGraph, health } = require('./model.js');
2
6
 
3
7
  const name = 'dsh-xray';
4
8
 
9
+ function xrayDir() {
10
+ const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
11
+ return path.join(home, 'xray');
12
+ }
13
+
5
14
  /**
6
15
  * dsh-xray — X-ray for your DeepSeek Harness.
7
16
  *
8
- * 0.1.x: static imaging ships in the CLI (bin/xray.js) — layer attribution,
9
- * declared-vs-actual diff, conflict detection, composition snapshot.
10
- * This Cordis entry is a mount point; runtime imaging (dependency graph,
11
- * health, context cost, agent self-introspection) lands here in 0.2.0.
17
+ * Mounted in the tree, this plugin:
18
+ * 1. writes a runtime snapshot to $DSH_HOME/xray/runtime.json (throttled,
19
+ * refreshed on every fiber status change) for the CLI to read;
20
+ * 2. counts status transitions and HMR updates per plugin;
21
+ * 3. registers an `xray_composition` tool when `ctx.tools` is available,
22
+ * so agents can introspect their own capability set.
12
23
  */
13
24
  function apply(ctx) {
14
- ctx.logger(name).info('dsh-xray mounted; run `npx dsh-xray` for static composition imaging (runtime imaging lands in 0.2.0)');
25
+ const logger = ctx.logger(name);
26
+ const dir = xrayDir();
27
+ const file = path.join(dir, 'runtime.json');
28
+ const transitions = new Map(); // plugin name -> [{state, at}] ring buffer
29
+
30
+ let timer = null;
31
+ const writeSnapshot = () => {
32
+ timer = null;
33
+ try {
34
+ const snap = snapshotRegistry(ctx);
35
+ snap.transitions = Object.fromEntries(transitions);
36
+ fs.mkdirSync(dir, { recursive: true });
37
+ const tmp = `${file}.tmp`;
38
+ fs.writeFileSync(tmp, JSON.stringify(snap, null, 2));
39
+ fs.renameSync(tmp, file);
40
+ } catch (err) {
41
+ logger.warn(`snapshot write failed: ${err.message}`);
42
+ }
43
+ };
44
+ const schedule = () => {
45
+ if (!timer) timer = setTimeout(writeSnapshot, 1000);
46
+ };
47
+
48
+ ctx.effect(() => {
49
+ // internal/status fires on every fiber lifecycle transition.
50
+ const disposeStatus = ctx.on('internal/status', (fiber) => {
51
+ try {
52
+ const key = fiber.name ?? 'unknown';
53
+ if (!transitions.has(key)) transitions.set(key, []);
54
+ const log = transitions.get(key);
55
+ log.push({ state: stateName(fiber.state), at: Date.now() });
56
+ if (log.length > 20) log.shift();
57
+ } catch {
58
+ /* never break the host on a diagnostic path */
59
+ }
60
+ schedule();
61
+ });
62
+ schedule(); // initial snapshot
63
+ return [
64
+ disposeStatus,
65
+ () => {
66
+ clearTimeout(timer);
67
+ writeSnapshot(); // final state on unload
68
+ },
69
+ ];
70
+ }, 'xray-runtime-snapshot');
71
+
72
+ // Agent self-introspection tool: only when a tool registry exists
73
+ // (headless/web both have one; keep it optional so xray mounts anywhere).
74
+ ctx.plugin({
75
+ name: 'dsh-xray-tool',
76
+ inject: ['tools'],
77
+ apply: (tctx) => {
78
+ // Resolve dsh-tools through the profile's flat closure too: a `link:`
79
+ // installed plugin cannot see $DSH_HOME/profiles/node_modules from its
80
+ // own directory (the documented dual-anchor rule covers loader mounting,
81
+ // not require() from inside the plugin).
82
+ let defineTool;
83
+ try {
84
+ const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
85
+ const resolved = require.resolve('@deepseek-ai/dsh-tools', {
86
+ paths: [__dirname, path.join(home, 'profiles')],
87
+ });
88
+ ({ defineTool } = require(resolved));
89
+ } catch {
90
+ logger.info('dsh-tools not resolvable; xray_composition tool not registered');
91
+ return;
92
+ }
93
+ tctx.tools.register(
94
+ defineTool({
95
+ name: 'xray_composition',
96
+ description:
97
+ 'Introspect the live plugin composition of this harness: every mounted plugin, ' +
98
+ 'the services it requires (inject) and provides, its lifecycle state, and ' +
99
+ 'unhealthy plugins with their last transitions. Use to answer "what capabilities ' +
100
+ 'do I have / what plugin provides X / why is Y unavailable".',
101
+ parameters: {
102
+ view: {
103
+ type: 'string',
104
+ description: 'summary | deps | health (default summary)',
105
+ },
106
+ },
107
+ output: {
108
+ schema: { type: 'object', additionalProperties: true },
109
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
110
+ },
111
+ async execute(args) {
112
+ const snap = snapshotRegistry(ctx);
113
+ snap.transitions = Object.fromEntries(transitions);
114
+ if (args.view === 'deps') return serviceGraph(snap);
115
+ if (args.view === 'health') return health(snap);
116
+ return {
117
+ plugins: snap.plugins.length,
118
+ unhealthy: health(snap).unhealthy.length,
119
+ services: Object.keys(serviceGraph(snap).services).length,
120
+ capturedAt: snap.capturedAt,
121
+ };
122
+ },
123
+ }),
124
+ );
125
+ },
126
+ });
15
127
  }
16
128
 
17
- module.exports = { name, apply };
129
+ module.exports = { name, apply, xrayDir };
18
130
  module.exports.default = module.exports;
package/lib/model.js CHANGED
@@ -1,4 +1,3 @@
1
- 'use strict';
2
1
  // Model layer: pure functions over collector output. No IO here.
3
2
 
4
3
  const crypto = require('node:crypto');
@@ -60,7 +59,8 @@ function attribute(staticData) {
60
59
  name: row.name ?? null,
61
60
  disabled: row.disabled === true,
62
61
  origin: origin ? { layer: origin.layer, kind: origin.kind } : null,
63
- overrides: events.filter((e) => e.action === 'override')
62
+ overrides: events
63
+ .filter((e) => e.action === 'override')
64
64
  .map((e) => ({ layer: e.layer, kind: e.kind, fields: e.fields })),
65
65
  };
66
66
  });
@@ -125,8 +125,11 @@ function diff(staticData, dumpData) {
125
125
  // Installed out-of-tree plugins whose bundle patch rows never made it in.
126
126
  const inactivePackages = staticData.packages
127
127
  .filter((p) => p.dsh?.bundle)
128
- .filter((p) => ![...declared.rows.values()].some((r) => r.name === p.name)
129
- && !dumpData.rows.some((r) => r.name === p.name))
128
+ .filter(
129
+ (p) =>
130
+ ![...declared.rows.values()].some((r) => r.name === p.name) &&
131
+ !dumpData.rows.some((r) => r.name === p.name),
132
+ )
130
133
  .map((p) => ({ name: p.name, version: p.version }));
131
134
 
132
135
  return {
@@ -157,4 +160,91 @@ function snapshot(staticData, dumpData) {
157
160
  };
158
161
  }
159
162
 
160
- module.exports = { replayLayers, attribute, conflicts, diff, snapshot };
163
+ /** F4: service dependency graph from a runtime snapshot. */
164
+ function serviceGraph(snap) {
165
+ const services = {}; // service name -> { providers: [], consumers: [] }
166
+ const touch = (s) => (services[s] ??= { providers: [], consumers: [] });
167
+ // Primary source: the reflect store (Impl records — authoritative provider
168
+ // ownership). Callback `provide` fields are a fallback for older snapshots.
169
+ for (const s of snap.services ?? []) {
170
+ const node = touch(s.name);
171
+ if (s.provider && !node.providers.includes(s.provider)) node.providers.push(s.provider);
172
+ }
173
+ const providerOf = new Map(); // plugin name -> provided services
174
+ for (const p of snap.plugins) {
175
+ for (const s of p.provide) {
176
+ const node = touch(s);
177
+ if (!node.providers.includes(p.name)) node.providers.push(p.name);
178
+ }
179
+ for (const s of p.inject) touch(s).consumers.push(p.name);
180
+ }
181
+ for (const [name, node] of Object.entries(services)) {
182
+ for (const provider of node.providers) {
183
+ if (!providerOf.has(provider)) providerOf.set(provider, []);
184
+ providerOf.get(provider).push(name);
185
+ }
186
+ }
187
+ // Cascade: disabling a provider of service S affects every consumer of S,
188
+ // transitively through services those consumers themselves provide.
189
+ const cascade = {};
190
+ for (const [provider, provided] of providerOf) {
191
+ const affected = new Set();
192
+ const queue = [...provided];
193
+ const seen = new Set(queue);
194
+ while (queue.length) {
195
+ const s = queue.shift();
196
+ for (const consumer of services[s]?.consumers ?? []) {
197
+ if (consumer === provider || affected.has(consumer)) continue;
198
+ affected.add(consumer);
199
+ for (const next of providerOf.get(consumer) ?? []) {
200
+ if (!seen.has(next)) {
201
+ seen.add(next);
202
+ queue.push(next);
203
+ }
204
+ }
205
+ }
206
+ }
207
+ if (affected.size) cascade[provider] = [...affected].sort();
208
+ }
209
+ const unsatisfied = [];
210
+ for (const p of snap.plugins) {
211
+ for (const s of p.inject) {
212
+ if (!services[s] || services[s].providers.length === 0) {
213
+ unsatisfied.push({ plugin: p.name, service: s });
214
+ }
215
+ }
216
+ }
217
+ return { services, cascade, unsatisfied };
218
+ }
219
+
220
+ /** F5: unhealthy plugins from fiber states and transition history. */
221
+ function health(snap) {
222
+ const healthy = [];
223
+ const waiting = [];
224
+ const unhealthy = [];
225
+ for (const p of snap.plugins) {
226
+ // FAILED = startup threw; a serialization error also counts. PENDING is
227
+ // normal (waiting for services) but surfaces as "waiting" for visibility.
228
+ const bad = p.fibers.filter((f) => f.error || f.state === 'FAILED');
229
+ const pending = p.fibers.filter((f) => f.state === 'PENDING');
230
+ const entry = {
231
+ name: p.name,
232
+ fibers: p.fibers.map((f) => ({ uid: f.uid, state: f.state, error: f.error })),
233
+ transitions: snap.transitions?.[p.name] ?? [],
234
+ };
235
+ if (bad.length) unhealthy.push(entry);
236
+ else if (pending.length) waiting.push(entry);
237
+ else healthy.push(entry);
238
+ }
239
+ return {
240
+ healthy: healthy.map((p) => p.name),
241
+ waiting: waiting.map((p) => ({
242
+ name: p.name,
243
+ inject: snap.plugins.find((x) => x.name === p.name)?.inject ?? [],
244
+ })),
245
+ unhealthy,
246
+ capturedAt: snap.capturedAt,
247
+ };
248
+ }
249
+
250
+ module.exports = { replayLayers, attribute, conflicts, diff, snapshot, serviceGraph, health };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-xray",
3
- "version": "0.1.2",
3
+ "version": "0.2.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",
@@ -29,7 +29,10 @@
29
29
  "yaml": "^2.6.0"
30
30
  },
31
31
  "scripts": {
32
- "test": "node --test tests/model.spec.js"
32
+ "test": "node --test tests/*.spec.js",
33
+ "lint": "biome check .",
34
+ "format": "biome check --write .",
35
+ "check": "biome check . && node --test tests/*.spec.js"
33
36
  },
34
37
  "keywords": [
35
38
  "dsh-plugin",
@@ -54,5 +57,8 @@
54
57
  "publishConfig": {
55
58
  "registry": "https://registry.npmjs.org/",
56
59
  "access": "public"
60
+ },
61
+ "devDependencies": {
62
+ "@biomejs/biome": "^2.5.9"
57
63
  }
58
64
  }