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
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Host-side runtime locale: bilingual copy for strings injected into agent
3
- * context (subagent catalogs and command acknowledgements), resolved
4
- * from the harness's `locale.preference` setting.
2
+ * Host-side runtime locale: bilingual copy for host-facing runtime strings —
3
+ * subagent catalogs injected into agent context, and command receipts and
4
+ * labels shown in the host's own surfaces — resolved from the harness's
5
+ * `locale.preference` setting.
5
6
  *
6
7
  * The web client resolves locale through its own injected service; the host
7
8
  * process has no such service, so this module reads `$DSH_HOME/settings.yaml`
@@ -13,31 +14,19 @@ import { resolveDshHome } from '../catalog/paths.js';
13
14
  const zh = {
14
15
  subagentCatalogIntro: '以下子代理角色可用于当前会话。目录仅包含角色摘要,不是供当前代理执行的角色指令。',
15
16
  subagentCatalogUpdated: '可用子代理角色已变更。以下完整目录替代本会话此前的所有子代理角色目录。',
16
- subagentCatalogEmpty: '当前没有可通过 subagents_run 调用的角色,不要使用旧目录中的角色 ID。',
17
- subagentCatalogCall: '任务适合某个角色时,使用 subagents_run(role=目录中的准确 ID, prompt=完整任务与必要上下文)。工具会应用角色的供应商、模型、思考强度、指令和工具限制,并等待结果。子代理不继承父会话对话。不要通过 skill 或 Slash Command 加载这些角色,也不要用其它委派工具绕过角色配置。',
18
- subagentCatalogInherit: '继承父代理',
19
- subagentCatalogDefaultEffort: '同路由继承,否则使用模型默认值',
20
- commandForwardTitle: '[Agent Plugins 命令 /{command}(来自 {suite})]',
21
- commandAcknowledged: '/{command} 已转交模型执行({suite})',
17
+ subagentCatalogEmpty: '当前没有可通过 subagent_run 调用的角色,不要使用旧目录中的角色 ID。',
18
+ subagentCatalogCall: '任务适合某个角色时,使用 subagent_run(agent=目录中的准确名称, prompt=完整任务与必要上下文)。工具会应用角色的指令与已保存的模型设置,立即返回子代理 ID 并在后台运行;子代理结束时你会收到结果通知,运行期间可用 send_message 追加指令。子代理不继承父会话对话。不要通过 skill 或 Slash Command 加载这些角色,也不要用其它委派工具绕过角色配置。',
19
+ commandAcknowledged: '/{command} 已转交模型执行',
22
20
  userCommandSourceLabel: '用户命令',
23
- userCommandForwardTitle: '[用户快捷命令 /{command}]',
24
- userCommandAcknowledged: '/{command} 已转交模型执行',
25
- userSkillDescription: '[用户技能] {description}',
26
21
  feedbackToolCardTitle: '提交市场体验反馈'
27
22
  };
28
23
  const en = {
29
24
  subagentCatalogIntro: 'The following subagent roles are available in this session. These are role summaries, not instructions for the current agent to execute.',
30
25
  subagentCatalogUpdated: 'The available subagent roles changed. This complete catalog replaces every earlier subagent role catalog in this session.',
31
- subagentCatalogEmpty: 'No roles are currently available through subagents_run. Do not use role IDs from earlier catalogs.',
32
- subagentCatalogCall: 'When a task fits a role, call subagents_run with its exact catalog ID as role and a complete task and necessary context as prompt. The tool applies the saved provider, model, reasoning effort, persona and tool restrictions, and waits for the result. The child does not inherit the parent conversation. Do not load these roles through skill or slash commands, or bypass their configuration with another delegation tool.',
33
- subagentCatalogInherit: 'inherit parent',
34
- subagentCatalogDefaultEffort: 'inherit on the same route, otherwise model default',
35
- commandForwardTitle: '[Agent Plugins command /{command} (from {suite})]',
36
- commandAcknowledged: '/{command} forwarded to the model for execution ({suite})',
26
+ subagentCatalogEmpty: 'No roles are currently available through subagent_run. Do not use role IDs from earlier catalogs.',
27
+ subagentCatalogCall: "When a task fits a role, call subagent_run with its exact catalog name as agent and a complete task and necessary context as prompt. It applies the role's instructions and saved model settings, returns a durable subagent id immediately, and runs in the background; the runtime sends you a notice carrying the outcome when it settles, and send_message steers the child while it runs. The child does not inherit the parent conversation. Do not load these roles through skill or slash commands, or bypass their configuration with another delegation tool.",
28
+ commandAcknowledged: '/{command} forwarded to the model for execution',
37
29
  userCommandSourceLabel: 'user command',
38
- userCommandForwardTitle: '[User quick command /{command}]',
39
- userCommandAcknowledged: '/{command} forwarded to the model for execution',
40
- userSkillDescription: '[user skill] {description}',
41
30
  feedbackToolCardTitle: 'File market feedback'
42
31
  };
