dsh-agent-plugins-market 0.6.2 → 0.7.1

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 (155) hide show
  1. package/README.md +117 -111
  2. package/README.zh.md +112 -98
  3. package/client/client.js +28533 -1511
  4. package/client/client.js.map +1 -1
  5. package/lib/application/catalog-context.js +188 -0
  6. package/lib/application/catalog.js +229 -1109
  7. package/lib/application/details.js +19 -8
  8. package/lib/application/install-store.js +95 -0
  9. package/lib/application/lsp-service.js +97 -0
  10. package/lib/application/mcp-service.js +263 -0
  11. package/lib/application/panel-resources.js +43 -14
  12. package/lib/application/ports.js +21 -0
  13. package/lib/application/project-agent-roles.js +8 -2
  14. package/lib/application/snapshot-cache.js +160 -0
  15. package/lib/application/source-store.js +521 -0
  16. package/lib/catalog/archive.js +35 -30
  17. package/lib/catalog/component-files.js +14 -6
  18. package/lib/catalog/fs-probes.js +43 -0
  19. package/lib/catalog/manifests.js +53 -92
  20. package/lib/catalog/native-project.js +2 -2
  21. package/lib/catalog/paths.js +74 -55
  22. package/lib/catalog/plugin-variables.js +38 -0
  23. package/lib/catalog/project-config.js +2 -1
  24. package/lib/catalog/project-hooks.js +23 -2
  25. package/lib/catalog/scan-resolvers.js +29 -45
  26. package/lib/catalog/skills-parse.js +61 -10
  27. package/lib/catalog/source-catalog.js +10 -4
  28. package/lib/catalog/suite-components.js +7 -7
  29. package/lib/catalog/suite-scanner.js +3 -1
  30. package/lib/catalog/surfaces.js +5 -26
  31. package/lib/catalog/validate.js +7 -11
  32. package/lib/contracts/market.js +2 -0
  33. package/lib/contracts/settings.js +63 -0
  34. package/lib/index.js +115 -217
  35. package/lib/model/layouts.js +10 -1
  36. package/lib/model/types.js +26 -3
  37. package/lib/routes.js +57 -15
  38. package/lib/runtime/agent-role-names.js +70 -0
  39. package/lib/runtime/agent-role-router.js +142 -106
  40. package/lib/runtime/commands-mounts.js +55 -11
  41. package/lib/runtime/deadline.js +54 -0
  42. package/lib/runtime/dynamic-context.js +140 -0
  43. package/lib/runtime/feedback-tool.js +101 -46
  44. package/lib/runtime/hooks-mounts.js +9 -11
  45. package/lib/runtime/host-locale.js +49 -24
  46. package/lib/runtime/lsp-direct-config.js +19 -19
  47. package/lib/runtime/lsp-mounts.js +182 -70
  48. package/lib/runtime/lsp-server-state.js +4 -4
  49. package/lib/runtime/lsp-status.js +1 -1
  50. package/lib/runtime/mcp-auth-record.js +42 -0
  51. package/lib/runtime/mcp-backend.js +14 -14
  52. package/lib/runtime/mcp-client/config.js +8 -1
  53. package/lib/runtime/mcp-client/connection.js +38 -43
  54. package/lib/runtime/mcp-client/host-contract.js +10 -0
  55. package/lib/runtime/mcp-client/host-seams.js +8 -88
  56. package/lib/runtime/mcp-client/oauth.js +5 -7
  57. package/lib/runtime/mcp-client/plugin-identity.js +28 -0
  58. package/lib/runtime/mcp-client/projection.js +202 -0
  59. package/lib/runtime/mcp-client/tools.js +4 -189
  60. package/lib/runtime/mcp-config.js +36 -14
  61. package/lib/runtime/mcp-direct-config.js +33 -16
  62. package/lib/runtime/mcp-mounts.js +35 -75
  63. package/lib/runtime/mcp-overrides.js +5 -5
  64. package/lib/runtime/mcp-redaction.js +3 -2
  65. package/lib/runtime/mcp-status.js +7 -8
  66. package/lib/runtime/mount-lifecycle.js +73 -0
  67. package/lib/runtime/profile-seam.js +321 -0
  68. package/lib/runtime/project-runtime.js +34 -17
  69. package/lib/runtime/reconcile-scheduler.js +97 -0
  70. package/lib/runtime/reconciler.js +6 -1
  71. package/lib/runtime/regions.js +0 -19
  72. package/lib/runtime/server-config.js +6 -4
  73. package/lib/runtime/settings-namespace.js +144 -0
  74. package/lib/runtime/skills-provider.js +38 -14
  75. package/lib/runtime/source-auto-update.js +66 -0
  76. package/lib/{model/state.js → runtime/state-store.js} +9 -11
  77. package/lib/runtime/storage-migration.js +35 -18
  78. package/lib/runtime/subagent-catalog.js +5 -4
  79. package/lib/runtime/timer-seat.js +80 -0
  80. package/lib/runtime/tool-registry-observer.js +20 -20
  81. package/lib/runtime/user-commands.js +7 -3
  82. package/lib/runtime/user-panels.js +125 -46
  83. package/lib/runtime/user-store.js +124 -69
  84. package/lib/types/application/catalog-context.d.ts +124 -0
  85. package/lib/types/application/catalog.d.ts +108 -304
  86. package/lib/types/application/install-store.d.ts +21 -0
  87. package/lib/types/application/lsp-service.d.ts +36 -0
  88. package/lib/types/application/mcp-service.d.ts +77 -0
  89. package/lib/types/application/panel-resources.d.ts +8 -0
  90. package/lib/types/application/ports.d.ts +72 -0
  91. package/lib/types/application/queries.d.ts +15 -26
  92. package/lib/types/application/snapshot-cache.d.ts +51 -0
  93. package/lib/types/application/source-store.d.ts +100 -0
  94. package/lib/types/catalog/archive.d.ts +3 -11
  95. package/lib/types/catalog/component-files.d.ts +4 -0
  96. package/lib/types/catalog/fs-probes.d.ts +10 -0
  97. package/lib/types/catalog/manifests.d.ts +17 -16
  98. package/lib/types/catalog/native-project.d.ts +2 -2
  99. package/lib/types/catalog/paths.d.ts +53 -15
  100. package/lib/types/catalog/plugin-variables.d.ts +16 -0
  101. package/lib/types/catalog/scan-pipeline.d.ts +3 -3
  102. package/lib/types/catalog/scan-resolvers.d.ts +3 -9
  103. package/lib/types/catalog/skills-parse.d.ts +13 -7
  104. package/lib/types/catalog/source-catalog.d.ts +10 -5
  105. package/lib/types/catalog/suite-scanner.d.ts +8 -6
  106. package/lib/types/catalog/validate.d.ts +0 -2
  107. package/lib/types/contracts/lsp-status.d.ts +35 -0
  108. package/lib/types/contracts/market.d.ts +13 -2
  109. package/lib/types/contracts/mcp-status.d.ts +4 -0
  110. package/lib/types/contracts/settings.d.ts +59 -0
  111. package/lib/types/model/layouts.d.ts +10 -0
  112. package/lib/types/model/types.d.ts +29 -5
  113. package/lib/types/runtime/agent-role-names.d.ts +6 -0
  114. package/lib/types/runtime/agent-role-router.d.ts +59 -52
  115. package/lib/types/runtime/commands-mounts.d.ts +15 -7
  116. package/lib/types/runtime/deadline.d.ts +17 -0
  117. package/lib/types/runtime/dynamic-context.d.ts +81 -0
  118. package/lib/types/runtime/feedback-tool.d.ts +33 -11
  119. package/lib/types/runtime/hooks-mounts.d.ts +1 -1
  120. package/lib/types/runtime/host-locale.d.ts +20 -2
  121. package/lib/types/runtime/lsp-direct-config.d.ts +3 -3
  122. package/lib/types/runtime/lsp-mounts.d.ts +69 -19
  123. package/lib/types/runtime/lsp-status.d.ts +2 -1
  124. package/lib/types/runtime/mcp-auth-record.d.ts +29 -0
  125. package/lib/types/runtime/mcp-backend.d.ts +10 -14
  126. package/lib/types/runtime/mcp-client/config.d.ts +8 -2
  127. package/lib/types/runtime/mcp-client/host-contract.d.ts +78 -0
  128. package/lib/types/runtime/mcp-client/host-seams.d.ts +20 -64
  129. package/lib/types/runtime/mcp-client/oauth.d.ts +1 -1
  130. package/lib/types/runtime/mcp-client/plugin-identity.d.ts +16 -0
  131. package/lib/types/runtime/mcp-client/projection.d.ts +45 -0
  132. package/lib/types/runtime/mcp-client/tools.d.ts +5 -72
  133. package/lib/types/runtime/mcp-config.d.ts +25 -4
  134. package/lib/types/runtime/mcp-direct-config.d.ts +18 -4
  135. package/lib/types/runtime/mcp-mounts.d.ts +10 -15
  136. package/lib/types/runtime/mcp-overrides.d.ts +1 -1
  137. package/lib/types/runtime/mcp-status.d.ts +1 -1
  138. package/lib/types/runtime/mount-lifecycle.d.ts +53 -0
  139. package/lib/types/runtime/profile-seam.d.ts +64 -0
  140. package/lib/types/runtime/project-runtime.d.ts +10 -3
  141. package/lib/types/runtime/reconcile-scheduler.d.ts +45 -0
  142. package/lib/types/runtime/reconciler.d.ts +2 -0
  143. package/lib/types/runtime/regions.d.ts +1 -4
  144. package/lib/types/runtime/settings-namespace.d.ts +76 -0
  145. package/lib/types/runtime/skills-provider.d.ts +11 -2
  146. package/lib/types/runtime/source-auto-update.d.ts +32 -0
  147. package/lib/types/{model/state.d.ts → runtime/state-store.d.ts} +2 -2
  148. package/lib/types/runtime/storage-migration.d.ts +5 -3
  149. package/lib/types/runtime/subagent-catalog.d.ts +2 -0
  150. package/lib/types/runtime/timer-seat.d.ts +51 -0
  151. package/lib/types/runtime/tool-registry-observer.d.ts +12 -6
  152. package/lib/types/runtime/user-panels.d.ts +69 -19
  153. package/lib/types/runtime/user-store.d.ts +67 -20
  154. package/package.json +68 -24
  155. package/schemas/README.md +1 -1
