dsh-agent-plugins-market 0.6.0 → 0.6.2

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 (134) hide show
  1. package/README.md +143 -169
  2. package/README.zh.md +136 -170
  3. package/client/client.js +8202 -684
  4. package/client/client.js.map +1 -1
  5. package/lib/application/catalog.js +236 -47
  6. package/lib/application/details.js +19 -10
  7. package/lib/application/panel-resources.js +95 -0
  8. package/lib/application/project-agent-roles.js +39 -0
  9. package/lib/catalog/codex-mcp.js +114 -0
  10. package/lib/catalog/component-files.js +151 -0
  11. package/lib/catalog/manifests.js +105 -44
  12. package/lib/catalog/native-project.js +44 -23
  13. package/lib/catalog/paths.js +6 -6
  14. package/lib/catalog/project-config.js +96 -0
  15. package/lib/catalog/project-hooks.js +108 -0
  16. package/lib/catalog/scan-pipeline.js +6 -1
  17. package/lib/catalog/scan-resolvers.js +70 -26
  18. package/lib/catalog/source-catalog.js +16 -3
  19. package/lib/catalog/suite-components.js +125 -0
  20. package/lib/catalog/surfaces.js +60 -46
  21. package/lib/catalog/validate.js +16 -11
  22. package/lib/contracts/market.js +16 -1
  23. package/lib/index.js +187 -35
  24. package/lib/model/layouts.js +49 -0
  25. package/lib/routes.js +106 -6
  26. package/lib/runtime/agent-role-router.js +226 -0
  27. package/lib/runtime/commands-mounts.js +27 -67
  28. package/lib/runtime/feedback-tool.js +181 -0
  29. package/lib/runtime/hooks-mounts.js +66 -13
  30. package/lib/runtime/host-locale.js +23 -7
  31. package/lib/runtime/legacy-root-migration.js +12 -0
  32. package/lib/runtime/lsp-direct-config.js +6 -3
  33. package/lib/runtime/lsp-mounts.js +38 -24
  34. package/lib/runtime/lsp-server-state.js +19 -0
  35. package/lib/runtime/lsp-status.js +3 -2
  36. package/lib/runtime/mcp-backend.js +6 -2
  37. package/lib/runtime/mcp-client/config.js +6 -0
  38. package/lib/runtime/mcp-client/connection.js +8 -2
  39. package/lib/runtime/mcp-client/tools.js +6 -3
  40. package/lib/runtime/mcp-config.js +26 -13
  41. package/lib/runtime/mcp-direct-config.js +56 -0
  42. package/lib/runtime/mcp-mounts.js +25 -9
  43. package/lib/runtime/mcp-overrides.js +11 -5
  44. package/lib/runtime/mcp-status.js +10 -7
  45. package/lib/runtime/model-catalog.js +48 -0
  46. package/lib/runtime/project-runtime.js +136 -0
  47. package/lib/runtime/reconciler.js +38 -28
  48. package/lib/runtime/server-config.js +75 -0
  49. package/lib/runtime/skills-provider.js +31 -77
  50. package/lib/runtime/storage-migration.js +169 -0
  51. package/lib/runtime/subagent-catalog.js +129 -0
  52. package/lib/runtime/user-commands.js +86 -0
  53. package/lib/runtime/user-panels.js +176 -0
  54. package/lib/runtime/user-store.js +142 -0
  55. package/lib/types/application/catalog.d.ts +39 -9
  56. package/lib/types/application/panel-resources.d.ts +13 -0
  57. package/lib/types/application/project-agent-roles.d.ts +3 -0
  58. package/lib/types/application/queries.d.ts +17 -1
  59. package/lib/types/catalog/codex-mcp.d.ts +4 -0
  60. package/lib/types/catalog/component-files.d.ts +17 -0
  61. package/lib/types/catalog/manifests.d.ts +14 -9
  62. package/lib/types/catalog/native-project.d.ts +2 -5
  63. package/lib/types/catalog/paths.d.ts +4 -4
  64. package/lib/types/catalog/project-config.d.ts +6 -0
  65. package/lib/types/catalog/project-hooks.d.ts +10 -0
  66. package/lib/types/catalog/scan-pipeline.d.ts +2 -0
  67. package/lib/types/catalog/scan-resolvers.d.ts +4 -3
  68. package/lib/types/catalog/source-catalog.d.ts +1 -1
  69. package/lib/types/catalog/suite-components.d.ts +5 -0
  70. package/lib/types/catalog/surfaces.d.ts +2 -2
  71. package/lib/types/catalog/validate.d.ts +2 -0
  72. package/lib/types/contracts/market.d.ts +60 -0
  73. package/lib/types/contracts/mcp-status.d.ts +5 -1
  74. package/lib/types/index.d.ts +2 -2
  75. package/lib/types/model/layouts.d.ts +102 -0
  76. package/lib/types/model/types.d.ts +51 -6
  77. package/lib/types/routes.d.ts +6 -1
  78. package/lib/types/runtime/agent-role-router.d.ts +95 -0
  79. package/lib/types/runtime/commands-mounts.d.ts +16 -7
  80. package/lib/types/runtime/feedback-tool.d.ts +49 -0
  81. package/lib/types/runtime/hooks-mounts.d.ts +4 -0
  82. package/lib/types/runtime/host-locale.d.ts +1 -1
  83. package/lib/types/runtime/legacy-root-migration.d.ts +2 -0
  84. package/lib/types/runtime/lsp-mounts.d.ts +4 -0
  85. package/lib/types/runtime/lsp-server-state.d.ts +3 -0
  86. package/lib/types/runtime/lsp-status.d.ts +1 -0
  87. package/lib/types/runtime/mcp-backend.d.ts +9 -1
  88. package/lib/types/runtime/mcp-client/config.d.ts +4 -3
  89. package/lib/types/runtime/mcp-client/tools.d.ts +3 -0
  90. package/lib/types/runtime/mcp-config.d.ts +11 -5
  91. package/lib/types/runtime/mcp-direct-config.d.ts +7 -0
  92. package/lib/types/runtime/mcp-mounts.d.ts +2 -1
  93. package/lib/types/runtime/mcp-overrides.d.ts +2 -0
  94. package/lib/types/runtime/mcp-status.d.ts +1 -1
  95. package/lib/types/runtime/model-catalog.d.ts +7 -0
  96. package/lib/types/runtime/project-runtime.d.ts +32 -0
  97. package/lib/types/runtime/reconciler.d.ts +2 -0
  98. package/lib/types/runtime/server-config.d.ts +10 -0
  99. package/lib/types/runtime/skills-provider.d.ts +1 -5
  100. package/lib/types/runtime/storage-migration.d.ts +15 -0
  101. package/lib/types/runtime/subagent-catalog.d.ts +52 -0
  102. package/lib/types/runtime/user-commands.d.ts +24 -0
  103. package/lib/types/runtime/user-panels.d.ts +91 -0
  104. package/lib/types/runtime/user-store.d.ts +51 -0
  105. package/package.json +10 -3
  106. package/schemas/README.md +69 -7
  107. package/schemas/agent-plugins/spec.md +48 -0
  108. package/schemas/claude-code/marketplace.schema.json +143 -0
  109. package/schemas/claude-code/plugin.schema.json +105 -0
  110. package/schemas/claude-code/spec.md +108 -0
  111. package/schemas/codex/marketplace.schema.json +78 -0
  112. package/schemas/codex/plugin.schema.json +77 -0
  113. package/schemas/codex/spec.md +91 -0
  114. package/schemas/cursor/marketplace.schema.json +82 -0
  115. package/schemas/cursor/plugin.schema.json +58 -0
  116. package/schemas/cursor/spec.md +92 -0
  117. package/schemas/github-copilot/marketplace.schema.json +107 -0
  118. package/schemas/github-copilot/plugin.schema.json +89 -0
  119. package/schemas/github-copilot/spec.md +105 -0
  120. package/schemas/kimi/marketplace.schema.json +38 -0
  121. package/schemas/kimi/plugin.schema.json +84 -0
  122. package/schemas/kimi/spec.md +94 -0
  123. package/schemas/qoder/marketplace.schema.json +115 -0
  124. package/schemas/qoder/plugin.schema.json +127 -0
  125. package/schemas/qoder/spec.md +111 -0
  126. package/schemas/skill-collection/spec.md +29 -0
  127. package/schemas/universal/marketplace.schema.json +98 -0
  128. package/schemas/universal/plugin.schema.json +49 -0
  129. package/schemas/universal/spec.md +53 -0
  130. package/schemas/zcode/marketplace.schema.json +161 -0
  131. package/schemas/zcode/plugin.schema.json +89 -0
  132. package/schemas/zcode/spec.md +102 -0
  133. package/lib/catalog/legacy-root-migration.js +0 -51
  134. package/lib/types/catalog/legacy-root-migration.d.ts +0 -14
