@khorsheed/dsh-capability-catalog 0.1.95

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.
Files changed (109) hide show
  1. package/LICENSE +21 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +286 -0
  4. package/README.zh.md +138 -0
  5. package/cordis.patch.yml +8 -0
  6. package/lib/client.js +10100 -0
  7. package/lib/index.js +4022 -0
  8. package/lib/invariant.js +25 -0
  9. package/lib/mcpConnector-C2TbkPBY.js +69 -0
  10. package/lib/typert.host.d.ts +3 -0
  11. package/lib/typert.host.js +1417 -0
  12. package/lib/typert.remote-client.d.ts +68 -0
  13. package/lib/typert.remote-client.js +1136 -0
  14. package/lib/types/capabilities.d.ts +103 -0
  15. package/lib/types/capabilities.js +116 -0
  16. package/lib/types/channels.d.ts +41 -0
  17. package/lib/types/channels.js +75 -0
  18. package/lib/types/client/AddMcpDialog.d.ts +13 -0
  19. package/lib/types/client/AddMcpDialog.js +79 -0
  20. package/lib/types/client/AddSkillModal.d.ts +17 -0
  21. package/lib/types/client/AddSkillModal.js +138 -0
  22. package/lib/types/client/BundleFileTree.d.ts +7 -0
  23. package/lib/types/client/BundleFileTree.js +74 -0
  24. package/lib/types/client/CapabilityCatalogCard.d.ts +57 -0
  25. package/lib/types/client/CapabilityCatalogCard.js +479 -0
  26. package/lib/types/client/CredentialField.d.ts +14 -0
  27. package/lib/types/client/CredentialField.js +9 -0
  28. package/lib/types/client/McpCards.d.ts +13 -0
  29. package/lib/types/client/McpCards.js +12 -0
  30. package/lib/types/client/McpServerManageModal.d.ts +18 -0
  31. package/lib/types/client/McpServerManageModal.js +63 -0
  32. package/lib/types/client/MetadataRow.d.ts +10 -0
  33. package/lib/types/client/MetadataRow.js +6 -0
  34. package/lib/types/client/ModalShell.d.ts +16 -0
  35. package/lib/types/client/ModalShell.js +27 -0
  36. package/lib/types/client/ModeChips.d.ts +38 -0
  37. package/lib/types/client/ModeChips.js +42 -0
  38. package/lib/types/client/SchemaView.d.ts +11 -0
  39. package/lib/types/client/SchemaView.js +143 -0
  40. package/lib/types/client/SkillCards.d.ts +28 -0
  41. package/lib/types/client/SkillCards.js +43 -0
  42. package/lib/types/client/SkillDetailModal.d.ts +69 -0
  43. package/lib/types/client/SkillDetailModal.js +167 -0
  44. package/lib/types/client/ToolCards.d.ts +28 -0
  45. package/lib/types/client/ToolCards.js +52 -0
  46. package/lib/types/client/ToolDetailModal.d.ts +12 -0
  47. package/lib/types/client/ToolDetailModal.js +29 -0
  48. package/lib/types/client/icons.d.ts +26 -0
  49. package/lib/types/client/icons.js +10 -0
  50. package/lib/types/client/index.d.ts +21 -0
  51. package/lib/types/client/index.js +196 -0
  52. package/lib/types/client/locales.d.ts +392 -0
  53. package/lib/types/client/locales.js +395 -0
  54. package/lib/types/client/mcp-model.d.ts +24 -0
  55. package/lib/types/client/mcp-model.js +87 -0
  56. package/lib/types/client/mode-model.d.ts +108 -0
  57. package/lib/types/client/mode-model.js +140 -0
  58. package/lib/types/client/preset-display.d.ts +21 -0
  59. package/lib/types/client/preset-display.js +20 -0
  60. package/lib/types/client/settle.d.ts +40 -0
  61. package/lib/types/client/settle.js +57 -0
  62. package/lib/types/client/slots.d.ts +86 -0
  63. package/lib/types/client/slots.js +1 -0
  64. package/lib/types/envHint.d.ts +22 -0
  65. package/lib/types/envHint.js +76 -0
  66. package/lib/types/import.d.ts +106 -0
  67. package/lib/types/import.js +635 -0
  68. package/lib/types/index.d.ts +216 -0
  69. package/lib/types/index.js +739 -0
  70. package/lib/types/invariant.d.ts +16 -0
  71. package/lib/types/invariant.js +23 -0
  72. package/lib/types/mcpConnector.d.ts +24 -0
  73. package/lib/types/mcpConnector.js +73 -0
  74. package/lib/types/mcpStore.d.ts +68 -0
  75. package/lib/types/mcpStore.js +235 -0
  76. package/lib/types/mcpTools.d.ts +45 -0
  77. package/lib/types/mcpTools.js +125 -0
  78. package/lib/types/mcps.d.ts +64 -0
  79. package/lib/types/mcps.js +157 -0
  80. package/lib/types/modes.d.ts +65 -0
  81. package/lib/types/modes.js +97 -0
  82. package/lib/types/namespace.d.ts +9 -0
  83. package/lib/types/namespace.js +8 -0
  84. package/lib/types/official-tools.d.ts +3 -0
  85. package/lib/types/official-tools.js +44 -0
  86. package/lib/types/preset-scope.d.ts +105 -0
  87. package/lib/types/preset-scope.js +117 -0
  88. package/lib/types/remote.d.ts +48 -0
  89. package/lib/types/remote.js +120 -0
  90. package/lib/types/scoped-delivery.d.ts +243 -0
  91. package/lib/types/scoped-delivery.js +686 -0
  92. package/lib/types/scoped-edits.d.ts +90 -0
  93. package/lib/types/scoped-edits.js +234 -0
  94. package/lib/types/settings.d.ts +28 -0
  95. package/lib/types/settings.js +28 -0
  96. package/lib/types/shellEnv.d.ts +65 -0
  97. package/lib/types/shellEnv.js +232 -0
  98. package/lib/types/skills.d.ts +103 -0
  99. package/lib/types/skills.js +303 -0
  100. package/lib/types/tool-origin.d.ts +29 -0
  101. package/lib/types/tool-origin.js +30 -0
  102. package/lib/types/types.d.ts +315 -0
  103. package/lib/types/types.js +8 -0
  104. package/lib/types/zip.d.ts +24 -0
  105. package/lib/types/zip.js +80 -0
  106. package/locale/en.json +6 -0
  107. package/locale/zh.json +6 -0
  108. package/package.json +119 -0
  109. package/scripts/gen-official-tools.mts +52 -0
