dsh-plugin-tool-management 0.16.6 → 0.18.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/CHANGELOG.md +115 -0
- package/README.md +20 -4
- package/README_EN.md +20 -4
- package/docs/images/1-EN.png +0 -0
- package/docs/images/1.png +0 -0
- package/docs/images/2-EN.png +0 -0
- package/docs/images/2.png +0 -0
- package/docs/images/3-EN.png +0 -0
- package/docs/images/3.png +0 -0
- package/docs/images/4-EN.png +0 -0
- package/docs/images/4.png +0 -0
- package/docs/images/5-EN.png +0 -0
- package/docs/images/5.png +0 -0
- package/docs/images/6-EN.png +0 -0
- package/docs/images/6.png +0 -0
- package/docs/images/7-EN.png +0 -0
- package/docs/images/7.png +0 -0
- package/docs/images/8-EN.png +0 -0
- package/docs/images/8.png +0 -0
- package/docs/update.md +59 -0
- package/lib/audit-log.js +6 -2
- package/lib/client.js +1615 -148
- package/lib/compat/capability-specs.js +321 -0
- package/lib/compat/host-probe.js +261 -0
- package/lib/compat/host-roots.js +168 -0
- package/lib/compat/probe-types.js +6 -0
- package/lib/compat/probe.js +54 -714
- package/lib/context-inject-contract.js +114 -0
- package/lib/context-inject-frame.js +147 -0
- package/lib/context-inject-select.js +127 -0
- package/lib/context-inject-surface.js +97 -0
- package/lib/context-inject.js +57 -459
- package/lib/host-names.js +150 -0
- package/lib/http-api.js +229 -0
- package/lib/hub.js +84 -11
- package/lib/imports/parsers.js +40 -15
- package/lib/index.js +235 -843
- package/lib/mcp/inspect-probe.js +199 -0
- package/lib/mcp/loader-token.js +26 -0
- package/lib/mcp/manager.js +267 -558
- package/lib/mcp/patch-yaml.js +207 -40
- package/lib/mcp/secret-guard.js +122 -16
- package/lib/mcp/tool-visibility.js +388 -0
- package/lib/memories/archive-engine.js +163 -16
- package/lib/memories/archive.js +10 -1
- package/lib/memories/discovery.js +261 -0
- package/lib/memories/export-plan.js +42 -0
- package/lib/memories/index-io.js +125 -12
- package/lib/memories/memories-types.js +1 -0
- package/lib/memories/service.js +167 -78
- package/lib/memories/snapshot.js +41 -260
- package/lib/op-registry.js +72 -8
- package/lib/ops/compat.js +17 -2
- package/lib/ops/prompts.js +18 -5
- package/lib/ops/quick-prompts.js +26 -0
- package/lib/ops/scene-records.js +157 -10
- package/lib/ops/scene-sync.js +71 -1
- package/lib/ops/sessions.js +63 -13
- package/lib/ops/snapshot.js +123 -24
- package/lib/ops/trash.js +45 -9
- package/lib/prompts/quick-service.js +211 -0
- package/lib/prompts/service.js +7 -2
- package/lib/request-gate.js +115 -56
- package/lib/scene-linkage.js +184 -0
- package/lib/scene-prompt-sync.js +46 -7
- package/lib/scenes/candidates.js +21 -3
- package/lib/sessions/archive-schemas.js +121 -0
- package/lib/sessions/path-key.js +46 -0
- package/lib/sessions/workspace.js +45 -168
- package/lib/settings-sidecars.js +143 -0
- package/lib/skills/core-types.js +8 -0
- package/lib/skills/core-util.js +81 -0
- package/lib/skills/core.js +97 -2297
- package/lib/skills/dir-watch.js +71 -0
- package/lib/skills/frontmatter.js +96 -0
- package/lib/skills/homes.js +14 -0
- package/lib/skills/import-pipeline.js +231 -0
- package/lib/skills/manager-state.js +800 -0
- package/lib/skills/official.js +269 -0
- package/lib/skills/provider-registration.js +89 -0
- package/lib/skills/service.js +4 -234
- package/lib/skills/skill-roots.js +287 -0
- package/lib/skills/skill-trash.js +265 -0
- package/lib/skills/system-trash.js +97 -0
- package/lib/skills/upload-pipeline.js +374 -0
- package/lib/slash-settings.js +25 -0
- package/lib/subagents/persona-parse.js +312 -0
- package/lib/subagents/persona-types.js +5 -0
- package/lib/subagents/persona-write.js +93 -0
- package/lib/subagents/run-provider.js +159 -0
- package/lib/subagents/service.js +20 -639
- package/lib/subagents/tool-filter.js +98 -0
- package/lib/token-config.js +240 -0
- package/lib/tool-table-switch.js +163 -0
- package/lib/tools/scene.js +40 -9
- package/package.json +3 -2
package/lib/compat/probe.js
CHANGED
|
@@ -30,18 +30,18 @@
|
|
|
30
30
|
* leave the rest working.
|
|
31
31
|
*/
|
|
32
32
|
import { createRequire } from 'node:module';
|
|
33
|
-
import { existsSync
|
|
33
|
+
import { existsSync } from 'node:fs';
|
|
34
34
|
import { dirname, join } from 'node:path';
|
|
35
35
|
import { fileURLToPath } from 'node:url';
|
|
36
|
+
import { CAPABILITY_SPECS } from './capability-specs.js';
|
|
37
|
+
import { isAsarPath, isFn, OWNER_CLASSES, realPathWithReason, referencePrototypes, safeResolve, textMatchOf, versionOf, versionOfPackageAt, } from './host-probe.js';
|
|
38
|
+
// 宿主安装根的发现与「该走哪条恢复路」的判定 2026-10-01 整段搬到 ./host-roots.js,正文一字
|
|
39
|
+
// 未改(moved-verify 逐字对拍)。probe.js 的公开面照旧:搬走的那 12 件全部从这里再出口 ——
|
|
40
|
+
// index.ts / ops/compat.ts / sessions/bridge.ts 都按 ./compat/probe.js 引它们,少一件 equiv
|
|
41
|
+
// 对拍就报「导出面丢失」。
|
|
42
|
+
export { asarHostPackageRoot, findingsById, hostRootCandidates, IDENTITY_PACKAGES, npxCacheHostRoots, OPERATION_ROUTES, recoveryFor, refusalsFor, routeFor, VERIFIED_HOST_VERSION, } from './host-roots.js';
|
|
43
|
+
import { hostRootCandidates, IDENTITY_PACKAGES, routeFor, VERIFIED_HOST_VERSION } from './host-roots.js';
|
|
36
44
|
/** Packages whose physical module identity matters to this plugin. */
|
|
37
|
-
export const IDENTITY_PACKAGES = [
|
|
38
|
-
'@deepseek-ai/cordis',
|
|
39
|
-
'@deepseek-ai/dsh-tools',
|
|
40
|
-
'@deepseek-ai/dsh-workspace',
|
|
41
|
-
'@deepseek-ai/dsh-session-projection-cache',
|
|
42
|
-
'@deepseek-ai/dsh-storage-domain',
|
|
43
|
-
'@deepseek-ai/dsh-spill-local',
|
|
44
|
-
];
|
|
45
45
|
/**
|
|
46
46
|
* 本插件是不是从**源码检出**跑起来的(仓库里那份,`scripts/host-deps.mjs` 就躺在旁边)。
|
|
47
47
|
*
|
|
@@ -55,17 +55,33 @@ export function isSourceInstall() {
|
|
|
55
55
|
return SOURCE_INSTALL;
|
|
56
56
|
}
|
|
57
57
|
/**
|
|
58
|
-
* peer range
|
|
59
|
-
* 预发布规则(prerelease 版本只匹配同 [major,minor,patch] 元组的比较器)意味着**每一代
|
|
60
|
-
* rc 都要显式列进范围**:`>=0.1.5-rc.2` 匹配不了 `0.1.7-rc.2`(实测),所以范围是逐代
|
|
61
|
-
* 枚举的并集。上界依旧不设:宿主跨代升级由运行时能力探测兜底(见下)。
|
|
58
|
+
* peer range 与已验证版本。
|
|
62
59
|
*
|
|
63
|
-
*
|
|
60
|
+
* 官方全程走 prerelease 渠道(x.y.z-rc.N),而 semver 有一条硬规则:**带 prerelease 标签的
|
|
61
|
+
* 版本,只有在"同 [major,minor,patch] 元组"的比较器也带 prerelease 标签时才能匹配**。所以
|
|
62
|
+
* `>=0.1.5-rc.2` 匹配不了 `0.1.7-rc.2`,`^0.2.0-rc.2` 也匹配不了 `0.2.1-rc.1`(实测:
|
|
63
|
+
* `0.1.8-rc.1` / `0.2.1-rc.1` / `0.3.0-rc.1` 全部 false)—— 范围只能**逐代枚举并集**,
|
|
64
|
+
* 每适配一代新 rc 就补一段(见 README 的升级清单)。
|
|
65
|
+
*
|
|
66
|
+
* 上界刻意不设:宿主跨代升级由**运行时能力探测**兜底(`assessHost` 逐项探测并降级),
|
|
67
|
+
* 而不是在安装期拒绝整包。所以这里用不带上界的 `>=`,**不用** `^`(`^0.2.0` 的隐含上界
|
|
68
|
+
* `<0.3.0` 会在宿主发稳定版 0.3.0 时把插件挡在门外 —— 与"不设上界"自相矛盾)。
|
|
69
|
+
*
|
|
70
|
+
* 2026-09-30 审查 §5 F9 + §5.4:这一段此前与 `package.json` 的 peerDependencies **漂移**成
|
|
71
|
+
* 两种写法(这里是 `^` 三段、那里是 `>=` 两段,且下界不同)。现在两处是**同一个字符串**,
|
|
72
|
+
* 并由 `test/contracts.test.mjs` 的一条断言钉住 —— 漂移会让"界面/doctor 报的范围"与
|
|
73
|
+
* "安装期实际校验的范围"各说各话,而两者都自称是插件的兼容范围。
|
|
74
|
+
*
|
|
75
|
+
* 已知且**无法**用 range 消除的缺口(如实标注):尚未枚举的新 rc 元组(如 0.2.1-rc.1)
|
|
76
|
+
* 在 npm7+ 下会触发 ERESOLVE 告警。这不是写法问题 —— semver 规则决定了不存在能匹配
|
|
77
|
+
* "任意未来 prerelease"的 range。缓解方式:README 写明宿主升级时同步这段范围,
|
|
78
|
+
* 以及 `--legacy-peer-deps` 这条逃生门。
|
|
79
|
+
*
|
|
80
|
+
* 0.2.0-rc.2(桌面版首发)已实测通过:`assessHost` 全通过,宿主版本读得出来,
|
|
64
81
|
* 归档宿主的身份判定见 `hostPackageRoot` / `isAsarPath`。所以它同时进范围与「已验证」。
|
|
65
82
|
*/
|
|
66
|
-
export const EXPECTED_PEER_RANGE = '
|
|
83
|
+
export const EXPECTED_PEER_RANGE = '>=0.1.7-rc.2 || >=0.2.0-rc.1';
|
|
67
84
|
/** The release this plugin's adapters were last verified against. */
|
|
68
|
-
export const VERIFIED_HOST_VERSION = '0.2.0-rc.2';
|
|
69
85
|
/**
|
|
70
86
|
* peer range 的下界。
|
|
71
87
|
*
|
|
@@ -80,550 +96,6 @@ export const VERIFIED_HOST_VERSION = '0.2.0-rc.2';
|
|
|
80
96
|
* `compat-status` 的载荷,doctor 与外部脚本可能仍在读。
|
|
81
97
|
*/
|
|
82
98
|
export const EXPECTED_MIN_HOST_VERSION = EXPECTED_PEER_RANGE.match(/(\d+\.\d+\.\d+(?:-rc\.\d+)?)/)?.[1] ?? VERIFIED_HOST_VERSION;
|
|
83
|
-
const isFn = (value) => typeof value === 'function';
|
|
84
|
-
/**
|
|
85
|
-
* Resolve a package the way this plugin resolves it, without throwing.
|
|
86
|
-
*
|
|
87
|
-
* `import.meta.resolve` 给的是**URL**:目录里带空格的路径(桌面版装在
|
|
88
|
-
* `D:\DeepSeek Harness\...`)会以 `%20` 形式回来,直接当路径用会得到一个
|
|
89
|
-
* 永远打不开的名字 —— 版本号读不出来(显示 `unknown`)、asar 路径也匹配不上。
|
|
90
|
-
* 用 `fileURLToPath` 老实解码(它同时处理盘符与百分号转义)。
|
|
91
|
-
*/
|
|
92
|
-
function safeResolve(specifier) {
|
|
93
|
-
try {
|
|
94
|
-
return fileURLToPath(import.meta.resolve(specifier));
|
|
95
|
-
}
|
|
96
|
-
catch {
|
|
97
|
-
try {
|
|
98
|
-
return createRequire(import.meta.url).resolve(specifier);
|
|
99
|
-
}
|
|
100
|
-
catch {
|
|
101
|
-
return null;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
/**
|
|
106
|
-
* Whether a path lives inside an electron `app.asar` archive.
|
|
107
|
-
*
|
|
108
|
-
* 桌面版把整个 DSH 装进 `resources/app.asar`:宿主的 `@deepseek-ai/*` 物理上就在
|
|
109
|
-
* 归档里,插件(装在 `$DSH_HOME/profiles/<name>/node_modules`)在外面的磁盘上。
|
|
110
|
-
* 归档里的模块**不可能**与外面的目录是同一个物理文件,`@deepseek-ai/*` 又是
|
|
111
|
-
* peer(官方包由宿主提供),所以「不同路径」是这种打包方式的必然结果,不是安装
|
|
112
|
-
* 故障。判据取 `.asar` 这个路径段(`join` 出来的分隔符在 Windows 上是 `\`)。
|
|
113
|
-
*/
|
|
114
|
-
function isAsarPath(value) {
|
|
115
|
-
if (value === null)
|
|
116
|
-
return false;
|
|
117
|
-
return /[\\/][^\\/]+\.asar[\\/]/i.test(value);
|
|
118
|
-
}
|
|
119
|
-
/**
|
|
120
|
-
* Resolve a path through every junction/symlink to its physical file.
|
|
121
|
-
* Node loads modules by real path, so two different-looking paths that
|
|
122
|
-
* realpath to one file ARE the same module — which is exactly the property
|
|
123
|
-
* this plugin depends on, and the reason `@deepseek-ai/*` is junctioned into
|
|
124
|
-
* the host installation instead of being copied.
|
|
125
|
-
*
|
|
126
|
-
* 取不到真实形态时**不再拿输入去比**(issue #1 要求):那会把「路径不可解析」
|
|
127
|
-
* 伪装成「两份不同拷贝」,给出一个编出来的结论。改由调用方把原因报成说明。
|
|
128
|
-
*/
|
|
129
|
-
function realPathWithReason(value) {
|
|
130
|
-
if (value === null)
|
|
131
|
-
return { reason: '路径为空' };
|
|
132
|
-
try {
|
|
133
|
-
return { path: realpathSync.native !== undefined ? realpathSync.native(value) : realpathSync(value) };
|
|
134
|
-
}
|
|
135
|
-
catch (error) {
|
|
136
|
-
const detail = error;
|
|
137
|
-
const code = typeof detail.code === 'string' ? detail.code : '';
|
|
138
|
-
const message = typeof detail.message === 'string' ? detail.message : String(error);
|
|
139
|
-
return { reason: code === '' ? message : `${code} ${message}` };
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
/** Read a package version from a resolvable manifest. */
|
|
143
|
-
function versionOf(specifier) {
|
|
144
|
-
const entry = safeResolve(specifier);
|
|
145
|
-
if (entry === null)
|
|
146
|
-
return undefined;
|
|
147
|
-
let dir = dirname(entry);
|
|
148
|
-
for (let i = 0; i < 4; i += 1) {
|
|
149
|
-
try {
|
|
150
|
-
const parsed = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
|
|
151
|
-
if (parsed.name === specifier && typeof parsed.version === 'string')
|
|
152
|
-
return parsed.version;
|
|
153
|
-
}
|
|
154
|
-
catch {
|
|
155
|
-
/* keep walking up */
|
|
156
|
-
}
|
|
157
|
-
const parent = dirname(dir);
|
|
158
|
-
if (parent === dir)
|
|
159
|
-
break;
|
|
160
|
-
dir = parent;
|
|
161
|
-
}
|
|
162
|
-
return undefined;
|
|
163
|
-
}
|
|
164
|
-
/**
|
|
165
|
-
* Version of the package a resolved entry path belongs to: walk up until a
|
|
166
|
-
* `package.json` whose `name` matches the package directory we came from.
|
|
167
|
-
*
|
|
168
|
-
* 与 `versionOf` 的分工:那个从**说明符**出发(要能解析),这个从**已经拿到的路径**
|
|
169
|
-
* 出发。归档宿主那种「路径不同但版本相同」的判断只能用后者 —— 前者在 `.asar` 里
|
|
170
|
-
* 读得动,但两边路径不同时它无法回答「这两个文件各属于哪个版本」。
|
|
171
|
-
*/
|
|
172
|
-
function versionOfPackageAt(entry) {
|
|
173
|
-
const expected = packageNameOfPath(entry);
|
|
174
|
-
let dir = dirname(entry);
|
|
175
|
-
for (let i = 0; i < 5; i += 1) {
|
|
176
|
-
try {
|
|
177
|
-
const parsed = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
|
|
178
|
-
if (typeof parsed.version === 'string' && (expected === null || parsed.name === expected))
|
|
179
|
-
return parsed.version;
|
|
180
|
-
}
|
|
181
|
-
catch { /* keep walking up */ }
|
|
182
|
-
const parent = dirname(dir);
|
|
183
|
-
if (parent === dir)
|
|
184
|
-
break;
|
|
185
|
-
dir = parent;
|
|
186
|
-
}
|
|
187
|
-
return null;
|
|
188
|
-
}
|
|
189
|
-
/** `.../node_modules/@scope/name/lib/index.js` -> `@scope/name` (null when the path has no such segment). */
|
|
190
|
-
function packageNameOfPath(entry) {
|
|
191
|
-
const parts = entry.split(/[\\/]/);
|
|
192
|
-
const at = parts.lastIndexOf('node_modules');
|
|
193
|
-
if (at === -1 || at + 1 >= parts.length)
|
|
194
|
-
return null;
|
|
195
|
-
const first = parts[at + 1];
|
|
196
|
-
if (first === undefined)
|
|
197
|
-
return null;
|
|
198
|
-
if (first.startsWith('@')) {
|
|
199
|
-
const second = parts[at + 2];
|
|
200
|
-
return second === undefined ? null : `${first}/${second}`;
|
|
201
|
-
}
|
|
202
|
-
return first;
|
|
203
|
-
}
|
|
204
|
-
/**
|
|
205
|
-
* Compare a live member with this plugin's copy of the implementation.
|
|
206
|
-
* `undefined` means "not comparable" — the member is absent on the live object
|
|
207
|
-
* or the reference copy does not have it either.
|
|
208
|
-
*/
|
|
209
|
-
function textMatchOf(live, reference) {
|
|
210
|
-
if (!isFn(live) || !isFn(reference))
|
|
211
|
-
return undefined;
|
|
212
|
-
if (live === reference)
|
|
213
|
-
return true;
|
|
214
|
-
try {
|
|
215
|
-
return Function.prototype.toString.call(live) === Function.prototype.toString.call(reference);
|
|
216
|
-
}
|
|
217
|
-
catch {
|
|
218
|
-
return undefined;
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
/**
|
|
222
|
-
* The prototype that defines a live instance's members, following the prototype
|
|
223
|
-
* chain so proxies, subclass instances and cross-realm objects all answer
|
|
224
|
-
* honestly.
|
|
225
|
-
*/
|
|
226
|
-
function prototypeOf(instance) {
|
|
227
|
-
if (instance === null || typeof instance !== 'object')
|
|
228
|
-
return undefined;
|
|
229
|
-
if (typeof instance === 'function')
|
|
230
|
-
return instance;
|
|
231
|
-
const proto = Object.getPrototypeOf(instance);
|
|
232
|
-
return proto !== null && typeof proto === 'object' ? proto : undefined;
|
|
233
|
-
}
|
|
234
|
-
// The official package + class each capability owner's members live on. This is
|
|
235
|
-
// the single answer to "which class defines this owner" — both the live
|
|
236
|
-
// reference-prototype lookup below and the exported static table read it, so a
|
|
237
|
-
// host drift is described in exactly one place.
|
|
238
|
-
const WORKSPACE_CLASS = { pkg: '@deepseek-ai/dsh-workspace', target: 'WorkspaceRegistry' };
|
|
239
|
-
const PROJECTION_CLASS = { pkg: '@deepseek-ai/dsh-session-projection-cache', target: 'SessionProjectionCache' };
|
|
240
|
-
// `sessions.*` members are host-private members, but they are still declared on
|
|
241
|
-
// an official class: `@deepseek-ai/dsh-session` is a peer this plugin declares,
|
|
242
|
-
// and `SessionStore` is where `flush`/`liveEntryFor`/`detachEntered`/`enter`/
|
|
243
|
-
// `announce` live. Without this entry the doctor could not see the two
|
|
244
|
-
// capabilities the delete route depends on.
|
|
245
|
-
const SESSIONS_CLASS = { pkg: '@deepseek-ai/dsh-session', target: 'SessionStore' };
|
|
246
|
-
// 0.15.0 新增的两块契约面 —— 恰是 0.1.7 升级咬人的两处(prepareDocument 返回语义变了、
|
|
247
|
-
// read 改名 readDocument),此前不在表里,故障只能靠用户实测发现。类名按官方安装源码核对
|
|
248
|
-
// (dsh-settings 导出 SettingsForms;dsh-agent-preset-registry 导出 AgentPresetRegistry)。
|
|
249
|
-
const SETTINGS_CLASS = { pkg: '@deepseek-ai/dsh-settings', target: 'SettingsForms' };
|
|
250
|
-
const ROSTER_CLASS = { pkg: '@deepseek-ai/dsh-agent-preset-registry', target: 'AgentPresetRegistry' };
|
|
251
|
-
const OWNER_CLASSES = {
|
|
252
|
-
workspace: WORKSPACE_CLASS,
|
|
253
|
-
projectionCache: PROJECTION_CLASS,
|
|
254
|
-
sessions: SESSIONS_CLASS,
|
|
255
|
-
settings: SETTINGS_CLASS,
|
|
256
|
-
presetRoster: ROSTER_CLASS,
|
|
257
|
-
};
|
|
258
|
-
/** The named export's prototype, or undefined when the package is absent/trimmed. */
|
|
259
|
-
function classPrototypeOf(ref) {
|
|
260
|
-
try {
|
|
261
|
-
const mod = createRequire(import.meta.url)(ref.pkg);
|
|
262
|
-
const klass = mod[ref.target];
|
|
263
|
-
return klass?.prototype;
|
|
264
|
-
}
|
|
265
|
-
catch {
|
|
266
|
-
return undefined; // optional in tests and in trimmed deployments
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
/**
|
|
270
|
-
* 哨兵值:不可能命中任何真实会话/工作区(会话 id 由宿主生成,不含此串)。
|
|
271
|
-
* 行为探针真调宿主方法时一律传它 —— 最坏情形是宿主受控拒绝(not found),
|
|
272
|
-
* 而「哨兵输入都能产生意外效果」本身就是要探出来的「不该路由信任」。
|
|
273
|
-
*/
|
|
274
|
-
const PROBE_SENTINEL = '__dshm_probe_never_exists__';
|
|
275
|
-
/**
|
|
276
|
-
* 用哨兵实参**真调**一个宿主方法(2026-09-19 分层探针第二层),验证「存在且可调用」:
|
|
277
|
-
* - 同步抛 `TypeError` → 实现坏了/签名漂移,返回失败详情(shape-mismatch);
|
|
278
|
-
* - 受控同步拒绝(普通 `Error`,如 not found)或返回值(含 rejected Promise)→ 通过。
|
|
279
|
-
*
|
|
280
|
-
* 只判同步段:探针框架是同步的(`assessHost` 不能 await),异步结果分类留待
|
|
281
|
-
* 异步化改造(见审查 07 跟进项)。返回的 Promise 一律挂 `.catch` 吞掉,探针
|
|
282
|
-
* 不制造 unhandled rejection。调用必须以 `target` 为 receiver —— 私有方法全靠
|
|
283
|
-
* `this.*` 拿内部状态,脱离 receiver 调用会把好实现误判成 TypeError。
|
|
284
|
-
*
|
|
285
|
-
* 副作用口径(按官方源码逐个核过):liveEntryFor/announce/flush 哨兵输入在
|
|
286
|
-
* 查表处受控抛错,零副作用;detachEntered 传普通对象在未知 id 处早退;
|
|
287
|
-
* deleteSession/archiveSession 等原生入口对未知 id 是读路径校验后受控拒绝。
|
|
288
|
-
* `enter` 不在此列(会往 store 里写哨兵条目),它只做存在性 + 文本比对。
|
|
289
|
-
*/
|
|
290
|
-
function callableWithSentinel(target, name, ...args) {
|
|
291
|
-
try {
|
|
292
|
-
const method = target[name];
|
|
293
|
-
if (!isFn(method))
|
|
294
|
-
return `${name} 不是函数(methods 检查应已拦下,此处兜底)`;
|
|
295
|
-
const returned = method.call(target, ...args);
|
|
296
|
-
if (returned !== null && typeof returned === 'object' && isFn(returned.then)) {
|
|
297
|
-
const settled = returned;
|
|
298
|
-
if (isFn(settled.catch))
|
|
299
|
-
settled.catch(() => { });
|
|
300
|
-
}
|
|
301
|
-
return undefined;
|
|
302
|
-
}
|
|
303
|
-
catch (error) {
|
|
304
|
-
if (error instanceof TypeError)
|
|
305
|
-
return `${name} 哨兵真调抛 TypeError:${String(error?.message ?? error)}`;
|
|
306
|
-
return undefined;
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
/** Official prototypes this plugin's adapters mirror, when importable. */
|
|
310
|
-
function referencePrototypes() {
|
|
311
|
-
const out = {};
|
|
312
|
-
const workspace = classPrototypeOf(WORKSPACE_CLASS);
|
|
313
|
-
if (workspace !== undefined)
|
|
314
|
-
out.workspace = workspace;
|
|
315
|
-
const cache = classPrototypeOf(PROJECTION_CLASS);
|
|
316
|
-
if (cache !== undefined)
|
|
317
|
-
out.cache = cache;
|
|
318
|
-
// sessions 的五个私有方法全声明在官方 SessionStore.prototype 上(本插件 peer 依赖
|
|
319
|
-
// 同包同版本),与 workspace/cache 同机制接入文本比对 —— 此前该域只有存在性检查。
|
|
320
|
-
const sessions = classPrototypeOf(SESSIONS_CLASS);
|
|
321
|
-
if (sessions !== undefined)
|
|
322
|
-
out.sessions = sessions;
|
|
323
|
-
// 两块新契约面的参考副本:包不在 devDeps/宿主锚点里时优雅缺省(textMatch 记 undefined),
|
|
324
|
-
// 存在性探测不依赖它们。
|
|
325
|
-
const settings = classPrototypeOf(SETTINGS_CLASS);
|
|
326
|
-
if (settings !== undefined)
|
|
327
|
-
out.settings = settings;
|
|
328
|
-
const roster = classPrototypeOf(ROSTER_CLASS);
|
|
329
|
-
if (roster !== undefined)
|
|
330
|
-
out.roster = roster;
|
|
331
|
-
return out;
|
|
332
|
-
}
|
|
333
|
-
/**
|
|
334
|
-
* The capability table. This IS the contract with the host: everything the
|
|
335
|
-
* adapters reach for is listed here with what happens when it is absent, so a
|
|
336
|
-
* missing member produces a named, visible degradation instead of a throw from
|
|
337
|
-
* somewhere in the middle of a delete sequence.
|
|
338
|
-
*/
|
|
339
|
-
const CAPABILITY_SPECS = [
|
|
340
|
-
// ---- workspace registry: read side -------------------------------------
|
|
341
|
-
{
|
|
342
|
-
id: 'workspace.read-state',
|
|
343
|
-
label: '读取工作区状态',
|
|
344
|
-
kind: 'read',
|
|
345
|
-
owner: 'workspace',
|
|
346
|
-
fallback: 'refuse-operation',
|
|
347
|
-
methods: ['requireState'],
|
|
348
|
-
probe: (t) => {
|
|
349
|
-
try {
|
|
350
|
-
const state = t.requireState();
|
|
351
|
-
if (state === null || typeof state !== 'object')
|
|
352
|
-
return 'requireState() 未返回对象';
|
|
353
|
-
const value = state;
|
|
354
|
-
if (!Array.isArray(value.archivedSessionIds))
|
|
355
|
-
return 'requireState().archivedSessionIds 不是数组';
|
|
356
|
-
if (!Array.isArray(value.workspaceIds))
|
|
357
|
-
return 'requireState().workspaceIds 不是数组';
|
|
358
|
-
return undefined;
|
|
359
|
-
}
|
|
360
|
-
catch (error) {
|
|
361
|
-
return `requireState() 抛错:${String(error?.message ?? error)}`;
|
|
362
|
-
}
|
|
363
|
-
},
|
|
364
|
-
},
|
|
365
|
-
{
|
|
366
|
-
id: 'workspace.read-table',
|
|
367
|
-
label: '读取工作区表',
|
|
368
|
-
kind: 'read',
|
|
369
|
-
owner: 'workspace',
|
|
370
|
-
fallback: 'refuse-operation',
|
|
371
|
-
methods: ['requireTable'],
|
|
372
|
-
probe: (t) => {
|
|
373
|
-
try {
|
|
374
|
-
const table = t.requireTable();
|
|
375
|
-
if (table === null || typeof table !== 'object' || !isFn(table.get))
|
|
376
|
-
return 'requireTable() 未返回可 get 的表';
|
|
377
|
-
return undefined;
|
|
378
|
-
}
|
|
379
|
-
catch (error) {
|
|
380
|
-
return `requireTable() 抛错:${String(error?.message ?? error)}`;
|
|
381
|
-
}
|
|
382
|
-
},
|
|
383
|
-
},
|
|
384
|
-
{
|
|
385
|
-
id: 'workspace.index-shape',
|
|
386
|
-
label: '工作区索引结构',
|
|
387
|
-
kind: 'read',
|
|
388
|
-
owner: 'workspace',
|
|
389
|
-
fallback: 'refuse-operation',
|
|
390
|
-
fields: [
|
|
391
|
-
{ name: 'headers', instanceOf: 'Map' },
|
|
392
|
-
{ name: 'sessionPaths', instanceOf: 'Map' },
|
|
393
|
-
{ name: 'invalidSessionPaths', instanceOf: 'Map' },
|
|
394
|
-
{ name: 'entities', instanceOf: 'Map' },
|
|
395
|
-
],
|
|
396
|
-
},
|
|
397
|
-
{
|
|
398
|
-
id: 'workspace.read-header',
|
|
399
|
-
label: '读取会话头部',
|
|
400
|
-
kind: 'read',
|
|
401
|
-
owner: 'workspace',
|
|
402
|
-
fallback: 'refuse-operation',
|
|
403
|
-
methods: ['readSessionHeader'],
|
|
404
|
-
},
|
|
405
|
-
// ---- workspace registry: write side ------------------------------------
|
|
406
|
-
{
|
|
407
|
-
id: 'workspace.enqueue',
|
|
408
|
-
label: '串行写事务',
|
|
409
|
-
kind: 'write',
|
|
410
|
-
owner: 'workspace',
|
|
411
|
-
fallback: 'disable-destructive',
|
|
412
|
-
methods: ['enqueueOperation'],
|
|
413
|
-
},
|
|
414
|
-
{
|
|
415
|
-
id: 'workspace.set-state',
|
|
416
|
-
label: '写工作区状态',
|
|
417
|
-
kind: 'write',
|
|
418
|
-
owner: 'workspace',
|
|
419
|
-
fallback: 'disable-destructive',
|
|
420
|
-
methods: ['setState'],
|
|
421
|
-
},
|
|
422
|
-
{
|
|
423
|
-
id: 'workspace.index-header',
|
|
424
|
-
label: '索引会话头部',
|
|
425
|
-
kind: 'write',
|
|
426
|
-
owner: 'workspace',
|
|
427
|
-
fallback: 'disable-destructive',
|
|
428
|
-
methods: ['indexHeader'],
|
|
429
|
-
},
|
|
430
|
-
{
|
|
431
|
-
id: 'workspace.archive-native',
|
|
432
|
-
label: '宿主原生归档入口',
|
|
433
|
-
kind: 'write',
|
|
434
|
-
owner: 'workspace',
|
|
435
|
-
fallback: 'native-entry',
|
|
436
|
-
methods: ['archiveSession'],
|
|
437
|
-
optional: true,
|
|
438
|
-
// 分层探针第二层(哨兵真调):官方实现对未知 id 走读路径校验后受控拒绝
|
|
439
|
-
//(WorkspaceUnknownSessionError);同步 TypeError 才是「不该路由信任」的信号。
|
|
440
|
-
probe: (t) => callableWithSentinel(t, 'archiveSession', PROBE_SENTINEL),
|
|
441
|
-
},
|
|
442
|
-
{
|
|
443
|
-
id: 'workspace.unarchive-native',
|
|
444
|
-
label: '宿主原生恢复入口',
|
|
445
|
-
kind: 'write',
|
|
446
|
-
owner: 'workspace',
|
|
447
|
-
fallback: 'native-entry',
|
|
448
|
-
methods: ['unarchiveSession'],
|
|
449
|
-
optional: true,
|
|
450
|
-
probe: (t) => callableWithSentinel(t, 'unarchiveSession', PROBE_SENTINEL),
|
|
451
|
-
},
|
|
452
|
-
{
|
|
453
|
-
id: 'workspace.batch-native',
|
|
454
|
-
label: '宿主原生批量入口',
|
|
455
|
-
kind: 'write',
|
|
456
|
-
owner: 'workspace',
|
|
457
|
-
fallback: 'native-entry',
|
|
458
|
-
methods: ['archiveWorkspaceSessions'],
|
|
459
|
-
optional: true,
|
|
460
|
-
probe: (t) => callableWithSentinel(t, 'archiveWorkspaceSessions', [PROBE_SENTINEL]),
|
|
461
|
-
},
|
|
462
|
-
{
|
|
463
|
-
id: 'workspace.delete-native',
|
|
464
|
-
label: '宿主原生删除入口',
|
|
465
|
-
kind: 'delete',
|
|
466
|
-
owner: 'workspace',
|
|
467
|
-
// NOT `disable-destructive`: when this slot is empty the plugin performs the
|
|
468
|
-
// whole delete itself, so nothing is disabled. `native-entry` describes what
|
|
469
|
-
// actually happens (the adapter takes over).
|
|
470
|
-
fallback: 'native-entry',
|
|
471
|
-
methods: ['deleteSession'],
|
|
472
|
-
optional: true,
|
|
473
|
-
// 删除路由的 native 判定此前只看方法存在(07 审查五档问题 2):宿主升级把同名
|
|
474
|
-
// 方法换成别的签名,路由仍会走 native 裸调。哨兵真调把口径提到「可调用」,
|
|
475
|
-
// 文本比对收紧(见 inspectCapability 的 delete 类分支)负责「实现漂移」那一层。
|
|
476
|
-
probe: (t) => callableWithSentinel(t, 'deleteSession', PROBE_SENTINEL),
|
|
477
|
-
},
|
|
478
|
-
// ---- sessions runtime (private members, used only on the live branch) ---
|
|
479
|
-
{
|
|
480
|
-
id: 'sessions.detach-live',
|
|
481
|
-
label: '实时会话落盘与分离',
|
|
482
|
-
kind: 'delete',
|
|
483
|
-
owner: 'sessions',
|
|
484
|
-
fallback: 'disable-destructive',
|
|
485
|
-
methods: ['flush', 'liveEntryFor', 'detachEntered'],
|
|
486
|
-
// 零副作用真调(按官方源码核对):liveEntryFor/flush 对哨兵在查表处受控抛错;
|
|
487
|
-
// detachEntered 传普通对象在未知 id 处早退(entry 形状 {id} 即可)。
|
|
488
|
-
probe: (t) => callableWithSentinel(t, 'liveEntryFor', PROBE_SENTINEL)
|
|
489
|
-
?? callableWithSentinel(t, 'flush', PROBE_SENTINEL)
|
|
490
|
-
?? callableWithSentinel(t, 'detachEntered', { id: PROBE_SENTINEL }),
|
|
491
|
-
},
|
|
492
|
-
{
|
|
493
|
-
id: 'sessions.cold-announce',
|
|
494
|
-
label: '冷会话移除广播',
|
|
495
|
-
kind: 'delete',
|
|
496
|
-
owner: 'sessions',
|
|
497
|
-
fallback: 'disable-destructive',
|
|
498
|
-
methods: ['enter', 'announce'],
|
|
499
|
-
// announce 哨兵真调:内部先 liveEntryFor 查表,哨兵输入在查表处受控抛错。
|
|
500
|
-
// **enter 不真调**(官方实现对任意输入都会往 store 写入条目),它只有存在性 + 文本比对。
|
|
501
|
-
probe: (t) => callableWithSentinel(t, 'announce', PROBE_SENTINEL),
|
|
502
|
-
},
|
|
503
|
-
// ---- projection cache ---------------------------------------------------
|
|
504
|
-
{
|
|
505
|
-
id: 'projection.write',
|
|
506
|
-
label: '投影缓存写入路径',
|
|
507
|
-
kind: 'write',
|
|
508
|
-
owner: 'projectionCache',
|
|
509
|
-
fallback: 'disable-destructive',
|
|
510
|
-
methods: ['write', 'put', 'requireTable'],
|
|
511
|
-
},
|
|
512
|
-
{
|
|
513
|
-
// B2:此前它只是 bridge 运行时那句拒绝里的**临时 id**(`acquireCacheGuard` 里现场拼的),
|
|
514
|
-
// 于是客户端压根不知道它 —— 宿主表不可删时,"路由说可用、点下去必拒"(V13)。提升为
|
|
515
|
-
// 能力表的正式条目后,路由判定与运行时前提同一份依据。
|
|
516
|
-
id: 'projection.table-delete',
|
|
517
|
-
label: '投影缓存行删除',
|
|
518
|
-
kind: 'delete',
|
|
519
|
-
owner: 'projectionCache',
|
|
520
|
-
fallback: 'disable-destructive',
|
|
521
|
-
// 探测内容就是运行时那句硬前提:`requireTable().delete` 在不在。
|
|
522
|
-
probe: (target) => {
|
|
523
|
-
if (typeof target?.requireTable !== 'function')
|
|
524
|
-
return '宿主投影缓存缺少 requireTable';
|
|
525
|
-
let table;
|
|
526
|
-
try {
|
|
527
|
-
table = target.requireTable();
|
|
528
|
-
}
|
|
529
|
-
catch (error) {
|
|
530
|
-
return `宿主投影缓存 requireTable() 抛错:${String(error)}`;
|
|
531
|
-
}
|
|
532
|
-
if (typeof table?.delete !== 'function')
|
|
533
|
-
return '宿主投影缓存存储不支持安全删除(table.delete 缺失)';
|
|
534
|
-
return undefined;
|
|
535
|
-
},
|
|
536
|
-
},
|
|
537
|
-
{
|
|
538
|
-
id: 'projection.delete-native',
|
|
539
|
-
label: '投影缓存删除屏障',
|
|
540
|
-
kind: 'delete',
|
|
541
|
-
owner: 'projectionCache',
|
|
542
|
-
// Absent on rc.2: `sessions/bridge.ts` installs a checked write barrier
|
|
543
|
-
// instead, so absence is a routing fact, not a failure. Only a cache whose
|
|
544
|
-
// write path cannot be wrapped at all is a real problem.
|
|
545
|
-
fallback: 'native-entry',
|
|
546
|
-
optional: true,
|
|
547
|
-
probe: (t) => {
|
|
548
|
-
if (isFn(t?.delete) && isFn(t?.whenIdle))
|
|
549
|
-
return undefined;
|
|
550
|
-
const wrappable = ['write', 'put'].filter((name) => !isFn(t?.[name]));
|
|
551
|
-
if (wrappable.length > 0)
|
|
552
|
-
return `宿主缓存无法安全包裹(缺少 ${wrappable.join(', ')})`;
|
|
553
|
-
let table;
|
|
554
|
-
try {
|
|
555
|
-
table = t.requireTable();
|
|
556
|
-
}
|
|
557
|
-
catch (error) {
|
|
558
|
-
return `requireTable() 抛错:${String(error?.message ?? error)}`;
|
|
559
|
-
}
|
|
560
|
-
if (!isFn(table?.delete))
|
|
561
|
-
return '宿主缓存存储不支持行删除(table.delete 缺失)';
|
|
562
|
-
return undefined;
|
|
563
|
-
},
|
|
564
|
-
},
|
|
565
|
-
// ---- settings document path(0.1.7 的两处适配面之一)----------------------
|
|
566
|
-
// `prepareDocument()` 在 0.1.7 把返回值从「主目录下的设置文档路径」改成「profile 补丁
|
|
567
|
-
// 路径」(`configEditor.documentPath`)—— 方法一直在、语义变了,方法存在性探测抓不住
|
|
568
|
-
// (0.15.0 的 MCP 页事故就是这么静默发生的)。这一条能拦的是「方法消失 / 签名漂移到
|
|
569
|
-
// 同步抛 TypeError」;**返回值形状**是异步结果,同步探针看不到,那一半由 ensurePaths
|
|
570
|
-
// 的 await 后自检负责(src/index.ts:认 profile 形状上溯 + 主目录不变量检查,异常走
|
|
571
|
-
// noteRuntime)。两半合起来才是这个契约的完整探测。
|
|
572
|
-
{
|
|
573
|
-
id: 'settings.document-path',
|
|
574
|
-
label: '设置文档路径(主目录推导源)',
|
|
575
|
-
kind: 'read',
|
|
576
|
-
owner: 'settings',
|
|
577
|
-
fallback: 'refuse-operation',
|
|
578
|
-
methods: ['prepareDocument'],
|
|
579
|
-
// 零副作用真调:官方实现就是 `Promise.resolve(this.documentPath)`,纯读。拿不到
|
|
580
|
-
// 异步结果没关系 —— 同步 TypeError(签名漂移)才是这一层要拦的。
|
|
581
|
-
probe: (t) => callableWithSentinel(t, 'prepareDocument'),
|
|
582
|
-
// 健康行也常显观测路径(官方 SettingsForms 有同步的 `documentPath` getter,
|
|
583
|
-
// prepareDocument 就是它的 Promise 包装):语义再变,页面上这行字一眼见底。
|
|
584
|
-
describe: (t) => {
|
|
585
|
-
try {
|
|
586
|
-
const p = t.documentPath;
|
|
587
|
-
return typeof p === 'string' && p !== '' ? `观测路径:${p}` : undefined;
|
|
588
|
-
}
|
|
589
|
-
catch {
|
|
590
|
-
return undefined;
|
|
591
|
-
}
|
|
592
|
-
},
|
|
593
|
-
},
|
|
594
|
-
// ---- agent preset roster(0.1.7 的两处适配面之二)-------------------------
|
|
595
|
-
// 0.1.7 把 `read(id)`(直返组合文本)改名成 `readDocument(id)`(返回文档对象,组合
|
|
596
|
-
// YAML 在 `.content`)。读取口是「read 优先、readDocument 兜底」双入口
|
|
597
|
-
// (preset-reach.ts 的 readCompositionText),两个名字**任一在场即可** —— 这正是
|
|
598
|
-
// methods 列表表达不了的 either-or,用 probe 写。list / composedPreset 缺一个,
|
|
599
|
-
// 注入边界矩阵与边界提示就瞎一半,同为必需。
|
|
600
|
-
{
|
|
601
|
-
id: 'preset.roster-surface',
|
|
602
|
-
label: '预设名册读取面',
|
|
603
|
-
kind: 'read',
|
|
604
|
-
owner: 'presetRoster',
|
|
605
|
-
fallback: 'inform-only',
|
|
606
|
-
probe: (t) => {
|
|
607
|
-
const o = t;
|
|
608
|
-
const hasRead = isFn(o.read);
|
|
609
|
-
const hasDoc = isFn(o.readDocument);
|
|
610
|
-
if (!hasRead && !hasDoc)
|
|
611
|
-
return 'read 与 readDocument 都缺失:组合文本读不到,注入边界矩阵与极简兜底注入失明';
|
|
612
|
-
const missing = ['list', 'composedPreset'].filter((name) => !isFn(o[name]));
|
|
613
|
-
if (missing.length > 0)
|
|
614
|
-
return `名册缺少 ${missing.join(', ')}:注入边界矩阵不完整`;
|
|
615
|
-
return undefined;
|
|
616
|
-
},
|
|
617
|
-
// 健康行附注实际走的读取路。0.1.7 起 `read` 改名 `readDocument`,兜底成功是**正常形态**
|
|
618
|
-
// 而非降级 —— 这句话只出现在绿色行上(运行时上报会渲染成问题行,那里不放)。
|
|
619
|
-
describe: (t) => {
|
|
620
|
-
const o = t;
|
|
621
|
-
if (!isFn(o.read) && isFn(o.readDocument))
|
|
622
|
-
return '读取走 readDocument(宿主 0.1.7 起的形态)';
|
|
623
|
-
return undefined;
|
|
624
|
-
},
|
|
625
|
-
},
|
|
626
|
-
];
|
|
627
99
|
/**
|
|
628
100
|
* The capability sets each operation depends on, grouped by how the plugin
|
|
629
101
|
* reaches the same outcome when a member is missing. Order matters: the first
|
|
@@ -633,33 +105,7 @@ const CAPABILITY_SPECS = [
|
|
|
633
105
|
* - `adapter`: the checked compatibility adapter (the only route that touches
|
|
634
106
|
* private members), used when the host has no native entry.
|
|
635
107
|
*/
|
|
636
|
-
export const OPERATION_ROUTES = {
|
|
637
|
-
archive: { native: ['workspace.archive-native'], adapter: ['workspace.enqueue', 'workspace.set-state'] },
|
|
638
|
-
unarchive: { native: ['workspace.unarchive-native'], adapter: ['workspace.enqueue', 'workspace.set-state'] },
|
|
639
|
-
batch: { native: ['workspace.batch-native'], adapter: ['workspace.enqueue', 'workspace.set-state'] },
|
|
640
|
-
delete: {
|
|
641
|
-
native: ['workspace.delete-native'],
|
|
642
|
-
// NOTE: `projection.delete-native` is deliberately NOT a route requirement.
|
|
643
|
-
// A host cache without its own delete barrier is expected on rc.2; the
|
|
644
|
-
// bridge wraps it (`workspace.js` / `sessions/bridge.ts`) and reports a
|
|
645
|
-
// refusal itself when even that is impossible. Requiring the native barrier
|
|
646
|
-
// here would disable deletion on exactly the host this plugin was verified
|
|
647
|
-
// against.
|
|
648
|
-
adapter: [
|
|
649
|
-
'workspace.enqueue', 'workspace.set-state', 'workspace.index-header',
|
|
650
|
-
'sessions.detach-live', 'sessions.cold-announce', 'projection.write',
|
|
651
|
-
// B2:运行时硬前提(`table.delete`)也算一条路由要求 —— 不算进来的话,宿主表不可删时
|
|
652
|
-
// 路由说可用、点下去必拒。batch 的 adapter 路由**有意**保持只有 enqueue/set-state:
|
|
653
|
-
// 它的逐条失败会在结果里按 sessionId 报出来,此处不放宽也不收紧。
|
|
654
|
-
'projection.table-delete',
|
|
655
|
-
],
|
|
656
|
-
},
|
|
657
|
-
list: { native: [], adapter: ['workspace.read-state', 'workspace.read-table', 'workspace.index-shape'] },
|
|
658
|
-
};
|
|
659
108
|
/** Texts a user can act on, per capability, when it is the reason for a refusal. */
|
|
660
|
-
function recoveryFor(_finding) {
|
|
661
|
-
return `宿主 ${VERIFIED_HOST_VERSION} 的该项能力未通过探测;请更新本插件到与本机 DSH 匹配的版本,或改用宿主原生入口(先运行 node scripts/doctor.mjs 查看差异)。`;
|
|
662
|
-
}
|
|
663
109
|
/**
|
|
664
110
|
* One capability's result. `target` is the live host object; `reference` is
|
|
665
111
|
* this plugin's copy of the same implementation when the package is importable.
|
|
@@ -830,6 +276,8 @@ export function assessHost(ctx) {
|
|
|
830
276
|
// ('agentPresets' 与 preset-reach.ts 的 presetRosterOf 同名 —— 注入矩阵实际用的就是它)。
|
|
831
277
|
const settings = unwrap(ctx.settings !== undefined ? ctx.settings : get('settings'));
|
|
832
278
|
const roster = unwrap(get('agentPresets'));
|
|
279
|
+
// fs 走 ctx 属性优先(它在插件 inject 清单里),与 settings 同一条取法。
|
|
280
|
+
const fsService = unwrap(ctx.fs !== undefined ? ctx.fs : get('fs'));
|
|
833
281
|
const references = referencePrototypes();
|
|
834
282
|
const targets = {
|
|
835
283
|
workspace: { target: registry, reference: references.workspace },
|
|
@@ -838,6 +286,7 @@ export function assessHost(ctx) {
|
|
|
838
286
|
persistence: { target: unwrap(get('sessionPersistence')), reference: undefined },
|
|
839
287
|
settings: { target: settings, reference: references.settings },
|
|
840
288
|
presetRoster: { target: roster, reference: references.roster },
|
|
289
|
+
fs: { target: fsService, reference: undefined },
|
|
841
290
|
};
|
|
842
291
|
const findings = CAPABILITY_SPECS.map((spec) => {
|
|
843
292
|
const slot = targets[spec.owner];
|
|
@@ -991,50 +440,30 @@ export function assessHost(ctx) {
|
|
|
991
440
|
* 调用方:从锚点解析不到的包会被判成 `same = null`,见 assessHost 里的 `unverified`。
|
|
992
441
|
*/
|
|
993
442
|
function hostPackageRoot() {
|
|
994
|
-
|
|
995
|
-
// 再从插件自身位置逐级上溯,最后扫 npx 缓存,并确认那一层里真有 `dsh/package.json`)。此前运行时只按"插件
|
|
996
|
-
// 自己解析到的包"上溯,dev 布局下会把仓库里的副本当成宿主锚点、比出假的 `true` —— 于是
|
|
997
|
-
// 界面说 ok、doctor 说 SEPARATE COPY(V8)。两处口径分裂本身就是缺陷。
|
|
998
|
-
const home = process.env.DSH_HOME || join(process.env.USERPROFILE || process.env.HOME || '', '.dsh');
|
|
999
|
-
const candidates = [];
|
|
1000
|
-
if (home !== '')
|
|
1001
|
-
candidates.push(join(home, 'profiles', 'node_modules', '@deepseek-ai'));
|
|
1002
|
-
// 桌面版:整个 DSH 装进 `resources/app.asar`,宿主的 `@deepseek-ai/*` 就在归档里。
|
|
1003
|
-
// 这一候选必须排在 npx 缓存**之前** —— 否则装过 web 版的机器上会先命中 npx 缓存里
|
|
1004
|
-
// 那个 0.1.7,于是拿「另一个安装」当锚点比出 6 条假的「两份不同拷贝」(实测事故)。
|
|
1005
|
-
const asarHost = asarHostPackageRoot();
|
|
1006
|
-
if (asarHost !== null)
|
|
1007
|
-
candidates.push(asarHost);
|
|
1008
|
-
for (const anchor of IDENTITY_PACKAGES) {
|
|
1009
|
-
// 锚点候选只认**真实形态**:取不到就跳过(这里没有可比的对象,如实跳过即可,
|
|
1010
|
-
// 与身份比对不同 —— 那边取不到必须报出来,见 assessHost 的 realpath 说明)。
|
|
1011
|
-
const real = realPathWithReason(safeResolve(anchor));
|
|
1012
|
-
if ('reason' in real)
|
|
1013
|
-
continue;
|
|
1014
|
-
const resolved = real.path;
|
|
1015
|
-
let dir = dirname(resolved);
|
|
1016
|
-
for (let i = 0; i < 4; i += 1) {
|
|
1017
|
-
if (dir.endsWith(join('node_modules', '@deepseek-ai'))) {
|
|
1018
|
-
candidates.push(dir);
|
|
1019
|
-
break;
|
|
1020
|
-
}
|
|
1021
|
-
const parent = dirname(dir);
|
|
1022
|
-
if (parent === dir)
|
|
1023
|
-
break;
|
|
1024
|
-
dir = parent;
|
|
1025
|
-
}
|
|
1026
|
-
}
|
|
1027
|
-
// 0.1.7 起宿主不再维护 `profiles/node_modules` 那棵 junction 树(link-backend 已移除),
|
|
1028
|
-
// `dsh web` 经 npx 跑在 `<npm 缓存>/_npx/<hash>/node_modules` 里 —— 缓存扫描是 dev 场景
|
|
1029
|
-
// (上溯只找到 checkout 自己的副本)下最后的宿主发现手段。多命中时取最新(npx 升级会
|
|
1030
|
-
// 换 hash 目录、删旧目录)。
|
|
1031
|
-
candidates.push(...npxCacheHostRoots());
|
|
1032
|
-
for (const candidate of candidates) {
|
|
443
|
+
for (const candidate of hostRootCandidates()) {
|
|
1033
444
|
if (existsSync(join(candidate, 'dsh', 'package.json')))
|
|
1034
445
|
return candidate;
|
|
1035
446
|
}
|
|
1036
447
|
return null;
|
|
1037
448
|
}
|
|
449
|
+
/**
|
|
450
|
+
* 宿主 `@deepseek-ai` 目录的**候选序列**(按优先级)—— 全仓"宿主在哪"的唯一口径。
|
|
451
|
+
*
|
|
452
|
+
* 为什么单独导出(2026-09-30 审查 §5 F10):运行时(本文件)与 `scripts/doctor.mjs` /
|
|
453
|
+
* `scripts/host-deps.mjs` 此前**各写一份**,而且顺序不一样 —— doctor 少了桌面版那一条,
|
|
454
|
+
* 于是在桌面宿主上要么报 "host installation not found",要么拿 npx 缓存里**另一代宿主**
|
|
455
|
+
* 当锚点比出假 SEPARATE COPY。那正是本文件注释里记过的实测事故,只修了运行时。
|
|
456
|
+
*
|
|
457
|
+
* 顺序是有讲究的:
|
|
458
|
+
* ① 桌面版归档(`resources/app.asar`)—— 必须排在 npx 缓存**之前**,否则装过 web 版的
|
|
459
|
+
* 机器上会先命中缓存里那个 0.1.7;
|
|
460
|
+
* ② `$DSH_HOME/profiles/node_modules/@deepseek-ai`(web 版旧布局的 junction 树);
|
|
461
|
+
* ③ 从本插件自身解析到的锚点逐级上溯(dev 布局:仓库/安装目录自己的 node_modules);
|
|
462
|
+
* ④ npx 缓存(`dsh web` 经 npx 跑时的落点;多命中取最新)。
|
|
463
|
+
*
|
|
464
|
+
* 只列候选、不判存在性:调用方各有自己的"像不像一份安装"的判据(运行时要求
|
|
465
|
+
* `dsh/package.json`,host-deps 还要读出版本号)。
|
|
466
|
+
*/
|
|
1038
467
|
/**
|
|
1039
468
|
* 正在运行的桌面版 DSH 的 `@deepseek-ai` 目录(在 `resources/app.asar` 里),没有则 null。
|
|
1040
469
|
*
|
|
@@ -1042,59 +471,11 @@ function hostPackageRoot() {
|
|
|
1042
471
|
* 包名或环境变量猜,因此在 `dsh web`(node 跑)下一律返回 null。归档内路径由 electron
|
|
1043
472
|
* 自己的 fs 修补解析,归档外的普通 node 读不到,所以这一步失败是静默的正常路径。
|
|
1044
473
|
*/
|
|
1045
|
-
function asarHostPackageRoot() {
|
|
1046
|
-
const resources = process.resourcesPath;
|
|
1047
|
-
if (typeof resources !== 'string' || resources === '')
|
|
1048
|
-
return null;
|
|
1049
|
-
const dir = join(resources, 'app.asar', 'dsh', 'node_modules', '@deepseek-ai');
|
|
1050
|
-
try {
|
|
1051
|
-
if (!existsSync(join(dir, 'dsh', 'package.json')))
|
|
1052
|
-
return null;
|
|
1053
|
-
}
|
|
1054
|
-
catch {
|
|
1055
|
-
return null;
|
|
1056
|
-
}
|
|
1057
|
-
return dir;
|
|
1058
|
-
}
|
|
1059
474
|
/**
|
|
1060
475
|
* npx 缓存里的宿主 `@deepseek-ai` 目录,按目录 mtime 新到旧排。npm 缓存位置依次看
|
|
1061
476
|
* `npm_config_cache`、`~/.npmrc` 的 `cache=`、`%LOCALAPPDATA%\npm-cache` —— probe 全程同步,
|
|
1062
477
|
* 不起子进程,读不到就当没有这批候选。
|
|
1063
478
|
*/
|
|
1064
|
-
function npxCacheHostRoots() {
|
|
1065
|
-
const home = process.env.USERPROFILE || process.env.HOME || '';
|
|
1066
|
-
let cache = process.env.npm_config_cache || '';
|
|
1067
|
-
if (!cache && home) {
|
|
1068
|
-
try {
|
|
1069
|
-
const rc = readFileSync(join(home, '.npmrc'), 'utf8');
|
|
1070
|
-
cache = rc.match(/^\s*cache\s*=\s*(.+?)\s*$/m)?.[1] ?? '';
|
|
1071
|
-
}
|
|
1072
|
-
catch { /* no .npmrc — defaults below */ }
|
|
1073
|
-
}
|
|
1074
|
-
if (!cache && process.env.LOCALAPPDATA)
|
|
1075
|
-
cache = join(process.env.LOCALAPPDATA, 'npm-cache');
|
|
1076
|
-
if (!cache)
|
|
1077
|
-
return [];
|
|
1078
|
-
const roots = [];
|
|
1079
|
-
const npxRoot = join(cache, '_npx');
|
|
1080
|
-
let entries = [];
|
|
1081
|
-
try {
|
|
1082
|
-
entries = readdirSync(npxRoot);
|
|
1083
|
-
}
|
|
1084
|
-
catch {
|
|
1085
|
-
return [];
|
|
1086
|
-
}
|
|
1087
|
-
for (const entry of entries) {
|
|
1088
|
-
const dir = join(npxRoot, entry, 'node_modules', '@deepseek-ai');
|
|
1089
|
-
if (!existsSync(join(dir, 'dsh', 'package.json')))
|
|
1090
|
-
continue;
|
|
1091
|
-
try {
|
|
1092
|
-
roots.push({ dir, mtime: statSync(dir).mtimeMs });
|
|
1093
|
-
}
|
|
1094
|
-
catch { /* raced — skip */ }
|
|
1095
|
-
}
|
|
1096
|
-
return roots.sort((left, right) => right.mtime - left.mtime).map((root) => root.dir);
|
|
1097
|
-
}
|
|
1098
479
|
/** Human-readable summary line for logs and the settings page header. */
|
|
1099
480
|
export function summarize(assessment) {
|
|
1100
481
|
const total = assessment.findings.length;
|
|
@@ -1103,21 +484,7 @@ export function summarize(assessment) {
|
|
|
1103
484
|
return `宿主 ${assessment.identity.version} · 能力 ${ok}/${total} · ${state}`;
|
|
1104
485
|
}
|
|
1105
486
|
/** Findings by id, for route decisions. */
|
|
1106
|
-
export function findingsById(assessment) {
|
|
1107
|
-
return new Map(assessment.findings.map((finding) => [finding.id, finding]));
|
|
1108
|
-
}
|
|
1109
487
|
/** Refusals for every degraded capability in the list, in list order. */
|
|
1110
|
-
export function refusalsFor(assessment, ids) {
|
|
1111
|
-
const index = findingsById(assessment);
|
|
1112
|
-
const out = [];
|
|
1113
|
-
for (const id of ids) {
|
|
1114
|
-
const finding = index.get(id);
|
|
1115
|
-
if (finding === undefined || finding.state === 'ok')
|
|
1116
|
-
continue;
|
|
1117
|
-
out.push({ id: finding.id, label: finding.label, detail: finding.detail, recovery: recoveryFor(finding) });
|
|
1118
|
-
}
|
|
1119
|
-
return out;
|
|
1120
|
-
}
|
|
1121
488
|
/**
|
|
1122
489
|
* Choose how one operation should reach the host.
|
|
1123
490
|
*
|
|
@@ -1128,33 +495,6 @@ export function refusalsFor(assessment, ids) {
|
|
|
1128
495
|
* @param operation - operation name from {@link OPERATION_ROUTES}.
|
|
1129
496
|
* @returns the route to take plus, when `none`, what is missing and why.
|
|
1130
497
|
*/
|
|
1131
|
-
export function routeFor(assessment, operation) {
|
|
1132
|
-
const routes = OPERATION_ROUTES[operation];
|
|
1133
|
-
const index = findingsById(assessment);
|
|
1134
|
-
const allOk = (ids) => ids.every((id) => {
|
|
1135
|
-
const finding = index.get(id);
|
|
1136
|
-
// A capability that the host does not expose at all cannot block a route
|
|
1137
|
-
// it is not part of; absent ids are treated as satisfied.
|
|
1138
|
-
return finding === undefined || finding.state === 'ok';
|
|
1139
|
-
});
|
|
1140
|
-
if (routes.native.length > 0 && allOk(routes.native))
|
|
1141
|
-
return { via: 'native', refusals: [] };
|
|
1142
|
-
if (allOk(routes.adapter))
|
|
1143
|
-
return { via: 'adapter', refusals: [] };
|
|
1144
|
-
const needed = routes.native.length > 0 ? [...new Set([...routes.native, ...routes.adapter])] : routes.adapter;
|
|
1145
|
-
const refusals = refusalsFor(assessment, needed);
|
|
1146
|
-
if (refusals.length > 0)
|
|
1147
|
-
return { via: 'none', refusals };
|
|
1148
|
-
return {
|
|
1149
|
-
via: 'none',
|
|
1150
|
-
refusals: [{
|
|
1151
|
-
id: operation,
|
|
1152
|
-
label: operation,
|
|
1153
|
-
detail: '宿主未提供该操作的任何可用路径',
|
|
1154
|
-
recovery: recoveryFor({ id: operation }),
|
|
1155
|
-
}],
|
|
1156
|
-
};
|
|
1157
|
-
}
|
|
1158
498
|
/**
|
|
1159
499
|
* The error a refused operation throws. Kept as a distinct class so callers can
|
|
1160
500
|
* tell "the host cannot do this safely" from "the operation failed".
|