@@ -16,13 +16,16 @@ import { discoverSuitesInSource } from '../catalog/suite-scanner.js';
16
16
  import { archiveInstall } from '../catalog/archive.js';
17
17
  import { gitClone, gitCurrentBranch, gitHead, gitRemoteUrl, gitRemove, gitSetRemoteUrl, gitSync } from '../catalog/git.js';
18
18
  import { buildMcpStatus } from '../runtime/mcp-status.js';
19
+ import { addUserMcpServer, loadUserMcpSuite, USER_MCP_SOURCE, USER_MCP_SUITE } from '../runtime/mcp-direct-config.js';
19
20
  import { buildLspStatus } from '../runtime/lsp-status.js';
20
21
  import { loadLspServers, saveLspServers } from '../runtime/lsp-direct-config.js';
21
- import { loadSuiteOverrides, mergeOverridePatch, saveSuiteOverrides } from '../runtime/mcp-overrides.js';
22
+ import { loadDisabledLspServers, saveDisabledLspServers } from '../runtime/lsp-server-state.js';
23
+ import { applyOverride, loadSuiteOverrides, mergeOverridePatch, saveSuiteOverrides } from '../runtime/mcp-overrides.js';
24
+ import { applyLspOverrides, lspConfig, restoreRedactedConfig, saveLspOverride, validateServerLsp, validateServerMcp } from '../runtime/server-config.js';
22
25
  import { probeHostMcpClient } from '../runtime/mcp-backend.js';
23
26
  import { githubCloneUrl, resolveRegion } from '../runtime/regions.js';
24
27
  import { readLocalePreference } from '../runtime/host-locale.js';
25
- import { redactMcpOverrides } from '../runtime/mcp-redaction.js';
28
+ import { redactMcpConfig, redactMcpOverrides } from '../runtime/mcp-redaction.js';
26
29
  import { buildSuiteDetail, readSkillContent } from './details.js';
27
30
  import { deriveSourceIdCandidates, expandHome, isDirectory, pathExists, qualifiedSuiteId, resolveProjectRoot, sanitizeId, sourceCheckoutDir, sourcesDir, STATE_FILE_NAME } from '../catalog/paths.js';
28
31
  import { loadState, saveState, EMPTY_STATE } from '../model/state.js';
@@ -44,12 +47,13 @@ export class Catalog {
44
47
  * discovery (cheap mapping) instead of re-walking every checkout — a full
45
48
  * rescan of a 2,500-suite catalog costs ~1s and UI mutations happen in
46
49
  * bursts. Content-changing mutations (add / update / remove / adopt /
47
- * refresh / acquire) set {@link scanCacheDirty}, which bypasses the cache
48
- * until the next scan; the TTL bounds staleness for in-place working-tree
50
+ * refresh / acquire) clear every cached discovery generation; the TTL
51
+ * bounds staleness for in-place working-tree
49
52
  * edits of local sources.
