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,464 +1,146 @@
1
1
  /**
2
- * Catalog application module: the host-authoritative owner of source state,
3
- * discovery, install mutations, and coherent suite snapshots.
2
+ * Catalog application module: the use-case surface the HTTP routes, the host
3
+ * entry, and the runtime mount adapters call.
4
4
  *
5
- * The module deliberately keeps the existing filesystem and Git operations
6
- * behind one interface while callers migrate from SuiteManager. User and
7
- * project snapshots use their own persisted state files and share the same
8
- * source-selection and suite-scanning rules.
5
+ * The class is a thin facade. Persisted state, the serialized mutation queue,
6
+ * the revision and scan generations, and the change pipeline live in
7
+ * {@link CatalogContext}; discovery caching in {@link SnapshotCache}; and each
8
+ * use-case family in its own collaborator (source acquisition, install state,
9
+ * MCP, LSP). The facade composes them and forwards, so a caller never has to
10
+ * know which collaborator owns a given invariant.
9
11
  */
10
- import { mkdir, readdir, rename } from 'node:fs/promises';
11
- import { join } from 'node:path';
12
- import { repoName } from '../catalog/manifests.js';
13
- import { canonicalGitUrl } from '../catalog/scan-resolvers.js';
14
- import { discoverSourceListWithNotes } from '../catalog/source-catalog.js';
15
- import { discoverSuitesInSource } from '../catalog/suite-scanner.js';
16
- import { archiveInstall } from '../catalog/archive.js';
17
- import { gitClone, gitCurrentBranch, gitHead, gitRemoteUrl, gitRemove, gitSetRemoteUrl, gitSync } from '../catalog/git.js';
18
- import { buildMcpStatus } from '../runtime/mcp-status.js';
19
- import { addUserMcpServer, loadUserMcpSuite, USER_MCP_SOURCE, USER_MCP_SUITE } from '../runtime/mcp-direct-config.js';
20
- import { buildLspStatus } from '../runtime/lsp-status.js';
21
- import { loadLspServers, saveLspServers } from '../runtime/lsp-direct-config.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';
25
- import { probeHostMcpClient } from '../runtime/mcp-backend.js';
26
- import { githubCloneUrl, resolveRegion } from '../runtime/regions.js';
27
- import { readLocalePreference } from '../runtime/host-locale.js';
28
- import { redactMcpConfig, redactMcpOverrides } from '../runtime/mcp-redaction.js';
12
+ import { qualifiedSuiteId } from '../catalog/paths.js';
13
+ import { loadUserMcpSuite } from '../runtime/mcp-direct-config.js';
14
+ import { loadSuiteOverrides } from '../runtime/mcp-overrides.js';
15
+ import { applyLspOverrides } from '../runtime/server-config.js';
29
16
  import { buildSuiteDetail, readSkillContent } from './details.js';
30
- import { deriveSourceIdCandidates, expandHome, isDirectory, pathExists, qualifiedSuiteId, resolveProjectRoot, sanitizeId, sourceCheckoutDir, sourcesDir, STATE_FILE_NAME } from '../catalog/paths.js';
31
- import { loadState, saveState, EMPTY_STATE } from '../model/state.js';
32
- import { effectiveSurfaces, SUITE_SURFACE_KEYS, resolveSourceKind } from '../model/types.js';
17
+ import { CatalogContext } from './catalog-context.js';
18
+ import { InstallStore } from './install-store.js';
19
+ import { LspService } from './lsp-service.js';
20
+ import { McpService } from './mcp-service.js';
21
+ import { resolveCatalogPorts } from './ports.js';
22
+ import { SourceStore, codeloadTarballUrl } from './source-store.js';
33
23
  export class Catalog {
34
- options;
35
- state = EMPTY_STATE;
36
- mutationQueue = Promise.resolve();
37
- statePath;
38
- headCache = new Map();
39
- listeners = new Set();
40
- revision = 0;
41
- userSnapshot;
42
- userSnapshotExpiresAt = 0;
43
- userSnapshotPromise;
44
- /**
45
- * Discovery scan cache: per-sources-fingerprint scan results with a TTL.
46
- * Install / enable / surface toggles re-derive the snapshot from cached
47
- * discovery (cheap mapping) instead of re-walking every checkout — a full
48
- * rescan of a 2,500-suite catalog costs ~1s and UI mutations happen in
49
- * bursts. Content-changing mutations (add / update / remove / adopt /
50
- * refresh / acquire) clear every cached discovery generation; the TTL
51
- * bounds staleness for in-place working-tree
52
- * edits of local sources.
53
- */
54
- scanCache = new Map();
55
- scanGeneration = 0;
56
- scanPromises = new Map();
57
- static SCAN_CACHE_TTL_MS = 30_000;
58
- static SCAN_CACHE_MAX_ENTRIES = 8;
59
- /**
60
- * User-dimension snapshot TTL: bounded staleness for edits made outside the
61
- * plugin (a new skill dropped into a local source's working tree, a hand
62
- * edit in a project layout) becomes visible on the next read past the TTL
63
- * instead of living until the next mutation. Configurable; a 0 value
64
- * disables snapshot caching entirely.
65
- */
66
- userSnapshotTtlMs;
67
- /** Project-dimension snapshots keyed by project root, with TTL freshness. */
68
- projectSnapshots = new Map();
69
- projectSnapshotPromises = new Map();
70
- projectSnapshotTtlMs;
71
- /** Latest MCP mount diagnostics (suiteId -> reasons), fed by host reconcile. */
72
- mcpDiagnostics = [];
73
- /** Latest LSP mount diagnostics source, wired by the host entry. */
74
- lspStatusSource;
75
- /** Credential-store seam for dropping a grant record (MCP re-authorize). */
76
- credentialsStore;
77
- toolSnapshotProvider = () => [];
78
- /** Mount-registry seam: flags a mount key for an explicit rebuild (re-authorize). */
79
- mcpRemount = () => { };
80
- /** Resolves the mount key owning one derived serverName; absent when the server is not mounted. */
81
- mcpServerOwner = () => undefined;
82
- /** Backend source (the host settings namespace scope); default is the built-in client. */
83
- mcpBackendProvider = async () => 'builtin';
84
- /** Backend writer (a scope update); absent until the settings service resolves. */
85
- mcpBackendWriter = async () => {
86
- throw new Error('the settings service is not mounted');
87
- };
88
- /** Download-region source (the host settings namespace scope). */
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
- }
24
+ context;
25
+ ports;
26
+ snapshots;
27
+ sourceStore;
28
+ installs;
29
+ mcp;
30
+ lsp;
98
31
  constructor(options) {
99
- this.options = options;
100
- this.statePath = join(options.userRoot, STATE_FILE_NAME);
101
- this.projectSnapshotTtlMs = options.projectSnapshotTtlMs ?? 5_000;
102
- this.userSnapshotTtlMs = options.userSnapshotTtlMs ?? Catalog.SCAN_CACHE_TTL_MS;
103
- }
104
- /** Install the host tool snapshot provider used by the MCP status surface. */
105
- setMcpToolSnapshotProvider(provider) {
106
- this.toolSnapshotProvider = provider;
107
- }
108
- /** Install the credential-store seam backing the MCP re-authorize action. */
109
- setCredentialsStore(store) {
110
- this.credentialsStore = store;
111
- }
112
- /**
113
- * Drop one MCP server's OAuth grant record so the next mount re-runs the
114
- * browser authorization — the path for "I picked too narrow a scope".
115
- * @param serverName - the derived serverName whose folded form keys the record.
116
- * @throws when the credentials service is not mounted.
117
- */
118
- async reauthorizeMcpServer(serverName) {
119
- await this.enqueue(async () => {
120
- await this.credentialsStore?.deleteGrantRecord(serverName);
121
- // Dropping a grant changes no resolved config field, so a fingerprint
122
- // reconcile would keep the live bridge — with its in-memory tokens —
123
- // untouched. The serverName maps back to exactly one mount key (the
124
- // registry reserved the name), so flag it for an explicit rebuild.
125
- const key = this.mcpServerOwner(serverName);
126
- if (key !== undefined)
127
- this.mcpRemount?.(key.suiteId, key.serverKey);
128
- await this.notifyChanged(true);
129
- });
130
- }
131
- /**
132
- * Install the MCP remount seam used by re-authorize: the registry flags the
133
- * mount so the next reconcile tears the live bridge down and rebuilds it.
134
- */
135
- setMcpRemountHook(hook, serverOwner) {
136
- this.mcpRemount = hook;
137
- this.mcpServerOwner = serverOwner;
138
- }
139
- /** Whether the re-authorize action can run in this composition. */
140
- mcpReauthorizeAvailable() {
141
- return this.credentialsStore !== undefined;
142
- }
143
- /** Install the LSP mount status source used by the LSP status surface. */
144
- setLspStatusSource(source) {
145
- this.lspStatusSource = source;
32
+ this.context = new CatalogContext(options);
33
+ this.ports = resolveCatalogPorts(options.ports);
34
+ this.snapshots = this.context.snapshots;
35
+ this.sourceStore = new SourceStore(this.context, this.ports);
36
+ this.installs = new InstallStore(this.context, this.sourceStore);
37
+ this.mcp = new McpService(this.context, this.ports, this.sourceStore);
38
+ this.lsp = new LspService(this.context, this.ports);
146
39
  }