@@ -0,0 +1,686 @@
1
+ /**
2
+ * Preset-scoped skill delivery: a plugin-owned skills root whose entries are
3
+ * delivered **into one preset's scope layer** instead of into the global layer.
4
+ *
5
+ * The skill registry is layered by the scope a registration is made from, and a
6
+ * read merges the global layer with the viewing agent's scope chain. A provider
7
+ * registered through a context scoped to a preset's standing key is therefore
8
+ * consulted only for sessions on that preset — no read-time identity guessing,
9
+ * and no dependence on the borrowed lookup options.
10
+ *
11
+ * The per-skill policy lives in the skill's own frontmatter
12
+ * (`metadata.presetScope: [<preset-id>, …]`); an absent or empty list means the
13
+ * entry is not delivered by this module at all. A managed name that ALSO exists
14
+ * in a default skill root is refused delivery and reported: a leftover copy is
15
+ * served by the host provider in every preset, so silently delivering a second
16
+ * candidate would make the scope a lie.
17
+ *
18
+ * Scope of enforcement: this is a discovery/delivery policy for one Harness
19
+ * instance, not an authorization boundary. `ScopeKey` values are ordinary
20
+ * objects and the roster hands real ones to any caller, so a same-process plugin
21
+ * can claim membership. In-process agents and children joining the parent's
22
+ * composition are covered; native codex/claude-code and ACP backends start their
23
+ * own runtime and never consult this registry at all.
24
+ * @module @khorsheed/dsh-capability-catalog/scoped-delivery
25
+ */
26
+ import { existsSync } from 'node:fs';
27
+ import { watch } from 'node:fs';
28
+ import { mkdir, readFile, readdir, stat } from 'node:fs/promises';
29
+ import { homedir } from 'node:os';
30
+ import { join } from 'node:path';
31
+ import { createScope } from '@deepseek-ai/dsh-scope';
32
+ import { acquireStandingScope } from './preset-scope.js';
33
+ let presetRegistryProbe;
34
+ /**
35
+ * Resolve the preset-registry module by name. The 0.1.7-rc.1 host renamed
36
+ * `@deepseek-ai/dsh-agent-presets` to `@deepseek-ai/dsh-agent-preset-registry`
37
+ * (the livePresetMounts API carried over); 0.1.5/0.1.6 hosts install only the
38
+ * old name, and a host without preset composition installs neither — `null`,
39
+ * and {@link ScopedSkillDelivery.liveKeys} then reports nothing live, the same
40
+ * degrade the call's own catch encodes. A STATIC import of either name is a
41
+ * module-resolution failure at load on hosts installing only the other —
42
+ * pack-dist strips `dependencies`, so the specifier resolves against the
43
+ * host's install tree. Both specifiers stay static string literals so tsdown
44
+ * externalizes them and tsc type-checks them.
45
+ * @returns the module namespace, or null when neither name resolves.
46
+ */
47
+ export function loadPresetRegistry() {
48
+ presetRegistryProbe ??= import('@deepseek-ai/dsh-agent-preset-registry')
49
+ .catch(() => import('@deepseek-ai/dsh-agent-presets'))
50
+ .catch(() => null);
51
+ return presetRegistryProbe;
52
+ }
53
+ /** Test seam: drop the cached probe so a mocked resolution path re-runs. */
54
+ export function resetPresetRegistryProbeForTest() {
55
+ presetRegistryProbe = undefined;
56
+ }
57
+ /** Provider label for managed entries; also the candidate's `provider` field. */
58
+ export const SCOPED_PROVIDER_NAME = 'capability-catalog';
59
+ /** Candidate rank, beside the filesystem provider's `custom` roots. */
60
+ export const MANAGED_SKILL_RANK = 300;
61
+ /** Directory under the harness home that holds preset-scoped skills. */
62
+ export const SCOPED_SKILLS_DIR = join('capability-catalog', 'skills');
63
+ /** Debounce for filesystem-triggered rescans. */
64
+ const RESCAN_DEBOUNCE_MS = 150;
65
+ /** Frontmatter block, then the body it precedes. */
66
+ const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
67
+ /** Kebab-case skill / preset id grammar (the registry's own). */
68
+ const KEBAB_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
69
+ /** The managed root for one harness home. */
70
+ export function scopedSkillsRoot(dshHome) {
71
+ return join(dshHome, SCOPED_SKILLS_DIR);
72
+ }
73
+ /**
74
+ * Read the `presetScope` list out of a frontmatter block.
75
+ *
76
+ * Accepted forms, at any indentation (the canonical one is
77
+ * `metadata.presetScope`):
78
+ *
79
+ * ```yaml
80
+ * metadata:
81
+ * presetScope: [dsh-writing, dev]
82
+ * presetScope:
83
+ * - dsh-writing
84
+ * ```
85
+ * @param front - the frontmatter text without its `---` fences.
86
+ * @returns the declared ids, or undefined when the key is absent.
87
+ */
88
+ export function parsePresetScopeFrontmatter(front) {
89
+ const lines = front.split('\n');
90
+ for (let index = 0; index < lines.length; index += 1) {
91
+ const match = /^(\s*)presetScope\s*:\s*(.*)$/.exec(lines[index] ?? '');
92
+ if (match === null)
93
+ continue;
94
+ const indent = (match[1] ?? '').length;
95
+ const rest = (match[2] ?? '').trim();
96
+ if (rest.startsWith('[')) {
97
+ const inner = rest.slice(1, rest.lastIndexOf(']') === -1 ? undefined : rest.lastIndexOf(']'));
98
+ return presetIdsOf(inner.split(','));
99
+ }
100
+ if (rest !== '')
101
+ return presetIdsOf([rest]);
102
+ const block = [];
103
+ for (let item = index + 1; item < lines.length; item += 1) {
104
+ const entry = /^(\s*)-\s*(.*?)\s*$/.exec(lines[item] ?? '');
105
+ if (entry === null)
106
+ break;
107
+ if ((entry[1] ?? '').length <= indent)
108
+ break;
109
+ block.push(entry[2] ?? '');
110
+ }
111
+ return presetIdsOf(block);
112
+ }
113
+ return undefined;
114
+ }
115
+ /** Normalize YAML scalars onto the preset-id grammar. */
116
+ function presetIdsOf(values) {
117
+ const ids = [];
118
+ for (const value of values) {
119
+ const id = value.trim().replace(/^['"]|['"]$/g, '');
120
+ if (id === '' || !KEBAB_ID.test(id))
121
+ continue;
122
+ if (!ids.includes(id))
123
+ ids.push(id);
124
+ }
125
+ return ids;
126
+ }
127
+ /** Whether a string is a usable skill / preset id. */
128
+ export function isKebabId(value) {
129
+ return KEBAB_ID.test(value);
130
+ }
131
+ /** Split a SKILL.md into its frontmatter block and body. */
132
+ export function splitFrontmatter(content) {
133
+ const match = FRONTMATTER.exec(content);
134
+ if (match === null)
135
+ return undefined;
136
+ return { front: match[1] ?? '', body: match[2] ?? '' };
137
+ }
138
+ /** Read a boolean frontmatter flag (`key: true|false`). */
139
+ function frontmatterFlag(front, key) {
140
+ const match = new RegExp(`^\\s*${key}\\s*:\\s*(true|false)\\s*$`, 'm').exec(front);
141
+ if (match === null)
142
+ return undefined;
143
+ return match[1] === 'true';
144
+ }
145
+ /** Read a scalar frontmatter value, unquoting a simple quoted or plain form. */
146
+ function frontmatterScalar(front, key) {
147
+ const match = new RegExp(`^\\s*${key}\\s*:\\s*(.+)$`, 'm').exec(front);
148
+ if (match === null)
149
+ return undefined;
150
+ const value = (match[1] ?? '').trim().replace(/^['"]|['"]$/g, '');
151
+ return value === '' ? undefined : value;
152
+ }
153
+ /**
154
+ * Parse one managed `SKILL.md` into its delivery record.
155
+ * @param content - the file text.
156
+ * @param dir - the skill's directory (its resource base).
157
+ * @param path - the absolute `SKILL.md` path.
158
+ * @returns the record, or undefined when the frontmatter is unusable.
159
+ */
160
+ export function managedSkillFrom(content, dir, path) {
161
+ const split = splitFrontmatter(content);
162
+ if (split === undefined)
163
+ return undefined;
164
+ const name = frontmatterScalar(split.front, 'name');
165
+ const description = frontmatterScalar(split.front, 'description');
166
+ if (name === undefined || description === undefined || !KEBAB_ID.test(name))
167
+ return undefined;
168
+ const whenToUse = frontmatterScalar(split.front, 'when-to-use') ?? frontmatterScalar(split.front, 'whenToUse');
169
+ const presetScope = parsePresetScopeFrontmatter(split.front);
170
+ return {
171
+ name,
172
+ description,
173
+ ...whenToUse === undefined ? {} : { whenToUse },
174
+ modelInvocable: frontmatterFlag(split.front, 'disable-model-invocation') !== true,
175
+ userInvocable: frontmatterFlag(split.front, 'user-invocable') !== false,
176
+ presetScope,
177
+ dir,
178
+ path,
179
+ content,
180
+ };
181
+ }
182
+ /** Whether this skill asks to be delivered to a specific preset set. */
183
+ export function isScoped(skill) {
184
+ return skill.presetScope !== undefined && skill.presetScope.length > 0;
185
+ }
186
+ /** Project one managed skill onto a provider candidate for one preset. */
187
+ export function candidateFor(skill) {
188
+ return {
189
+ name: skill.name,
190
+ description: skill.description,
191
+ ...skill.whenToUse === undefined ? {} : { whenToUse: skill.whenToUse },
192
+ invocation: { modelInvocable: skill.modelInvocable, userInvocable: skill.userInvocable },
193
+ source: 'custom',
194
+ provider: SCOPED_PROVIDER_NAME,
195
+ rank: MANAGED_SKILL_RANK,
196
+ locator: skill.path,
197
+ path: skill.path,
198
+ metadata: { presetScope: [...(skill.presetScope ?? [])] },
199
+ resourceBase: { kind: 'directory', path: skill.dir },
200
+ };
201
+ }
202
+ /** Project one managed skill onto the complete definition the loader returns. */
203
+ export function definitionFor(skill) {
204
+ return { ...candidateFor(skill), content: skill.content };
205
+ }
206
+ /** The default roots a duplicate copy could live in. */
207
+ export function defaultSkillRoots(dshHome, workdir) {
208
+ const roots = [
209
+ join(dshHome, 'skills'),
210
+ join(dshHome, '.agents', 'skills'),
211
+ join(process.env.DSH_AGENTS_HOME ?? join(homedir(), '.agents'), 'skills'),
212
+ ];
213
+ if (workdir !== undefined && workdir !== '') {
214
+ roots.push(join(workdir, '.dsh', 'skills'), join(workdir, '.agents', 'skills'));
215
+ }
216
+ return roots;
217
+ }
218
+ /**
219
+ * Find managed names that a default root also supplies.
220
+ *
221
+ * A duplicate is what makes a scope claim false: the host provider delivers the
222
+ * default root's copy into every preset regardless of this module's decision.
223
+ * @param skills - the managed entries under consideration.
224
+ * @param dshHome - the harness home.
225
+ * @param workdir - the session workspace, when known.
226
+ * @returns skill name → the default root that already supplies it.
227
+ */
228
+ export function detectConflicts(skills, dshHome, workdir) {
229
+ const conflicts = new Map();
230
+ for (const root of defaultSkillRoots(dshHome, workdir)) {
231
+ for (const skill of skills) {
232
+ if (conflicts.has(skill.name))
233
+ continue;
234
+ if (existsSync(join(root, skill.name, 'SKILL.md')) || existsSync(join(root, `${skill.name}.md`))) {
235
+ conflicts.set(skill.name, root);
236
+ }
237
+ }
238
+ }
239
+ return conflicts;
240
+ }
241
+ /**
242
+ * Scan the managed root for skill directories.
243
+ * @param root - the managed skills root.
244
+ * @returns every readable, usable entry, sorted by name.
245
+ */
246
+ export async function scanManagedSkills(root) {
247
+ let children;
248
+ try {
249
+ children = await readdir(root, { withFileTypes: true });
250
+ }
251
+ catch {
252
+ return [];
253
+ }
254
+ const found = [];
255
+ for (const child of children) {
256
+ if (!child.isDirectory() || !KEBAB_ID.test(child.name))
257
+ continue;
258
+ const dir = join(root, child.name);
259
+ const path = join(dir, 'SKILL.md');
260
+ try {
261
+ const [content, info] = await Promise.all([readFile(path, 'utf8'), stat(path)]);
262
+ if (!info.isFile())
263
+ continue;
264
+ const skill = managedSkillFrom(content, dir, path);
265
+ if (skill !== undefined)
266
+ found.push(skill);
267
+ }
268
+ catch {
269
+ // An unreadable entry is reported by omission; the settings surface lists
270
+ // only what could actually be delivered.
271
+ }
272
+ }
273
+ return found.sort((left, right) => left.name.localeCompare(right.name));
274
+ }
275
+ /** The sentinel preset id of the delivery that serves every preset. */
276
+ const GLOBAL_PRESET = '*';
277
+ /**
278
+ * Delivers managed skills into the scope layers of the presets their
279
+ * frontmatter names.
280
+ *
281
+ * Lifecycle: {@link start} once (watcher + first reconcile), {@link reconcile}
282
+ * whenever policy or files may have changed, {@link dispose} when the owning
283
+ * plugin unloads. Reconciliation never fails loudly: every problem becomes a
284
+ * status field, because a catalog must not fail the boot of the instance it is
285
+ * describing.
286
+ */
287
+ export class ScopedSkillDelivery {
288
+ ctx;
289
+ deps;
290
+ entries = [];
291
+ managed = [];
292
+ conflicts = new Map();
293
+ presetErrors = new Map();
294
+ watcher;
295
+ timer;
296
+ watching = false;
297
+ failure;
298
+ started = false;
299
+ disposed = false;
300
+ /** The probed registry's mount list (undefined until start()'s probe resolves). */
301
+ presetMounts;
302
+ /**
303
+ * @param ctx - the host context the delivery scopes are minted under.
304
+ * @param deps - borrowed services and paths, all optional at runtime.
305
+ */
306
+ constructor(ctx, deps) {
307
+ this.ctx = ctx;
308
+ this.deps = deps;
309
+ }
310
+ /** Start watching the managed root and deliver what it currently holds. */
311
+ async start() {
312
+ if (this.started || this.disposed)
313
+ return;
314
+ this.started = true;
315
+ // Resolve the registry's mount list before the first reconcile: the
316
+ // 0.1.7-rc.1 host renamed the package (see loadPresetRegistry). An
317
+ // unresolved probe leaves presetMounts undefined and liveKeys() reports
318
+ // nothing live — the same degrade the call's own catch encodes.
319
+ this.presetMounts = (await loadPresetRegistry())?.livePresetMounts;
320
+ await this.ensureRoot();
321
+ this.watchRoot();
322
+ this.ctx.on('agent-preset/selected', () => { void this.reconcile(); });
323
+ await this.reconcile();
324
+ }
325
+ /** Re-read policy and files, then converge the delivery registrations. */
326
+ async reconcile() {
327
+ if (this.disposed)
328
+ return;
329
+ const registry = this.deps.registry();
330
+ const roster = this.deps.roster();
331
+ if (registry?.registerProvider === undefined) {
332
+ this.failure = 'the skill registry exposes no provider registration';
333
+ this.disposeEntries();
334
+ this.managed = [];
335
+ return;
336
+ }
337
+ if (roster?.standingKeyFor === undefined && roster?.acquireScope === undefined) {
338
+ this.failure = 'no agent-preset roster is composed';
339
+ this.disposeEntries();
340
+ this.managed = [];
341
+ return;
342
+ }
343
+ const root = this.root();
344
+ this.managed = await scanManagedSkills(root);
345
+ this.conflicts = detectConflicts(this.managed, this.deps.dshHome(), this.deps.workdir?.());
346
+ // presetId → the skills it must serve. A conflicted skill is excluded: the
347
+ // default root already supplies that name everywhere, so delivering a
348
+ // second candidate could not change what any preset sees.
349
+ const byPreset = new Map();
350
+ for (const skill of this.managed) {
351
+ if (!isScoped(skill) || this.conflicts.has(skill.name))
352
+ continue;
353
+ for (const presetId of skill.presetScope ?? []) {
354
+ const list = byPreset.get(presetId) ?? [];
355
+ list.push(skill);
356
+ byPreset.set(presetId, list);
357
+ }
358
+ }
359
+ // A managed skill with no declared scope is delivered to EVERY preset —
360
+ // through one provider in the global layer, because the managed root is
361
+ // scanned by no host provider.
362
+ const unscoped = this.managed.filter(skill => !this.conflicts.has(skill.name) && !isScoped(skill));
363
+ this.presetErrors = new Map();
364
+ const live = this.liveKeys();
365
+ // Resolve each named preset once: the roster's standing-scope acquisition
366
+ // (either roster face) ensures the standing mount, so repeated calls are
367
+ // wasted work and repeated mounts.
368
+ const resolved = new Map();
369
+ for (const presetId of byPreset.keys()) {
370
+ resolved.set(presetId, await this.resolveKey(roster, presetId));
371
+ }
372
+ // Drop registrations that no longer have work, or whose generation is gone.
373
+ for (const entry of [...this.entries]) {
374
+ if (entry.presetId === GLOBAL_PRESET)
375
+ continue;
376
+ if (!byPreset.has(entry.presetId)) {
377
+ await this.dropEntry(entry);
378
+ continue;
379
+ }
380
+ const wantedKey = resolved.get(entry.presetId);
381
+ if (wantedKey === entry.key)
382
+ continue;
383
+ // A newer generation of the same preset: keep serving sessions that are
384
+ // still joined to the old key, and let the new key get its own entry.
385
+ if (wantedKey !== undefined && entry.key !== undefined && live.has(entry.key))
386
+ continue;
387
+ await this.dropEntry(entry);
388
+ }
389
+ for (const presetId of byPreset.keys()) {
390
+ const key = resolved.get(presetId);
391
+ if (key === undefined)
392
+ continue;
393
+ if (this.entries.some(entry => entry.presetId === presetId && entry.key === key))
394
+ continue;
395
+ await this.register(presetId, key);
396
+ }
397
+ const globalEntry = this.entries.find(entry => entry.presetId === GLOBAL_PRESET);
398
+ if (unscoped.length > 0) {
399
+ if (globalEntry === undefined)
400
+ await this.registerGlobal();
401
+ }
402
+ else if (globalEntry !== undefined) {
403
+ await this.dropEntry(globalEntry);
404
+ }
405
+ this.failure = undefined;
406
+ this.invalidateAll();
407
+ }
408
+ /** The current status, for the settings surface and the model-facing tool. */
409
+ status() {
410
+ const delivered = new Set();
411
+ for (const entry of this.entries) {
412
+ for (const skill of this.skillsFor(entry.presetId))
413
+ delivered.add(skill.name);
414
+ }
415
+ const skills = this.managed.map(skill => {
416
+ const conflict = this.conflicts.get(skill.name);
417
+ return {
418
+ name: skill.name,
419
+ description: skill.description,
420
+ modelInvocable: skill.modelInvocable,
421
+ userInvocable: skill.userInvocable,
422
+ path: skill.path,
423
+ presets: [...(skill.presetScope ?? [])],
424
+ delivered: delivered.has(skill.name),
425
+ ...conflict === undefined ? {} : { conflict: join(conflict, skill.name) },
426
+ };
427
+ });
428
+ const presets = [...new Set(this.entries.map(entry => entry.presetId))]
429
+ .sort((left, right) => left.localeCompare(right))
430
+ .map(presetId => {
431
+ const skills = this.managed
432
+ .filter(skill => !this.conflicts.has(skill.name) && (skill.presetScope ?? []).includes(presetId))
433
+ .map(skill => skill.name);
434
+ const error = this.presetErrors.get(presetId);
435
+ return { presetId, skills, ...error === undefined ? {} : { error } };
436
+ });
437
+ for (const [presetId, error] of this.presetErrors) {
438
+ if (!presets.some(row => row.presetId === presetId))
439
+ presets.push({ presetId, skills: [], error });
440
+ }
441
+ return {
442
+ enabled: this.failure === undefined && this.entries.length > 0,
443
+ ...this.failure === undefined ? {} : { reason: this.failure },
444
+ root: this.root(),
445
+ watching: this.watching,
446
+ skills,
447
+ presets,
448
+ // Custom roots are configured inside host compositions this plugin cannot
449
+ // read; a duplicate there would not be detected.
450
+ customRootsUnverifiable: true,
451
+ };
452
+ }
453
+ /** Stop watching and dispose every delivery registration. */
454
+ async dispose() {
455
+ if (this.disposed)
456
+ return;
457
+ this.disposed = true;
458
+ if (this.timer !== undefined)
459
+ clearTimeout(this.timer);
460
+ this.timer = undefined;
461
+ this.watcher?.close();
462
+ this.watcher = undefined;
463
+ this.watching = false;
464
+ await this.disposeEntries();
465
+ this.managed = [];
466
+ }
467
+ /** The managed root under the current harness home. */
468
+ root() {
469
+ return scopedSkillsRoot(this.deps.dshHome());
470
+ }
471
+ /** Create the managed root if it is still absent. */
472
+ async ensureRoot() {
473
+ try {
474
+ await mkdir(this.root(), { recursive: true });
475
+ }
476
+ catch (error) {
477
+ this.failure = `the managed root could not be created: ${describeError(error)}`;
478
+ }
479
+ }
480
+ /** Watch the managed root, degrading to on-demand rescans when it cannot. */
481
+ watchRoot() {
482
+ try {
483
+ this.watcher = watch(this.root(), { recursive: true }, () => this.scheduleRescan());
484
+ this.watching = true;
485
+ }
486
+ catch (error) {
487
+ this.watching = false;
488
+ this.deps.log(`capability-catalog: managed root is not watched (${describeError(error)}); scope changes apply on the next reconcile`);
489
+ }
490
+ }
491
+ /** Coalesce a burst of filesystem events into one reconcile. */
492
+ scheduleRescan() {
493
+ if (this.timer !== undefined)
494
+ clearTimeout(this.timer);
495
+ this.timer = setTimeout(() => {
496
+ this.timer = undefined;
497
+ void this.reconcile().catch((error) => {
498
+ this.deps.log(`capability-catalog: scoped skill reconcile failed: ${describeError(error)}`);
499
+ });
500
+ }, RESCAN_DEBOUNCE_MS);
501
+ }
502
+ /** The standing keys that still have a live mount in this process. */
503
+ liveKeys() {
504
+ const keys = new Set();
505
+ const livePresetMounts = this.presetMounts;
506
+ // Undefined until start()'s probe resolves (a file event can fire first),
507
+ // or forever on a host with neither registry name — the same degrade the
508
+ // catch below encodes: nothing is KNOWN to be live, registrations for
509
+ // needed presets are still kept, only stale generations are dropped.
510
+ if (livePresetMounts === undefined)
511
+ return keys;
512
+ try {
513
+ for (const mount of livePresetMounts()) {
514
+ if (mount.key !== undefined)
515
+ keys.add(mount.key);
516
+ }
517
+ }
518
+ catch {
519
+ // Without the mount list nothing is known to be live; registrations for
520
+ // needed presets are still kept, only stale generations are dropped.
521
+ }
522
+ return keys;
523
+ }
524
+ /** Resolve one preset's standing key, remembering why it failed. */
525
+ async resolveKey(roster, presetId) {
526
+ try {
527
+ const read = await acquireStandingScope(roster, presetId);
528
+ try {
529
+ if (read.key === undefined) {
530
+ this.presetErrors.set(presetId, 'the roster resolved no standing scope');
531
+ return undefined;
532
+ }
533
+ return read.key;
534
+ }
535
+ finally {
536
+ // The key outlives the lease (a generation is reaped only once its
537
+ // preset is unregistered AND unleased), so entries keep it; a release
538
+ // failure is logged, not mistaken for a resolution failure.
539
+ await read.dispose?.().catch((error) => {
540
+ this.deps.log(`capability-catalog: scope lease release failed: ${describeError(error)}`);
541
+ });
542
+ }
543
+ }
544
+ catch (error) {
545
+ this.presetErrors.set(presetId, describeError(error));
546
+ return undefined;
547
+ }
548
+ }
549
+ /** Register one delivery provider into a preset's layer. */
550
+ async register(presetId, key) {
551
+ const scope = createScope(this.ctx, key);
552
+ try {
553
+ // A scope-minted context owns no dependency access of its own, and the
554
+ // registry only records the layer a registration was made *through* — so
555
+ // the provider is registered through an injected child of that scope. The
556
+ // scope tag survives the extra level.
557
+ const injected = await injectableScope(scope.ctx);
558
+ if (injected === undefined) {
559
+ await scope.dispose();
560
+ this.presetErrors.set(presetId, `the ${SKILLS_SERVICE} service was not injectable in the delivery scope`);
561
+ return;
562
+ }
563
+ let invalidate;
564
+ injected.skills.registerProvider((control) => {
565
+ invalidate = control.invalidate;
566
+ return this.providerFor(presetId, control);
567
+ });
568
+ this.entries.push({
569
+ presetId,
570
+ key,
571
+ dispose: () => scope.dispose(),
572
+ invalidate: () => invalidate?.(),
573
+ });
574
+ }
575
+ catch (error) {
576
+ await scope.dispose().catch(() => { });
577
+ this.presetErrors.set(presetId, `delivery scope could not be created: ${describeError(error)}`);
578
+ }
579
+ }
580
+ /** Register the provider that serves every preset (the unscoped skills). */
581
+ async registerGlobal() {
582
+ try {
583
+ const injected = await injectableScope(this.ctx);
584
+ if (injected === undefined) {
585
+ this.failure = `the ${SKILLS_SERVICE} service was not injectable for the global delivery`;
586
+ return;
587
+ }
588
+ let invalidate;
589
+ const dispose = injected.skills.registerProvider((control) => {
590
+ invalidate = control.invalidate;
591
+ return this.providerFor(GLOBAL_PRESET, control);
592
+ });
593
+ this.entries.push({
594
+ presetId: GLOBAL_PRESET,
595
+ dispose: () => { dispose(); },
596
+ invalidate: () => invalidate?.(),
597
+ });
598
+ }
599
+ catch (error) {
600
+ this.failure = `the global delivery scope could not be created: ${describeError(error)}`;
601
+ }
602
+ }
603
+ /**
604
+ * The provider serving one preset. It reads only its own preset's assignment
605
+ * and re-reads the files, so a policy or content edit needs no re-registration
606
+ * beyond the invalidation reconcile already performs.
607
+ */
608
+ providerFor(presetId, _control) {
609
+ return {
610
+ name: SCOPED_PROVIDER_NAME,
611
+ list: async () => this.skillsFor(presetId).map(skill => candidateFor(skill)),
612
+ get: async (candidate) => {
613
+ const skill = this.skillsFor(presetId).find(entry => entry.name === candidate.name);
614
+ return skill === undefined ? undefined : definitionFor(skill);
615
+ },
616
+ };
617
+ }
618
+ /** The managed skills one delivery currently serves. */
619
+ skillsFor(presetId) {
620
+ return this.managed.filter(skill => !this.conflicts.has(skill.name)
621
+ && (presetId === GLOBAL_PRESET
622
+ ? !isScoped(skill)
623
+ : (skill.presetScope ?? []).includes(presetId)));
624
+ }
625
+ /** Invalidate every registration's completed catalogs. */
626
+ invalidateAll() {
627
+ for (const entry of this.entries)
628
+ entry.invalidate();
629
+ }
630
+ /** Dispose every registration. */
631
+ async disposeEntries() {
632
+ const entries = this.entries.splice(0, this.entries.length);
633
+ for (const entry of entries) {
634
+ try {
635
+ await entry.dispose();
636
+ }
637
+ catch (error) {
638
+ this.deps.log(`capability-catalog: delivery scope disposal failed: ${describeError(error)}`);
639
+ }
640
+ }
641
+ }
642
+ /** Dispose one registration and forget it. */
643
+ async dropEntry(entry) {
644
+ const index = this.entries.indexOf(entry);
645
+ if (index >= 0)
646
+ this.entries.splice(index, 1);
647
+ try {
648
+ await entry.dispose();
649
+ }
650
+ catch (error) {
651
+ this.deps.log(`capability-catalog: delivery scope disposal failed: ${describeError(error)}`);
652
+ }
653
+ }
654
+ }
655
+ /** Render an unknown failure for a status field or a log line. */
656
+ function describeError(error) {
657
+ return error instanceof Error ? error.message : String(error);
658
+ }
659
+ /** The registry every delivery registration is made through. */
660
+ const SKILLS_SERVICE = 'skills';
661
+ /** How long an injection may take before the registration is abandoned. */
662
+ const INJECT_TIMEOUT_MS = 500;
663
+ /**
664
+ * A context within `scope` that may access the skill registry.
665
+ *
666
+ * A scope-minted context carries no dependency access by itself, and Cordis
667
+ * resolves an `inject` callback after the current task — hence the bounded
668
+ * await, which gives `reconcile()` a settled answer instead of a registration
669
+ * that appears at some later tick.
670
+ * @param scopeCtx - the context minted for one preset's standing key.
671
+ * @returns the injected context, or undefined when it never became available.
672
+ */
673
+ function injectableScope(scopeCtx) {
674
+ return new Promise(resolve => {
675
+ let settled = false;
676
+ const finish = (value) => {
677
+ if (settled)
678
+ return;
679
+ settled = true;
680
+ clearTimeout(timer);
681
+ resolve(value);
682
+ };
683
+ const timer = setTimeout(() => finish(undefined), INJECT_TIMEOUT_MS);
684
+ scopeCtx.inject([SKILLS_SERVICE], (injected) => finish(injected));
685
+ });
686
+ }