50
53
  */
51
54
  scanCache = new Map();
52
- scanCacheDirty = true;
55
+ scanGeneration = 0;
56
+ scanPromises = new Map();
53
57
  static SCAN_CACHE_TTL_MS = 30_000;
54
58
  static SCAN_CACHE_MAX_ENTRIES = 8;
55
59
  /**
@@ -83,6 +87,14 @@ export class Catalog {
83
87
  };
84
88
  /** Download-region source (the host settings namespace scope). */
85
89
  downloadRegionProvider = async () => 'auto';
90
+ scanProjectLayouts = true;
91
+ /** Apply the host's project-layout switch and invalidate every project snapshot. */
92
+ async setScanProjectLayouts(enabled) {
93
+ if (this.scanProjectLayouts === enabled)
94
+ return;
95
+ this.scanProjectLayouts = enabled;
96
+ await this.notifyChanged();
97
+ }
86
98
  constructor(options) {
87
99
  this.options = options;
88
100
  this.statePath = join(options.userRoot, STATE_FILE_NAME);
@@ -140,18 +152,119 @@ export class Catalog {
140
152
  /** Build the flat MCP service inventory for the status surface. */
141
153
  async mcpStatus() {
142
154
  const snapshot = await this.readUserCatalog();
143
- return buildMcpStatus(snapshot.suites, this.mcpDiagnostics, this.toolSnapshotProvider(), await this.allMcpOverrides());
155
+ const suites = [...snapshot.suites, await loadUserMcpSuite(this.options.dataRoot)];
156
+ const payload = buildMcpStatus(suites, this.mcpDiagnostics, this.toolSnapshotProvider(), await this.allMcpOverrides(suites));
157
+ for (const entry of payload.entries)
158
+ if (entry.suiteId === `${USER_MCP_SOURCE}/${USER_MCP_SUITE}`) {
159
+ entry.kind = 'direct';
160
+ entry.managed = true;
161
+ }
162
+ const oauthBackend = (await this.mcpBackend()) === 'builtin' && this.mcpReauthorizeAvailable();
163
+ for (const entry of payload.entries) {
164
+ const auth = entry.config?.auth;
165
+ entry.canReauthorize =
166
+ oauthBackend &&
167
+ (entry.kind === 'plugin' || entry.managed === true) &&
168
+ (entry.transport === 'sse' || entry.transport === 'streamable-http') &&
169
+ auth?.enabled !== false &&
170
+ !['disabled', 'foreign', 'orphaned'].includes(entry.state);
171
+ }
172
+ return payload;
173
+ }
174
+ /** Persist a user-owned MCP service and reconcile its bridge mount. */
175
+ async addMcpServer(name, server) {
176
+ return this.enqueue(async () => {
177
+ await addUserMcpServer(this.options.dataRoot, name, server);
178
+ await this.notifyChanged(true);
179
+ });
180
+ }
181
+ /** Read effective service configuration without exposing credential literals. */
182
+ async serverConfig(kind, id) {
183
+ const config = await this.resolveServerConfig(kind, id);
184
+ return { kind, id, editable: true, config: redactMcpConfig(config.value) };
185
+ }
186
+ async resolveServerConfig(kind, id) {
187
+ if (kind === 'lsp' && id.startsWith('direct/')) {
188
+ const key = id.slice(7);
189
+ const server = (await loadLspServers(this.options.dataRoot)).servers[key];
190
+ if (server === undefined)
191
+ throw new Error('LSP server not found');
192
+ return { key, suiteKey: 'direct', root: this.options.dataRoot, value: lspConfig(server) };
193
+ }
194
+ const suites = [...(await this.readUserCatalog()).suites, await loadUserMcpSuite(this.options.dataRoot)];
195
+ for (const suite of await applyLspOverrides(this.options.dataRoot, suites)) {
196
+ const suiteKey = qualifiedSuiteId(suite.sourceId, suite.id);
197
+ if (kind === 'mcp') {
198
+ for (const [key, server] of Object.entries(suite.mcp?.servers ?? {})) {
199
+ if (id !== `plugin:${suiteKey}/${key}`)
200
+ continue;
201
+ const overrides = await loadSuiteOverrides(this.options.dataRoot, suiteKey);
202
+ return { key, suiteKey, root: suite.root, value: { ...applyOverride(server, overrides[key]) } };
203
+ }
204
+ }
205
+ else {
206
+ for (const [key, server] of Object.entries(suite.lsp?.servers ?? {})) {
207
+ if (id === `${suiteKey}/${key}`)
208
+ return { key, suiteKey, root: suite.root, value: lspConfig(server) };
209
+ }
210
+ }
211
+ }
212
+ throw new Error('service configuration is not managed by this plugin');
213
+ }
214
+ /** Validate a complete replacement before writing; plugin checkouts remain untouched. */
215
+ async saveServerConfig(kind, id, config) {
216
+ return this.enqueue(async () => {
217
+ const current = await this.resolveServerConfig(kind, id);
218
+ const value = restoreRedactedConfig(config, current.value);
219
+ if (kind === 'mcp') {
220
+ const server = await validateServerMcp(current.root, current.key, value);
221
+ const overrides = await loadSuiteOverrides(this.options.dataRoot, current.suiteKey);
222
+ const enabled = overrides[current.key]?.enabled;
223
+ overrides[current.key] = { config: server, ...(enabled === undefined ? {} : { enabled }) };
224
+ await saveSuiteOverrides(this.options.dataRoot, current.suiteKey, overrides);
225
+ }
226
+ else {
227
+ const server = validateServerLsp(current.key, value);
228
+ if (current.suiteKey === 'direct') {
229
+ const direct = await loadLspServers(this.options.dataRoot);
230
+ if (direct.errors.length > 0)
231
+ throw new Error(direct.errors.join('; '));
232
+ direct.servers[current.key] = server;
233
+ await saveLspServers(this.options.dataRoot, { lspServers: Object.fromEntries(Object.entries(direct.servers).map(([key, spec]) => [key, lspConfig(spec)])) });
234
+ }
235
+ else
236
+ await saveLspOverride(this.options.dataRoot, id, server);
237
+ }
238
+ await this.notifyChanged(true);
239
+ });
144
240
  }
145
241
  /** The LSP status surface: declared servers merged with mount diagnostics. */
146
242
  async lspStatus() {
147
243
  const snapshot = await this.readUserCatalog();
148
244
  const direct = await loadLspServers(this.options.dataRoot);
149
- return buildLspStatus(snapshot.suites, this.lspStatusSource ?? { diagnosticsSnapshot: () => new Map(), hasLiveMounts: () => false }, direct);
245
+ return buildLspStatus(await applyLspOverrides(this.options.dataRoot, snapshot.suites), this.lspStatusSource ?? { diagnosticsSnapshot: () => new Map(), hasLiveMounts: () => false }, direct);
150
246
  }
151
247
  /** The user's direct LSP server table (normalized specs). */
152
248
  async lspServers() {
153
249
  return (await loadLspServers(this.options.dataRoot)).servers;
154
250
  }
251
+ /** Create one direct LSP declaration without replacing other user servers. */
252
+ async addLspServer(name, config) {
253
+ return this.enqueue(async () => {
254
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(name))
255
+ throw new Error('invalid LSP server name');
256
+ const direct = await loadLspServers(this.options.dataRoot);
257
+ if (direct.errors.length > 0)
258
+ throw new Error(direct.errors.join('; '));
259
+ if (Object.hasOwn(direct.servers, name))
260
+ throw new Error('LSP server already exists');
261
+ const spec = validateServerLsp(name, config);
262
+ await saveLspServers(this.options.dataRoot, {
263
+ lspServers: { ...Object.fromEntries(Object.entries(direct.servers).map(([key, value]) => [key, lspConfig(value)])), [name]: lspConfig(spec) }
264
+ });
265
+ await this.notifyChanged(true);
266
+ });
267
+ }
155
268
  /** Validate and persist the user's direct LSP server table. */
156
269
  async setLspServers(raw) {
157
270
  return this.enqueue(async () => {
@@ -160,6 +273,17 @@ export class Catalog {
160
273
  return servers;
161
274
  });
162
275
  }
276
+ async setLspServerEnabled(id, enabled) {
277
+ return this.enqueue(async () => {
278
+ const disabled = await loadDisabledLspServers(this.options.dataRoot);
279
+ if (enabled)
280
+ disabled.delete(id);
281
+ else
282
+ disabled.add(id);
283
+ await saveDisabledLspServers(this.options.dataRoot, disabled);
284
+ await this.notifyChanged(true);
285
+ });
286
+ }
163
287
  /** One suite's persisted MCP overrides, addressed by qualified suite id. */
164
288
  async mcpOverrides(sourceId, suiteId) {
165
289
  const suiteKey = qualifiedSuiteId(sourceId, suiteId);
@@ -177,12 +301,18 @@ export class Catalog {
177
301
  return this.enqueue(async () => {
178
302
  const suiteKey = qualifiedSuiteId(sourceId, suiteId);
179
303
  const source = this.state.sources.find(entry => entry.id === sourceId);
180
- if (source === undefined)
181
- throw new Error(`unknown source "${sourceId}"`);
182
- const checkout = this.sourceCheckoutPath(source);
183
- if (!(await isDirectory(checkout)))
184
- await this.acquire(source);
185
- const suites = await discoverSuitesInSource(checkout, sourceId, 'user', source.url);
304
+ let suites;
305
+ if (sourceId === USER_MCP_SOURCE && suiteId === USER_MCP_SUITE) {
306
+ suites = [await loadUserMcpSuite(this.options.dataRoot)];
307
+ }
308
+ else {
309
+ if (source === undefined)
310
+ throw new Error(`unknown source "${sourceId}"`);
311
+ const checkout = this.sourceCheckoutPath(source);
312
+ if (!(await isDirectory(checkout)))
313
+ await this.acquire(source);
314
+ suites = await discoverSuitesInSource(checkout, sourceId, 'user', source.url);
315
+ }
186
316
  const suite = suites.find(entry => entry.id === suiteId);
187
317
  if (suite === undefined)
188
318
  throw new Error(`suite "${suiteId}" not found in source "${sourceId}"`);
@@ -246,11 +376,10 @@ export class Catalog {
246
376
  setDownloadRegionProvider(provider) {
247
377
  this.downloadRegionProvider = provider;
248
378
  }
249
- /** All persisted MCP overrides keyed by qualified suite id (mount-time provider). */
250
- async allMcpOverrides() {
251
- const snapshot = await this.readUserCatalog();
379
+ /** MCP overrides for a supplied runtime selection, or the full market catalog when omitted. */
380
+ async allMcpOverrides(suites) {
252
381
  const map = new Map();
253
- for (const suite of snapshot.suites) {
382
+ for (const suite of suites ?? (await this.readUserCatalog()).suites) {
254
383
  const suiteKey = qualifiedSuiteId(suite.sourceId, suite.id);
255
384
  const overrides = await loadSuiteOverrides(this.options.dataRoot, suiteKey);
256
385
  if (Object.keys(overrides).length > 0)
@@ -261,7 +390,7 @@ export class Catalog {
261
390
  /** Load persisted user state once at plugin activation. */
262
391
  async load() {
263
392
  this.state = await loadState(this.statePath);
264
- this.scanCacheDirty = true;
393
+ this.invalidateScans();
265
394
  this.invalidateSnapshot(false);
266
395
  }
267
396
  get sources() {
@@ -279,16 +408,24 @@ export class Catalog {
279
408
  return this.buildSnapshot(this.state, 'user', this.options.userRoot);
280
409
  if (this.userSnapshot !== undefined && Date.now() < this.userSnapshotExpiresAt)
281
410
  return this.userSnapshot;
282
- this.userSnapshotPromise ??= this.buildSnapshot(this.state, 'user', this.options.userRoot)
411
+ if (this.userSnapshotPromise !== undefined)
412
+ return this.userSnapshotPromise;
413
+ const revision = this.revision;
414
+ const generation = this.scanGeneration;
415
+ const promise = this.buildSnapshot(this.state, 'user', this.options.userRoot)
283
416
  .then(snapshot => {
284
- this.userSnapshot = snapshot;
285
- this.userSnapshotExpiresAt = Date.now() + this.userSnapshotTtlMs;
417
+ if (revision === this.revision && generation === this.scanGeneration) {
418
+ this.userSnapshot = snapshot;
419
+ this.userSnapshotExpiresAt = Date.now() + this.userSnapshotTtlMs;
420
+ }
286
421
  return snapshot;
287
422
  })
288
423
  .finally(() => {
289
- this.userSnapshotPromise = undefined;
424
+ if (this.userSnapshotPromise === promise)
425
+ this.userSnapshotPromise = undefined;
290
426
  });
291
- return this.userSnapshotPromise;
427
+ this.userSnapshotPromise = promise;
428
+ return promise;
292
429
  }
293
430
  /** Read one coherent project-dimension snapshot for a workspace cwd. */
294
431
  async readProjectCatalog(cwd) {
@@ -301,13 +438,18 @@ export class Catalog {
301
438
  const inFlight = this.projectSnapshotPromises.get(projectRoot);
302
439
  if (inFlight !== undefined)
303
440
  return inFlight;
441
+ const revision = this.revision;
442
+ const generation = this.scanGeneration;
304
443
  const promise = this.buildProjectSnapshot(projectRoot)
305
444
  .then(snapshot => {
306
- this.projectSnapshots.set(projectRoot, { snapshot, expiresAt: Date.now() + this.projectSnapshotTtlMs });
445
+ if (revision === this.revision && generation === this.scanGeneration) {
446
+ this.projectSnapshots.set(projectRoot, { snapshot, expiresAt: Date.now() + this.projectSnapshotTtlMs });
447
+ }
307
448
  return snapshot;
308
449
  })
309
450
  .finally(() => {
310
- this.projectSnapshotPromises.delete(projectRoot);
451
+ if (this.projectSnapshotPromises.get(projectRoot) === promise)
452
+ this.projectSnapshotPromises.delete(projectRoot);
311
453
  });
312
454
  this.projectSnapshotPromises.set(projectRoot, promise);
313
455
  return promise;
@@ -423,14 +565,20 @@ export class Catalog {
423
565
  unmanaged: await this.unmanagedSources()
424
566
  };
425
567
  }
426
- /** Enabled user-dimension suites from one user snapshot. */
568
+ /** Runtime discovery scans only sources containing an enabled install. No acquisition or network access. */
427
569
  async enabledUserSuites() {
428
- return (await this.readUserCatalog()).enabledSuites;
570
+ const enabledSources = new Set(Object.entries(this.state.installed)
571
+ .filter(([, entry]) => entry.enabled)
572
+ .map(([key]) => key.slice(0, key.indexOf('/'))));
573
+ const sources = this.state.sources.filter(source => enabledSources.has(source.id));
574
+ const suites = sources.length === 0 ? [] : (await this.buildSnapshot({ ...this.state, sources }, 'user', this.options.userRoot)).enabledSuites;
575
+ const direct = await loadUserMcpSuite(this.options.dataRoot);
576
+ return applyLspOverrides(this.options.dataRoot, Object.keys(direct.mcp.servers).length === 0 ? suites : [...suites, direct]);
429
577
  }
430
578
  /** Enabled user- and project-dimension suites for a workspace cwd. */
431
579
  async enabledSuitesForCwd(cwd) {
432
- const [user, project] = await Promise.all([this.readUserCatalog(), this.readProjectCatalog(cwd)]);
433
- return { user: user.enabledSuites, project: project.enabledSuites };
580
+ const [user, project] = await Promise.all([this.enabledUserSuites(), this.readProjectCatalog(cwd)]);
581
+ return { user, project: project.enabledSuites };
434
582
  }
435
583
  /** All suites of one dimension. */
436
584
  async suitesForDimension(dimension, dimensionRoot) {
@@ -681,8 +829,16 @@ export class Catalog {
681
829
  await this.notifyChanged();
682
830
  });
683
831
  }
684
- /** Remove a source, its install entries, and its managed checkout. */
685
- async removeSource(sourceId) {
832
+ /**
833
+ * Remove a source and its install entries. `deleteCheckout` (the UI's
834
+ * "also delete the managed market directory" option) physically removes the
835
+ * `.sources/<id>` checkout — including an adopted one, since a directory
836
+ * under `.sources` is manager-owned storage. A `local` source whose URL
837
+ * points outside `.sources` is registration-only: its directory is never
838
+ * touched. Deleting the checkout is what keeps a removed source from
839
+ * reappearing as an unmanaged entry.
840
+ */
841
+ async removeSource(sourceId, deleteCheckout = false) {
686
842
  return this.enqueue(async () => {
687
843
  const source = this.state.sources.find(entry => entry.id === sourceId);
688
844
  this.state = {
@@ -692,10 +848,11 @@ export class Catalog {
692
848
  };
693
849
  await saveState(this.statePath, this.state);
694
850
  this.headCache.delete(sourceId);
695
- // Adopted checkouts (manual clones) and local paths are user-owned
696
- // directories; only self-acquired checkouts are deleted.
697
- const userOwned = source !== undefined && (source.local === true || source.adopted === true);
698
- if (!userOwned)
851
+ // A checkout under `.sources` is manager-owned even when it was first
852
+ // discovered/adopted. Only an explicit local path points outside the
853
+ // manager's storage and must remain untouched.
854
+ const externalLocal = source?.local === true && source.url !== sourceCheckoutDir(this.options.userRoot, sourceId);
855
+ if (!externalLocal && deleteCheckout)
699
856
  await gitRemove(sourceCheckoutDir(this.options.userRoot, sourceId));
700
857
  await this.notifyChanged();
701
858
  });
@@ -853,9 +1010,18 @@ export class Catalog {
853
1010
  await saveState(this.statePath, this.state);
854
1011
  // New sources change the fingerprint, but their content arrives through
855
1012
  // an acquire, so mark the scan cache dirty conservatively.
856
- this.scanCacheDirty = true;
1013
+ this.invalidateScans();
857
1014
  this.invalidateSnapshot(true);
858
1015
  }
1016
+ /**
1017
+ * Panel changed hook: the HTTP layer notifies after a user-panel mutation
1018
+ * (skills / commands / agent personas). Catalog state is untouched — this
1019
+ * only runs the change pipeline so the runtime remounts commands and the
1020
+ * skill providers re-read their catalogs.
1021
+ */
1022
+ async notifyPanelsChanged() {
1023
+ await this.notifyChanged();
1024
+ }
859
1025
  async setInstalled(sourceId, suiteId, entry) {
860
1026
  this.state = { ...this.state, installed: { ...this.state.installed, [installKey(sourceId, suiteId)]: entry } };
861
1027
  await saveState(this.statePath, this.state);
@@ -910,12 +1076,12 @@ export class Catalog {
910
1076
  return source.local === true ? expandHome(source.url) : sourceCheckoutDir(this.options.userRoot, source.id);
911
1077
  }
912
1078
  async buildSnapshot(state, dimension, dimensionRoot, skipScanCache = false) {
913
- const fingerprint = JSON.stringify([dimension, dimensionRoot, state.sources]);
1079
+ const fingerprint = JSON.stringify([dimension, dimensionRoot, state.sources, this.scanProjectLayouts]);
914
1080
  const cached = this.scanCache.get(fingerprint);
915
1081
  // The user snapshot's TTL also bounds scan reuse: a snapshot rebuild that
916
1082
  // replays a 30s scan cache would silently outlive its own staleness bound.
917
1083
  const scanCacheTtl = dimension === 'user' ? Math.min(this.userSnapshotTtlMs, Catalog.SCAN_CACHE_TTL_MS) : Catalog.SCAN_CACHE_TTL_MS;
918
- const cacheFresh = !skipScanCache && !this.scanCacheDirty && cached !== undefined && Date.now() - cached.at < scanCacheTtl;
1084
+ const cacheFresh = !skipScanCache && cached !== undefined && Date.now() - cached.at < scanCacheTtl;
919
1085
  let discovered;
920
1086
  let scanNotes;
921
1087
  if (cacheFresh && cached !== undefined) {
@@ -923,15 +1089,28 @@ export class Catalog {
923
1089
  scanNotes = cached.scanNotes;
924
1090
  }
925
1091
  else {
926
- const result = await discoverSourceListWithNotes(state.sources, dimension, dimensionRoot);
1092
+ const generation = this.scanGeneration;
1093
+ let pending = this.scanPromises.get(fingerprint);
1094
+ if (pending === undefined) {
1095
+ pending = discoverSourceListWithNotes(state.sources, dimension, dimensionRoot, this.scanProjectLayouts);
1096
+ this.scanPromises.set(fingerprint, pending);
1097
+ }
1098
+ let result;
1099
+ try {
1100
+ result = await pending;
1101
+ }
1102
+ finally {
1103
+ if (this.scanPromises.get(fingerprint) === pending)
1104
+ this.scanPromises.delete(fingerprint);
1105
+ }
927
1106
  discovered = result.suites;
928
1107
  scanNotes = result.scanNotes;
929
- // The fresh scan satisfies the invalidation: content mutations mark the
930
- // cache dirty, and the next scan (this one) makes it clean again.
931
- this.scanCacheDirty = false;
932
- if (this.scanCache.size >= Catalog.SCAN_CACHE_MAX_ENTRIES)
933
- this.scanCache.clear();
934
- this.scanCache.set(fingerprint, { at: Date.now(), discovered, scanNotes });
1108
+ // A scan started before a content mutation must not repopulate its cache.
1109
+ if (generation === this.scanGeneration) {
1110
+ if (this.scanCache.size >= Catalog.SCAN_CACHE_MAX_ENTRIES)
1111
+ this.scanCache.clear();
1112
+ this.scanCache.set(fingerprint, { at: Date.now(), discovered, scanNotes });
1113
+ }
935
1114
  }
936
1115
  const suites = discovered.map(suite => {
937
1116
  const installed = state.installed[installKey(suite.sourceId, suite.id)];
@@ -941,7 +1120,11 @@ export class Catalog {
941
1120
  return {
942
1121
  ...suite,
943
1122
  enabled,
944
- activeSurfaces: effectiveSurfaces(installed?.surfaces),
1123
+ activeSurfaces: {
1124
+ ...effectiveSurfaces(installed?.surfaces),
1125
+ ...(suite.manifest.layout === 'project-native' ? suite.activeSurfaces : {}),
1126
+ ...(dimension === 'project' ? { lsp: false } : {})
1127
+ },
945
1128
  ...(installed?.lockCommit === undefined ? {} : { lockCommit: installed.lockCommit }),
946
1129
  ...(installed?.installedAt === undefined ? {} : { installedAt: installed.installedAt })
947
1130
  };
@@ -961,6 +1144,12 @@ export class Catalog {
961
1144
  this.userSnapshotExpiresAt = 0;
962
1145
  this.userSnapshotPromise = undefined;
963
1146
  this.projectSnapshots.clear();
1147
+ this.projectSnapshotPromises.clear();
1148
+ }
1149
+ invalidateScans() {
1150
+ this.scanGeneration++;
1151
+ this.scanCache.clear();
1152
+ this.scanPromises.clear();
964
1153
  }
965
1154
  /**
966
1155
  * Invalidate snapshots and run the change pipeline. `keepScanCache` marks
@@ -970,7 +1159,7 @@ export class Catalog {
970
1159
  */
971
1160
  async notifyChanged(keepScanCache = false) {
972
1161
  if (!keepScanCache)
973
- this.scanCacheDirty = true;
1162
+ this.invalidateScans();
974
1163
  this.invalidateSnapshot(true);
975
1164
  await this.options.onChanged();
976
1165
  for (const listener of this.listeners)
@@ -1,7 +1,8 @@
1
1
  /** Suite detail and preview projections owned by the application layer. */
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { parse as parseYaml } from 'yaml';
4
- import { discoverLspEntries, listMdFiles } from '../catalog/surfaces.js';
4
+ import { discoverLspEntries } from '../catalog/surfaces.js';
5
+ import { defaultMarkdownResources, resourceText } from '../catalog/component-files.js';
5
6
  import { credentialRefsInServer } from '../runtime/mcp-config.js';
6
7
  import { redactMcpConfig, redactMcpOverrides } from '../runtime/mcp-redaction.js';
7
8
  import { applyOverride } from '../runtime/mcp-overrides.js';
@@ -41,9 +42,15 @@ export async function buildSuiteDetail(suite, installed, diagnostics, mcpOverrid
41
42
  credentialRefs: credentialRefsInServer(effective)
42
43
  };
43
44
  }),
44
- hooks: remoteUrl === undefined ? await hooksPreviews(suite.root) : { count: 0, entries: [] },
45
- commands: remoteUrl === undefined ? await markdownPreviews(`${suite.root}/commands`) : [],
46
- agents: remoteUrl === undefined ? await markdownPreviews(`${suite.root}/agents`) : [],
45
+ hooks: remoteUrl === undefined
46
+ ? suite.hooks === undefined
47
+ ? suite.resources === undefined
48
+ ? await hooksPreviews(suite.root)
49
+ : { count: 0, entries: [] }
50
+ : normalizedHookPreviews(suite)
51
+ : { count: 0, entries: [] },
52
+ commands: remoteUrl === undefined ? await markdownPreviews(suite.resources?.commands ?? (await defaultMarkdownResources(suite.root, 'commands'))) : [],
53
+ agents: remoteUrl === undefined ? await markdownPreviews(suite.resources?.agents ?? (await defaultMarkdownResources(suite.root, 'agents'))) : [],
47
54
  lsp: remoteUrl === undefined ? await lspDetail(suite) : { servers: [], raw: [] },
48
55
  errors: suite.errors,
49
56
  mcpErrors: diagnostics.filter(diagnostic => diagnostic.suiteId === suite.id).map(diagnostic => `${diagnostic.serverKey}: ${diagnostic.reason}`)
@@ -67,13 +74,11 @@ async function readPreview(path, capBytes = 64 * 1024) {
67
74
  const text = await readFile(path, 'utf8');
68
75
  return text.length > capBytes ? `${text.slice(0, capBytes)}\n… (truncated)` : text;
69
76
  }
70
- async function markdownPreviews(dir) {
71
- const names = await listMdFiles(dir);
77
+ async function markdownPreviews(resources) {
72
78
  const previews = [];
73
- for (const name of names) {
74
- const file = `${dir}/${name}`;
79
+ for (const resource of resources) {
75
80
  try {
76
- const content = await readPreview(file);
81
+ const content = (await resourceText(resource)).slice(0, 64 * 1024);
77
82
  const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content);
78
83
  let description;
79
84
  if (match !== null) {
@@ -84,7 +89,7 @@ async function markdownPreviews(dir) {
84
89
  description = desc;
85
90
  }
86
91
  }
87
- previews.push({ name: name.slice(0, -3), ...(description === undefined ? {} : { description }), content });
92
+ previews.push({ name: resource.name, ...(description === undefined ? {} : { description }), content });
88
93
  }
89
94
  catch {
90
95
  // Unreadable preview files are omitted from the detail response.
@@ -92,6 +97,10 @@ async function markdownPreviews(dir) {
92
97
  }
93
98
  return previews;
94
99
  }
100
+ function normalizedHookPreviews(suite) {
101
+ const entries = Object.entries(suite.hooks.events).flatMap(([event, groups]) => groups.flatMap(group => group.hooks.map(hook => ({ event, ...(group.matcher === undefined ? {} : { matcher: group.matcher }), command: hook.command }))));
102
+ return { count: entries.length, entries };
103
+ }
95
104
  async function hooksPreviews(root) {
96
105
  for (const relative of ['hooks/hooks.json', 'hooks.json']) {
97
106
  let text;
@@ -0,0 +1,95 @@
1
+ /** Installed suite and user resources share one inventory; paths never come from HTTP callers. */
2
+ import { realpath, unlink, writeFile } from 'node:fs/promises';
3
+ import { isAbsolute, relative } from 'node:path';
4
+ import { defaultMarkdownResources, resourceText } from '../catalog/component-files.js';
5
+ import { stripFrontmatter } from '../catalog/skills-parse.js';
6
+ import { parseFrontmatterRecord } from '../runtime/user-store.js';
7
+ export function createPanelResources(catalog, users) {
8
+ return {
9
+ skills: new PanelResources(catalog, users.skills, 'skills'),
10
+ commands: new PanelResources(catalog, users.commands, 'commands'),
11
+ agents: new PanelResources(catalog, users.agents, 'agents')
12
+ };
13
+ }
14
+ class PanelResources {
15
+ catalog;
16
+ users;
17
+ kind;
18
+ constructor(catalog, users, kind) {
19
+ this.catalog = catalog;
20
+ this.users = users;
21
+ this.kind = kind;
22
+ }
23
+ async list(strict = false) {
24
+ const entries = await this.users.list(strict);
25
+ const snapshot = await this.catalog.readUserCatalog();
26
+ const installed = new Set((await this.catalog.overview()).suites.filter(suite => suite.installed).map(suite => JSON.stringify([suite.sourceId, suite.suiteId])));
27
+ for (const suite of snapshot.suites) {
28
+ if (!installed.has(JSON.stringify([suite.sourceId, suite.id])) || suite.remote !== undefined)
29
+ continue;
30
+ const files = this.kind === 'skills'
31
+ ? suite.skills.map(skill => ({ name: skill.name, file: skill.file }))
32
+ : (suite.resources?.[this.kind] ?? (await defaultMarkdownResources(suite.root, this.kind)));
33
+ for (const resource of files) {
34
+ const { name, file } = resource;
35
+ const rawText = await resourceText(resource).catch(error => {
36
+ if (strict && error.code !== 'ENOENT')
37
+ throw error;
38
+ return undefined;
39
+ });
40
+ if (rawText === undefined)
41
+ continue;
42
+ let metadata;
43
+ try {
44
+ metadata = parseFrontmatterRecord(rawText);
45
+ }
46
+ catch (error) {
47
+ metadata = { disabled: true, validationError: String(error) };
48
+ }
49
+ entries.push({
50
+ id: JSON.stringify([suite.sourceId, suite.id, this.kind, name]),
51
+ name,
52
+ origin: 'plugin',
53
+ suiteName: suite.manifest.name,
54
+ disabled: !suite.enabled || suite.activeSurfaces?.[this.kind] === false || metadata.disabled === true,
55
+ metadata,
56
+ rawText,
57
+ content: stripFrontmatter(rawText),
58
+ path: file,
59
+ description: typeof metadata.description === 'string' ? metadata.description : ''
60
+ });
61
+ }
62
+ }
63
+ return entries;
64
+ }
65
+ async get(id) {
66
+ return (await this.list()).find(entry => (entry.id ?? entry.name) === id);
67
+ }
68
+ create(name, text) {
69
+ return this.users.create(name, text);
70
+ }
71
+ async pluginPath(id) {
72
+ const entry = await this.get(id);
73
+ if (entry?.origin !== 'plugin')
74
+ throw new Error('Unknown installed plugin resource');
75
+ if (entry.path.endsWith('.json'))
76
+ throw new Error('Inline manifest resources are read-only; edit their source manifest');
77
+ const root = await realpath(this.users.root());
78
+ const path = await realpath(entry.path);
79
+ const rel = relative(root, path);
80
+ if (rel === '..' || rel.startsWith('../') || isAbsolute(rel))
81
+ throw new Error('External source files are read-only; create a user resource to customize them');
82
+ return path;
83
+ }
84
+ async update(id, text) {
85
+ if (!id.startsWith('['))
86
+ return this.users.update(id, text);
87
+ parseFrontmatterRecord(text);
88
+ await writeFile(await this.pluginPath(id), text, 'utf8');
89
+ }
90
+ async remove(id) {
91
+ if (!id.startsWith('['))
92
+ return this.users.remove(id);
93
+ await unlink(await this.pluginPath(id));
94
+ }
95
+ }
@@ -0,0 +1,39 @@
1
+ /** Resolve read-only project roles from the calling session, never from tool-supplied paths. */
2
+ import { defaultMarkdownResources, resourceText } from '../catalog/component-files.js';
3
+ import { parseAgentRole } from '../runtime/agent-role-router.js';
4
+ export async function projectAgentRoles(catalog, parent) {
5
+ const cwd = parent?.session?.header?.cwd;
6
+ if (typeof cwd !== 'string' || cwd === '')
7
+ return [];
8
+ const snapshot = await catalog.readProjectCatalog(cwd);
9
+ const entries = [];
10
+ for (const suite of snapshot.enabledSuites) {
11
+ if (suite.activeSurfaces?.agents === false)
12
+ continue;
13
+ for (const resource of suite.resources?.agents ?? (await defaultMarkdownResources(suite.root, 'agents'))) {
14
+ const path = resource.file;
15
+ const rawText = await resourceText(resource).catch(error => {
16
+ if (error.code !== 'ENOENT')
17
+ throw error;
18
+ return undefined;
19
+ });
20
+ if (rawText === undefined)
21
+ continue;
22
+ try {
23
+ const policy = parseAgentRole(rawText);
24
+ entries.push({
25
+ name: JSON.stringify([suite.sourceId, suite.id, 'agents', resource.name]),
26
+ path,
27
+ rawText,
28
+ title: policy.title ?? resource.name.replace(/\.agent$/, ''),
29
+ description: policy.description ?? `${suite.manifest.name}: ${resource.name}`,
30
+ disabled: policy.disabled
31
+ });
32
+ }
33
+ catch {
34
+ // Malformed routing metadata cannot authorize a project role.
35
+ }
36
+ }
37
+ }
38
+ return entries;
39
+ }