dsh-agent-plugins-market 0.6.2 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.md +117 -111
  2. package/README.zh.md +112 -98
  3. package/client/client.js +28533 -1511
  4. package/client/client.js.map +1 -1
  5. package/lib/application/catalog-context.js +188 -0
  6. package/lib/application/catalog.js +229 -1109
  7. package/lib/application/details.js +19 -8
  8. package/lib/application/install-store.js +95 -0
  9. package/lib/application/lsp-service.js +97 -0
  10. package/lib/application/mcp-service.js +263 -0
  11. package/lib/application/panel-resources.js +43 -14
  12. package/lib/application/ports.js +21 -0
  13. package/lib/application/project-agent-roles.js +8 -2
  14. package/lib/application/snapshot-cache.js +160 -0
  15. package/lib/application/source-store.js +521 -0
  16. package/lib/catalog/archive.js +35 -30
  17. package/lib/catalog/component-files.js +14 -6
  18. package/lib/catalog/fs-probes.js +43 -0
  19. package/lib/catalog/manifests.js +53 -92
  20. package/lib/catalog/native-project.js +2 -2
  21. package/lib/catalog/paths.js +74 -55
  22. package/lib/catalog/plugin-variables.js +38 -0
  23. package/lib/catalog/project-config.js +2 -1
  24. package/lib/catalog/project-hooks.js +23 -2
  25. package/lib/catalog/scan-resolvers.js +29 -45
  26. package/lib/catalog/skills-parse.js +61 -10
  27. package/lib/catalog/source-catalog.js +10 -4
  28. package/lib/catalog/suite-components.js +7 -7
  29. package/lib/catalog/suite-scanner.js +3 -1
  30. package/lib/catalog/surfaces.js +5 -26
  31. package/lib/catalog/validate.js +7 -11
  32. package/lib/contracts/market.js +2 -0
  33. package/lib/contracts/settings.js +63 -0
  34. package/lib/index.js +115 -217
  35. package/lib/model/layouts.js +10 -1
  36. package/lib/model/types.js +26 -3
  37. package/lib/routes.js +57 -15
  38. package/lib/runtime/agent-role-names.js +70 -0
  39. package/lib/runtime/agent-role-router.js +142 -106
  40. package/lib/runtime/commands-mounts.js +55 -11
  41. package/lib/runtime/deadline.js +54 -0
  42. package/lib/runtime/dynamic-context.js +140 -0
  43. package/lib/runtime/feedback-tool.js +101 -46
  44. package/lib/runtime/hooks-mounts.js +9 -11
  45. package/lib/runtime/host-locale.js +49 -24
  46. package/lib/runtime/lsp-direct-config.js +19 -19
  47. package/lib/runtime/lsp-mounts.js +182 -70
  48. package/lib/runtime/lsp-server-state.js +4 -4
  49. package/lib/runtime/lsp-status.js +1 -1
  50. package/lib/runtime/mcp-auth-record.js +42 -0
  51. package/lib/runtime/mcp-backend.js +14 -14
  52. package/lib/runtime/mcp-client/config.js +8 -1
  53. package/lib/runtime/mcp-client/connection.js +38 -43
  54. package/lib/runtime/mcp-client/host-contract.js +10 -0
  55. package/lib/runtime/mcp-client/host-seams.js +8 -88
  56. package/lib/runtime/mcp-client/oauth.js +5 -7
  57. package/lib/runtime/mcp-client/plugin-identity.js +28 -0
  58. package/lib/runtime/mcp-client/projection.js +202 -0
  59. package/lib/runtime/mcp-client/tools.js +4 -189
  60. package/lib/runtime/mcp-config.js +36 -14
  61. package/lib/runtime/mcp-direct-config.js +33 -16
  62. package/lib/runtime/mcp-mounts.js +35 -75
  63. package/lib/runtime/mcp-overrides.js +5 -5
  64. package/lib/runtime/mcp-redaction.js +3 -2
  65. package/lib/runtime/mcp-status.js +7 -8
  66. package/lib/runtime/mount-lifecycle.js +73 -0
  67. package/lib/runtime/profile-seam.js +321 -0
  68. package/lib/runtime/project-runtime.js +34 -17
  69. package/lib/runtime/reconcile-scheduler.js +97 -0
  70. package/lib/runtime/reconciler.js +6 -1
  71. package/lib/runtime/regions.js +0 -19
  72. package/lib/runtime/server-config.js +6 -4
  73. package/lib/runtime/settings-namespace.js +144 -0
  74. package/lib/runtime/skills-provider.js +38 -14
  75. package/lib/runtime/source-auto-update.js +66 -0
  76. package/lib/{model/state.js → runtime/state-store.js} +9 -11
  77. package/lib/runtime/storage-migration.js +35 -18
  78. package/lib/runtime/subagent-catalog.js +5 -4
  79. package/lib/runtime/timer-seat.js +80 -0
  80. package/lib/runtime/tool-registry-observer.js +20 -20
  81. package/lib/runtime/user-commands.js +7 -3
  82. package/lib/runtime/user-panels.js +125 -46
  83. package/lib/runtime/user-store.js +124 -69
  84. package/lib/types/application/catalog-context.d.ts +124 -0
  85. package/lib/types/application/catalog.d.ts +108 -304
  86. package/lib/types/application/install-store.d.ts +21 -0
  87. package/lib/types/application/lsp-service.d.ts +36 -0
  88. package/lib/types/application/mcp-service.d.ts +77 -0
  89. package/lib/types/application/panel-resources.d.ts +8 -0
  90. package/lib/types/application/ports.d.ts +72 -0
  91. package/lib/types/application/queries.d.ts +15 -26
  92. package/lib/types/application/snapshot-cache.d.ts +51 -0
  93. package/lib/types/application/source-store.d.ts +100 -0
  94. package/lib/types/catalog/archive.d.ts +3 -11
  95. package/lib/types/catalog/component-files.d.ts +4 -0
  96. package/lib/types/catalog/fs-probes.d.ts +10 -0
  97. package/lib/types/catalog/manifests.d.ts +17 -16
  98. package/lib/types/catalog/native-project.d.ts +2 -2
  99. package/lib/types/catalog/paths.d.ts +53 -15
  100. package/lib/types/catalog/plugin-variables.d.ts +16 -0
  101. package/lib/types/catalog/scan-pipeline.d.ts +3 -3
  102. package/lib/types/catalog/scan-resolvers.d.ts +3 -9
  103. package/lib/types/catalog/skills-parse.d.ts +13 -7
  104. package/lib/types/catalog/source-catalog.d.ts +10 -5
  105. package/lib/types/catalog/suite-scanner.d.ts +8 -6
  106. package/lib/types/catalog/validate.d.ts +0 -2
  107. package/lib/types/contracts/lsp-status.d.ts +35 -0
  108. package/lib/types/contracts/market.d.ts +13 -2
  109. package/lib/types/contracts/mcp-status.d.ts +4 -0
  110. package/lib/types/contracts/settings.d.ts +59 -0
  111. package/lib/types/model/layouts.d.ts +10 -0
  112. package/lib/types/model/types.d.ts +29 -5
  113. package/lib/types/runtime/agent-role-names.d.ts +6 -0
  114. package/lib/types/runtime/agent-role-router.d.ts +59 -52
  115. package/lib/types/runtime/commands-mounts.d.ts +15 -7
  116. package/lib/types/runtime/deadline.d.ts +17 -0
  117. package/lib/types/runtime/dynamic-context.d.ts +81 -0
  118. package/lib/types/runtime/feedback-tool.d.ts +33 -11
  119. package/lib/types/runtime/hooks-mounts.d.ts +1 -1
  120. package/lib/types/runtime/host-locale.d.ts +20 -2
  121. package/lib/types/runtime/lsp-direct-config.d.ts +3 -3
  122. package/lib/types/runtime/lsp-mounts.d.ts +69 -19
  123. package/lib/types/runtime/lsp-status.d.ts +2 -1
  124. package/lib/types/runtime/mcp-auth-record.d.ts +29 -0
  125. package/lib/types/runtime/mcp-backend.d.ts +10 -14
  126. package/lib/types/runtime/mcp-client/config.d.ts +8 -2
  127. package/lib/types/runtime/mcp-client/host-contract.d.ts +78 -0
  128. package/lib/types/runtime/mcp-client/host-seams.d.ts +20 -64
  129. package/lib/types/runtime/mcp-client/oauth.d.ts +1 -1
  130. package/lib/types/runtime/mcp-client/plugin-identity.d.ts +16 -0
  131. package/lib/types/runtime/mcp-client/projection.d.ts +45 -0
  132. package/lib/types/runtime/mcp-client/tools.d.ts +5 -72
  133. package/lib/types/runtime/mcp-config.d.ts +25 -4
  134. package/lib/types/runtime/mcp-direct-config.d.ts +18 -4
  135. package/lib/types/runtime/mcp-mounts.d.ts +10 -15
  136. package/lib/types/runtime/mcp-overrides.d.ts +1 -1
  137. package/lib/types/runtime/mcp-status.d.ts +1 -1
  138. package/lib/types/runtime/mount-lifecycle.d.ts +53 -0
  139. package/lib/types/runtime/profile-seam.d.ts +64 -0
  140. package/lib/types/runtime/project-runtime.d.ts +10 -3
  141. package/lib/types/runtime/reconcile-scheduler.d.ts +45 -0
  142. package/lib/types/runtime/reconciler.d.ts +2 -0
  143. package/lib/types/runtime/regions.d.ts +1 -4
  144. package/lib/types/runtime/settings-namespace.d.ts +76 -0
  145. package/lib/types/runtime/skills-provider.d.ts +11 -2
  146. package/lib/types/runtime/source-auto-update.d.ts +32 -0
  147. package/lib/types/{model/state.d.ts → runtime/state-store.d.ts} +2 -2
  148. package/lib/types/runtime/storage-migration.d.ts +5 -3
  149. package/lib/types/runtime/subagent-catalog.d.ts +2 -0
  150. package/lib/types/runtime/timer-seat.d.ts +51 -0
  151. package/lib/types/runtime/tool-registry-observer.d.ts +12 -6
  152. package/lib/types/runtime/user-panels.d.ts +69 -19
  153. package/lib/types/runtime/user-store.d.ts +67 -20
  154. package/package.json +68 -24
  155. package/schemas/README.md +1 -1
