dsh-xray 0.1.1 → 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 +21 -0
- package/README.md +12 -6
- package/README.zh.md +12 -6
- package/bin/xray.js +106 -12
- package/lib/collect/dump.js +0 -1
- package/lib/collect/runtime.js +106 -0
- package/lib/collect/static.js +8 -2
- package/lib/index.js +119 -7
- package/lib/model.js +95 -5
- package/package.json +14 -2
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.
|
|
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
|
|
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.
|
|
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.
|
|
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`
|
|
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.
|
|
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
|
-
|
|
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 {
|
|
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(
|
|
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(
|
|
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) {
|
|
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(
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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 {
|
package/lib/collect/dump.js
CHANGED
|
@@ -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 };
|
package/lib/collect/static.js
CHANGED
|
@@ -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({
|
|
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
|
-
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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)
|
|
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
|
|
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(
|
|
129
|
-
|
|
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
|
-
|
|
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,7 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-xray",
|
|
3
|
-
"version": "0.
|
|
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
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/alloevil/dsh-xray.git"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://github.com/alloevil/dsh-xray#readme",
|
|
10
|
+
"bugs": "https://github.com/alloevil/dsh-xray/issues",
|
|
5
11
|
"license": "MIT",
|
|
6
12
|
"main": "lib/index.js",
|
|
7
13
|
"bin": {
|
|
@@ -23,7 +29,10 @@
|
|
|
23
29
|
"yaml": "^2.6.0"
|
|
24
30
|
},
|
|
25
31
|
"scripts": {
|
|
26
|
-
"test": "node --test tests
|
|
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"
|
|
27
36
|
},
|
|
28
37
|
"keywords": [
|
|
29
38
|
"dsh-plugin",
|
|
@@ -48,5 +57,8 @@
|
|
|
48
57
|
"publishConfig": {
|
|
49
58
|
"registry": "https://registry.npmjs.org/",
|
|
50
59
|
"access": "public"
|
|
60
|
+
},
|
|
61
|
+
"devDependencies": {
|
|
62
|
+
"@biomejs/biome": "^2.5.9"
|
|
51
63
|
}
|
|
52
64
|
}
|