147
- /** Subscribe to completed catalog mutations; returns an unsubscribe function. */
148
- subscribe(listener) {
149
- this.listeners.add(listener);
150
- return () => this.listeners.delete(listener);
151
- }
152
- /** Build the flat MCP service inventory for the status surface. */
153
- async mcpStatus() {
154
- const snapshot = await this.readUserCatalog();
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
- });
240
- }
241
- /** The LSP status surface: declared servers merged with mount diagnostics. */
242
- async lspStatus() {
243
- const snapshot = await this.readUserCatalog();
244
- const direct = await loadLspServers(this.options.dataRoot);
245
- return buildLspStatus(await applyLspOverrides(this.options.dataRoot, snapshot.suites), this.lspStatusSource ?? { diagnosticsSnapshot: () => new Map(), hasLiveMounts: () => false }, direct);
40
+ /** Latest MCP mount diagnostics (suiteId -> reasons), fed by host reconcile. */
41
+ get mcpDiagnostics() {
42
+ return this.mcp.diagnostics;
246
43
  }
247
- /** The user's direct LSP server table (normalized specs). */
248
- async lspServers() {
249
- return (await loadLspServers(this.options.dataRoot)).servers;
44
+ set mcpDiagnostics(value) {
45
+ this.mcp.diagnostics = value;
250
46
  }
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
- });
47
+ /** The user-dimension suite root this catalog operates. */
48
+ get userRoot() {
49
+ return this.context.userRoot;
267
50
  }
268
- /** Validate and persist the user's direct LSP server table. */
269
- async setLspServers(raw) {
270
- return this.enqueue(async () => {
271
- const { servers } = await saveLspServers(this.options.dataRoot, raw);
272
- await this.notifyChanged(true);
273
- return servers;
274
- });
51
+ /** The plugin storage root holding per-suite `${PLUGIN_DATA}` directories and overrides. */
52
+ get dataRoot() {
53
+ return this.context.dataRoot;
275
54
  }
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
- });
55
+ get sources() {
56
+ return this.context.state.sources;
286
57
  }
