dsh-agent-plugins-market 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/README.md +117 -111
  2. package/README.zh.md +112 -98
  3. package/client/client.js +547 -540
  4. package/client/client.js.map +1 -1
  5. package/lib/application/catalog-context.js +181 -0
  6. package/lib/application/catalog.js +217 -1110
  7. package/lib/application/details.js +6 -6
  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 +246 -0
  11. package/lib/application/panel-resources.js +29 -12
  12. package/lib/application/ports.js +21 -0
  13. package/lib/application/project-agent-roles.js +1 -1
  14. package/lib/application/snapshot-cache.js +160 -0
  15. package/lib/application/source-store.js +521 -0
  16. package/lib/catalog/archive.js +34 -29
  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 +66 -55
  22. package/lib/catalog/project-config.js +2 -1
  23. package/lib/catalog/project-hooks.js +9 -1
  24. package/lib/catalog/scan-resolvers.js +29 -45
  25. package/lib/catalog/skills-parse.js +14 -10
  26. package/lib/catalog/source-catalog.js +3 -2
  27. package/lib/catalog/suite-components.js +5 -5
  28. package/lib/catalog/suite-scanner.js +3 -1
  29. package/lib/catalog/surfaces.js +5 -26
  30. package/lib/catalog/validate.js +7 -11
  31. package/lib/contracts/market.js +1 -0
  32. package/lib/index.js +91 -208
  33. package/lib/model/types.js +26 -3
  34. package/lib/routes.js +45 -15
  35. package/lib/runtime/agent-role-names.js +70 -0
  36. package/lib/runtime/agent-role-router.js +116 -102
  37. package/lib/runtime/commands-mounts.js +9 -6
  38. package/lib/runtime/deadline.js +28 -0
  39. package/lib/runtime/feedback-tool.js +94 -43
  40. package/lib/runtime/hooks-mounts.js +9 -11
  41. package/lib/runtime/host-locale.js +10 -21
  42. package/lib/runtime/lsp-direct-config.js +19 -19
  43. package/lib/runtime/lsp-mounts.js +159 -68
  44. package/lib/runtime/lsp-server-state.js +4 -4
  45. package/lib/runtime/lsp-status.js +1 -1
  46. package/lib/runtime/mcp-auth-record.js +42 -0
  47. package/lib/runtime/mcp-backend.js +6 -5
  48. package/lib/runtime/mcp-client/config.js +0 -1
  49. package/lib/runtime/mcp-client/connection.js +32 -37
  50. package/lib/runtime/mcp-client/host-contract.js +10 -0
  51. package/lib/runtime/mcp-client/oauth.js +5 -7
  52. package/lib/runtime/mcp-client/plugin-identity.js +28 -0
  53. package/lib/runtime/mcp-client/projection.js +202 -0
  54. package/lib/runtime/mcp-client/tools.js +4 -189
  55. package/lib/runtime/mcp-config.js +29 -10
  56. package/lib/runtime/mcp-direct-config.js +33 -16
  57. package/lib/runtime/mcp-mounts.js +35 -75
  58. package/lib/runtime/mcp-overrides.js +5 -5
  59. package/lib/runtime/mcp-redaction.js +3 -2
  60. package/lib/runtime/mcp-status.js +7 -8
  61. package/lib/runtime/mount-lifecycle.js +73 -0
  62. package/lib/runtime/profile-seam.js +321 -0
  63. package/lib/runtime/project-runtime.js +11 -8
  64. package/lib/runtime/reconcile-scheduler.js +99 -0
  65. package/lib/runtime/reconciler.js +4 -0
  66. package/lib/runtime/server-config.js +6 -4
  67. package/lib/runtime/settings-namespace.js +136 -0
  68. package/lib/runtime/skills-provider.js +5 -3
  69. package/lib/runtime/source-auto-update.js +70 -0
  70. package/lib/{model/state.js → runtime/state-store.js} +9 -11
  71. package/lib/runtime/storage-migration.js +35 -18
  72. package/lib/runtime/subagent-catalog.js +5 -4
  73. package/lib/runtime/user-commands.js +2 -2
  74. package/lib/runtime/user-panels.js +31 -21
  75. package/lib/runtime/user-store.js +12 -13
  76. package/lib/types/application/catalog-context.d.ts +118 -0
  77. package/lib/types/application/catalog.d.ts +99 -304
  78. package/lib/types/application/install-store.d.ts +21 -0
  79. package/lib/types/application/lsp-service.d.ts +36 -0
  80. package/lib/types/application/mcp-service.d.ts +71 -0
  81. package/lib/types/application/panel-resources.d.ts +8 -0
  82. package/lib/types/application/ports.d.ts +72 -0
  83. package/lib/types/application/queries.d.ts +13 -26
  84. package/lib/types/application/snapshot-cache.d.ts +51 -0
  85. package/lib/types/application/source-store.d.ts +100 -0
  86. package/lib/types/catalog/archive.d.ts +3 -11
  87. package/lib/types/catalog/component-files.d.ts +4 -0
  88. package/lib/types/catalog/fs-probes.d.ts +10 -0
  89. package/lib/types/catalog/manifests.d.ts +17 -16
  90. package/lib/types/catalog/native-project.d.ts +2 -2
  91. package/lib/types/catalog/paths.d.ts +47 -15
  92. package/lib/types/catalog/scan-pipeline.d.ts +3 -3
  93. package/lib/types/catalog/scan-resolvers.d.ts +3 -9
  94. package/lib/types/catalog/skills-parse.d.ts +0 -7
  95. package/lib/types/catalog/source-catalog.d.ts +3 -3
  96. package/lib/types/catalog/suite-scanner.d.ts +8 -6
  97. package/lib/types/catalog/validate.d.ts +0 -2
  98. package/lib/types/contracts/lsp-status.d.ts +35 -0
  99. package/lib/types/contracts/market.d.ts +3 -2
  100. package/lib/types/contracts/mcp-status.d.ts +4 -0
  101. package/lib/types/model/types.d.ts +29 -5
  102. package/lib/types/runtime/agent-role-names.d.ts +6 -0
  103. package/lib/types/runtime/agent-role-router.d.ts +54 -51
  104. package/lib/types/runtime/deadline.d.ts +18 -0
  105. package/lib/types/runtime/feedback-tool.d.ts +33 -11
  106. package/lib/types/runtime/hooks-mounts.d.ts +1 -1
  107. package/lib/types/runtime/host-locale.d.ts +1 -1
  108. package/lib/types/runtime/lsp-direct-config.d.ts +3 -3
  109. package/lib/types/runtime/lsp-mounts.d.ts +62 -19
  110. package/lib/types/runtime/lsp-status.d.ts +2 -1
  111. package/lib/types/runtime/mcp-auth-record.d.ts +29 -0
  112. package/lib/types/runtime/mcp-backend.d.ts +7 -4
  113. package/lib/types/runtime/mcp-client/config.d.ts +0 -2
  114. package/lib/types/runtime/mcp-client/host-contract.d.ts +78 -0
  115. package/lib/types/runtime/mcp-client/oauth.d.ts +1 -1
  116. package/lib/types/runtime/mcp-client/plugin-identity.d.ts +16 -0
  117. package/lib/types/runtime/mcp-client/projection.d.ts +45 -0
  118. package/lib/types/runtime/mcp-client/tools.d.ts +5 -72
  119. package/lib/types/runtime/mcp-config.d.ts +25 -4
  120. package/lib/types/runtime/mcp-direct-config.d.ts +18 -4
  121. package/lib/types/runtime/mcp-mounts.d.ts +10 -15
  122. package/lib/types/runtime/mcp-overrides.d.ts +1 -1
  123. package/lib/types/runtime/mcp-status.d.ts +1 -1
  124. package/lib/types/runtime/mount-lifecycle.d.ts +53 -0
  125. package/lib/types/runtime/profile-seam.d.ts +64 -0
  126. package/lib/types/runtime/reconcile-scheduler.d.ts +45 -0
  127. package/lib/types/runtime/reconciler.d.ts +2 -0
  128. package/lib/types/runtime/settings-namespace.d.ts +76 -0
  129. package/lib/types/runtime/source-auto-update.d.ts +33 -0
  130. package/lib/types/{model/state.d.ts → runtime/state-store.d.ts} +2 -2
  131. package/lib/types/runtime/storage-migration.d.ts +5 -3
  132. package/lib/types/runtime/subagent-catalog.d.ts +2 -0
  133. package/lib/types/runtime/user-panels.d.ts +8 -11
  134. package/lib/types/runtime/user-store.d.ts +2 -4
  135. package/package.json +48 -21
  136. package/schemas/README.md +1 -1
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Legacy profile LSP layer: detection and one-action removal.
3
+ *
4
+ * Releases before the plugin provisioned LSP itself told users to expose the
5
+ * capability from their profile — a `cordis.patch.yml` `insert` row for
6
+ * `@deepseek-ai/dsh-lsp` and `@deepseek-ai/dsh-tool-lsp`, anchored by profile
7
+ * dependencies on the packages. `LspMountRegistry` now mounts both seams from
8
+ * this plugin's own aligned copies, so that layer registers a second `lsp`
9
+ * service and the mount fails with `seam-conflict`.
10
+ *
11
+ * The plugin cannot drop the layer by patching: a profile's own patch file is
12
+ * applied *after* every bundle layer, so this package's patch can never
13
+ * disable the row. The user has to remove it, and that is the whole job here:
14
+ * find the profile, and edit out exactly those rows.
15
+ *
16
+ * Editing happens on a file the user wrote by hand, so the rules are
17
+ * conservative: the YAML document model preserves comments and unknown keys,
18
+ * the rewrite is verified against an independently computed result before it
19
+ * lands, and the previous content is kept beside it. Nothing else is touched —
20
+ * a profile's `dependencies` stay as they are, because dropping a dependency
21
+ * means running pnpm and this module never spawns anything.
22
+ *
23
+ * All paths come from `node:path` and all file work from `node:fs/promises`
24
+ * plus the harness atomic-write helper, so the same code runs on macOS, Linux
25
+ * and Windows; no shell, no path-separator assumption.
26
+ */
27
+ import { readdir, readFile, stat } from 'node:fs/promises';
28
+ import { join } from 'node:path';
29
+ import { isMap, isNode, isScalar, isSeq, parseDocument } from 'yaml';
30
+ import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
31
+ import { resolveDshHome } from '../catalog/paths.js';
32
+ /** Every package the plugin provisions for LSP, in declaration order. */
33
+ export const LSP_PACKAGE_SPECIFIERS = ['@deepseek-ai/dsh-lsp', '@deepseek-ai/dsh-lsp-stdio', '@deepseek-ai/dsh-tool-lsp'];
34
+ /**
35
+ * The specifiers whose `insert` row registers a seam this plugin now owns.
36
+ * The stdio provider is excluded: it publishes no service, so a row for it
37
+ * cannot conflict.
38
+ */
39
+ const SEAM_SPECIFIERS = new Set(['@deepseek-ai/dsh-lsp', '@deepseek-ai/dsh-tool-lsp']);
40
+ /** The profile patch filename the harness reads; part of the profile contract. */
41
+ const PROFILE_PATCH_FILENAME = 'cordis.patch.yml';
42
+ /** Default patch-file lifecycle when a profile manifest omits `dsh.profile.patchReload`. */
43
+ const DEFAULT_PATCH_RELOAD = 'live';
44
+ /**
45
+ * Find every profile that still registers a legacy LSP seam.
46
+ *
47
+ * Best effort by design: profile files are the user's, so a directory that
48
+ * cannot be read or a patch file that does not parse is skipped rather than
49
+ * failing the status surface that calls this.
50
+ * @param home - the Harness home; defaults to `$DSH_HOME` or `~/.dsh`.
51
+ * @returns each matching profile, ordered by profile name.
52
+ */
53
+ export async function findLegacyLspSeams(home = resolveDshHome()) {
54
+ const profilesDir = join(home, 'profiles');
55
+ let entries;
56
+ try {
57
+ entries = await readdir(profilesDir);
58
+ }
59
+ catch {
60
+ return [];
61
+ }
62
+ const found = [];
63
+ for (const name of entries.sort()) {
64
+ // `node_modules` is the installation fallback sibling, not a profile, and
65
+ // dot-directories hold launcher bookkeeping or the user's own backups.
66
+ if (name === 'node_modules' || name.startsWith('.'))
67
+ continue;
68
+ const dir = join(profilesDir, name);
69
+ try {
70
+ if (!(await stat(dir)).isDirectory())
71
+ continue;
72
+ }
73
+ catch {
74
+ continue;
75
+ }
76
+ const seam = await inspectProfile(name, dir);
77
+ if (seam !== undefined)
78
+ found.push(seam);
79
+ }
80
+ return found;
81
+ }
82
+ /** Read one profile directory and report its legacy layer, or undefined when it has none. */
83
+ async function inspectProfile(profile, dir) {
84
+ const patchPath = join(dir, PROFILE_PATCH_FILENAME);
85
+ let text;
86
+ try {
87
+ text = await readFile(patchPath, 'utf8');
88
+ }
89
+ catch {
90
+ return undefined;
91
+ }
92
+ const document = parseDocument(text);
93
+ if (document.errors.length > 0)
94
+ return undefined;
95
+ const rows = seamRows(document);
96
+ const manifest = await readManifest(join(dir, 'package.json'));
97
+ // Only a row (or a bundle layer) claims a seam. A bare dependency cannot
98
+ // register anything, so a profile that lists the packages but inserts no row
99
+ // is not the cause of a conflict and stays out of the report.
100
+ if (rows.length === 0 && manifest.bundles.length === 0)
101
+ return undefined;
102
+ return { profile, dir, patchPath, rows, bundles: manifest.bundles, dependencies: manifest.dependencies, patchReload: manifest.patchReload };
103
+ }
104
+ /**
105
+ * Remove one profile's legacy LSP rows.
106
+ *
107
+ * The file is re-read here rather than trusting the caller's snapshot, so a
108
+ * migration always edits the current on-disk content. The rewrite is verified
109
+ * before it lands and the pre-edit content is written beside it first, so a
110
+ * failure leaves the profile exactly as it was.
111
+ * @param seam - a profile previously returned by {@link findLegacyLspSeams}.
112
+ * @param now - timestamp used in the backup filename; injectable for tests.
113
+ * @returns what was removed and where the backup went.
114
+ * @throws Error when the profile no longer carries a removable row, or the rewrite does not verify.
115
+ */
116
+ export async function migrateLegacyLspSeam(seam, now = new Date()) {
117
+ let original;
118
+ try {
119
+ original = await readFile(seam.patchPath, 'utf8');
120
+ }
121
+ catch (error) {
122
+ throw new Error(`cannot read the profile patch file ${seam.patchPath}: ${messageOf(error)}`, { cause: error });
123
+ }
124
+ const document = parseDocument(original);
125
+ if (document.errors.length > 0) {
126
+ throw new Error(`the profile patch file ${seam.patchPath} does not parse: ${document.errors[0]?.message ?? 'unknown error'}`);
127
+ }
128
+ const removed = stripSeamRows(document);
129
+ if (removed.length === 0)
130
+ throw new Error(`the profile ${seam.profile} no longer registers an LSP layer; reload the panel and try again`);
131
+ const next = document.toString();
132
+ assertStripped(original, next);
133
+ const mode = await fileMode(seam.patchPath);
134
+ const backupPath = `${seam.patchPath}.bak-lsp-seam-${stamp(now)}`;
135
+ await writeFileAtomic(backupPath, original, { mode });
136
+ await writeFileAtomic(seam.patchPath, next, { mode });
137
+ return { profile: seam.profile, patchPath: seam.patchPath, backupPath, removed, restartRequired: seam.patchReload !== 'live' };
138
+ }
139
+ /**
140
+ * Drop every `insert` entry naming a seam package, in place.
141
+ * A patch entry whose whole `insert` list is consumed leaves the document when
142
+ * nothing else remains — the entry existed only to insert.
143
+ * @returns the removed rows, in file order.
144
+ */
145
+ function stripSeamRows(document) {
146
+ const removed = [];
147
+ for (const item of rootItems(document)) {
148
+ const container = mapNode(item, 'insert');
149
+ if (!isSeq(container))
150
+ continue;
151
+ const entries = container.items;
152
+ const keep = [];
153
+ for (const entry of entries) {
154
+ const name = mapText(entry, 'name');
155
+ if (name !== undefined && SEAM_SPECIFIERS.has(name)) {
156
+ removed.push({ id: mapText(entry, 'id') ?? '', name });
157
+ continue;
158
+ }
159
+ keep.push(entry);
160
+ }
161
+ if (keep.length === entries.length)
162
+ continue;
163
+ if (keep.length > 0) {
164
+ container.items = keep;
165
+ continue;
166
+ }
167
+ // The `- insert:` form carries no other key, so the entry goes with its
168
+ // list; the `- id: <group>` form configures a group and stays.
169
+ if (isMap(item) && item.items.length > 1) {
170
+ item.delete('insert');
171
+ continue;
172
+ }
173
+ const root = document.contents;
174
+ if (isSeq(root))
175
+ root.items = root.items.filter(candidate => candidate !== item);
176
+ }
177
+ return removed;
178
+ }
179
+ /**
180
+ * Prove the rewrite removed exactly the seam rows and nothing else.
181
+ *
182
+ * The expected result is computed independently from the plain parsed data, so
183
+ * this compares two derivations rather than trusting the edit that produced
184
+ * one of them. A mismatch aborts before anything is written.
185
+ */
186
+ function assertStripped(original, next) {
187
+ if (seamRows(parseDocument(next)).length !== 0)
188
+ throw new Error('the rewritten profile patch file still registers an LSP layer');
189
+ const before = parseDocument(original).toJS();
190
+ const after = parseDocument(next).toJS();
191
+ const expected = stripSeamEntriesFromData(before);
192
+ if (JSON.stringify(expected) !== JSON.stringify(after)) {
193
+ throw new Error('the rewritten profile patch file would change unrelated patch entries; remove the LSP rows by hand instead');
194
+ }
195
+ }
196
+ /** The same removal applied to plain parsed data, as the independent expectation. */
197
+ function stripSeamEntriesFromData(data) {
198
+ if (!Array.isArray(data))
199
+ return data;
200
+ const result = [];
201
+ for (const entry of data) {
202
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
203
+ result.push(entry);
204
+ continue;
205
+ }
206
+ const record = entry;
207
+ if (!Array.isArray(record['insert'])) {
208
+ result.push(entry);
209
+ continue;
210
+ }
211
+ const keep = record['insert'].filter(item => {
212
+ if (typeof item !== 'object' || item === null || Array.isArray(item))
213
+ return true;
214
+ const name = item['name'];
215
+ return !(typeof name === 'string' && SEAM_SPECIFIERS.has(name));
216
+ });
217
+ if (keep.length === record['insert'].length) {
218
+ result.push(entry);
219
+ continue;
220
+ }
221
+ if (keep.length > 0) {
222
+ result.push({ ...record, insert: keep });
223
+ continue;
224
+ }
225
+ const rest = Object.entries(record).filter(([key]) => key !== 'insert');
226
+ if (rest.length > 0)
227
+ result.push(Object.fromEntries(rest));
228
+ }
229
+ return result;
230
+ }
231
+ /** Collect the `insert` rows that name one of {@link SEAM_SPECIFIERS}. */
232
+ function seamRows(document) {
233
+ const found = [];
234
+ for (const item of rootItems(document)) {
235
+ const entries = sequenceItems(mapNode(item, 'insert'));
236
+ if (entries === undefined)
237
+ continue;
238
+ for (const entry of entries) {
239
+ const name = mapText(entry, 'name');
240
+ if (name === undefined || !SEAM_SPECIFIERS.has(name))
241
+ continue;
242
+ found.push({ id: mapText(entry, 'id') ?? '', name });
243
+ }
244
+ }
245
+ return found;
246
+ }
247
+ /** The root sequence of a patch document; empty when the file holds something else. */
248
+ function rootItems(document) {
249
+ return sequenceItems(document.contents) ?? [];
250
+ }
251
+ /** Read a mapping key as a child node, or undefined when the node is not a mapping. */
252
+ function mapNode(node, key) {
253
+ if (!isMap(node))
254
+ return undefined;
255
+ const value = node.get(key, true);
256
+ return isNode(value) ? value : undefined;
257
+ }
258
+ /** Read a mapping key that holds a string scalar. */
259
+ function mapText(node, key) {
260
+ const value = mapNode(node, key);
261
+ return isScalar(value) && typeof value.value === 'string' ? value.value : undefined;
262
+ }
263
+ /** The items of a sequence node, or undefined when the node is not one. */
264
+ function sequenceItems(node) {
265
+ return isSeq(node) ? node.items : undefined;
266
+ }
267
+ /** Read the manifest facts, treating an absent or malformed file as an empty profile. */
268
+ async function readManifest(path) {
269
+ let parsed;
270
+ try {
271
+ parsed = JSON.parse(await readFile(path, 'utf8'));
272
+ }
273
+ catch {
274
+ return { bundles: [], dependencies: [], patchReload: DEFAULT_PATCH_RELOAD };
275
+ }
276
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
277
+ return { bundles: [], dependencies: [], patchReload: DEFAULT_PATCH_RELOAD };
278
+ }
279
+ const record = parsed;
280
+ const known = new Set(LSP_PACKAGE_SPECIFIERS);
281
+ const dependencies = [];
282
+ for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
283
+ const table = record[field];
284
+ if (typeof table !== 'object' || table === null || Array.isArray(table))
285
+ continue;
286
+ for (const name of Object.keys(table))
287
+ if (known.has(name) && !dependencies.includes(name))
288
+ dependencies.push(name);
289
+ }
290
+ const scope = objectAt(objectAt(record['dsh'], 'profile'));
291
+ const rawBundles = scope['bundles'];
292
+ const bundles = Array.isArray(rawBundles) ? rawBundles.filter((value) => typeof value === 'string' && known.has(value)) : [];
293
+ return { bundles, dependencies, patchReload: scope['patchReload'] === 'startup' ? 'startup' : DEFAULT_PATCH_RELOAD };
294
+ }
295
+ /** Read one nested object field, or an empty record when the value is not an object. */
296
+ function objectAt(value, key) {
297
+ const target = key === undefined ? value : typeof value === 'object' && value !== null && !Array.isArray(value) ? value[key] : undefined;
298
+ return typeof target === 'object' && target !== null && !Array.isArray(target) ? target : {};
299
+ }
300
+ /** Preserve the patch file's permission bits; Windows reports a synthetic mode. */
301
+ async function fileMode(path) {
302
+ try {
303
+ return (await stat(path)).mode & 0o777 || 0o644;
304
+ }
305
+ catch {
306
+ return 0o644;
307
+ }
308
+ }
309
+ /** A filename-safe local timestamp: no colons, so Windows accepts it. */
310
+ function stamp(now) {
311
+ const pad = (value, width = 2) => String(value).padStart(width, '0');
312
+ return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
313
+ }
314
+ function messageOf(error) {
315
+ return error instanceof Error ? error.message : String(error);
316
+ }
317
+ /** One-line remediation shared by the mount diagnostic and the status payload. */
318
+ export function describeLegacySeam(seam) {
319
+ const rows = seam.rows.map(row => (row.id === '' ? row.name : `${row.id} (${row.name})`)).join(', ');
320
+ return `profile "${seam.profile}" still inserts the LSP layer (${rows}) from ${seam.patchPath}`;
321
+ }
@@ -1,7 +1,6 @@
1
1
  import { CommandMountRegistry } from './commands-mounts.js';