43
32
  const DICTS = { zh, en };
@@ -1,47 +1,47 @@
1
1
  /**
2
2
  * Direct user configuration for LSP servers: a named table of `lspServers`
3
- * entries stored under the plugin data root, merged at runtime alongside the
4
- * suites' inline declarations.
3
+ * entries stored in the shared Agent layout root, merged at runtime alongside
4
+ * the suites' inline declarations.
5
5
  *
6
- * The file lives at `${dataRoot}/lsp-servers.json` and is the single source
7
- * for the `direct` kind on the LSP status surface. Parsing reuses the same
8
- * fail-closed `lsp-spec` rules as suite declarations; a broken file degrades
9
- * to no direct servers plus a diagnostic, never a thrown discovery.
6
+ * The file lives at `~/.agents/lsp.json` and is the single source for the
7
+ * `direct` kind on the LSP status surface. Parsing reuses the same fail-closed
8
+ * `lsp-spec` rules as suite declarations; a broken file degrades to no direct
9
+ * servers plus a diagnostic, never a thrown discovery.
10
10
  */
11
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
12
- import { dirname, join } from 'node:path';
11
+ import { readFile } from 'node:fs/promises';
12
+ import { join } from 'node:path';
13
+ import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
13
14
  import { parseLspServers } from '../catalog/lsp-spec.js';
14
15
  /** The direct LSP server configuration file path. */