@@ -1,5 +1,4 @@
1
- import { applyOverride } from './mcp-overrides.js';
2
- import { credentialRefsInServer, deriveServerName } from './mcp-config.js';
1
+ import { credentialRefsInServer, effectiveMcpServers, deriveServerName } from './mcp-config.js';
3
2
  import { inspectToolRegistry } from './tool-registry-observer.js';
4
3
  import { redactMcpConfig, redactUrl } from './mcp-redaction.js';
5
4
  import { qualifiedSuiteId } from '../catalog/paths.js';
@@ -29,16 +28,15 @@ export function buildMcpStatus(suites, diagnostics, observed, overrides = new Ma
29
28
  if (suite.mcp === undefined || suite.installedAt === undefined || !suite.enabled)
30
29
  continue;
31
30
  const suiteKey = qualifiedSuiteId(suite.sourceId, suite.id);
32
- const suiteOverrides = overrides.get(suiteKey);
33
- for (const [serverKey, server] of Object.entries(suite.mcp.servers)) {
34
- const override = suiteOverrides?.[serverKey];
31
+ for (const { serverKey, server: effective, override, enabled, credentialRefs: refs } of effectiveMcpServers(suite, overrides.get(suiteKey))) {
35
32
  const serverName = deriveServerName(suite.id, serverKey);
36
33
  const tools = observedByServer.get(serverName) ?? [];
37
34
  claimedServers.add(serverName);
38
35
  const diagnostic = diagnosticsByKey.get(`${suiteKey}\u0000${serverKey}`);
39
- const effective = applyOverride(server, override);
40
- const credentialRefs = [...new Set([...credentialRefsInServer(effective), ...(diagnostic?.credentialRefs ?? [])])].sort();
41
- const disabled = override?.enabled === false || suite.activeSurfaces?.mcp === false;
36
+ const credentialRefs = [...new Set([...refs, ...(diagnostic?.credentialRefs ?? [])])].sort();
37
+ // The declaration stays on the inventory when an override disables it:
38
+ // the panel shows what the suite ships and how the user changed it.
39
+ const disabled = !enabled || suite.activeSurfaces.mcp === false;
42
40
  const orphaned = disabled && tools.length > 0;
43
41
  // A duplicate copy shares the live serverName, so observed tools land
44
42
  // on it too — that does not make it connected: its own diagnostic says
@@ -74,6 +72,7 @@ export function buildMcpStatus(suites, diagnostics, observed, overrides = new Ma
74
72
  tools: disabled && !orphaned ? [] : tools.map(tool => ({ name: tool.name, ...(tool.description === undefined ? {} : { description: tool.description }) })),
75
73
  advertisedTools: tools.length > 0,
76
74
  retryable: diagnostic?.code === 'mount-failed' || diagnostic?.code === 'unmount-failed',
75
+ ...(effective.type === 'stdio' || effective.auth !== undefined ? {} : { oauthDefault: true }),
77
76
  ...(diagnostic?.code === undefined ? {} : { code: diagnostic.code }),
78
77
  ...(reason === undefined ? {} : { reason }),
79
78
  ...(credentialRefs.length === 0 ? {} : { credentialRefs })
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Shared mount lifecycle: the pass queue and the bounded retry schedule that
3
+ * every surface registry runs on top of its own mount/unmount semantics. Which
4
+ * failures are worth retrying stays the surface's own decision; only the timing
5
+ * and the bookkeeping live here.
6
+ */
7
+ /** A pass failure reaches the caller's promise, so the queue tail stays usable. */
8
+ const swallow = () => undefined;
9
+ /** Serializes reconcile passes: each pass starts after the previous one settles. */
10
+ export class SerialPassQueue {
11
+ tail = Promise.resolve();
12
+ /** Queue one pass behind the in-flight one, so a disable cannot race an in-flight spawn. */
13
+ run(pass) {
14
+ const run = this.tail.then(pass);
15
+ this.tail = run.then(swallow, swallow);
16
+ return run;
17
+ }
18
+ }
19
+ /**
20
+ * Bounded retry schedule for a failed mount/unmount. The cap keeps a
21
+ * crash-looping or permanently broken server from consuming attempt budget
22
+ * forever, and the count resets with every reconcile pass.
23
+ */
24
+ const RETRY_SCHEDULE_MS = [1_500, 5_000, 15_000, 45_000, 120_000];
25
+ const MAX_RETRY_ATTEMPTS = RETRY_SCHEDULE_MS.length;
26
+ /** Delayed re-attempts for a mount that is not live yet, keyed by mount key. */
27
+ export class RetryScheduler {
28
+ options;
29
+ /** Pending retry timers keyed by mount key, so a teardown can cancel them. */
30
+ timers = new Map();
31
+ /** Attempt count per mount key, dropped when its target stops being retryable. */
32
+ attempts = new Map();
33
+ constructor(options) {
34
+ this.options = options;
35
+ }
36
+ /**
37
+ * Arm the retry for one failed mount, replacing any timer already pending for
38
+ * its key. A target carrying no reason is a deterministic skip rather than a
39
+ * transient failure: it drops that key's budget and cancels its timer.
40
+ */
41
+ schedule(target) {
42
+ const pending = this.timers.get(target.key);
43
+ if (pending !== undefined)
44
+ clearTimeout(pending);
45
+ this.timers.delete(target.key);
46
+ if (target.reason === undefined) {
47
+ this.attempts.delete(target.key);
48
+ return;
49
+ }
50
+ const attempt = (this.attempts.get(target.key) ?? 0) + 1;
51
+ this.attempts.set(target.key, attempt);
52
+ if (attempt > MAX_RETRY_ATTEMPTS) {
53
+ this.options.log(`${target.label}: giving up after ${MAX_RETRY_ATTEMPTS} attempts — ${target.reason}`);
54
+ return;
55
+ }
56
+ const delay = RETRY_SCHEDULE_MS[attempt - 1] ?? RETRY_SCHEDULE_MS[RETRY_SCHEDULE_MS.length - 1];
57
+ const timer = setTimeout(() => {
58
+ this.timers.delete(target.key);
59
+ // Replay the surface's last known input: a retry must not resurrect a
60
+ // server that has since been disabled or uninstalled.
61
+ void this.options.replay().catch(() => { });
62
+ }, delay);
63
+ timer.unref?.();
64
+ this.timers.set(target.key, timer);
65
+ }
66
+ /** Cancel every pending retry and drop all attempt budgets. */
67
+ clear() {
68
+ for (const timer of this.timers.values())
69
+ clearTimeout(timer);
70
+ this.timers.clear();
71
+ this.attempts.clear();
72
+ }
73
+ }
@@ -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,18 +1,19 @@
1
1
  import { CommandMountRegistry } from './commands-mounts.js';
2
+ import { expandPluginPaths, pluginRootOf } from '../catalog/plugin-variables.js';
3
+ import { suiteDataDir } from '../catalog/paths.js';
2
4
  import { McpMountRegistry } from './mcp-mounts.js';
3
5
  import { HooksMountRegistry } from './hooks-mounts.js';
4
- import { randomUUID } from 'node:crypto';
5
6
  import { readFile } from 'node:fs/promises';
6
7
  import { stripFrontmatter } from '../catalog/skills-parse.js';
7
8
  import { parseFrontmatterRecord } from './user-store.js';
8
9
  /** Mount under existing and newly created agents; the caller owns refresh and teardown. */
9
- export function mountProjectCommands(ctx, catalog, t) {
10
- return mountProjectSurface(ctx, catalog, 'commands', scope => new CommandMountRegistry(scope, t));
10
+ export function mountProjectCommands(ctx, catalog, t, dataRoot) {
11
+ return mountProjectSurface(ctx, catalog, 'commands', scope => new CommandMountRegistry(scope, t, dataRoot));
11
12
  }
12
13
  /** MCP uses a separate injected child so network startup cannot delay local commands. */
13
14
  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());
15
+ return mountProjectSurface(ctx, catalog, 'tools', scope => {
16
+ const registry = new McpMountRegistry(scope, dataRoot);
16
17
  registry.setBackendProvider(() => catalog.mcpBackend());
17
18
  return registry;
18
19
  });
@@ -22,14 +23,18 @@ export function mountProjectHooks(ctx, catalog) {
22
23
  return mountProjectSurface(ctx, catalog, ['shell', 'sessionProjections'], scope => new HooksMountRegistry(scope));
23
24
  }
24
25
  /** Kimi's startup skill and system-prompt declarations use the existing scoped prompt service. */
25
- export function mountSuiteInstructions(ctx, catalog) {
26
- return mountProjectSurface(ctx, catalog, 'systemPrompt', scope => {
26
+ export function mountSuiteInstructions(ctx, catalog, dataRoot) {
27
+ return mountProjectSurface(ctx, catalog, 'systemPrompt', (scope, agent) => {
27
28
  let text = '';
28
29
  const host = scope;
29
30
  const dispose = host.systemPrompt.section({ name: 'agent-plugins:instructions', order: 500, text: () => text });
30
31
  return {
31
32
  async reconcile(projectSuites) {
32
- const result = await suiteInstructions([...(await catalog.enabledUserSuites()), ...projectSuites]);
33
+ const projectDir = agent.session.header.cwd;
34
+ const result = await suiteInstructions([...(await catalog.enabledUserSuites()), ...projectSuites], {
35
+ ...(dataRoot === undefined ? {} : { dataRoot }),
36
+ ...(projectDir === undefined ? {} : { projectDir })
37
+ });
33
38
  text = result.text;
34
39
  return result.errors;
35
40
  },
@@ -40,14 +45,22 @@ export function mountSuiteInstructions(ctx, catalog) {
40
45
  };
41
46
  }, true);
42
47
  }
43
- export async function suiteInstructions(suites) {
48
+ export async function suiteInstructions(suites, paths = {}) {
44
49
  const chunks = [];
45
50
  const errors = [];
46
51
  for (const suite of suites) {
47
- if (!suite.enabled || suite.activeSurfaces?.skills === false)
52
+ if (!suite.enabled || suite.activeSurfaces.skills === false)
48
53
  continue;
54
+ // Startup instructions are author-written suite text like any other
55
+ // surface: the paths they name have to resolve before the model reads them.
56
+ const root = pluginRootOf(suite);
57
+ const context = {
58
+ ...(root === undefined ? {} : { root, ...(paths.dataRoot === undefined ? {} : { data: suiteDataDir(paths.dataRoot, suite.sourceId, suite.id) }) }),
59
+ ...(paths.projectDir === undefined ? {} : { projectDir: paths.projectDir })
60
+ };
61
+ const expand = (text) => expandPluginPaths(text, context);
49
62
  if (suite.systemPrompt !== undefined)
50
- chunks.push(suite.systemPrompt);
63
+ chunks.push(expand(suite.systemPrompt));
51
64
  if (suite.manifest.startupSkill === undefined)
52
65
  continue;
53
66
  const skill = suite.skills.find(skill => skill.name === suite.manifest.startupSkill);
@@ -59,9 +72,9 @@ export async function suiteInstructions(suites) {
59
72
  const content = await readFile(skill.file, 'utf8');
60
73
  if (parseFrontmatterRecord(content).disabled === true)
61
74
  continue;
62
- chunks.push(stripFrontmatter(content));
75
+ chunks.push(expand(stripFrontmatter(content)));
63
76
  if (suite.manifest.skillInstructions !== undefined)
64
- chunks.push(suite.manifest.skillInstructions);
77
+ chunks.push(expand(suite.manifest.skillInstructions));
65
78
  }
66
79
  catch {
67
80
  errors.push({ suiteId: `${suite.sourceId}/${suite.id}`, reason: `startup skill ${skill.name} is unreadable` });
@@ -72,8 +85,9 @@ export async function suiteInstructions(suites) {
72
85
  function mountProjectSurface(ctx, catalog, service, create, withoutProject = false) {
73
86
  const host = ctx;
74
87
  const mounts = new Map();
88
+ const serviceLabel = typeof service === 'string' ? service : service.join(',');
75
89
  let disposed = false;
76
- const warn = (error) => ctx.logger?.warn(`[dsh-agent-plugins-market] project ${service}: ${String(error)}`);
90
+ const warn = (error) => ctx.logger?.warn(`[dsh-agent-plugins-market] project ${serviceLabel}: ${String(error)}`);
77
91
  const attach = (agent) => {
78
92
  const cwd = agent.session.header.cwd;
79
93
  if (disposed || (cwd === undefined && !withoutProject) || mounts.has(agent))
@@ -101,7 +115,7 @@ function mountProjectSurface(ctx, catalog, service, create, withoutProject = fal
101
115
  scope.effect(() => async () => {
102
116
  active = false;
103
117
  await registry.disposeAll();
104
- }, `dsh-agent-plugins-market: project ${service}`);
118
+ }, `dsh-agent-plugins-market: project ${serviceLabel}`);
105
119
  void refresh().catch(warn);
106
120
  });
107
121
  mounts.set(agent, { fiber, refresh: () => refresh() });
@@ -115,9 +129,12 @@ function mountProjectSurface(ctx, catalog, service, create, withoutProject = fal
115
129
  const unwatchDisposed = ctx.on('agent/disposed', ({ agent }) => {
116
130
  void detach(agent).catch(warn);
117
131
  });
118
- const unwatchStart = ctx.on('agent/session-start', async ({ agent }) => {
132
+ const unwatchStart = ctx.on('agent/session-start', ({ agent }) => {
119
133
  attach(agent);
120
- await mounts.get(agent)?.refresh();
134
+ // The refresh chain can reject — its queue body re-reads the project
135
+ // catalog — and the host emits this event without awaiting the listener,
136
+ // so an unhandled rejection here would surface at every session start.
137
+ void mounts.get(agent)?.refresh().catch(warn);
121
138
  });
122
139
  for (const agent of host.agents.list())
123
140
  attach(agent);
@@ -0,0 +1,97 @@
1
+ import { coalesce } from './timer-seat.js';
2
+ /** Credential updates are batched for this long before one reconcile runs. */
3
+ const CREDENTIAL_DEBOUNCE_MS = 150;
4
+ export class ReconcileScheduler {
5
+ ctx;
6
+ runtime;
7
+ host;
8
+ disposed = false;
9
+ requested = false;
10
+ pass;
11
+ pendingCredentialRefs = new Set();
12
+ credentialFlush;
13
+ releaseCredentialUpdates;
14
+ constructor(ctx, runtime, host) {
15
+ this.ctx = ctx;
16
+ this.runtime = runtime;
17
+ this.host = host;
18
+ const eventHost = ctx;
19
+ // One reconcile per burst of credential updates: the host fires one event
20
+ // per reference, and every mount that reads them must see the whole set.
21
+ this.credentialFlush = coalesce(ctx, () => this.flushCredentialUpdates(), CREDENTIAL_DEBOUNCE_MS);
22
+ this.releaseCredentialUpdates =
23
+ eventHost.on?.('credentials/reference-updated', ref => {
24
+ this.pendingCredentialRefs.add(ref);
25
+ this.credentialFlush();
26
+ }) ?? (() => { });
27
+ }
28
+ /** Reconcile now, joining the pass in flight and remembering one follow-up for it. */
29
+ request() {
30
+ if (this.disposed)
31
+ return Promise.resolve();
32
+ this.requested = true;
33
+ this.pass ??= (async () => {
34
+ try {
35
+ do {
36
+ this.requested = false;
37
+ await this.reconcileOnce();
38
+ } while (this.requested && !this.disposed);
39
+ }
40
+ finally {
41
+ this.pass = undefined;
42
+ }
43
+ })();
44
+ return this.pass;
45
+ }
46
+ /** Stop scheduling; later events are dropped. */
47
+ dispose() {
48
+ this.disposed = true;
49
+ this.releaseCredentialUpdates();
50
+ this.credentialFlush.dispose();
51
+ this.pendingCredentialRefs.clear();
52
+ }
53
+ async reconcileOnce() {
54
+ try {
55
+ const suites = await this.host.enabledSuites();
56
+ if (this.disposed)
57
+ return;
58
+ const diagnostics = await this.runtime.reconcile(suites);
59
+ this.host.publishMcpDiagnostics(diagnostics.mcp);
60
+ for (const diagnostic of diagnostics.mcp) {
61
+ this.ctx.logger?.warn(`[dsh-agent-plugins-market] suite "${diagnostic.suiteId}" mcp server "${diagnostic.serverKey}": ${diagnostic.reason}`);
62
+ }
63
+ for (const diagnostic of diagnostics.lsp) {
64
+ this.ctx.logger?.warn(`[dsh-agent-plugins-market] suite "${diagnostic.suiteId}" lsp server "${diagnostic.serverKey}": ${diagnostic.reason}`);
65
+ }
66
+ for (const diagnostic of diagnostics.commands) {
67
+ if (diagnostic.reason !== '')
68
+ this.ctx.logger?.warn(`[dsh-agent-plugins-market] suite "${diagnostic.suiteId}" command "${diagnostic.command}": ${diagnostic.reason}`);
69
+ }
70
+ for (const diagnostic of diagnostics.hooks) {
71
+ this.ctx.logger?.warn(`[dsh-agent-plugins-market] suite "${diagnostic.suiteId}" hooks: ${diagnostic.reason}`);
72
+ }
73
+ for (const error of diagnostics.errors) {
74
+ this.ctx.logger?.warn(`[dsh-agent-plugins-market] ${error.surface} reconcile failed: ${error.reason}`);
75
+ }
76
+ }
77
+ catch (error) {
78
+ this.ctx.logger?.warn(`[dsh-agent-plugins-market] runtime reconcile failed: ${error instanceof Error ? error.message : String(error)}`);
79
+ throw error;
80
+ }
81
+ }
82
+ /**
83
+ * Coalesce credential updates: the debounce collapses a burst into one pass,
84
+ * and the pending refs are kept until that pass runs, so an update arriving
85
+ * mid-reconcile is never dropped.
86
+ */
87
+ flushCredentialUpdates() {
88
+ const refs = [...this.pendingCredentialRefs];
89
+ this.pendingCredentialRefs.clear();
90
+ // Re-read the catalog first: an unknown ref means the snapshot predates
91
+ // this change, so reconcile anyway to refresh the known reference set.
92
+ void this.request().catch(() => { });
93
+ if (refs.length > 0) {
94
+ this.ctx.logger?.info?.(`[dsh-agent-plugins-market] reconciling after credential update: ${refs.join(', ')}`);
95
+ }
96
+ }
97
+ }
@@ -12,9 +12,10 @@ export class RuntimeReconciler {
12
12
  disposed = false;
13
13
  constructor(ctx, dataRoot, t = bindHostLocale(undefined)) {
14
14
  this.mcp = new McpMountRegistry(ctx, dataRoot);
15
- this.commands = new CommandMountRegistry(ctx, t);
15
+ this.commands = new CommandMountRegistry(ctx, t, dataRoot);
16
16
  this.hooks = new HooksMountRegistry(ctx);
17
17
  this.lspRegistry = new LspMountRegistry(ctx);
18
+ this.lspRegistry.setPluginDataRoot(dataRoot);
18
19
  }
19
20
  /** The LSP mount registry, consumed by the LSP status surface. */
20
21
  get lsp() {
@@ -40,6 +41,10 @@ export class RuntimeReconciler {
40
41
  forceMcpRemount(suiteId, serverKey) {
41
42
  this.mcp.forceRemount(suiteId, serverKey);
42
43
  }
44
+ /** Flag every live MCP mount for a rebuild on the next reconcile. */
45
+ forceMcpRemountAll() {
46
+ this.mcp.forceRemountAll();
47
+ }
43
48
  /** The mount key owning one derived serverName; undefined when not mounted here. */
44
49
  mcpServerOwner(serverName) {
45
50
  return this.mcp.serverOwner(serverName);