2
2
  import { McpMountRegistry } from './mcp-mounts.js';
3
3
  import { HooksMountRegistry } from './hooks-mounts.js';
4
- import { randomUUID } from 'node:crypto';
5
4
  import { readFile } from 'node:fs/promises';
6
5
  import { stripFrontmatter } from '../catalog/skills-parse.js';
7
6
  import { parseFrontmatterRecord } from './user-store.js';
@@ -11,8 +10,8 @@ export function mountProjectCommands(ctx, catalog, t) {
11
10
  }
12
11
  /** MCP uses a separate injected child so network startup cannot delay local commands. */
13
12
  export function mountProjectMcp(ctx, catalog, dataRoot) {
14
- return mountProjectSurface(ctx, catalog, 'tools', (scope, agent) => {
15
- const registry = new McpMountRegistry(scope, dataRoot, agent.session.id ?? randomUUID());
13
+ return mountProjectSurface(ctx, catalog, 'tools', scope => {
14
+ const registry = new McpMountRegistry(scope, dataRoot);
16
15
  registry.setBackendProvider(() => catalog.mcpBackend());
17
16
  return registry;
18
17
  });
@@ -44,7 +43,7 @@ export async function suiteInstructions(suites) {
44
43
  const chunks = [];
45
44
  const errors = [];
46
45
  for (const suite of suites) {
47
- if (!suite.enabled || suite.activeSurfaces?.skills === false)
46
+ if (!suite.enabled || suite.activeSurfaces.skills === false)
48
47
  continue;
49
48
  if (suite.systemPrompt !== undefined)
50
49
  chunks.push(suite.systemPrompt);
@@ -72,8 +71,9 @@ export async function suiteInstructions(suites) {
72
71
  function mountProjectSurface(ctx, catalog, service, create, withoutProject = false) {
73
72
  const host = ctx;
74
73
  const mounts = new Map();
74
+ const serviceLabel = typeof service === 'string' ? service : service.join(',');
75
75
  let disposed = false;
76
- const warn = (error) => ctx.logger?.warn(`[dsh-agent-plugins-market] project ${service}: ${String(error)}`);
76
+ const warn = (error) => ctx.logger?.warn(`[dsh-agent-plugins-market] project ${serviceLabel}: ${String(error)}`);
77
77
  const attach = (agent) => {
78
78
  const cwd = agent.session.header.cwd;
79
79
  if (disposed || (cwd === undefined && !withoutProject) || mounts.has(agent))
@@ -101,7 +101,7 @@ function mountProjectSurface(ctx, catalog, service, create, withoutProject = fal
101
101
  scope.effect(() => async () => {
102
102
  active = false;
103
103
  await registry.disposeAll();
104
- }, `dsh-agent-plugins-market: project ${service}`);
104
+ }, `dsh-agent-plugins-market: project ${serviceLabel}`);
105
105
  void refresh().catch(warn);
106
106
  });
107
107
  mounts.set(agent, { fiber, refresh: () => refresh() });
@@ -115,9 +115,12 @@ function mountProjectSurface(ctx, catalog, service, create, withoutProject = fal
115
115
  const unwatchDisposed = ctx.on('agent/disposed', ({ agent }) => {
116
116
  void detach(agent).catch(warn);
117
117
  });
118
- const unwatchStart = ctx.on('agent/session-start', async ({ agent }) => {
118
+ const unwatchStart = ctx.on('agent/session-start', ({ agent }) => {
119
119
  attach(agent);
120
- await mounts.get(agent)?.refresh();
120
+ // The refresh chain can reject — its queue body re-reads the project
121
+ // catalog — and the host emits this event without awaiting the listener,
122
+ // so an unhandled rejection here would surface at every session start.
123
+ void mounts.get(agent)?.refresh().catch(warn);
121
124
  });
122
125
  for (const agent of host.agents.list())
123
126
  attach(agent);
@@ -0,0 +1,99 @@
1
+ /** Credential updates are batched for this long before one reconcile runs. */
2
+ const CREDENTIAL_DEBOUNCE_MS = 150;
3
+ export class ReconcileScheduler {
4
+ ctx;
5
+ runtime;
6
+ host;
7
+ disposed = false;
8
+ requested = false;
9
+ pass;
10
+ pendingCredentialRefs = new Set();
11
+ credentialFlush;
12
+ releaseCredentialUpdates;
13
+ constructor(ctx, runtime, host) {
14
+ this.ctx = ctx;
15
+ this.runtime = runtime;
16
+ this.host = host;
17
+ const eventHost = ctx;
18
+ this.releaseCredentialUpdates =
19
+ eventHost.on?.('credentials/reference-updated', ref => {
20
+ this.pendingCredentialRefs.add(ref);
21
+ if (this.credentialFlush !== undefined)
22
+ clearTimeout(this.credentialFlush);
23
+ this.credentialFlush = setTimeout(() => this.flushCredentialUpdates(), CREDENTIAL_DEBOUNCE_MS);
24
+ this.credentialFlush.unref?.();
25
+ }) ?? (() => { });
26
+ }
27
+ /** Reconcile now, joining the pass in flight and remembering one follow-up for it. */
28
+ request() {
29
+ if (this.disposed)
30
+ return Promise.resolve();
31
+ this.requested = true;
32
+ this.pass ??= (async () => {
33
+ try {
34
+ do {
35
+ this.requested = false;
36
+ await this.reconcileOnce();
37
+ } while (this.requested && !this.disposed);
38
+ }
39
+ finally {
40
+ this.pass = undefined;
41
+ }
42
+ })();
43
+ return this.pass;
44
+ }
45
+ /** Stop scheduling; later events are dropped. */
46
+ dispose() {
47
+ this.disposed = true;
48
+ this.releaseCredentialUpdates();
49
+ if (this.credentialFlush !== undefined)
50
+ clearTimeout(this.credentialFlush);
51
+ this.credentialFlush = undefined;
52
+ this.pendingCredentialRefs.clear();
53
+ }
54
+ async reconcileOnce() {
55
+ try {
56
+ const suites = await this.host.enabledSuites();
57
+ if (this.disposed)
58
+ return;
59
+ const diagnostics = await this.runtime.reconcile(suites);
60
+ this.host.publishMcpDiagnostics(diagnostics.mcp);
61
+ for (const diagnostic of diagnostics.mcp) {
62
+ this.ctx.logger?.warn(`[dsh-agent-plugins-market] suite "${diagnostic.suiteId}" mcp server "${diagnostic.serverKey}": ${diagnostic.reason}`);
63
+ }
64
+ for (const diagnostic of diagnostics.lsp) {
65
+ this.ctx.logger?.warn(`[dsh-agent-plugins-market] suite "${diagnostic.suiteId}" lsp server "${diagnostic.serverKey}": ${diagnostic.reason}`);
66
+ }
67
+ for (const diagnostic of diagnostics.commands) {
68
+ if (diagnostic.reason !== '')
69
+ this.ctx.logger?.warn(`[dsh-agent-plugins-market] suite "${diagnostic.suiteId}" command "${diagnostic.command}": ${diagnostic.reason}`);
70
+ }
71
+ for (const diagnostic of diagnostics.hooks) {
72
+ this.ctx.logger?.warn(`[dsh-agent-plugins-market] suite "${diagnostic.suiteId}" hooks: ${diagnostic.reason}`);
73
+ }
74
+ for (const error of diagnostics.errors) {
75
+ this.ctx.logger?.warn(`[dsh-agent-plugins-market] ${error.surface} reconcile failed: ${error.reason}`);
76
+ }
77
+ }
78
+ catch (error) {
79
+ this.ctx.logger?.warn(`[dsh-agent-plugins-market] runtime reconcile failed: ${error instanceof Error ? error.message : String(error)}`);
80
+ throw error;
81
+ }
82
+ }
83
+ /**
84
+ * Coalesce credential updates: the debounce collapses a burst into one pass,
85
+ * and the pending refs are kept until that pass runs, so an update arriving
86
+ * mid-reconcile is never dropped.
87
+ */
88
+ flushCredentialUpdates() {
89
+ this.credentialFlush = undefined;
90
+ const refs = [...this.pendingCredentialRefs];
91
+ this.pendingCredentialRefs.clear();
92
+ // Re-read the catalog first: an unknown ref means the snapshot predates
93
+ // this change, so reconcile anyway to refresh the known reference set.
94
+ void this.request().catch(() => { });
95
+ if (refs.length > 0) {
96
+ this.ctx.logger?.info?.(`[dsh-agent-plugins-market] reconciling after credential update: ${refs.join(', ')}`);
97
+ }
98
+ }
99
+ }
@@ -40,6 +40,10 @@ export class RuntimeReconciler {
40
40
  forceMcpRemount(suiteId, serverKey) {
41
41
  this.mcp.forceRemount(suiteId, serverKey);
42
42
  }
43
+ /** Flag every live MCP mount for a rebuild on the next reconcile. */
44
+ forceMcpRemountAll() {
45
+ this.mcp.forceRemountAll();
46
+ }
43
47
  /** The mount key owning one derived serverName; undefined when not mounted here. */
44
48
  mcpServerOwner(serverName) {
45
49
  return this.mcp.serverOwner(serverName);
@@ -1,5 +1,6 @@
1
- import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
1
+ import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
+ import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
3
4
  import { MCP_SCHEMA_ID, validateAgainstSchema, validateMcpJson } from '../catalog/validate.js';
4
5
  import { parseLspServers } from '../catalog/lsp-spec.js';
5
6
  import { qualifiedSuiteId } from '../catalog/paths.js';
@@ -56,10 +57,11 @@ export async function loadLspOverrides(root) {
56
57
  export async function saveLspOverride(root, id, config) {
57
58
  const overrides = await loadLspOverrides(root);
58
59
  overrides[id] = config;
59
- await mkdir(root, { recursive: true });
60
60
  const path = join(root, 'lsp-overrides.json');
61
- await writeFile(`${path}.tmp`, JSON.stringify(Object.fromEntries(Object.entries(overrides).map(([key, value]) => [key, lspConfig(value)])), null, 2), { mode: 0o600 });
62
- await rename(`${path}.tmp`, path);
61
+ await writeFileAtomic(path, JSON.stringify(Object.fromEntries(Object.entries(overrides).map(([key, value]) => [key, lspConfig(value)])), null, 2), {
62
+ mode: 0o600,
63
+ dirMode: 0o700
64
+ });
63
65
  }
64
66
  export async function applyLspOverrides(root, suites) {
65
67
  const overrides = await loadLspOverrides(root);
@@ -0,0 +1,136 @@
1
+ import { FEEDBACK_TOOL_NAME, mountFeedbackTool } from './feedback-tool.js';
2
+ import { MCP_SETTINGS_NAMESPACE, MarketSettingsSchema, readMcpBackend } from './mcp-backend.js';
3
+ import { narrowDownloadRegion } from './regions.js';
4
+ export class MarketSettingsNamespace {
5
+ ctx;
6
+ dataRoot;
7
+ locale;
8
+ host;
9
+ scope;
10
+ watchers = [];
11
+ feedbackDisposer;
12
+ /**
13
+ * The feedback tool's last reported mount state. A settings change that does
14
+ * not move it (region, backend, project layouts) must not re-log the same
15
+ * line, and every other state is exactly one line: mounted, skipped by the
16
+ * switch, or skipped because the host has no tools registry to register on.
17
+ */
18
+ feedbackMountState;
19
+ constructor(ctx, dataRoot, locale, host) {
20
+ this.ctx = ctx;
21
+ this.dataRoot = dataRoot;
22
+ this.locale = locale;
23
+ this.host = host;
24
+ }
25
+ /** Register the namespace; the host resolves it whenever the settings service mounts. */
26
+ mount() {
27
+ this.ctx.inject(['settings'], settingsCtx => this.register(settingsCtx));
28
+ }
29
+ /** The persisted MCP backend choice; the built-in client until registration lands. */
30
+ async backend() {
31
+ return this.scope !== undefined && this.scope.get().mcpEnhanced === false ? 'host' : 'builtin';
32
+ }
33
+ /**
34
+ * Persist a backend choice.
35
+ * @throws when the settings service is not mounted.
36
+ */
37
+ async setBackend(backend) {
38
+ if (this.scope === undefined)
39
+ throw new Error('the settings service is not mounted');
40
+ await this.scope.update({ mcpEnhanced: backend !== 'host' });
41
+ }
42
+ /** The persisted download-region setting. */
43
+ async downloadRegion() {
44
+ return narrowDownloadRegion(this.scope?.get().downloadRegion);
45
+ }
46
+ /** Release every watcher and unmount the feedback tool. */
47
+ dispose() {
48
+ for (const release of this.watchers.splice(0))
49
+ release();
50
+ this.feedbackDisposer?.();
51
+ this.feedbackDisposer = undefined;
52
+ }
53
+ register(settingsCtx) {
54
+ try {
55
+ this.ctx.logger?.info?.('[dsh-agent-plugins-market] settings inject resolved — registering namespace');
56
+ const settings = settingsCtx.settings;
57
+ const scope = settings.register(MCP_SETTINGS_NAMESPACE, MarketSettingsSchema, {
58
+ base: { mcpEnhanced: true, downloadRegion: 'auto', feedbackEnabled: true, scanProjectLayouts: false }
59
+ });
60
+ this.scope = scope;
61
+ this.ctx.logger?.info?.('[dsh-agent-plugins-market] settings namespace registered — plugin-config card will serve');
62
+ this.syncProjectLayouts();
63
+ this.watchers.push(scope.watch(() => this.syncProjectLayouts()));
64
+ this.syncAutoUpdateSources();
65
+ this.watchers.push(scope.watch(() => this.syncAutoUpdateSources()));
66
+ // One-time migration from the earlier data-root settings.json choice.
67
+ void readMcpBackend(this.dataRoot).then(backend => {
68
+ if (backend === 'host')
69
+ void scope.update({ mcpEnhanced: false }).catch(() => { });
70
+ });
71
+ let previousBackend = scope.get().mcpEnhanced !== false;
72
+ this.watchers.push(scope.watch(() => {
73
+ const backend = scope.get().mcpEnhanced !== false;
74
+ if (backend === previousBackend)
75
+ return;
76
+ previousBackend = backend;
77
+ this.host.refreshMcpMounts();
78
+ }));
79
+ // The experience-feedback model tool: gated by the namespace's
80
+ // `feedbackEnabled` field (default on); the switch unregisters it. The
81
+ // tool mount is doubly contained — its failure must never take the
82
+ // settings namespace (and with it the config card) down with it. The
83
+ // mount outcome is logged on every transition so an absent
84
+ // `report_market_issue` in a session is traceable to its cause.
85
+ this.syncFeedbackTool();
86
+ this.watchers.push(scope.watch(() => this.syncFeedbackTool()));
87
+ }
88
+ catch (error) {
89
+ this.ctx.logger?.error?.(`[dsh-agent-plugins-market] settings namespace registration failed — the plugin-config card will be hidden this boot: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
90
+ }
91
+ }
92
+ /** Apply the background source-update switch; OFF is the default. */
93
+ syncAutoUpdateSources() {
94
+ try {
95
+ this.host.setAutoUpdateSources(this.scope?.get().autoUpdateSources === true);
96
+ }
97
+ catch (error) {
98
+ this.ctx.logger?.error?.(`[dsh-agent-plugins-market] background source update switch failed: ${String(error)}`);
99
+ }
100
+ }
101
+ syncProjectLayouts() {
102
+ void this.host.setScanProjectLayouts(this.scope?.get().scanProjectLayouts === true).catch(error => {
103
+ this.ctx.logger?.error?.(`[dsh-agent-plugins-market] project layout reconciliation failed: ${String(error)}`);
104
+ });
105
+ }
106
+ syncFeedbackTool() {
107
+ try {
108
+ const wanted = this.scope?.get().feedbackEnabled !== false;
109
+ if (!wanted) {
110
+ if (this.feedbackDisposer !== undefined) {
111
+ this.feedbackDisposer();
112
+ this.feedbackDisposer = undefined;
113
+ }
114
+ }
115
+ else if (this.feedbackDisposer === undefined) {
116
+ this.feedbackDisposer = mountFeedbackTool(this.ctx, this.dataRoot, (key, params) => this.locale.t(key, params)) ?? undefined;
117
+ }
118
+ const state = !wanted ? 'off' : this.feedbackDisposer === undefined ? 'unavailable' : 'mounted';
119
+ if (state === this.feedbackMountState)
120
+ return;
121
+ this.feedbackMountState = state;
122
+ if (state === 'mounted') {
123
+ this.ctx.logger?.info?.(`[dsh-agent-plugins-market] ${FEEDBACK_TOOL_NAME} mounted on the host tools registry`);
124
+ }
125
+ else if (state === 'off') {
126
+ this.ctx.logger?.info?.(`[dsh-agent-plugins-market] ${FEEDBACK_TOOL_NAME} not mounted: feedbackEnabled is off`);
127
+ }
128
+ else {
129
+ this.ctx.logger?.warn?.(`[dsh-agent-plugins-market] ${FEEDBACK_TOOL_NAME} not mounted: the host exposes no tools registry`);
130
+ }
131
+ }
132
+ catch (error) {
133
+ this.ctx.logger?.error?.(`[dsh-agent-plugins-market] feedback tool mount failed: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
134
+ }
135
+ }
136
+ }