15
- export function lspServersPath(dataRoot) {
16
- return join(dataRoot, 'lsp-servers.json');
16
+ export function lspServersPath(agentsRoot) {
17
+ return join(agentsRoot, 'lsp.json');
17
18
  }
18
19
  /** Load the user-configured LSP servers; unreadable or invalid files yield an empty table. */
19
- export async function loadLspServers(dataRoot) {
20
+ export async function loadLspServers(agentsRoot) {
20
21
  let raw;
21
22
  try {
22
- raw = JSON.parse(await readFile(lspServersPath(dataRoot), 'utf8'));
23
+ raw = JSON.parse(await readFile(lspServersPath(agentsRoot), 'utf8'));
23
24
  }
24
25
  catch (error) {
25
- return { servers: {}, errors: error.code === 'ENOENT' ? [] : [`lsp-servers.json: ${error instanceof Error ? error.message : String(error)}`] };
26
+ return { servers: {}, errors: error.code === 'ENOENT' ? [] : [`lsp.json: ${error instanceof Error ? error.message : String(error)}`] };
26
27
  }
27
28
  const errors = [];
28
29
  const table = raw?.['lspServers'];
29
30
  if (table === undefined || table === null || Array.isArray(table) || typeof table !== 'object')
30
- return { servers: {}, errors: ['lsp-servers.json: lspServers must be an object'] };
31
+ return { servers: {}, errors: ['lsp.json: lspServers must be an object'] };
31
32
  const servers = parseLspServers(table, errors);
32
- return { servers, errors: errors.map(error => `lsp-servers.json: ${error}`) };
33
+ return { servers, errors: errors.map(error => `lsp.json: ${error}`) };
33
34
  }
34
35
  /** Validate and persist the user-configured LSP servers; returns normalized specs. */
35
- export async function saveLspServers(dataRoot, raw) {
36
+ export async function saveLspServers(agentsRoot, raw) {
36
37
  const errors = [];
37
38
  const table = raw?.['lspServers'];
38
39
  const servers = parseLspServers(table, errors);
39
40
  if (errors.length > 0) {
40
41
  throw new Error(`invalid lspServers: ${errors[0]}`);
41
42
  }
42
- const path = lspServersPath(dataRoot);
43
- await mkdir(dirname(path), { recursive: true });
43
+ const path = lspServersPath(agentsRoot);
44
44
  const persisted = Object.fromEntries(Object.entries(servers).map(([name, { key: _key, ...config }]) => [name, config]));
45
- await writeFile(path, `${JSON.stringify({ lspServers: persisted }, null, 2)}\n`, { mode: 0o600 });
45
+ await writeFileAtomic(path, `${JSON.stringify({ lspServers: persisted }, null, 2)}\n`, { mode: 0o600, dirMode: 0o700 });
46
46
  return { servers };
47
47
  }
@@ -1,12 +1,8 @@
1
1
  import { DIRECT_LSP_SUITE_ID } from './lsp-status.js';
2
+ import { SerialPassQueue, RetryScheduler } from './mount-lifecycle.js';
3
+ import { describeLegacySeam, findLegacyLspSeams } from './profile-seam.js';
2
4
  import { qualifiedSuiteId } from '../catalog/paths.js';
3
- /**
4
- * Bounded retry schedule for a failed mount/unmount, mirroring the MCP
5
- * mounts: a permanently broken server must stop consuming attempt budget,
6
- * and the schedule resets with every reconcile pass.
7
- */
8
- const RETRY_SCHEDULE_MS = [1_500, 5_000, 15_000, 45_000, 120_000];
9
- const MAX_RETRY_ATTEMPTS = RETRY_SCHEDULE_MS.length;
5
+ import { effectiveSurfaces } from '../model/types.js';
10
6
  /** Build one dsh-lsp-stdio Config.servers entry from a normalized spec. */
11
7
  export function toLspServerConfig(spec) {
12
8
  return {
@@ -18,50 +14,77 @@ export function toLspServerConfig(spec) {
18
14
  ...(spec.configuration === undefined ? {} : { configuration: spec.configuration })
19
15
  };
20
16
  }
21
- /** Import spec kept as a string literal so the optional dependency stays dynamic. */
17
+ /** Import specifiers kept as string literals so the host packages stay dynamically loaded. */
22
18
  const LSP_STDIO_IMPORT = '@deepseek-ai/dsh-lsp-stdio';
19
+ const LSP_SERVICE_IMPORT = '@deepseek-ai/dsh-lsp';
20
+ const LSP_TOOL_IMPORT = '@deepseek-ai/dsh-tool-lsp';
21
+ /**
22
+ * Build one lazy loader for a host package. The specifier stays a literal in a
23
+ * top-level `const` so an absent package degrades to `undefined` instead of
24
+ * failing the import of this module.
25
+ */
26
+ function lazyImport(specifier) {
27
+ return async () => {
28
+ try {
29
+ return (await import(/* @vite-ignore */ specifier));
30
+ }
31
+ catch {
32
+ return undefined;
33
+ }
34
+ };
35
+ }
23
36
  export class LspMountRegistry {
24
37
  ctx;
25
38
  live = new Map();
26
39
  /** Serialize mount and unmount passes so a disable cannot race an in-flight spawn. */
27
- reconcileQueue = Promise.resolve();
28
- /** Pending retry timers keyed by mount key, so a teardown can cancel them. */
29
- retries = new Map();
30
- /** Attempt count per mount key; reset whenever a retry succeeds. */
31
- attempts = new Map();
40
+ passes = new SerialPassQueue();
41
+ /** Delayed re-attempts for mounts that are not live yet. */
42
+ retries;
32
43
  /** Snapshot of the last reconciled suite set, replayed by retry passes. */
33
44
  lastEnabled = [];
34
45
  /** Host module loader; overridable for tests. */
35
46
  loadHost;
47
+ /**
48
+ * Reads the profile files when a seam conflict needs explaining. Injectable
49
+ * because it touches the machine's own `$DSH_HOME`, so a test must not
50
+ * inherit whatever the developer running it happens to have configured.
51
+ */
52
+ locateSeams;
53
+ /** `ctx.lsp` seam loader, mounted only when the profile supplies no service. */
54
+ loadService;
55
+ /** `lsp` tool loader, mounted only when the profile publishes no such tool. */
56
+ loadTool;
57
+ /** Capability plugins this registry mounted, in mount order, disposed with the last server. */
58
+ capability = [];
59
+ /** Whether the capability seam was already resolved for the current wanted set. */
60
+ capabilityReady = false;
61
+ /** Why the capability seam is unavailable; re-attempted on the next reconcile pass. */
62
+ capabilityFailure;
36
63
  /** Latest diagnostic per suite id; cleared when its suite mounts or leaves the wanted set. */
37
64
  lastDiagnostics = new Map();
38
65
  /** Direct (user-configured) server provider; defaults to none. */
39
66
  directProvider = async () => ({});
40
67
  disabledProvider = async () => new Set();
41
68
  disabledSnapshot = new Set();
42
- constructor(ctx, loadHost) {
69
+ constructor(ctx, loadHost, loadService, loadTool, locateSeams) {
43
70
  this.ctx = ctx;
44
- this.loadHost =
45
- loadHost ??
46
- (async () => {
47
- try {
48
- return (await import(/* @vite-ignore */ LSP_STDIO_IMPORT));
49
- }
50
- catch {
51
- return undefined;
52
- }
53
- });
71
+ this.loadHost = loadHost ?? lazyImport(LSP_STDIO_IMPORT);
72
+ this.loadService = loadService ?? lazyImport(LSP_SERVICE_IMPORT);
73
+ this.loadTool = loadTool ?? lazyImport(LSP_TOOL_IMPORT);
74
+ this.locateSeams = locateSeams ?? findLegacyLspSeams;
75
+ this.retries = new RetryScheduler({
76
+ replay: () => this.reconcile(this.lastEnabled),
77
+ log: message => this.ctx.logger?.warn?.(`[dsh-agent-plugins-market] ${message}`)
78
+ });
54
79
  }
55
80
  /** Queue one reconciliation behind any in-flight mount/unmount pass. */
56
81
  reconcile(enabledSuites) {
57
- const run = this.reconcileQueue.then(() => this.reconcileNow(enabledSuites));
58
- this.reconcileQueue = run.then(() => undefined, () => undefined);
59
- return run;
82
+ return this.passes.run(() => this.reconcileNow(enabledSuites));
60
83
  }
61
84
  /** Mount/unmount LSP servers to match the enabled suites plus direct config exactly. */
62
85
  async reconcileNow(enabledSuites) {
63
86
  this.lastEnabled = [...enabledSuites];
64
- const active = enabledSuites.filter(suite => suite.activeSurfaces?.lsp !== false);
87
+ const active = enabledSuites.filter(suite => suite.activeSurfaces.lsp !== false);
65
88
  const wanted = new Map();
66
89
  const diagnostics = [];
67
90
  const disabled = await this.disabledProvider();
@@ -102,29 +125,39 @@ export class LspMountRegistry {
102
125
  surfaces: { skills: 0, mcp: 0, hooks: 0, commands: 0, agents: 0, lsp: directKeys.length },
103
126
  dimension: 'user',
104
127
  enabled: true,
128
+ // Direct servers carry no install state and no per-surface
129
+ // overrides, so every surface keeps its enabled default.
130
+ activeSurfaces: effectiveSurfaces(undefined),
105
131
  errors: []
106
132
  },
107
133
  config
108
134
  });
109
135
  }
136
+ // The last remaining server releases the capability seam; the first one mounts it.
137
+ if (wanted.size === 0)
138
+ await this.releaseCapability();
110
139
  for (const [key, live] of [...this.live]) {
111
- if (!wanted.has(key) || live.fingerprint !== JSON.stringify(wanted.get(key).config)) {
140
+ const target = wanted.get(key);
141
+ if (target === undefined || live.fingerprint !== JSON.stringify(target.config)) {
112
142
  const reason = await this.unmount(key, live);
113
143
  this.lastDiagnostics.delete(key);
114
144
  if (reason !== undefined)
115
145
  diagnostics.push({ suiteId: key, serverKey: live.serverKeys.join(','), reason, code: 'unmount-failed' });
116
146
  }
117
147
  }
148
+ const capabilityFailure = wanted.size > 0 ? await this.ensureCapability() : undefined;
118
149
  for (const [key, entry] of wanted) {
119
150
  if (this.live.has(key)) {
120
151
  this.lastDiagnostics.delete(key);
121
152
  continue;
122
153
  }
123
- const failure = await this.mountWith(key, entry.suite, entry.config);
154
+ const failure = capabilityFailure === undefined
155
+ ? await this.mountWith(key, entry.suite, entry.config)
156
+ : { suiteId: key, serverKey: Object.keys(entry.config).join(','), reason: capabilityFailure.reason, code: capabilityFailure.code };
124
157
  if (failure !== undefined) {
125
158
  diagnostics.push(failure);
126
159
  this.lastDiagnostics.set(key, failure);
127
- this.scheduleRetry(key, failure.code === 'host-missing' ? undefined : failure);
160
+ this.retries.schedule({ key, label: failure.suiteId, ...(failure.code === 'host-missing' ? {} : { reason: failure.reason }) });
128
161
  }
129
162
  else {
130
163
  this.lastDiagnostics.delete(key);
@@ -132,6 +165,98 @@ export class LspMountRegistry {
132
165
  }
133
166
  return diagnostics;
134
167
  }
168
+ /**
169
+ * Mount the capability seam this plugin delivers end to end: `ctx.lsp`, then the model-facing
170
+ * `lsp` tool.
171
+ *
172
+ * The plugin never asks what the profile already carries. Its dependency declaration decides
173
+ * the version, so deferring to a seam another layer registered would silently run whatever
174
+ * version that layer picked. Both mounts are therefore unconditional; a seam that is already
175
+ * taken comes back as a `seam-conflict` diagnostic naming the layer to remove, which is the
176
+ * only way the aligned copy can own it.
177
+ * @returns the failure, or undefined when the seam is ready.
178
+ */
179
+ async ensureCapability() {
180
+ // A previous failure is re-attempted on the next pass: the user can fix a profile and the
181
+ // retry schedule re-runs reconcile, while a module that still cannot load fails just as fast.
182
+ if (this.capabilityReady && this.capabilityFailure === undefined)
183
+ return undefined;
184
+ this.capabilityReady = true;
185
+ this.capabilityFailure = undefined;
186
+ const ctx = this.ctx;
187
+ if (typeof ctx.plugin !== 'function')
188
+ return (this.capabilityFailure = { reason: 'the host context does not support dynamic plugin mounting', code: 'host-missing' });
189
+ for (const [load, specifier] of [
190
+ [this.loadService, LSP_SERVICE_IMPORT],
191
+ [this.loadTool, LSP_TOOL_IMPORT]
192
+ ]) {
193
+ const failure = await this.mountCapability(load, specifier);
194
+ if (failure !== undefined)
195
+ return (this.capabilityFailure = failure);
196
+ }
197
+ return undefined;
198
+ }
199
+ /** Mount one capability plugin and remember it for teardown with the last server. */
200
+ async mountCapability(load, specifier) {
201
+ const module = await load();
202
+ if (module === undefined)
203
+ return { reason: `the ${specifier} package is not installed in this profile`, code: 'host-missing' };
204
+ const ctx = this.ctx;
205
+ let handle;
206
+ try {
207
+ handle = ctx.plugin(module.default ?? module, {});
208
+ await handle.await();
209
+ }
210
+ catch (error) {
211
+ // A failed startup can leave a half-mounted handle behind; drop it before reporting.
212
+ if (handle !== undefined) {
213
+ try {
214
+ await handle.dispose();
215
+ }
216
+ catch {
217
+ // Ignore teardown errors: the startup failure is the real signal.
218
+ }
219
+ }
220
+ const message = error instanceof Error ? error.message : String(error);
221
+ // `service "lsp" has been registered` / `tool "lsp" is already registered`: another layer
222
+ // owns the seam. Report the layer to drop rather than falling back to its version.
223
+ if (/already registered|has been registered/.test(message)) {
224
+ return { reason: await this.seamConflictReason(specifier, message), code: 'seam-conflict' };
225
+ }
226
+ return { reason: `mount failed: ${message}`, code: 'mount-failed' };
227
+ }
228
+ this.capability.push(handle);
229
+ return undefined;
230
+ }
231
+ /**
232
+ * Explain a taken seam, naming the profile layer to remove when one can be
233
+ * found on disk. Releases before the plugin provisioned LSP itself told users
234
+ * to add that layer by hand, so the message points at the exact file and rows
235
+ * rather than at "the profile" in general.
236
+ */
237
+ async seamConflictReason(specifier, message) {
238
+ const head = `the profile already registers its own ${specifier} (${message})`;
239
+ const seams = await this.locateSeams().catch(() => []);
240
+ const seam = seams[0];
241
+ if (seam === undefined) {
242
+ return `${head}; remove that layer from the profile (a manual \`cordis.patch.yml\` row, or a profile dependency on the package) so this plugin's aligned copy owns the seam`;
243
+ }
244
+ return `${head}; ${describeLegacySeam(seam)} — remove those rows (the LSP panel offers a one-click removal) so this plugin's aligned copy owns the seam`;
245
+ }
246
+ /** Dispose the capability seam this registry mounted, once no wanted server needs it. */
247
+ async releaseCapability() {
248
+ const handles = this.capability.splice(0);
249
+ this.capabilityReady = false;
250
+ this.capabilityFailure = undefined;
251
+ for (const handle of handles.reverse()) {
252
+ try {
253
+ await handle.dispose();
254
+ }
255
+ catch (error) {
256
+ this.ctx.logger?.warn?.(`[dsh-agent-plugins-market] LSP capability teardown failed: ${error instanceof Error ? error.message : String(error)}`);
257
+ }
258
+ }
259
+ }
135
260
  /** Install the direct (user-configured) server provider used at reconcile time. */
136
261
  setDirectProvider(provider) {
137
262
  this.directProvider = provider;
@@ -202,49 +327,15 @@ export class LspMountRegistry {
202
327
  return reason;
203
328
  }
204
329
  }
205
- /**
206
- * Schedule a delayed re-attempt for a mount that still is not live. A
207
- * `host-missing` failure passes no diagnostic and is never retried; the
208
- * schedule is bounded and resets with every reconcile pass.
209
- */
210
- scheduleRetry(key, failure) {
211
- const pending = this.retries.get(key);
212
- if (pending !== undefined)
213
- clearTimeout(pending);
214
- this.retries.delete(key);
215
- if (failure === undefined || failure.code === 'host-missing') {
216
- this.attempts.delete(key);
217
- return;
218
- }
219
- const attempt = (this.attempts.get(key) ?? 0) + 1;
220
- this.attempts.set(key, attempt);
221
- if (attempt > MAX_RETRY_ATTEMPTS) {
222
- this.ctx.logger?.warn?.(`[dsh-agent-plugins-market] ${failure.suiteId}: giving up after ${MAX_RETRY_ATTEMPTS} attempts — ${failure.reason}`);
223
- return;
224
- }
225
- const delay = RETRY_SCHEDULE_MS[attempt - 1] ?? RETRY_SCHEDULE_MS[RETRY_SCHEDULE_MS.length - 1];
226
- const timer = setTimeout(() => {
227
- this.retries.delete(key);
228
- // Re-run against the last known suite set: a retry must not resurrect
229
- // servers of a suite that has since been disabled or uninstalled.
230
- void this.reconcile(this.lastEnabled).catch(() => { });
231
- }, delay);
232
- timer.unref?.();
233
- this.retries.set(key, timer);
234
- }
235
330
  /** Dispose every live mount after queued reconciliation passes settle. */
236
331
  async disposeAll() {
237
- const run = this.reconcileQueue.then(async () => {
332
+ await this.passes.run(async () => {
238
333
  for (const [key, live] of [...this.live]) {
239
334
  await this.unmount(key, live);
240
335
  }
336
+ await this.releaseCapability();
241
337
  });
242
- this.reconcileQueue = run.then(() => undefined, () => undefined);
243
- await run;
244
- for (const timer of this.retries.values())
245
- clearTimeout(timer);
246
338
  this.retries.clear();
247
- this.attempts.clear();
248
339
  }
249
340
  }
250
- const HOST_MISSING_REASON = 'the @deepseek-ai/dsh-lsp-stdio package is not installed in this profile; upgrade to a DSH build carrying the LSP packages (@deepseek-ai/dsh-lsp, dsh-lsp-stdio, dsh-tool-lsp) and mount tool-lsp in the profile to expose the `lsp` tool';
341
+ const HOST_MISSING_REASON = `the ${LSP_STDIO_IMPORT} package could not be loaded from this profile; reinstall the plugin (\`dsh plugin --profile <profile> add dsh-agent-plugins-market\`) so its LSP dependencies are provisioned`;
@@ -1,5 +1,6 @@
1
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
- import { dirname, join } from 'node:path';
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
3
4
  export function lspServerStatePath(dataRoot) {
4
5
  return join(dataRoot, 'lsp-server-state.json');
5
6
  }
@@ -14,6 +15,5 @@ export async function loadDisabledLspServers(dataRoot) {
14
15
  }
15
16
  export async function saveDisabledLspServers(dataRoot, disabled) {
16
17
  const path = lspServerStatePath(dataRoot);
17
- await mkdir(dirname(path), { recursive: true });
18
- await writeFile(path, `${JSON.stringify({ disabled: [...new Set(disabled)].sort() }, null, 2)}\n`, 'utf8');
18
+ await writeFileAtomic(path, `${JSON.stringify({ disabled: [...new Set(disabled)].sort() }, null, 2)}\n`, { mode: 0o600, dirMode: 0o700 });
19
19
  }
@@ -40,7 +40,7 @@ export function buildLspStatus(suites, registry, direct = { servers: {}, errors:
40
40
  if (suite.lsp === undefined || suite.installedAt === undefined || !suite.enabled)
41
41
  continue;
42
42
  const suiteKey = qualifiedSuiteId(suite.sourceId, suite.id);
43
- const disabled = suite.activeSurfaces?.lsp === false;
43
+ const disabled = suite.activeSurfaces.lsp === false;
44
44
  const diagnostic = diagnostics.get(suiteKey);
45
45
  const { state, reason, retryable } = deriveState(disabled, diagnostic, anyLive);
46
46
  if (state === 'mounted')
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The credential record one MCP server's OAuth grant lives under.
3
+ *
4
+ * `runtime/mcp-client/oauth.ts` writes those records; this module addresses
5
+ * the same key so a grant can be dropped without waking the transport that
6
+ * wrote it. The scope and the name fold must stay identical to that writer's,
7
+ * or the delete silently misses the record.
8
+ *
9
+ * @module runtime/mcp-auth-record
10
+ */
11
+ import { credentialKey } from './mcp-client/host-seams.js';
12
+ /** Credential-record scope for MCP OAuth state; per-server ids follow it. */
13
+ export const MCP_AUTH_RECORD_SCOPE = 'mcp-auth';
14
+ /**
15
+ * Fold a `serverName` into a credential-key id segment. Server names carry
16
+ * `__` separators (e.g. `cloudflare__cloudflare-api`), and the key grammar
17
+ * admits only `[a-z0-9-]`, so non-conforming characters collapse to `-` —
18
+ * deterministic, so the same server always reads and writes one record.
19
+ */
20
+ export function mcpAuthRecordId(serverName) {
21
+ const folded = serverName
22
+ .toLowerCase()
23
+ .replace(/[^a-z0-9-]+/g, '-')
24
+ .replace(/^-+|-+$/g, '');
25
+ return folded === '' ? 'server' : folded;
26
+ }
27
+ /** The credential record key one MCP server's OAuth grant is stored under. */
28
+ export function mcpAuthRecordKey(serverName) {
29
+ return credentialKey(MCP_AUTH_RECORD_SCOPE, mcpAuthRecordId(serverName));
30
+ }
31
+ /**
32
+ * Drop one MCP server's OAuth grant record so the next mount re-runs the
33
+ * browser authorization — the path for "I picked too narrow a scope".
34
+ * @param store - the host credentials service, when one is mounted.
35
+ * @throws when the credentials service is not mounted.
36
+ */
37
+ export async function deleteMcpAuthGrant(store, serverName) {
38
+ const records = store;
39
+ if (typeof records?.deleteRecord !== 'function')
40
+ throw new Error('credentials service is not mounted');
41
+ await records.deleteRecord(mcpAuthRecordKey(serverName));
42
+ }
@@ -17,19 +17,20 @@ import { readFile } from 'node:fs/promises';
17
17
  import { createRequire } from 'node:module';
18
18
  import { join } from 'node:path';
19
19
  import z from '@deepseek-ai/schemastery';
20
- export const MCP_BACKENDS = ['builtin', 'host'];
21
20
  /** Settings namespace this plugin registers; the key the plugin-config tab pairs our card by. */
22
21
  export const MCP_SETTINGS_NAMESPACE = 'dsh-agent-plugins-market';
23
- /** Schema of the market settings namespace: the MCP enhancement switch, the download region, and the feedback tool switch. */
22
+ /** Schema of the market settings namespace: the switches this plugin's config card serves. */
24
23
  export const MarketSettingsSchema = z.object({
25
24
  /** ON (default) = the built-in bridge with OAuth and SSE; OFF = host client compat mode. */
26
25
  mcpEnhanced: z.boolean().default(true),
27
- /** Read native Agent layouts under the project root; default preserves existing discovery. */
28
- scanProjectLayouts: z.boolean().default(true),
26
+ /** Read native Agent layouts under the project root; OFF (default) scans configured sources only. */
27
+ scanProjectLayouts: z.boolean().default(false),
29
28
  /** Download region for GitHub acquisition; `auto` follows the interface language. */
30
29
  downloadRegion: z.union([z.const('auto'), z.const('global'), z.const('china')]).default('auto'),
31
30
  /** ON (default) = the `report_market_issue` model tool is registered; OFF = unregistered. */
32
- feedbackEnabled: z.boolean().default(true)
31
+ feedbackEnabled: z.boolean().default(true),
32
+ /** ON = refresh every configured source in the background; OFF (default) = never. */
33
+ autoUpdateSources: z.boolean().default(false)
33
34
  });
34
35
  /** Path of the legacy persisted settings file under the plugin data root. */
35
36
  export function marketSettingsPath(dataRoot) {
@@ -8,7 +8,6 @@
8
8
  *
9
9
  * @module runtime/mcp-client/config
10
10
  */
11
- export { RECONNECT_DEFAULTS } from './connection.js';
12
11
  /** Default timeout for individual MCP tool calls (ms). */
13
12
  export const DEFAULT_TOOL_CALL_TIMEOUT_MS = 60_000;
14
13
  /** Valid `serverName`, kept below the public tool-name budget. */
@@ -23,6 +23,7 @@
23
23
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
24
24
  import { ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js';
25
25
  import { MAX_TIMER_DELAY_MS } from './host-seams.js';
26
+ import { PLUGIN_NAME, PLUGIN_VERSION } from './plugin-identity.js';
26
27
  import { createTransport } from './transport.js';
27
28
  import { syncTools } from './tools.js';
28
29
  /** Defaults shared by the config and {@link resolveReconnectPolicy}. */
@@ -197,7 +198,7 @@ export function startConnection(host, config, policy, credentials) {
197
198
  * @param startup - Whether this is the bridge's activation attempt.
198
199
  */
199
200
  async function connectGeneration(startup) {
200
- const generation = new Client({ name: 'dsh-agent-plugins-market', version: '0.1.0' }, { capabilities: {} });
201
+ const generation = new Client({ name: PLUGIN_NAME, version: PLUGIN_VERSION }, { capabilities: {} });
201
202
  const closed = Promise.withResolvers();
202
203
  let attemptSettled = false;
203
204
  let closeObserved = false;
@@ -228,6 +229,31 @@ export function startConnection(host, config, policy, credentials) {
228
229
  host.logger.error(`${label}: tool re-sync failed: ${String(error)}`);
229
230
  }
230
231
  });
232
+ /**
233
+ * Retire a generation whose attempt failed: close it, wait for the
234
+ * transport's own close signal, then hand it to the disconnect path. A
235
+ * generation that will not close stops reconnection outright — respawning
236
+ * would overlap the server process still running behind it.
237
+ */
238
+ async function teardownFailedAttempt() {
239
+ try {
240
+ await generation.close();
241
+ }
242
+ catch {
243
+ /* transport already gone */
244
+ }
245
+ const quiesced = hasClosed() || (await waitForClose(closed.promise));
246
+ attemptSettled = true;
247
+ if (!isCurrent(generation))
248
+ return;
249
+ if (quiesced) {
250
+ generationDown(generation);
251
+ return;
252
+ }
253
+ client = undefined;
254
+ clientClosed = undefined;
255
+ host.logger.error(`${label}: failed generation did not close within ${GENERATION_CLOSE_TIMEOUT_MS}ms — reconnect stopped to avoid overlapping server processes; reload the plugin or restart the Host to retry`);
256
+ }
231
257
  const { transport, oauthProvider } = createTransport(config, credentials, (message) => {
232
258
  host.logger.info(message);
233
259
  });
@@ -261,11 +287,9 @@ export function startConnection(host, config, policy, credentials) {
261
287
  attemptSettled = true;
262
288
  if (!isCurrent(generation))
263
289
  return;
264
- if (hasClosed()) {
265
- generationDown(generation);
266
- return;
267
- }
268
- if (!legSettled) {
290
+ // The leg failed, or the transport closed while it ran: no generation
291
+ // is left to finish establishing.
292
+ if (hasClosed() || !legSettled) {
269
293
  generationDown(generation);
270
294
  return;
271
295
  }
@@ -277,20 +301,7 @@ export function startConnection(host, config, policy, credentials) {
277
301
  catch (syncError) {
278
302
  if (isCurrent(generation))
279
303
  host.logger.warn(`${label}: tool sync after authorization failed: ${String(syncError)}`);
280
- try {
281
- await generation.close();
282
- }
283
- catch {
284
- /* transport already gone */
285
- }
286
- const quiescedSync = hasClosed() || (await waitForClose(closed.promise));
287
- if (!quiescedSync) {
288
- client = undefined;
289
- clientClosed = undefined;
290
- host.logger.error(`${label}: failed generation did not close within ${GENERATION_CLOSE_TIMEOUT_MS}ms — reconnect stopped to avoid overlapping server processes`);
291
- return;
292
- }
293
- generationDown(generation);
304
+ await teardownFailedAttempt();
294
305
  return;
295
306
  }
296
307
  if (hasClosed()) {
@@ -304,23 +315,7 @@ export function startConnection(host, config, policy, credentials) {
304
315
  host.logger.info(`${label}: authorized and re-synced tools (attempt ${failedAttempts}/${policy.maxAttempts})`);
305
316
  return;
306
317
  }
307
- try {
308
- await generation.close();
309
- }
310
- catch {
311
- /* transport already gone */
312
- }
313
- const quiesced = hasClosed() || (await waitForClose(closed.promise));
314
- attemptSettled = true;
315
- if (!isCurrent(generation))
316
- return;
317
- if (!quiesced) {
318
- client = undefined;
319
- clientClosed = undefined;
320
- host.logger.error(`${label}: failed generation did not close within ${GENERATION_CLOSE_TIMEOUT_MS}ms — reconnect stopped to avoid overlapping server processes; reload the plugin or restart the Host to retry`);
321
- return;
322
- }
323
- generationDown(generation);
318
+ await teardownFailedAttempt();
324
319
  return;
325
320
  }
326
321
  attemptSettled = true;