287
- /** One suite's persisted MCP overrides, addressed by qualified suite id. */
288
- async mcpOverrides(sourceId, suiteId) {
289
- const suiteKey = qualifiedSuiteId(sourceId, suiteId);
290
- const snapshot = await this.readUserCatalog();
291
- if (!snapshot.suites.some(suite => suite.sourceId === sourceId && suite.id === suiteId))
292
- throw new Error(`suite "${suiteKey}" not found`);
293
- return redactMcpOverrides(await loadSuiteOverrides(this.options.dataRoot, suiteKey));
58
+ /** Whether one suite currently carries an install entry. */
59
+ isInstalled(sourceId, suiteId) {
60
+ return this.context.installed(sourceId, suiteId) !== undefined;
294
61
  }
295
- /**
296
- * Set or clear one server's MCP override and remount it. `override === null`
297
- * clears the override (back to the source config). The serverKey must exist
298
- * in the suite's parsed mcp.json.
299
- */
300
- async setMcpOverride(sourceId, suiteId, serverKey, override) {
301
- return this.enqueue(async () => {
302
- const suiteKey = qualifiedSuiteId(sourceId, suiteId);
303
- const source = this.state.sources.find(entry => entry.id === sourceId);
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
- }
316
- const suite = suites.find(entry => entry.id === suiteId);
317
- if (suite === undefined)
318
- throw new Error(`suite "${suiteId}" not found in source "${sourceId}"`);
319
- if (suite.mcp?.servers[serverKey] === undefined)
320
- throw new Error(`server "${serverKey}" is not defined by suite "${suiteId}"`);
321
- // Override files are keyed by the qualified id so two sources' same-named
322
- // suites never share one override record.
323
- const overrides = await loadSuiteOverrides(this.options.dataRoot, suiteKey);
324
- if (override === null) {
325
- delete overrides[serverKey];
326
- }
327
- else {
328
- // Merge onto the stored record: the UI never receives literal secret
329
- // values, so a verbatim write would drop keys it simply redacted.
330
- overrides[serverKey] = mergeOverridePatch(overrides[serverKey] ?? {}, override);
331
- }
332
- await saveSuiteOverrides(this.options.dataRoot, suiteKey, overrides);
333
- await this.notifyChanged(true);
334
- });
62
+ /** Apply the host's project-layout switch and invalidate every project snapshot. */
63
+ async setScanProjectLayouts(enabled) {
64
+ await this.context.setScanProjectLayouts(enabled);
335
65
  }
336
- /**
337
- * Re-run the MCP reconcile pass: retries failed mounts and clears residual
338
- * tools, without changing any catalog state.
339
- */
340
- async retryMounts() {
341
- await this.notifyChanged(true);
66
+ /** Load persisted user state once at plugin activation. */
67
+ async load() {
68
+ await this.context.load();
342
69
  }
343
- /** The persisted backend choice without the host-client probe (mount-time provider). */
344
- async mcpBackend() {
345
- return this.mcpBackendProvider();
70
+ /** Append config-seeded sources missing from user state and persist them. */
71
+ async mergeSources(sources) {
72
+ await this.installs.mergeSources(sources);
346
73
  }
347
74
  /**
348
- * The active MCP mount backend plus a live probe of the host client, and
349
- * the download-region setting with its locale-resolved effective route —
350
- * everything the plugin-config card renders.
75
+ * Panel changed hook: the HTTP layer notifies after a user-panel mutation
76
+ * (skills / commands / agent personas). Catalog state is untouched — this
77
+ * only runs the change pipeline so the runtime remounts commands and the
78
+ * skill providers re-read their catalogs.
351
79
  */
352
- async mcpBackendInfo() {
353
- const [backend, hostClient, regionSetting, locale] = await Promise.all([this.mcpBackendProvider(), probeHostMcpClient(), this.downloadRegionProvider(), readLocalePreference()]);
354
- return { backend, hostClient, downloadRegion: { setting: regionSetting, effective: resolveRegion(regionSetting, locale) } };
80
+ async notifyPanelsChanged() {
81
+ await this.context.notifyChanged();
355
82
  }
356
83
  /**
357
- * Switch the MCP mount backend and remount every suite server through it.
358
- * A host-client switch takes effect only where the host package resolves;
359
- * unresolvable or unsupported transports surface as per-server diagnostics.
84
+ * Wait for the derived refresh triggered by the latest change, bounded so a
85
+ * stuck mount reports back instead of holding an explicit retry open.
360
86
  */
361
- async setMcpBackend(backend) {
362
- return this.enqueue(async () => {
363
- await this.mcpBackendWriter(backend);
364
- await this.notifyChanged(true);
365
- });
366
- }
367
- /** Install the backend source (the host settings namespace's scope). */
368
- setMcpBackendProvider(provider) {
369
- this.mcpBackendProvider = provider;
370
- }
371
- /** Install the backend writer (a scope update on the same namespace). */
372
- setMcpBackendWriter(writer) {
373
- this.mcpBackendWriter = writer;
374
- }
375
- /** Install the download-region source (the host settings namespace scope). */
376
- setDownloadRegionProvider(provider) {
377
- this.downloadRegionProvider = provider;
378
- }
379
- /** MCP overrides for a supplied runtime selection, or the full market catalog when omitted. */
380
- async allMcpOverrides(suites) {
381
- const map = new Map();
382
- for (const suite of suites ?? (await this.readUserCatalog()).suites) {
383
- const suiteKey = qualifiedSuiteId(suite.sourceId, suite.id);
384
- const overrides = await loadSuiteOverrides(this.options.dataRoot, suiteKey);
385
- if (Object.keys(overrides).length > 0)
386
- map.set(suiteKey, overrides);
387
- }
388
- return map;
389
- }
390
- /** Load persisted user state once at plugin activation. */
391
- async load() {
392
- this.state = await loadState(this.statePath);
393
- this.invalidateScans();
394
- this.invalidateSnapshot(false);
395
- }
396
- get sources() {
397
- return this.state.sources;
398
- }
399
- /** The user-dimension suite root this catalog operates. */
400
- get userRoot() {
401
- return this.options.userRoot;
87
+ async refreshSettled(deadlineMs) {
88
+ return this.context.refreshSettled(deadlineMs);
402
89
  }
403
90
  /** Read one coherent user-dimension snapshot, reusing in-flight discovery. */
404
91
  async readUserCatalog() {
405
- // TTL 0 disables snapshot caching: every read observes fresh discovery,
406
- // mirroring the project dimension's caching-disabled semantics.
407
- if (this.userSnapshotTtlMs <= 0)
408
- return this.buildSnapshot(this.state, 'user', this.options.userRoot);
409
- if (this.userSnapshot !== undefined && Date.now() < this.userSnapshotExpiresAt)
410
- return this.userSnapshot;
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)
416
- .then(snapshot => {
417
- if (revision === this.revision && generation === this.scanGeneration) {
418
- this.userSnapshot = snapshot;
419
- this.userSnapshotExpiresAt = Date.now() + this.userSnapshotTtlMs;
420
- }
421
- return snapshot;
422
- })
423
- .finally(() => {
424
- if (this.userSnapshotPromise === promise)
425
- this.userSnapshotPromise = undefined;
426
- });
427
- this.userSnapshotPromise = promise;
428
- return promise;
92
+ return this.snapshots.readUserCatalog();
429
93
  }
430
94
  /** Read one coherent project-dimension snapshot for a workspace cwd. */
431
95
  async readProjectCatalog(cwd) {
432
- const projectRoot = await resolveProjectRoot(cwd);
433
- if (this.projectSnapshotTtlMs <= 0)
434
- return this.buildProjectSnapshot(projectRoot);
435
- const cached = this.projectSnapshots.get(projectRoot);
436
- if (cached !== undefined && cached.expiresAt > Date.now())
437
- return cached.snapshot;
438
- const inFlight = this.projectSnapshotPromises.get(projectRoot);
439
- if (inFlight !== undefined)
440
- return inFlight;
441
- const revision = this.revision;
442
- const generation = this.scanGeneration;
443
- const promise = this.buildProjectSnapshot(projectRoot)
444
- .then(snapshot => {
445
- if (revision === this.revision && generation === this.scanGeneration) {
446
- this.projectSnapshots.set(projectRoot, { snapshot, expiresAt: Date.now() + this.projectSnapshotTtlMs });
447
- }
448
- return snapshot;
449
- })
450
- .finally(() => {
451
- if (this.projectSnapshotPromises.get(projectRoot) === promise)
452
- this.projectSnapshotPromises.delete(projectRoot);
453
- });
454
- this.projectSnapshotPromises.set(projectRoot, promise);
455
- return promise;
96
+ return this.snapshots.readProjectCatalog(cwd);
456
97
  }
457
- async buildProjectSnapshot(projectRoot) {
458
- const state = await loadState(join(projectRoot, STATE_FILE_NAME));
459
- // TTL 0 means caching is disabled for this dimension: bypass the scan
460
- // cache so every read observes the working tree as it stands.
461
- return this.buildSnapshot(state, 'project', projectRoot, this.projectSnapshotTtlMs <= 0);
98
+ /** Runtime discovery scans only sources containing an enabled install. No acquisition or network access. */
99
+ async enabledUserSuites() {
100
+ const state = this.context.state;
101
+ const enabledSources = new Set(Object.entries(state.installed)
102
+ .filter(([, entry]) => entry.enabled)
103
+ .map(([key]) => key.slice(0, key.indexOf('/'))));
104
+ const sources = state.sources.filter(source => enabledSources.has(source.id));
105
+ const suites = sources.length === 0 ? [] : (await this.snapshots.build({ ...state, sources }, 'user', this.context.userRoot)).enabledSuites;
106
+ const direct = await loadUserMcpSuite(this.context.agentsRoot);
107
+ return applyLspOverrides(this.context.dataRoot, Object.keys(direct.mcp.servers).length === 0 ? suites : [...suites, direct]);
108
+ }
109
+ /** The full market overview from one user snapshot. */
110
+ async overview() {
111
+ const snapshot = await this.readUserCatalog();
112
+ const sourceRows = await this.sourceStore.overviewRows(snapshot);
113
+ const cards = snapshot.suites.map(suite => {
114
+ const isInstalled = this.isInstalled(suite.sourceId, suite.id);
115
+ return {
116
+ sourceId: suite.sourceId,
117
+ suiteId: suite.id,
118
+ name: suite.manifest.name,
119
+ version: suite.manifest.version,
120
+ description: suite.manifest.description,
121
+ keywords: suite.manifest.keywords ?? [],
122
+ surfaces: suite.surfaces,
123
+ enabled: suite.enabled,
124
+ installed: isInstalled,
125
+ ...(isInstalled ? { surfaceToggles: suite.activeSurfaces } : {}),
126
+ ...(suite.remote === undefined ? {} : { remoteUrl: suite.remote.url }),
127
+ dimension: suite.dimension,
128
+ layout: suite.manifest.layout,
129
+ errors: suite.errors,
130
+ mcpErrors: this.mcp.diagnostics.filter(diagnostic => diagnostic.suiteId === suite.id).map(diagnostic => `${diagnostic.serverKey}: ${diagnostic.reason}`)
131
+ };
132
+ });
133
+ return {
134
+ sources: sourceRows,
135
+ suites: cards,
136
+ totals: {
137
+ all: cards.length,
138
+ installed: cards.filter(card => card.installed).length,
139
+ enabled: cards.filter(card => card.enabled).length
140
+ },
141
+ roots: { user: this.context.userRoot, data: this.context.dataRoot },
142
+ unmanaged: await this.sourceStore.unmanaged()
143
+ };
462
144
  }
463
145
  /** One suite's full detail for the market detail modal. */
464
146
  async suiteDetail(sourceId, suiteId) {
@@ -467,7 +149,7 @@ export class Catalog {
467
149
  if (suite === undefined)
468
150
  throw new Error(`suite "${suiteId}" not found in source "${sourceId}"`);
469
151
  const suiteKey = qualifiedSuiteId(sourceId, suiteId);
470
- return buildSuiteDetail(suite, this.state.installed[installKey(sourceId, suiteId)], this.mcpDiagnostics, await loadSuiteOverrides(this.options.dataRoot, suiteKey));
152
+ return buildSuiteDetail(suite, this.context.installed(sourceId, suiteId), this.mcp.diagnostics, await loadSuiteOverrides(this.context.dataRoot, suiteKey));
471
153
  }
472
154
  /** One skill's full SKILL.md text for the market detail modal. */
473
155
  async skillContent(sourceId, suiteId, skillName) {
@@ -477,723 +159,161 @@ export class Catalog {
477
159
  throw new Error(`suite "${suiteId}" not found in source "${sourceId}"`);
478
160
  return readSkillContent(suite, skillName);
479
161
  }
480
- /** The full market overview from one user snapshot. */
481
- async overview() {
482
- const snapshot = await this.readUserCatalog();
483
- const sourceRows = [];
484
- for (const source of snapshot.sources) {
485
- const inFlight = this.currentSourceState?.sourceId === source.id ? this.currentSourceState : undefined;
486
- const checkout = this.sourceCheckoutPath(source);
487
- const kind = resolveSourceKind(source);
488
- let cloned = false;
489
- let lockCommit;
490
- let error;
491
- if (kind === 'local') {
492
- cloned = await isDirectory(checkout);
493
- if (!cloned)
494
- error = `local source directory ${checkout} is missing`;
495
- }
496
- else if (kind === 'archive') {
497
- cloned = await isDirectory(checkout);
498
- lockCommit = this.headCache.get(source.id);
499
- }
500
- else if (inFlight !== undefined) {
501
- // A mutation owns this source right now: do not race git against its checkout.
502
- cloned = inFlight.cloned || (await isDirectory(checkout));
503
- lockCommit = inFlight.head;
504
- }
505
- else {
506
- const cachedHead = this.headCache.get(source.id);
507
- if (cachedHead !== undefined && (await isDirectory(checkout))) {
508
- cloned = true;
509
- lockCommit = cachedHead;
510
- }
511
- else {
512
- try {
513
- lockCommit = await gitHead(checkout);
514
- cloned = true;
515
- this.headCache.set(source.id, lockCommit);
516
- }
517
- catch {
518
- // Not cloned yet or a broken checkout; refresh reports the actionable error.
519
- }
520
- }
521
- }
522
- const sourceSuites = snapshot.suites.filter(suite => suite.sourceId === source.id);
523
- const scanNotes = snapshot.scanNotes?.[source.id];
524
- sourceRows.push({
525
- id: source.id,
526
- url: source.url,
527
- ...(source.branch === undefined ? {} : { branch: source.branch }),
528
- ...(source.local === true ? { local: true } : {}),
529
- kind,
530
- ...(source.adopted === true ? { adopted: true } : {}),
531
- cloned,
532
- ...(lockCommit === undefined ? {} : { lockCommit }),
533
- ...(error === undefined ? {} : { error }),
534
- ...(scanNotes === undefined ? {} : { scanNotes }),
535
- suiteIds: sourceSuites.map(suite => suite.id)
536
- });
537
- }
538
- const installed = new Set(Object.keys(this.state.installed));
539
- const cards = snapshot.suites.map(suite => ({
540
- sourceId: suite.sourceId,
541
- suiteId: suite.id,
542
- name: suite.manifest.name,
543
- version: suite.manifest.version,
544
- description: suite.manifest.description,
545
- keywords: suite.manifest.keywords ?? [],
546
- surfaces: suite.surfaces,
547
- enabled: suite.enabled,
548
- installed: installed.has(installKey(suite.sourceId, suite.id)),
549
- ...(installed.has(installKey(suite.sourceId, suite.id)) ? { surfaceToggles: suite.activeSurfaces } : {}),
550
- ...(suite.remote === undefined ? {} : { remoteUrl: suite.remote.url }),
551
- dimension: suite.dimension,
552
- layout: suite.manifest.layout,
553
- errors: suite.errors,
554
- mcpErrors: this.mcpDiagnostics.filter(diagnostic => diagnostic.suiteId === suite.id).map(diagnostic => `${diagnostic.serverKey}: ${diagnostic.reason}`)
555
- }));
556
- return {
557
- sources: sourceRows,
558
- suites: cards,
559
- totals: {
560
- all: cards.length,
561
- installed: cards.filter(card => card.installed).length,
562
- enabled: cards.filter(card => card.enabled).length
563
- },
564
- roots: { user: this.options.userRoot, data: this.options.dataRoot },
565
- unmanaged: await this.unmanagedSources()
566
- };
567
- }
568
- /** Runtime discovery scans only sources containing an enabled install. No acquisition or network access. */
569
- async enabledUserSuites() {
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]);
577
- }
578
- /** Enabled user- and project-dimension suites for a workspace cwd. */
579
- async enabledSuitesForCwd(cwd) {
580
- const [user, project] = await Promise.all([this.enabledUserSuites(), this.readProjectCatalog(cwd)]);
581
- return { user, project: project.enabledSuites };
582
- }
583
- /** All suites of one dimension. */
584
- async suitesForDimension(dimension, dimensionRoot) {
585
- const state = dimension === 'user' && dimensionRoot === this.options.userRoot ? this.state : await loadState(join(dimensionRoot, STATE_FILE_NAME));
586
- return (await this.buildSnapshot(state, dimension, dimensionRoot)).suites;
587
- }
588
- /**
589
- * Add a source and acquire it immediately (clone, download, or in-place).
590
- *
591
- * Git sources with an already-present checkout whose `origin` matches the
592
- * input URL are adopted in place — the manual-clone repair path — so no
593
- * second clone is made and no `-2` suffixed id is invented. Adopted
594
- * checkouts are never deleted on source removal.
595
- */
162
+ // ---- Source acquisition and CRUD ----
163
+ /** Add a source and acquire it immediately (clone, download, or in-place). */
596
164
  async addSource(input) {
597
- return this.enqueue(async () => {
598
- const kind = input.local === true ? 'local' : resolveSourceKind({ url: input.url, kind: input.kind });
599
- if (kind === 'git') {
600
- // Adoption first: a pre-existing checkout with a matching origin URL
601
- // is registered as-is, skipping the clone entirely.
602
- const adoptable = await this.findAdoptableCheckout(input.url, deriveSourceIdCandidates(input.url));
603
- if (adoptable !== undefined) {
604
- const source = {
605
- id: adoptable,
606
- url: input.url,
607
- ...(input.branch === undefined ? {} : { branch: input.branch }),
608
- kind: 'git',
609
- adopted: true
610
- };
611
- const head = await tryHead(sourceCheckoutDir(this.options.userRoot, adoptable));
612
- if (head !== undefined)
613
- this.headCache.set(source.id, head);
614
- this.state = { ...this.state, sources: [...this.state.sources, source] };
615
- await saveState(this.statePath, this.state);
616
- await this.notifyChanged();
617
- return source;
618
- }
619
- }
620
- const baseId = await this.pickSourceId(deriveSourceIdCandidates(input.url), kind === 'local');
621
- const source = {
622
- id: baseId,
623
- url: input.url,
624
- ...(input.branch === undefined ? {} : { branch: input.branch }),
625
- ...(kind === 'local' ? { local: true } : { kind }),
626
- ...(input.kind === 'archive' && input.sha256 !== undefined && input.sha256 !== '' ? { sha256: input.sha256 } : {})
627
- };
628
- let checkout = this.sourceCheckoutPath(source);
629
- if (kind === 'local') {
630
- if (!(await isDirectory(checkout)))
631
- throw new Error(`local source directory ${checkout} is missing`);
632
- }
633
- else {
634
- this.beginSourceState(baseId, kind === 'archive' ? 'downloading' : 'cloning', false);
635
- try {
636
- const lock = await this.acquire(source);
637
- if (lock !== undefined)
638
- this.headCache.set(source.id, lock);
639
- }
640
- catch (error) {
641
- this.endSourceState();
642
- throw error;
643
- }
644
- this.updateSourceStep('reading');
645
- }
646
- // The repo's own manifest names the source; move the checkout along so
647
- // the registered id and its `.sources/` directory stay coherent. URL
648
- // derived candidates stay as readable fallbacks before numeric suffixes.
649
- const named = [...new Set([sanitizeId(await repoName(checkout)), ...deriveSourceIdCandidates(input.url)])];
650
- const finalId = await this.pickSourceId(named, kind === 'local', kind === 'local' ? undefined : checkout);
651
- if (kind !== 'local' && finalId !== baseId) {
652
- const targetDir = sourceCheckoutDir(this.options.userRoot, finalId);
653
- await rename(checkout, targetDir);
654
- checkout = targetDir;
655
- }
656
- source.id = finalId;
657
- if (resolveSourceKind(source) === 'git') {
658
- const head = await tryHead(checkout);
659
- if (head !== undefined)
660
- this.headCache.set(source.id, head);
661
- }
662
- this.endSourceState();
663
- this.state = { ...this.state, sources: [...this.state.sources, source] };
664
- await saveState(this.statePath, this.state);
665
- await this.notifyChanged();
666
- return source;
667
- });
668
- }
669
- /**
670
- * First candidate id whose `.sources/` checkout already exists and whose
671
- * `origin` remote matches the input URL (canonical-form equality) — the
672
- * signature of a manual clone of the very same repository. Registered ids
673
- * and checkouts of differently-origined directories never adopt.
674
- */
675
- async findAdoptableCheckout(url, candidates) {
676
- const registered = new Set(this.state.sources.map(source => source.id));
677
- for (const candidate of candidates) {
678
- if (registered.has(candidate))
679
- continue;
680
- const dir = sourceCheckoutDir(this.options.userRoot, candidate);
681
- if (!(await isDirectory(dir)))
682
- continue;
683
- let origin;
684
- try {
685
- origin = await gitRemoteUrl(dir);
686
- }
687
- catch {
688
- continue;
689
- }
690
- if (origin !== '' && canonicalGitUrl(origin) === canonicalGitUrl(url))
691
- return candidate;
692
- }
693
- return undefined;
165
+ return this.sourceStore.add(input);
694
166
  }
695
167
  /** Register one unmanaged `.sources/` checkout as a source without touching its files. */
696
168
  async adoptSource(id) {
697
- return this.enqueue(async () => {
698
- if (this.state.sources.some(source => source.id === id))
699
- throw new Error(`source "${id}" is already registered`);
700
- // The id becomes one path segment under `.sources/`: only flat safe
701
- // names qualify. Anything else (separators, `..`, leading dots) could
702
- // traverse out of the checkouts root and register an outside directory.
703
- if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(id) || id.includes('..')) {
704
- throw new Error(`invalid checkout id "${id}" — use letters, digits, dots, dashes, or underscores`);
705
- }
706
- const dir = sourceCheckoutDir(this.options.userRoot, id);
707
- if (!(await isDirectory(dir)))
708
- throw new Error(`no checkout directory at ${dir}`);
709
- let source;
710
- try {
711
- const origin = await gitRemoteUrl(dir);
712
- source = { id, url: origin, kind: 'git', adopted: true };
713
- const head = await tryHead(dir);
714
- if (head !== undefined)
715
- this.headCache.set(id, head);
716
- }
717
- catch {
718
- // Not a git checkout: read it in place, like a local source.
719
- source = { id, url: dir, local: true, adopted: true };
720
- }
721
- this.state = { ...this.state, sources: [...this.state.sources, source] };
722
- await saveState(this.statePath, this.state);
723
- await this.notifyChanged();
724
- return source;
725
- });
169
+ return this.sourceStore.adopt(id);
726
170
  }
727
171
  /** Unmanaged `.sources/` checkouts: present on disk, absent from state. */
728
172
  async unmanagedSources() {
729
- const checkoutRoot = sourcesDir(this.options.userRoot);
730
- let entries;
731
- try {
732
- entries = await readdir(checkoutRoot, { withFileTypes: true });
733
- }
734
- catch {
735
- return [];
736
- }
737
- const registered = new Set(this.state.sources.map(source => source.id));
738
- const unmanaged = [];
739
- for (const entry of entries) {
740
- if (!entry.isDirectory() || entry.name.startsWith('.') || registered.has(entry.name))
741
- continue;
742
- const dir = join(checkoutRoot, entry.name);
743
- try {
744
- unmanaged.push({ id: entry.name, url: await gitRemoteUrl(dir) });
745
- }
746
- catch {
747
- unmanaged.push({ id: entry.name });
748
- }
749
- }
750
- return unmanaged;
173
+ return this.sourceStore.unmanaged();
751
174
  }
752
- /** Progress snapshot of the source mutation currently in flight. */
753
- currentSourceState;
754
- /** Begin reporting progress for a source mutation. */
755
- beginSourceState(sourceId, step, cloned) {
756
- this.currentSourceState = { sourceId, step, cloned };
175
+ /** Update one source's URL, branch, kind, or archive digest. */
176
+ async updateSource(sourceId, patch) {
177
+ await this.sourceStore.update(sourceId, patch);
757
178
  }
758
- /** Advance the in-flight source mutation step. */
759
- updateSourceStep(step) {
760
- if (this.currentSourceState !== undefined)
761
- this.currentSourceState = { ...this.currentSourceState, step };
179
+ /** Remove a source and its install entries; `deleteCheckout` also deletes the managed checkout. */
180
+ async removeSource(sourceId, deleteCheckout = false) {
181
+ await this.sourceStore.remove(sourceId, deleteCheckout);
762
182
  }
763
- /** Stop reporting source mutation progress. */
764
- endSourceState() {
765
- this.currentSourceState = undefined;
183
+ /** Refresh one source checkout, or every source when sourceId is omitted. */
184
+ async refreshSource(sourceId) {
185
+ await this.sourceStore.refresh(sourceId);
766
186
  }
767
187
  /** Progress snapshot for the progress route. */
768
188
  sourceProgress() {
769
- const state = this.currentSourceState;
770
- return state === undefined ? { active: false, sourceId: '', step: '' } : { active: true, sourceId: state.sourceId, step: state.step };
771
- }
772
- /**
773
- * First candidate id not claimed by a registered source; for git candidates
774
- * the checkout directory must also be free, so a stale or foreign directory
775
- * under `.sources/` is never cloned over (it would fail `git clone` with a
776
- * confusing "destination exists" error). `ownedCheckout` marks the directory
777
- * the in-flight mutation already controls, keeping a rename onto it legal.
778
- * Local sources skip the disk check: they read their own path in place and
779
- * never occupy `.sources/`.
780
- */
781
- async pickSourceId(candidates, skipDisk, ownedCheckout) {
782
- const free = async (id) => {
783
- if (this.state.sources.some(source => source.id === id))
784
- return false;
785
- if (skipDisk)
786
- return true;
787
- const dir = sourceCheckoutDir(this.options.userRoot, id);
788
- return dir === ownedCheckout || !(await pathExists(dir));
789
- };
790
- for (const candidate of candidates) {
791
- if (await free(candidate))
792
- return candidate;
793
- }
794
- for (let suffix = 2;; suffix++) {
795
- const candidate = `${candidates[0]}-${suffix}`;
796
- if (await free(candidate))
797
- return candidate;
798
- }
189
+ return this.sourceStore.progress();
799
190
  }
800
- /** Update one source's URL, branch, kind, or archive digest. */
801
- async updateSource(sourceId, patch) {
802
- return this.enqueue(async () => {
803
- const index = this.state.sources.findIndex(source => source.id === sourceId);
804
- if (index === -1)
805
- throw new Error(`unknown source "${sourceId}"`);
806
- const current = this.state.sources[index];
807
- const nextLocal = patch.local !== undefined ? patch.local : current.local === true;
808
- // A local source carries the legacy flag only; `kind` stays unwritten
809
- // so the persisted shape matches the pre-kind records.
810
- const nextKind = nextLocal ? undefined : (patch.kind ?? current.kind);
811
- const next = {
812
- id: sourceId,
813
- url: patch.url ?? current.url,
814
- ...(patch.branch !== undefined ? { branch: patch.branch } : current.branch === undefined ? {} : { branch: current.branch }),
815
- ...(nextLocal ? { local: true } : {}),
816
- ...(nextKind === undefined ? {} : { kind: nextKind }),
817
- ...(patch.sha256 !== undefined ? (patch.sha256 === '' ? {} : { sha256: patch.sha256 }) : current.sha256 === undefined ? {} : { sha256: current.sha256 }),
818
- ...(current.adopted === true ? { adopted: true } : {})
819
- };
820
- const urlChanged = patch.url !== undefined && patch.url !== current.url;
821
- // A URL change invalidates the old checkout — except for adopted (user
822
- // cloned) and local sources, whose directories we never own.
823
- if (urlChanged && current.local !== true && current.adopted !== true) {
824
- this.headCache.delete(sourceId);
825
- await gitRemove(sourceCheckoutDir(this.options.userRoot, sourceId));
826
- }
827
- this.state = { ...this.state, sources: this.state.sources.map((source, i) => (i === index ? next : source)) };
828
- await saveState(this.statePath, this.state);
829
- await this.notifyChanged();
830
- });
191
+ /** Begin reporting progress for a source mutation. */
192
+ beginSourceState(sourceId, step, cloned) {
193
+ this.sourceStore.beginSourceState(sourceId, step, cloned);
831
194
  }
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) {
842
- return this.enqueue(async () => {
843
- const source = this.state.sources.find(entry => entry.id === sourceId);
844
- this.state = {
845
- ...this.state,
846
- sources: this.state.sources.filter(entry => entry.id !== sourceId),
847
- installed: Object.fromEntries(Object.entries(this.state.installed).filter(([key]) => !key.startsWith(`${sourceId}/`)))
848
- };
849
- await saveState(this.statePath, this.state);
850
- this.headCache.delete(sourceId);
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)
856
- await gitRemove(sourceCheckoutDir(this.options.userRoot, sourceId));
857
- await this.notifyChanged();
858
- });
195
+ /** Advance the in-flight source mutation step. */
196
+ updateSourceStep(step) {
197
+ this.sourceStore.updateSourceStep(step);
859
198
  }
860
- /** Refresh one source checkout, or every source when sourceId is omitted. */
861
- async refreshSource(sourceId) {
862
- return this.enqueue(async () => {
863
- const targets = sourceId === undefined ? this.state.sources : this.state.sources.filter(source => source.id === sourceId);
864
- for (const source of targets) {
865
- const kind = resolveSourceKind(source);
866
- if (kind === 'local') {
867
- if (!(await isDirectory(expandHome(source.url))))
868
- throw new Error(`local source directory ${expandHome(source.url)} is missing`);
869
- continue;
870
- }
871
- const checkout = sourceCheckoutDir(this.options.userRoot, source.id);
872
- this.headCache.delete(source.id);
873
- if (kind === 'archive') {
874
- // Re-download and swap; the fresh digest becomes the lock value.
875
- const lock = await this.acquire(source);
876
- if (lock !== undefined)
877
- this.headCache.set(source.id, lock);
878
- continue;
879
- }
880
- // Adopted checkouts are user-owned working trees: no `reset --hard`
881
- // (it would destroy uncommitted work), and no re-acquire either —
882
- // a broken HEAD means the user repairs the checkout, not that the
883
- // manager may clone or extract over their directory.
884
- if (source.adopted === true) {
885
- try {
886
- this.headCache.set(source.id, await gitHead(checkout));
887
- }
888
- catch {
889
- // A failed HEAD read means the next overview re-probes.
890
- }
891
- continue;
892
- }
893
- try {
894
- await gitHead(checkout);
895
- }
896
- catch {
897
- const lock = await this.acquire(source);
898
- if (lock !== undefined) {
899
- this.headCache.set(source.id, lock);
900
- }
901
- else {
902
- try {
903
- this.headCache.set(source.id, await gitHead(checkout));
904
- }
905
- catch {
906
- // A failed HEAD read means the next overview re-probes.
907
- }
908
- }
909
- continue;
910
- }
911
- // Shallow-friendly sync: fetch depth 1 into FETCH_HEAD, hard reset.
912
- const branch = source.branch ?? (await gitCurrentBranch(checkout).catch(() => undefined));
913
- // Keep origin on the region's route: a clone made before a region
914
- // switch (or an explicit flip) re-points origin so the fetch follows.
915
- // Only origins this plugin itself wrote (the direct or proxied
916
- // source.url) are re-pointed — a foreign remote is left untouched.
917
- if (source.kind === 'git' || (source.kind === undefined && source.local !== true)) {
918
- try {
919
- const region = resolveRegion(await this.downloadRegionProvider(), await readLocalePreference());
920
- const routed = githubCloneUrl(region, source.url);
921
- const origin = await gitRemoteUrl(checkout);
922
- if (origin !== routed && (origin === source.url || origin === githubCloneUrl('china', source.url))) {
923
- await gitSetRemoteUrl(checkout, routed);
924
- }
925
- }
926
- catch {
927
- // Origin reconciliation is best effort; plain sync still runs.
928
- }
929
- }
930
- await gitSync(checkout, branch, this.options.git ?? {});
931
- try {
932
- this.headCache.set(source.id, await gitHead(checkout));
933
- }
934
- catch {
935
- // A failed HEAD read means the next overview re-probes.
936
- }
937
- }
938
- await this.notifyChanged();
939
- });
199
+ /** Stop reporting source mutation progress. */
200
+ endSourceState() {
201
+ this.sourceStore.endSourceState();
940
202
  }
941
- /** Install a suite from a source and enable it. The client confirms the
942
- * injected surfaces (skills / MCP / hooks / commands) in a pre-install
943
- * dialog before this runs; disabling afterwards is always available. */
203
+ // ---- Install state ----
204
+ /** Install a suite from a source and enable it. */
944
205
  async install(sourceId, suiteId) {
945
- return this.enqueue(async () => {
946
- const source = this.state.sources.find(entry => entry.id === sourceId);
947
- if (source === undefined)
948
- throw new Error(`unknown source "${sourceId}"`);
949
- const checkout = this.sourceCheckoutPath(source);
950
- let freshLock;
951
- if (!(await isDirectory(checkout)))
952
- freshLock = await this.acquire(source);
953
- const suites = await discoverSuitesInSource(checkout, sourceId, 'user', source.url);
954
- const suite = suites.find(entry => entry.id === suiteId);
955
- if (suite === undefined)
956
- throw new Error(`suite "${suiteId}" not found in source "${sourceId}"`);
957
- if (suite.remote !== undefined)
958
- throw new Error(`suite "${suiteId}" is a remote reference (${suite.remote.url}); add its repository as a source before installing`);
959
- // Archive (or tarball-fallback) sources have no git HEAD; their digest
960
- // is the lock value.
961
- const lockCommit = (await tryHead(checkout)) ?? freshLock ?? this.headCache.get(sourceId);
962
- await this.setInstalled(sourceId, suiteId, { enabled: true, installedAt: new Date().toISOString(), lockCommit });
963
- await this.notifyChanged(true);
964
- });
206
+ await this.installs.install(sourceId, suiteId);
965
207
  }
966
208
  /** Uninstall a suite while retaining the source checkout. */
967
209
  async uninstall(sourceId, suiteId) {
968
- return this.enqueue(async () => {
969
- const key = installKey(sourceId, suiteId);
970
- if (this.state.installed[key] === undefined)
971
- throw new Error(`suite "${suiteId}" is not installed`);
972
- const rest = Object.fromEntries(Object.entries(this.state.installed).filter(([entryKey]) => entryKey !== key));
973
- this.state = { ...this.state, installed: rest };
974
- await saveState(this.statePath, this.state);
975
- await this.notifyChanged(true);
976
- });
210
+ await this.installs.uninstall(sourceId, suiteId);
977
211
  }
978
212
  /** Enable or disable an installed suite. */
979
213
  async setEnabled(sourceId, suiteId, enabled) {
980
- return this.enqueue(async () => {
981
- const key = installKey(sourceId, suiteId);
982
- const entry = this.state.installed[key];
983
- if (entry === undefined)
984
- throw new Error(`suite "${suiteId}" is not installed`);
985
- await this.setInstalled(sourceId, suiteId, { ...entry, enabled });
986
- await this.notifyChanged(true);
987
- });
214
+ await this.installs.setEnabled(sourceId, suiteId, enabled);
988
215
  }
989
216
  /** Enable or disable one runtime surface of an installed suite. */
990
217
  async setSurface(sourceId, suiteId, surface, enabled) {
991
- return this.enqueue(async () => {
992
- const key = installKey(sourceId, suiteId);
993
- const entry = this.state.installed[key];
994
- if (entry === undefined)
995
- throw new Error(`suite "${suiteId}" is not installed`);
996
- if (!SUITE_SURFACE_KEYS.includes(surface))
997
- throw new Error(`surface "${surface}" is not toggleable`);
998
- const surfaces = { ...(entry.surfaces ?? {}), [surface]: enabled };
999
- await this.setInstalled(sourceId, suiteId, { ...entry, surfaces });
1000
- await this.notifyChanged(true);
1001
- });
218
+ await this.installs.setSurface(sourceId, suiteId, surface, enabled);
1002
219
  }
1003
- /** Append config-seeded sources missing from user state and persist them. */
1004
- async mergeSources(sources) {
1005
- const existing = new Set(this.state.sources.map(source => source.id));
1006
- const additions = sources.filter(source => !existing.has(source.id));
1007
- if (additions.length === 0)
1008
- return;
1009
- this.state = { ...this.state, sources: [...this.state.sources, ...additions] };
1010
- await saveState(this.statePath, this.state);
1011
- // New sources change the fingerprint, but their content arrives through
1012
- // an acquire, so mark the scan cache dirty conservatively.
1013
- this.invalidateScans();
1014
- this.invalidateSnapshot(true);
220
+ // ---- MCP ----
221
+ /** Build the flat MCP service inventory for the status surface. */
222
+ async mcpStatus() {
223
+ return this.mcp.status();
1015
224
  }
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();
225
+ /** Persist a user-owned MCP service and reconcile its bridge mount. */
226
+ async addMcpServer(name, server) {
227
+ await this.mcp.addServer(name, server);
1024
228
  }
1025
- async setInstalled(sourceId, suiteId, entry) {
1026
- this.state = { ...this.state, installed: { ...this.state.installed, [installKey(sourceId, suiteId)]: entry } };
1027
- await saveState(this.statePath, this.state);
229
+ /** Read effective service configuration without exposing credential literals. */
230
+ async serverConfig(kind, id) {
231
+ return this.mcp.serverConfig(kind, id);
232
+ }
233
+ /** Validate a complete replacement before writing; plugin checkouts remain untouched. */
234
+ async saveServerConfig(kind, id, config) {
235
+ await this.mcp.saveServerConfig(kind, id, config);
236
+ }
237
+ /** One suite's persisted MCP overrides, addressed by qualified suite id. */
238
+ async mcpOverrides(sourceId, suiteId) {
239
+ return this.mcp.overrides(sourceId, suiteId);
240
+ }
241
+ /** Set or clear one server's MCP override and remount it. */
242
+ async setMcpOverride(sourceId, suiteId, serverKey, override) {
243
+ await this.mcp.setOverride(sourceId, suiteId, serverKey, override);
1028
244
  }
1029
245
  /**
1030
- * Acquire one source's content by kind: clone (git), download+extract
1031
- * (archive), or an in-place existence check (local). Returns a lock value
1032
- * when the acquisition yields one (archive SHA-256, tarball fallback
1033
- * digest); git HEAD is read separately by the callers.
246
+ * Enable or disable one declared MCP server, addressed by the same
247
+ * source-qualified suite id its status row carries. Disabling writes an
248
+ * override rather than editing the suite's `mcp.json`, so a refresh cannot
249
+ * clobber the user's choice.
1034
250
  */
1035
- async acquire(source) {
1036
- const kind = resolveSourceKind(source);
1037
- if (kind === 'local') {
1038
- if (!(await isDirectory(expandHome(source.url))))
1039
- throw new Error(`local source directory ${expandHome(source.url)} is missing`);
1040
- return undefined;
1041
- }
1042
- const checkout = sourceCheckoutDir(this.options.userRoot, source.id);
1043
- await mkdir(sourcesDir(this.options.userRoot), { recursive: true });
1044
- if (kind === 'archive') {
1045
- const { sha256 } = await archiveInstall(source.url, checkout, {
1046
- ...(source.sha256 === undefined ? {} : { sha256: source.sha256 }),
1047
- ...(this.options.git?.timeoutMs === undefined ? {} : { timeoutMs: this.options.git.timeoutMs }),
1048
- ...(this.options.git?.allowHttpArchives === true ? { allowHttp: true } : {})
1049
- });
1050
- return sha256;
1051
- }
1052
- try {
1053
- // The region routes github.com clones through the China mirror prefix;
1054
- // the proxied URL becomes `origin`, so refreshes follow the same route.
1055
- const region = resolveRegion(await this.downloadRegionProvider(), await readLocalePreference());
1056
- await gitClone(githubCloneUrl(region, source.url), source.branch, checkout, this.options.git ?? {});
1057
- return undefined;
1058
- }
1059
- catch (error) {
1060
- if (this.options.git?.fallbackTarball !== true)
1061
- throw error;
1062
- const tarballUrl = codeloadTarballUrl(source.url, source.branch);
1063
- if (tarballUrl === undefined)
1064
- throw error;
1065
- // The codeload URL ends in a branch path, not `.tar.gz`, so the format
1066
- // must be stated explicitly — extension detection cannot see it.
1067
- const { sha256 } = await archiveInstall(tarballUrl, checkout, {
1068
- ...(this.options.git?.timeoutMs === undefined ? {} : { timeoutMs: this.options.git.timeoutMs }),
1069
- format: 'targz'
1070
- });
1071
- return sha256;
1072
- }
251
+ async setMcpServerEnabled(suiteKey, serverKey, enabled) {
252
+ await this.mcp.setServerEnabled(suiteKey, serverKey, enabled);
1073
253
  }
1074
- /** The filesystem location of one source. */
1075
- sourceCheckoutPath(source) {
1076
- return source.local === true ? expandHome(source.url) : sourceCheckoutDir(this.options.userRoot, source.id);
254
+ /** Re-run the MCP reconcile pass without changing any catalog state. */
255
+ async retryMounts() {
256
+ await this.mcp.retryMounts();
1077
257
  }
1078
- async buildSnapshot(state, dimension, dimensionRoot, skipScanCache = false) {
1079
- const fingerprint = JSON.stringify([dimension, dimensionRoot, state.sources, this.scanProjectLayouts]);
1080
- const cached = this.scanCache.get(fingerprint);
1081
- // The user snapshot's TTL also bounds scan reuse: a snapshot rebuild that
1082
- // replays a 30s scan cache would silently outlive its own staleness bound.
1083
- const scanCacheTtl = dimension === 'user' ? Math.min(this.userSnapshotTtlMs, Catalog.SCAN_CACHE_TTL_MS) : Catalog.SCAN_CACHE_TTL_MS;
1084
- const cacheFresh = !skipScanCache && cached !== undefined && Date.now() - cached.at < scanCacheTtl;
1085
- let discovered;
1086
- let scanNotes;
1087
- if (cacheFresh && cached !== undefined) {
1088
- discovered = cached.discovered;
1089
- scanNotes = cached.scanNotes;
1090
- }
1091
- else {
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
- }
1106
- discovered = result.suites;
1107
- scanNotes = result.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
- }
1114
- }
1115
- const suites = discovered.map(suite => {
1116
- const installed = state.installed[installKey(suite.sourceId, suite.id)];
1117
- // Native project layouts (`.claude/`, `.agents/`) are the repository's
1118
- // own files read in place: they carry no install state and stay enabled.
1119
- const enabled = suite.manifest.layout === 'project-native' || installed?.enabled === true;
1120
- return {
1121
- ...suite,
1122
- enabled,
1123
- activeSurfaces: {
1124
- ...effectiveSurfaces(installed?.surfaces),
1125
- ...(suite.manifest.layout === 'project-native' ? suite.activeSurfaces : {}),
1126
- ...(dimension === 'project' ? { lsp: false } : {})
1127
- },
1128
- ...(installed?.lockCommit === undefined ? {} : { lockCommit: installed.lockCommit }),
1129
- ...(installed?.installedAt === undefined ? {} : { installedAt: installed.installedAt })
1130
- };
1131
- });
1132
- return {
1133
- revision: this.revision,
1134
- sources: [...state.sources],
1135
- suites,
1136
- enabledSuites: suites.filter(suite => suite.enabled),
1137
- ...(Object.keys(scanNotes).length > 0 ? { scanNotes } : {})
1138
- };
258
+ /** The persisted MCP backend choice without the host-client probe. */
259
+ async mcpBackend() {
260
+ return this.mcp.backend();
1139
261
  }
1140
- invalidateSnapshot(increment) {
1141
- if (increment)
1142
- this.revision++;
1143
- this.userSnapshot = undefined;
1144
- this.userSnapshotExpiresAt = 0;
1145
- this.userSnapshotPromise = undefined;
1146
- this.projectSnapshots.clear();
1147
- this.projectSnapshotPromises.clear();
262
+ /**
263
+ * The active MCP mount backend plus a live probe of the host client, and
264
+ * the download-region setting with its locale-resolved effective route.
265
+ */
266
+ async mcpBackendInfo() {
267
+ return this.mcp.backendInfo();
1148
268
  }
1149
- invalidateScans() {
1150
- this.scanGeneration++;
1151
- this.scanCache.clear();
1152
- this.scanPromises.clear();
269
+ /** Switch the MCP mount backend and remount every suite server through it. */
270
+ async setMcpBackend(backend) {
271
+ await this.mcp.setBackend(backend);
1153
272
  }
1154
273
  /**
1155
- * Invalidate snapshots and run the change pipeline. `keepScanCache` marks
1156
- * state-only mutations (install, enable, surface toggles, MCP overrides)
1157
- * whose inputs leave every checkout untouched, so the next snapshot
1158
- * re-derives from cached discovery instead of rescanning the filesystem.
274
+ * Drop one MCP server's OAuth grant record so the next mount re-runs the
275
+ * browser authorization.
276
+ * @throws when the credentials service is not mounted.
1159
277
  */
1160
- async notifyChanged(keepScanCache = false) {
1161
- if (!keepScanCache)
1162
- this.invalidateScans();
1163
- this.invalidateSnapshot(true);
1164
- await this.options.onChanged();
1165
- for (const listener of this.listeners)
1166
- listener();
278
+ async reauthorizeMcpServer(serverName) {
279
+ await this.mcp.reauthorize(serverName);
1167
280
  }
1168
- enqueue(operation) {
1169
- const result = this.mutationQueue.then(operation, operation);
1170
- this.mutationQueue = result.catch(() => { });
1171
- return result;
281
+ /** Whether the re-authorize action can run in this composition. */
282
+ mcpReauthorizeAvailable() {
283
+ return this.mcp.reauthorizeAvailable();
1172
284
  }
1173
- }
1174
- function installKey(sourceId, suiteId) {
1175
- return `${sourceId}/${suiteId}`;
1176
- }
1177
- /** Read a checkout's HEAD when it is a Git repository. */
1178
- async function tryHead(dir) {
1179
- try {
1180
- return await gitHead(dir);
285
+ /** MCP overrides for a supplied runtime selection, or the full market catalog when omitted. */
286
+ async allMcpOverrides(suites) {
287
+ return this.mcp.allOverrides(suites);
1181
288
  }
1182
- catch {
1183
- return undefined;
289
+ // ---- LSP ----
290
+ /** The LSP status surface: declared servers merged with mount diagnostics. */
291
+ async lspStatus() {
292
+ return this.lsp.status();
293
+ }
294
+ /** The user's direct LSP server table (normalized specs). */
295
+ async lspServers() {
296
+ return this.lsp.servers();
297
+ }
298
+ /** Create one direct LSP declaration without replacing other user servers. */
299
+ async addLspServer(name, config) {
300
+ await this.lsp.addServer(name, config);
301
+ }
302
+ /** Validate and persist the user's direct LSP server table. */
303
+ async setLspServers(raw) {
304
+ return this.lsp.setServers(raw);
305
+ }
306
+ /** Enable or disable one declared language server by row id. */
307
+ async setLspServerEnabled(id, enabled) {
308
+ await this.lsp.setEnabled(id, enabled);
309
+ }
310
+ /**
311
+ * Remove one profile's legacy LSP layer — the hand-written
312
+ * `cordis.patch.yml` rows an older release told users to add, which now
313
+ * register a seam this plugin owns.
314
+ */
315
+ async migrateLegacyLspSeam(profile) {
316
+ return this.lsp.migrateLegacySeam(profile);
1184
317
  }
1185
318
  }
1186
- /**
1187
- * The codeload tarball URL of a GitHub repository, or undefined for any
1188
- * other host. `https://github.com/<owner>/<repo>` maps to
1189
- * `https://codeload.github.com/<owner>/<repo>/tar.gz/refs/heads/<branch>`
1190
- * (or `/tar.gz/HEAD` for the default branch) — the git-protocol fallback
1191
- * when a clone cannot get through but plain HTTPS file download can.
1192
- */
1193
- export function codeloadTarballUrl(url, branch) {
1194
- const match = /^https:\/\/github\.com\/([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/i.exec(url.trim());
1195
- if (match === null)
1196
- return undefined;
1197
- const [, owner, repo] = match;
1198
- return `https://codeload.github.com/${owner}/${repo}/tar.gz/${branch === undefined ? 'HEAD' : `refs/heads/${branch}`}`;
1199
- }
319
+ export { codeloadTarballUrl };