@@ -1,5 +1,5 @@
1
1
  /** Suite detail and preview projections owned by the application layer. */
2
- import { readFile } from 'node:fs/promises';
2
+ import { readFile, stat } from 'node:fs/promises';
3
3
  import { parse as parseYaml } from 'yaml';
4
4
  import { discoverLspEntries } from '../catalog/surfaces.js';
5
5
  import { defaultMarkdownResources, resourceText } from '../catalog/component-files.js';
@@ -7,6 +7,16 @@ import { credentialRefsInServer } from '../runtime/mcp-config.js';
7
7
  import { redactMcpConfig, redactMcpOverrides } from '../runtime/mcp-redaction.js';
8
8
  import { applyOverride } from '../runtime/mcp-overrides.js';
9
9
  import { effectiveSurfaces } from '../model/types.js';
10
+ /** The checkout's last modification, or null when the path cannot be read. */
11
+ async function rootModifiedAt(root) {
12
+ try {
13
+ const stats = await stat(root);
14
+ return new Date(stats.mtimeMs).toISOString();
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
10
20
  /** Build the detail response for one normalized suite. */
11
21
  export async function buildSuiteDetail(suite, installed, diagnostics, mcpOverrides = {}) {
12
22
  const remoteUrl = suite.remote?.url;
@@ -18,6 +28,7 @@ export async function buildSuiteDetail(suite, installed, diagnostics, mcpOverrid
18
28
  description: suite.manifest.description ?? null,
19
29
  author: suite.manifest.author ?? null,
20
30
  keywords: suite.manifest.keywords ?? [],
31
+ updatedAt: await rootModifiedAt(suite.root),
21
32
  layout: suite.manifest.layout,
22
33
  dimension: suite.dimension,
23
34
  root: remoteUrl ?? suite.root,
@@ -47,7 +58,7 @@ export async function buildSuiteDetail(suite, installed, diagnostics, mcpOverrid
47
58
  ? suite.resources === undefined
48
59
  ? await hooksPreviews(suite.root)
49
60
  : { count: 0, entries: [] }
50
- : normalizedHookPreviews(suite)
61
+ : normalizedHookPreviews(suite.hooks)
51
62
  : { count: 0, entries: [] },
52
63
  commands: remoteUrl === undefined ? await markdownPreviews(suite.resources?.commands ?? (await defaultMarkdownResources(suite.root, 'commands'))) : [],
53
64
  agents: remoteUrl === undefined ? await markdownPreviews(suite.resources?.agents ?? (await defaultMarkdownResources(suite.root, 'agents'))) : [],
@@ -66,7 +77,7 @@ export async function readSkillContent(suite, skillName) {
66
77
  content = await readFile(skill.file, 'utf8');
67
78
  }
68
79
  catch (error) {
69
- throw new Error(`skill file unreadable: ${error instanceof Error ? error.message : String(error)}`);
80
+ throw new Error(`skill file unreadable: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
70
81
  }
71
82
  return { name: skill.name, description: skill.description, content, path: skill.file };
72
83
  }
@@ -79,10 +90,10 @@ async function markdownPreviews(resources) {
79
90
  for (const resource of resources) {
80
91
  try {
81
92
  const content = (await resourceText(resource)).slice(0, 64 * 1024);
82
- const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content);
93
+ const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content)?.[1];
83
94
  let description;
84
- if (match !== null) {
85
- const yaml = parseYaml(match[1]);
95
+ if (frontmatter !== undefined) {
96
+ const yaml = parseYaml(frontmatter);
86
97
  if (typeof yaml === 'object' && yaml !== null) {
87
98
  const desc = yaml['description'];
88
99
  if (typeof desc === 'string')
@@ -97,8 +108,8 @@ async function markdownPreviews(resources) {
97
108
  }
98
109
  return previews;
99
110
  }
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 }))));
111
+ function normalizedHookPreviews(hooks) {
112
+ const entries = Object.entries(hooks.events).flatMap(([event, groups]) => groups.flatMap(group => group.hooks.map(hook => ({ event, ...(group.matcher === undefined ? {} : { matcher: group.matcher }), command: hook.command }))));
102
113
  return { count: entries.length, entries };
103
114
  }
104
115
  async function hooksPreviews(root) {
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Install-state mutations over the persisted record: which suites are
3
+ * installed and enabled, which of their surfaces are on, and the config-seeded
4
+ * sources merged in at activation.
5
+ *
6
+ * The checkout a suite is read from stays source-owned: installing writes an
7
+ * entry, never a file inside the source.
8
+ */
9
+ import { isDirectory } from '../catalog/fs-probes.js';
10
+ import { discoverSuitesInSource } from '../catalog/suite-scanner.js';
11
+ import { SUITE_SURFACE_KEYS } from '../model/types.js';
12
+ import { installKey } from './catalog-context.js';
13
+ import { tryHead } from './source-store.js';
14
+ export class InstallStore {
15
+ context;
16
+ sources;
17
+ constructor(context, sources) {
18
+ this.context = context;
19
+ this.sources = sources;
20
+ }
21
+ /** Install a suite from a source and enable it. The client confirms the
22
+ * injected surfaces (skills / MCP / hooks / commands) in a pre-install
23
+ * dialog before this runs; disabling afterwards is always available. */
24
+ async install(sourceId, suiteId) {
25
+ return this.context.enqueue(async () => {
26
+ const source = this.context.state.sources.find(entry => entry.id === sourceId);
27
+ if (source === undefined)
28
+ throw new Error(`unknown source "${sourceId}"`);
29
+ const checkout = this.sources.checkoutPath(source);
30
+ let freshLock;
31
+ if (!(await isDirectory(checkout)))
32
+ freshLock = await this.sources.acquire(source);
33
+ const suites = await discoverSuitesInSource(checkout, sourceId, 'user', source.url);
34
+ const suite = suites.find(entry => entry.id === suiteId);
35
+ if (suite === undefined)
36
+ throw new Error(`suite "${suiteId}" not found in source "${sourceId}"`);
37
+ if (suite.remote !== undefined)
38
+ throw new Error(`suite "${suiteId}" is a remote reference (${suite.remote.url}); add its repository as a source before installing`);
39
+ // Archive (or tarball-fallback) sources have no git HEAD; their digest
40
+ // is the lock value.
41
+ const lockCommit = (await tryHead(checkout)) ?? freshLock ?? this.sources.knownHead(sourceId);
42
+ await this.setInstalled(sourceId, suiteId, { enabled: true, installedAt: new Date().toISOString(), lockCommit });
43
+ await this.context.notifyChanged(true);
44
+ });
45
+ }
46
+ /** Uninstall a suite while retaining the source checkout. */
47
+ async uninstall(sourceId, suiteId) {
48
+ return this.context.enqueue(async () => {
49
+ const key = installKey(sourceId, suiteId);
50
+ if (this.context.state.installed[key] === undefined)
51
+ throw new Error(`suite "${suiteId}" is not installed`);
52
+ const rest = Object.fromEntries(Object.entries(this.context.state.installed).filter(([entryKey]) => entryKey !== key));
53
+ await this.context.commit({ ...this.context.state, installed: rest });
54
+ await this.context.notifyChanged(true);
55
+ });
56
+ }
57
+ /** Enable or disable an installed suite. */
58
+ async setEnabled(sourceId, suiteId, enabled) {
59
+ return this.context.enqueue(async () => {
60
+ const entry = this.context.installed(sourceId, suiteId);
61
+ if (entry === undefined)
62
+ throw new Error(`suite "${suiteId}" is not installed`);
63
+ await this.setInstalled(sourceId, suiteId, { ...entry, enabled });
64
+ await this.context.notifyChanged(true);
65
+ });
66
+ }
67
+ /** Enable or disable one runtime surface of an installed suite. */
68
+ async setSurface(sourceId, suiteId, surface, enabled) {
69
+ return this.context.enqueue(async () => {
70
+ const entry = this.context.installed(sourceId, suiteId);
71
+ if (entry === undefined)
72
+ throw new Error(`suite "${suiteId}" is not installed`);
73
+ if (!SUITE_SURFACE_KEYS.includes(surface))
74
+ throw new Error(`surface "${surface}" is not toggleable`);
75
+ const surfaces = { ...(entry.surfaces ?? {}), [surface]: enabled };
76
+ await this.setInstalled(sourceId, suiteId, { ...entry, surfaces });
77
+ await this.context.notifyChanged(true);
78
+ });
79
+ }
80
+ /** Append config-seeded sources missing from user state and persist them. */
81
+ async mergeSources(sources) {
82
+ const existing = new Set(this.context.state.sources.map(source => source.id));
83
+ const additions = sources.filter(source => !existing.has(source.id));
84
+ if (additions.length === 0)
85
+ return;
86
+ await this.context.commit({ ...this.context.state, sources: [...this.context.state.sources, ...additions] });
87
+ // New sources change the fingerprint, but their content arrives through
88
+ // an acquire, so mark the scan cache dirty conservatively.
89
+ this.context.invalidateScans();
90
+ this.context.invalidateSnapshot(true);
91
+ }
92
+ async setInstalled(sourceId, suiteId, entry) {
93
+ await this.context.commit({ ...this.context.state, installed: { ...this.context.state.installed, [installKey(sourceId, suiteId)]: entry } });
94
+ }
95
+ }
@@ -0,0 +1,97 @@
1
+ import { loadLspServers, saveLspServers } from '../runtime/lsp-direct-config.js';
2
+ import { loadDisabledLspServers, saveDisabledLspServers } from '../runtime/lsp-server-state.js';
3
+ import { buildLspStatus } from '../runtime/lsp-status.js';
4
+ import { describeLegacySeam, findLegacyLspSeams, migrateLegacyLspSeam } from '../runtime/profile-seam.js';
5
+ import { applyLspOverrides, lspConfig, validateServerLsp } from '../runtime/server-config.js';
6
+ export class LspService {
7
+ context;
8
+ ports;
9
+ constructor(context, ports) {
10
+ this.context = context;
11
+ this.ports = ports;
12
+ }
13
+ /** The LSP status surface: declared servers merged with mount diagnostics. */
14
+ async status() {
15
+ const snapshot = await this.context.snapshots.readUserCatalog();
16
+ const direct = await loadLspServers(this.context.agentsRoot);
17
+ const payload = buildLspStatus(await applyLspOverrides(this.context.dataRoot, snapshot.suites), this.ports.lspStatusSource, direct);
18
+ // The profile scan is filesystem work, so it runs only while the conflict
19
+ // it explains is actually on screen — the panel polls this while rows start.
20
+ if (payload.entries.some(entry => entry.state === 'conflict'))
21
+ payload.legacySeam = await this.legacySeam();
22
+ return payload;
23
+ }
24
+ /** The profile layer behind a reported seam conflict, when one can be found. */
25
+ async legacySeam() {
26
+ const seams = await findLegacyLspSeams();
27
+ const seam = seams[0];
28
+ if (seam === undefined)
29
+ return undefined;
30
+ return {
31
+ profile: seam.profile,
32
+ patchPath: seam.patchPath,
33
+ rows: seam.rows.map(row => ({ id: row.id, name: row.name })),
34
+ otherProfiles: seams.slice(1).map(other => other.profile),
35
+ restartRequired: seam.patchReload !== 'live',
36
+ summary: describeLegacySeam(seam)
37
+ };
38
+ }
39
+ /**
40
+ * Remove one profile's legacy LSP layer, then re-reconcile.
41
+ *
42
+ * The removal edits a file the user owns, so the profile is matched against
43
+ * what was actually found on disk instead of trusting the request.
44
+ */
45
+ async migrateLegacySeam(profile) {
46
+ return this.context.enqueue(async () => {
47
+ const seams = await findLegacyLspSeams();
48
+ const seam = seams.find(candidate => candidate.profile === profile);
49
+ if (seam === undefined)
50
+ throw new Error(`the profile "${profile}" no longer registers an LSP layer`);
51
+ const result = await migrateLegacyLspSeam(seam);
52
+ await this.context.notifyChanged(true);
53
+ return { profile: result.profile, patchPath: result.patchPath, backupPath: result.backupPath, restartRequired: result.restartRequired };
54
+ });
55
+ }
56
+ /** The user's direct LSP server table (normalized specs). */
57
+ async servers() {
58
+ return (await loadLspServers(this.context.agentsRoot)).servers;
59
+ }
60
+ /** Create one direct LSP declaration without replacing other user servers. */
61
+ async addServer(name, config) {
62
+ return this.context.enqueue(async () => {
63
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(name))
64
+ throw new Error('invalid LSP server name');
65
+ const direct = await loadLspServers(this.context.agentsRoot);
66
+ if (direct.errors.length > 0)
67
+ throw new Error(direct.errors.join('; '));
68
+ if (Object.hasOwn(direct.servers, name))
69
+ throw new Error('LSP server already exists');
70
+ const spec = validateServerLsp(name, config);
71
+ await saveLspServers(this.context.agentsRoot, {
72
+ lspServers: { ...Object.fromEntries(Object.entries(direct.servers).map(([key, value]) => [key, lspConfig(value)])), [name]: lspConfig(spec) }
73
+ });
74
+ await this.context.notifyChanged(true);
75
+ });
76
+ }
77
+ /** Validate and persist the user's direct LSP server table. */
78
+ async setServers(raw) {
79
+ return this.context.enqueue(async () => {
80
+ const { servers } = await saveLspServers(this.context.agentsRoot, raw);
81
+ await this.context.notifyChanged(true);
82
+ return servers;
83
+ });
84
+ }
85
+ /** Enable or disable one declared language server by row id. */
86
+ async setEnabled(id, enabled) {
87
+ return this.context.enqueue(async () => {
88
+ const disabled = await loadDisabledLspServers(this.context.dataRoot);
89
+ if (enabled)
90
+ disabled.delete(id);
91
+ else
92
+ disabled.add(id);
93
+ await saveDisabledLspServers(this.context.dataRoot, disabled);
94
+ await this.context.notifyChanged(true);
95
+ });
96
+ }
97
+ }
@@ -0,0 +1,263 @@
1
+ /**
2
+ * MCP use cases: the flat service inventory, per-server overrides, the direct
3
+ * user servers, the mount-backend choice, and dropping one server's OAuth
4
+ * grant so the next mount re-runs the browser authorization.
5
+ *
6
+ * Server configuration is edited through saved overrides — a suite's own
7
+ * `mcp.json` stays source-owned and is never rewritten.
8
+ */
9
+ import { qualifiedSuiteId } from '../catalog/paths.js';
10
+ import { isDirectory } from '../catalog/fs-probes.js';
11
+ import { discoverSuitesInSource } from '../catalog/suite-scanner.js';
12
+ import { readLocalePreference } from '../runtime/host-locale.js';
13
+ import { probeHostMcpClient } from '../runtime/mcp-backend.js';
14
+ import { loadLspServers, saveLspServers } from '../runtime/lsp-direct-config.js';
15
+ import { addUserMcpServer, loadUserMcpSuite, USER_MCP_SOURCE, USER_MCP_SUITE } from '../runtime/mcp-direct-config.js';
16
+ import { applyOverride, loadSuiteOverrides, mergeOverridePatch, saveSuiteOverrides } from '../runtime/mcp-overrides.js';
17
+ import { redactMcpConfig, redactMcpOverrides } from '../runtime/mcp-redaction.js';
18
+ import { buildMcpStatus } from '../runtime/mcp-status.js';
19
+ import { resolveRegion } from '../runtime/regions.js';
20
+ import { applyLspOverrides, lspConfig, restoreRedactedConfig, saveLspOverride, validateServerLsp, validateServerMcp } from '../runtime/server-config.js';
21
+ export class McpService {
22
+ context;
23
+ ports;
24
+ sources;
25
+ /** Latest MCP mount diagnostics (suiteId -> reasons), fed by host reconcile. */
26
+ diagnostics = [];
27
+ constructor(context, ports, sources) {
28
+ this.context = context;
29
+ this.ports = ports;
30
+ this.sources = sources;
31
+ }
32
+ /** Build the flat MCP service inventory for the status surface. */
33
+ async status() {
34
+ const snapshot = await this.context.snapshots.readUserCatalog();
35
+ const suites = [...snapshot.suites, await loadUserMcpSuite(this.context.agentsRoot)];
36
+ const payload = buildMcpStatus(suites, this.diagnostics, this.ports.mcpToolSnapshot(), await this.allOverrides(suites));
37
+ for (const entry of payload.entries)
38
+ if (entry.suiteId === `${USER_MCP_SOURCE}/${USER_MCP_SUITE}`) {
39
+ entry.kind = 'direct';
40
+ entry.managed = true;
41
+ }
42
+ const oauthBackend = (await this.backend()) === 'builtin' && this.reauthorizeAvailable();
43
+ for (const entry of payload.entries) {
44
+ const auth = entry.config?.auth;
45
+ entry.canReauthorize =
46
+ oauthBackend &&
47
+ (entry.kind === 'plugin' || entry.managed === true) &&
48
+ (entry.transport === 'sse' || entry.transport === 'streamable-http') &&
49
+ auth?.enabled !== false &&
50
+ !['disabled', 'foreign', 'orphaned'].includes(entry.state);
51
+ }
52
+ return payload;
53
+ }
54
+ /** Persist a user-owned MCP service and reconcile its bridge mount. */
55
+ async addServer(name, server) {
56
+ return this.context.enqueue(async () => {
57
+ await addUserMcpServer(this.context.agentsRoot, name, server);
58
+ await this.context.notifyChanged(true);
59
+ });
60
+ }
61
+ /** Read effective service configuration without exposing credential literals. */
62
+ async serverConfig(kind, id) {
63
+ const config = await this.resolveServerConfig(kind, id);
64
+ return { kind, id, editable: true, config: redactMcpConfig(config.value) };
65
+ }
66
+ /** Validate a complete replacement before writing; plugin checkouts remain untouched. */
67
+ async saveServerConfig(kind, id, config) {
68
+ return this.context.enqueue(async () => {
69
+ const current = await this.resolveServerConfig(kind, id);
70
+ const value = restoreRedactedConfig(config, current.value);
71
+ if (kind === 'mcp') {
72
+ const server = await validateServerMcp(current.root, current.key, value);
73
+ const overrides = await loadSuiteOverrides(this.context.dataRoot, current.suiteKey);
74
+ const enabled = overrides[current.key]?.enabled;
75
+ overrides[current.key] = { config: server, ...(enabled === undefined ? {} : { enabled }) };
76
+ await saveSuiteOverrides(this.context.dataRoot, current.suiteKey, overrides);
77
+ }
78
+ else {
79
+ const server = validateServerLsp(current.key, value);
80
+ if (current.suiteKey === 'direct') {
81
+ const direct = await loadLspServers(this.context.agentsRoot);
82
+ if (direct.errors.length > 0)
83
+ throw new Error(direct.errors.join('; '));
84
+ direct.servers[current.key] = server;
85
+ await saveLspServers(this.context.agentsRoot, { lspServers: Object.fromEntries(Object.entries(direct.servers).map(([key, spec]) => [key, lspConfig(spec)])) });
86
+ }
87
+ else
88
+ await saveLspOverride(this.context.dataRoot, id, server);
89
+ }
90
+ await this.context.notifyChanged(true);
91
+ });
92
+ }
93
+ /** One suite's persisted MCP overrides, addressed by qualified suite id. */
94
+ async overrides(sourceId, suiteId) {
95
+ const suiteKey = qualifiedSuiteId(sourceId, suiteId);
96
+ const snapshot = await this.context.snapshots.readUserCatalog();
97
+ if (!snapshot.suites.some(suite => suite.sourceId === sourceId && suite.id === suiteId))
98
+ throw new Error(`suite "${suiteKey}" not found`);
99
+ return redactMcpOverrides(await loadSuiteOverrides(this.context.dataRoot, suiteKey));
100
+ }
101
+ /**
102
+ * Set or clear one server's MCP override and remount it. `override === null`
103
+ * clears the override (back to the source config). The serverKey must exist
104
+ * in the suite's parsed mcp.json.
105
+ */
106
+ async setOverride(sourceId, suiteId, serverKey, override) {
107
+ return this.context.enqueue(async () => {
108
+ const suiteKey = qualifiedSuiteId(sourceId, suiteId);
109
+ const source = this.context.state.sources.find(entry => entry.id === sourceId);
110
+ // Only the suite's declared servers matter here, and one branch reads
111
+ // freshly scanned suites — the scan shape covers both.
112
+ let suites;
113
+ if (sourceId === USER_MCP_SOURCE && suiteId === USER_MCP_SUITE) {
114
+ suites = [await loadUserMcpSuite(this.context.agentsRoot)];
115
+ }
116
+ else {
117
+ if (source === undefined)
118
+ throw new Error(`unknown source "${sourceId}"`);
119
+ const checkout = this.sources.checkoutPath(source);
120
+ if (!(await isDirectory(checkout)))
121
+ await this.sources.acquire(source);
122
+ suites = await discoverSuitesInSource(checkout, sourceId, 'user', source.url);
123
+ }
124
+ const suite = suites.find(entry => entry.id === suiteId);
125
+ if (suite === undefined)
126
+ throw new Error(`suite "${suiteId}" not found in source "${sourceId}"`);
127
+ if (suite.mcp?.servers[serverKey] === undefined)
128
+ throw new Error(`server "${serverKey}" is not defined by suite "${suiteId}"`);
129
+ // Override files are keyed by the qualified id so two sources' same-named
130
+ // suites never share one override record.
131
+ const overrides = await loadSuiteOverrides(this.context.dataRoot, suiteKey);
132
+ if (override === null) {
133
+ delete overrides[serverKey];
134
+ }
135
+ else {
136
+ // Merge onto the stored record: the UI never receives literal secret
137
+ // values, so a verbatim write would drop keys it simply redacted.
138
+ overrides[serverKey] = mergeOverridePatch(overrides[serverKey] ?? {}, override);
139
+ }
140
+ await saveSuiteOverrides(this.context.dataRoot, suiteKey, overrides);
141
+ await this.context.notifyChanged(true);
142
+ // The status surface reads the live tool registry, so an immediate read
143
+ // races the reconciler's teardown: a just-disabled server still carries
144
+ // its observed tools and reports the orphaned state with its switch on
145
+ // until the pass finishes. Joining the pass here is bounded — one stuck
146
+ // mount costs the request its deadline, never the plugin.
147
+ await this.context.refreshSettled();
148
+ });
149
+ }
150
+ /**
151
+ * Enable or disable one declared server, addressed by the source-qualified
152
+ * suite id the status rows carry. The qualified id is split here rather than
153
+ * by callers so the separator stays a server-side detail.
154
+ */
155
+ async setServerEnabled(suiteKey, serverKey, enabled) {
156
+ const separator = suiteKey.indexOf('/');
157
+ if (separator <= 0)
158
+ throw new Error(`invalid suite id "${suiteKey}"`);
159
+ await this.setOverride(suiteKey.slice(0, separator), suiteKey.slice(separator + 1), serverKey, { enabled });
160
+ }
161
+ /**
162
+ * Re-run the MCP reconcile pass: retries failed mounts and clears residual
163
+ * tools, without changing any catalog state.
164
+ *
165
+ * Every live mount is rebuilt rather than fingerprinted: a bridge whose
166
+ * server died underneath it keeps matching its own resolved config, so a
167
+ * retry is the only path that re-verifies a mount that looks connected. The
168
+ * wait is bounded — the pass keeps running, so a slow server reports its
169
+ * outcome to the status surface instead of holding the request open.
170
+ */
171
+ async retryMounts() {
172
+ this.ports.mcpRemountAll();
173
+ await this.context.notifyChanged(true);
174
+ await this.context.refreshSettled();
175
+ }
176
+ /** The persisted backend choice without the host-client probe (mount-time provider). */
177
+ async backend() {
178
+ return this.ports.mcpBackend();
179
+ }
180
+ /**
181
+ * The active MCP mount backend plus a live probe of the host client, and
182
+ * the download-region setting with its locale-resolved effective route —
183
+ * everything the plugin-config card renders.
184
+ */
185
+ async backendInfo() {
186
+ const [backend, hostClient, regionSetting, locale] = await Promise.all([this.ports.mcpBackend(), probeHostMcpClient(), this.ports.downloadRegion(), readLocalePreference()]);
187
+ return { backend, hostClient, downloadRegion: { setting: regionSetting, effective: resolveRegion(regionSetting, locale) } };
188
+ }
189
+ /**
190
+ * Switch the MCP mount backend and remount every suite server through it.
191
+ * A host-client switch takes effect only where the host package resolves;
192
+ * unresolvable or unsupported transports surface as per-server diagnostics.
193
+ */
194
+ async setBackend(backend) {
195
+ return this.context.enqueue(async () => {
196
+ await this.ports.setMcpBackend(backend);
197
+ await this.context.notifyChanged(true);
198
+ });
199
+ }
200
+ /**
201
+ * Drop one MCP server's OAuth grant record so the next mount re-runs the
202
+ * browser authorization — the path for "I picked too narrow a scope".
203
+ * @param serverName - the derived serverName whose folded form keys the record.
204
+ * @throws when the credentials service is not mounted.
205
+ */
206
+ async reauthorize(serverName) {
207
+ return this.context.enqueue(async () => {
208
+ await this.ports.credentialsStore?.deleteGrantRecord(serverName);
209
+ // Dropping a grant changes no resolved config field, so a fingerprint
210
+ // reconcile would keep the live bridge — with its in-memory tokens —
211
+ // untouched. The serverName maps back to exactly one mount key (the
212
+ // registry reserved the name), so flag it for an explicit rebuild.
213
+ const key = this.ports.mcpServerOwner(serverName);
214
+ if (key !== undefined)
215
+ this.ports.mcpRemount(key.suiteId, key.serverKey);
216
+ await this.context.notifyChanged(true);
217
+ });
218
+ }
219
+ /** Whether the re-authorize action can run in this composition. */
220
+ reauthorizeAvailable() {
221
+ return this.ports.credentialsStore !== undefined;
222
+ }
223
+ /** MCP overrides for a supplied runtime selection, or the full market catalog when omitted. */
224
+ async allOverrides(suites) {
225
+ const map = new Map();
226
+ for (const suite of suites ?? (await this.context.snapshots.readUserCatalog()).suites) {
227
+ const suiteKey = qualifiedSuiteId(suite.sourceId, suite.id);
228
+ const overrides = await loadSuiteOverrides(this.context.dataRoot, suiteKey);
229
+ if (Object.keys(overrides).length > 0)
230
+ map.set(suiteKey, overrides);
231
+ }
232
+ return map;
233
+ }
234
+ /** Resolve one editable server: its key, owning suite, root, and effective config. */
235
+ async resolveServerConfig(kind, id) {
236
+ if (kind === 'lsp' && id.startsWith('direct/')) {
237
+ const key = id.slice(7);
238
+ const server = (await loadLspServers(this.context.agentsRoot)).servers[key];
239
+ if (server === undefined)
240
+ throw new Error('LSP server not found');
241
+ return { key, suiteKey: 'direct', root: this.context.agentsRoot, value: lspConfig(server) };
242
+ }
243
+ const suites = [...(await this.context.snapshots.readUserCatalog()).suites, await loadUserMcpSuite(this.context.agentsRoot)];
244
+ for (const suite of await applyLspOverrides(this.context.dataRoot, suites)) {
245
+ const suiteKey = qualifiedSuiteId(suite.sourceId, suite.id);
246
+ if (kind === 'mcp') {
247
+ for (const [key, server] of Object.entries(suite.mcp?.servers ?? {})) {
248
+ if (id !== `plugin:${suiteKey}/${key}`)
249
+ continue;
250
+ const overrides = await loadSuiteOverrides(this.context.dataRoot, suiteKey);
251
+ return { key, suiteKey, root: suite.root, value: { ...applyOverride(server, overrides[key]) } };
252
+ }
253
+ }
254
+ else {
255
+ for (const [key, server] of Object.entries(suite.lsp?.servers ?? {})) {
256
+ if (id === `${suiteKey}/${key}`)
257
+ return { key, suiteKey, root: suite.root, value: lspConfig(server) };
258
+ }
259
+ }
260
+ }
261
+ throw new Error('service configuration is not managed by this plugin');
262
+ }
263
+ }
@@ -1,9 +1,23 @@
1
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';
2
+ import { realpath, stat, unlink } from 'node:fs/promises';
3
+ import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
4
4
  import { defaultMarkdownResources, resourceText } from '../catalog/component-files.js';
5
+ import { pluginRootOf } from '../catalog/plugin-variables.js';
6
+ import { isWithin, suiteDataDir } from '../catalog/paths.js';
5
7
  import { stripFrontmatter } from '../catalog/skills-parse.js';
6
8
  import { parseFrontmatterRecord } from '../runtime/user-store.js';
9
+ /**
10
+ * Id of one panel entry backed by a file inside an installed suite's checkout.
11
+ * The leading `[` is what distinguishes a plugin entry from a user-authored
12
+ * one; `update`/`remove` use {@link isPluginResourceId} to tell them apart.
13
+ */
14
+ export function pluginResourceId(sourceId, suiteId, kind, name) {
15
+ return JSON.stringify([sourceId, suiteId, kind, name]);
16
+ }
17
+ /** Whether a panel entry id addresses a file inside an installed suite. */
18
+ export function isPluginResourceId(id) {
19
+ return id.startsWith('[');
20
+ }
7
21
  export function createPanelResources(catalog, users) {
8
22
  return {
9
23
  skills: new PanelResources(catalog, users.skills, 'skills'),
@@ -23,9 +37,8 @@ class PanelResources {
23
37
  async list(strict = false) {
24
38
  const entries = await this.users.list(strict);
25
39
  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
40
  for (const suite of snapshot.suites) {
28
- if (!installed.has(JSON.stringify([suite.sourceId, suite.id])) || suite.remote !== undefined)
41
+ if (!this.catalog.isInstalled(suite.sourceId, suite.id) || suite.remote !== undefined)
29
42
  continue;
30
43
  const files = this.kind === 'skills'
31
44
  ? suite.skills.map(skill => ({ name: skill.name, file: skill.file }))
@@ -47,20 +60,31 @@ class PanelResources {
47
60
  metadata = { disabled: true, validationError: String(error) };
48
61
  }
49
62
  entries.push({
50
- id: JSON.stringify([suite.sourceId, suite.id, this.kind, name]),
63
+ id: pluginResourceId(suite.sourceId, suite.id, this.kind, name),
51
64
  name,
52
65
  origin: 'plugin',
53
66
  suiteName: suite.manifest.name,
54
- disabled: !suite.enabled || suite.activeSurfaces?.[this.kind] === false || metadata.disabled === true,
67
+ disabled: !suite.enabled || suite.activeSurfaces[this.kind] === false || metadata.disabled === true,
55
68
  metadata,
56
69
  rawText,
57
70
  content: stripFrontmatter(rawText),
58
71
  path: file,
59
- description: typeof metadata.description === 'string' ? metadata.description : ''
72
+ description: typeof metadata.description === 'string' ? metadata.description : '',
73
+ ...(pluginRootOf(suite) === undefined ? {} : { suiteRoot: suite.root, suiteData: suiteDataDir(this.catalog.dataRoot, suite.sourceId, suite.id) })
60
74
  });
61
75
  }
62
76
  }
63
- return entries;
77
+ // One pass stamps each entry's own last modification: the panel reads every
78
+ // file's text anyway, and both origins get the same treatment here.
79
+ return await Promise.all(entries.map(async (entry) => {
80
+ try {
81
+ const stats = await stat(entry.path);
82
+ return { ...entry, updatedAt: new Date(stats.mtimeMs).toISOString() };
83
+ }
84
+ catch {
85
+ return entry;
86
+ }
87
+ }));
64
88
  }
65
89
  async get(id) {
66
90
  return (await this.list()).find(entry => (entry.id ?? entry.name) === id);
@@ -68,27 +92,32 @@ class PanelResources {
68
92
  create(name, text) {
69
93
  return this.users.create(name, text);
70
94
  }
95
+ /**
96
+ * A plugin resource is writable only when it sits inside the user dimension
97
+ * root; suite checkouts elsewhere on disk stay read-only. The user-panel
98
+ * store lives in the shared Agent layout root instead, so containment is
99
+ * measured against the catalog's root, not the panel directory.
100
+ */
71
101
  async pluginPath(id) {
72
102
  const entry = await this.get(id);
73
103
  if (entry?.origin !== 'plugin')
74
104
  throw new Error('Unknown installed plugin resource');
75
105
  if (entry.path.endsWith('.json'))
76
106
  throw new Error('Inline manifest resources are read-only; edit their source manifest');
77
- const root = await realpath(this.users.root());
107
+ const root = await realpath(this.catalog.userRoot);
78
108
  const path = await realpath(entry.path);
79
- const rel = relative(root, path);
80
- if (rel === '..' || rel.startsWith('../') || isAbsolute(rel))
109
+ if (!isWithin(root, path))
81
110
  throw new Error('External source files are read-only; create a user resource to customize them');
82
111
  return path;
83
112
  }
84
113
  async update(id, text) {
85
- if (!id.startsWith('['))
114
+ if (!isPluginResourceId(id))
86
115
  return this.users.update(id, text);
87
116
  parseFrontmatterRecord(text);
88
- await writeFile(await this.pluginPath(id), text, 'utf8');
117
+ await writeFileAtomic(await this.pluginPath(id), text, { mode: 0o644 });
89
118
  }
90
119
  async remove(id) {
91
- if (!id.startsWith('['))
120
+ if (!isPluginResourceId(id))
92
121
  return this.users.remove(id);
93
122
  await unlink(await this.pluginPath(id));
94
123
  }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The seams a catalog runs on when nothing is wired. The MCP backend writer
3
+ * rejects rather than silently accepting a choice nothing can persist.
4
+ */
5
+ export const defaultCatalogPorts = {
6
+ mcpToolSnapshot: () => [],
7
+ credentialsStore: undefined,
8
+ mcpRemount: () => { },
9
+ mcpRemountAll: () => { },
10
+ mcpServerOwner: () => undefined,
11
+ lspStatusSource: { diagnosticsSnapshot: () => new Map(), hasLiveMounts: () => false },
12
+ mcpBackend: async () => 'builtin',
13
+ setMcpBackend: async () => {
14
+ throw new Error('the settings service is not mounted');
15
+ },
16
+ downloadRegion: async () => 'auto'
17
+ };
18
+ /** Fill in every seam the composition root left unwired. */
19
+ export function resolveCatalogPorts(overrides = {}) {
20
+ return { ...defaultCatalogPorts, ...overrides };
21
+ }