dsh-agent-plugins-market 0.7.0 → 0.7.2

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 (114) hide show
  1. package/README.md +31 -26
  2. package/README.zh.md +31 -26
  3. package/client/client.js +29164 -1136
  4. package/client/client.js.map +1 -1
  5. package/lib/application/catalog-context.js +8 -1
  6. package/lib/application/catalog.js +27 -2
  7. package/lib/application/details.js +13 -2
  8. package/lib/application/install-store.js +1 -1
  9. package/lib/application/mcp-service.js +191 -19
  10. package/lib/application/panel-resources.js +16 -4
  11. package/lib/application/project-agent-roles.js +7 -1
  12. package/lib/catalog/archive.js +16 -1
  13. package/lib/catalog/manifests.js +124 -13
  14. package/lib/catalog/paths.js +8 -0
  15. package/lib/catalog/plugin-variables.js +38 -0
  16. package/lib/catalog/project-hooks.js +14 -1
  17. package/lib/catalog/scan-resolvers.js +117 -12
  18. package/lib/catalog/skills-parse.js +47 -0
  19. package/lib/catalog/source-catalog.js +8 -3
  20. package/lib/catalog/suite-components.js +2 -2
  21. package/lib/catalog/surfaces.js +73 -27
  22. package/lib/catalog/validate.js +205 -54
  23. package/lib/contracts/market.js +3 -0
  24. package/lib/contracts/settings.js +63 -0
  25. package/lib/index.js +32 -13
  26. package/lib/model/layouts.js +44 -1
  27. package/lib/model/preset-source.js +8 -0
  28. package/lib/routes.js +43 -2
  29. package/lib/runtime/agent-role-router.js +203 -56
  30. package/lib/runtime/commands-mounts.js +50 -9
  31. package/lib/runtime/deadline.js +32 -6
  32. package/lib/runtime/dynamic-context.js +140 -0
  33. package/lib/runtime/feedback-tool.js +7 -3
  34. package/lib/runtime/host-locale.js +45 -15
  35. package/lib/runtime/lsp-mounts.js +23 -2
  36. package/lib/runtime/mcp-backend.js +14 -15
  37. package/lib/runtime/mcp-client/config.js +8 -0
  38. package/lib/runtime/mcp-client/connection.js +9 -8
  39. package/lib/runtime/mcp-client/host-seams.js +8 -88
  40. package/lib/runtime/mcp-client/oauth.js +82 -14
  41. package/lib/runtime/mcp-client/tools.js +2 -1
  42. package/lib/runtime/mcp-config.js +182 -35
  43. package/lib/runtime/mcp-direct-config.js +49 -6
  44. package/lib/runtime/mcp-mounts.js +15 -3
  45. package/lib/runtime/mcp-overrides.js +131 -1
  46. package/lib/runtime/mcp-redaction.js +8 -0
  47. package/lib/runtime/mcp-status.js +21 -5
  48. package/lib/runtime/project-runtime.js +23 -9
  49. package/lib/runtime/reconcile-scheduler.js +6 -8
  50. package/lib/runtime/reconciler.js +2 -1
  51. package/lib/runtime/regions.js +0 -19
  52. package/lib/runtime/server-config.js +49 -8
  53. package/lib/runtime/settings-namespace.js +21 -13
  54. package/lib/runtime/skills-provider.js +33 -11
  55. package/lib/runtime/source-auto-update.js +10 -14
  56. package/lib/runtime/subagent-catalog.js +62 -22
  57. package/lib/runtime/timer-seat.js +80 -0
  58. package/lib/runtime/tool-registry-observer.js +24 -20
  59. package/lib/runtime/user-commands.js +5 -1
  60. package/lib/runtime/user-panels.js +110 -41
  61. package/lib/runtime/user-store.js +114 -58
  62. package/lib/types/application/catalog-context.d.ts +6 -0
  63. package/lib/types/application/catalog.d.ts +19 -1
  64. package/lib/types/application/install-store.d.ts +1 -1
  65. package/lib/types/application/mcp-service.d.ts +38 -3
  66. package/lib/types/application/ports.d.ts +1 -1
  67. package/lib/types/application/queries.d.ts +8 -1
  68. package/lib/types/catalog/manifests.d.ts +6 -2
  69. package/lib/types/catalog/paths.d.ts +6 -0
  70. package/lib/types/catalog/plugin-variables.d.ts +16 -0
  71. package/lib/types/catalog/skills-parse.d.ts +13 -0
  72. package/lib/types/catalog/source-catalog.d.ts +7 -2
  73. package/lib/types/catalog/surfaces.d.ts +14 -4
  74. package/lib/types/catalog/validate.d.ts +72 -11
  75. package/lib/types/contracts/market.d.ts +77 -0
  76. package/lib/types/contracts/mcp-status.d.ts +11 -0
  77. package/lib/types/contracts/settings.d.ts +59 -0
  78. package/lib/types/model/layouts.d.ts +30 -0
  79. package/lib/types/model/preset-source.d.ts +18 -0
  80. package/lib/types/model/types.d.ts +37 -1
  81. package/lib/types/runtime/agent-role-router.d.ts +96 -18
  82. package/lib/types/runtime/commands-mounts.d.ts +15 -7
  83. package/lib/types/runtime/deadline.d.ts +10 -11
  84. package/lib/types/runtime/dynamic-context.d.ts +81 -0
  85. package/lib/types/runtime/host-locale.d.ts +20 -2
  86. package/lib/types/runtime/lsp-mounts.d.ts +7 -0
  87. package/lib/types/runtime/mcp-backend.d.ts +8 -15
  88. package/lib/types/runtime/mcp-client/config.d.ts +8 -0
  89. package/lib/types/runtime/mcp-client/host-seams.d.ts +20 -64
  90. package/lib/types/runtime/mcp-client/oauth.d.ts +4 -0
  91. package/lib/types/runtime/mcp-config.d.ts +75 -13
  92. package/lib/types/runtime/mcp-direct-config.d.ts +22 -0
  93. package/lib/types/runtime/mcp-overrides.d.ts +44 -0
  94. package/lib/types/runtime/mcp-redaction.d.ts +6 -0
  95. package/lib/types/runtime/project-runtime.d.ts +10 -3
  96. package/lib/types/runtime/reconcile-scheduler.d.ts +1 -1
  97. package/lib/types/runtime/regions.d.ts +1 -4
  98. package/lib/types/runtime/server-config.d.ts +26 -1
  99. package/lib/types/runtime/settings-namespace.d.ts +10 -10
  100. package/lib/types/runtime/skills-provider.d.ts +11 -2
  101. package/lib/types/runtime/source-auto-update.d.ts +3 -4
  102. package/lib/types/runtime/subagent-catalog.d.ts +14 -2
  103. package/lib/types/runtime/timer-seat.d.ts +51 -0
  104. package/lib/types/runtime/tool-registry-observer.d.ts +14 -6
  105. package/lib/types/runtime/user-panels.d.ts +65 -12
  106. package/lib/types/runtime/user-store.d.ts +65 -16
  107. package/package.json +23 -6
  108. package/schemas/1.0.0/mcp.schema.json +0 -22
  109. package/schemas/1.1.0/mcp.schema.json +120 -0
  110. package/schemas/1.1.0/plugin.schema.json +65 -0
  111. package/schemas/README.md +25 -19
  112. package/schemas/agent-plugins/spec.md +23 -9
  113. package/schemas/com.deepseek.harness/namespace.schema.json +42 -0
  114. package/schemas/com.deepseek.harness/spec.md +52 -0
@@ -1,18 +1,27 @@
1
+ import { createUserMessage } from '@deepseek-ai/dsh-llm';
1
2
  import { parse as parseYaml } from 'yaml';
2
3
  import { stripFrontmatter } from '../catalog/skills-parse.js';
3
4
  import { parseFrontmatterRecord } from './user-store.js';
4
- import { qualifiedSuiteId } from '../catalog/paths.js';
5
+ import { qualifiedSuiteId, suiteDataDir } from '../catalog/paths.js';
6
+ import { expandPluginPaths, pluginRootOf } from '../catalog/plugin-variables.js';
5
7
  import { defaultMarkdownResources, resourceText, resourceCommandName } from '../catalog/component-files.js';
6
8
  import { bindHostLocale } from './host-locale.js';
9
+ import { injectDynamicContext, shellSeamOf } from './dynamic-context.js';
7
10
  const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/;
8
11
  export class CommandMountRegistry {
9
12
  ctx;
10
13
  t;
14
+ dataRoot;
11
15
  fingerprints = new Map();
12
16
  live = new Map();
13
- constructor(ctx, t = bindHostLocale(undefined)) {
17
+ constructor(ctx, t = bindHostLocale(undefined), dataRoot) {
14
18
  this.ctx = ctx;
15
19
  this.t = t;
20
+ this.dataRoot = dataRoot;
21
+ }
22
+ /** The live shell seam, when the profile has one; dynamic context stays literal without it. */
23
+ shell() {
24
+ return shellSeamOf(this.ctx);
16
25
  }
17
26
  /** Register/unregister suite commands to match the enabled suites exactly. */
18
27
  async reconcile(enabledSuites) {
@@ -20,6 +29,8 @@ export class CommandMountRegistry {
20
29
  const wanted = new Map();
21
30
  for (const suite of enabledSuites) {
22
31
  const specs = suite.activeSurfaces.commands === false ? [] : await readCommands(suite.root, suite.resources?.commands);
32
+ const suiteRoot = pluginRootOf(suite);
33
+ const suiteData = suiteRoot === undefined || this.dataRoot === undefined ? undefined : suiteDataDir(this.dataRoot, suite.sourceId, suite.id);
23
34
  for (const spec of specs) {
24
35
  // The registry key is source-qualified: bare suite ids are unique per
25
36
  // source only, so two sources' same-named suites would collide.
@@ -29,7 +40,13 @@ export class CommandMountRegistry {
29
40
  diagnostics.push({ suiteId: suiteKey, command: spec.name, reason: 'duplicate normalized command name' });
30
41
  continue;
31
42
  }
32
- wanted.set(key, { ...spec, suiteId: suiteKey, suiteName: suite.manifest.name });
43
+ wanted.set(key, {
44
+ ...spec,
45
+ suiteId: suiteKey,
46
+ suiteName: suite.manifest.name,
47
+ ...(suiteRoot === undefined ? {} : { suiteRoot }),
48
+ ...(suiteData === undefined ? {} : { suiteData })
49
+ });
33
50
  }
34
51
  }
35
52
  for (const [key, disposer] of [...this.live]) {
@@ -56,13 +73,37 @@ export class CommandMountRegistry {
56
73
  name: spec.name,
57
74
  description: `[${spec.suiteName}] ${spec.description}`,
58
75
  ...(spec.hint === undefined ? {} : { input: { hint: spec.hint } }),
59
- handler: invocation => {
76
+ handler: async (invocation) => {
60
77
  const agent = invocation.agent;
61
- // The template rides the agent verbatim: a decorator line naming
62
- // this plugin or the suite would be text the command author never
63
- // wrote, and the follow-up's `source` already records provenance.
64
- const text = spec.body.replaceAll('$ARGUMENTS', invocation.rawInput.trim());
65
- agent.followup({ content: [{ type: 'text', text }], source: { kind: 'plugin', plugin: 'dsh-agent-plugins-market' } });
78
+ const workdir = agent.session?.header?.cwd;
79
+ // The template rides the agent verbatim apart from the placeholders
80
+ // its author wrote: a decorator line naming this plugin or the
81
+ // suite would be text the command author never wrote, and the
82
+ // follow-up's `source` already records provenance.
83
+ const body = expandPluginPaths(spec.body, {
84
+ ...(spec.suiteRoot === undefined ? {} : { root: spec.suiteRoot }),
85
+ ...(spec.suiteData === undefined ? {} : { data: spec.suiteData }),
86
+ ...(workdir === undefined ? {} : { projectDir: workdir })
87
+ }).replaceAll('$ARGUMENTS', invocation.rawInput.trim());
88
+ const shell = this.shell();
89
+ if (shell === undefined) {
90
+ agent.followup(createUserMessage({
91
+ content: [{ type: 'text', text: body }],
92
+ source: { kind: 'plugin', plugin: 'dsh-agent-plugins-market' }
93
+ }));
94
+ return { kind: 'success', text: this.t('commandAcknowledged', { command: spec.name }) };
95
+ }
96
+ let text;
97
+ try {
98
+ text = await injectDynamicContext(body, { shell, ...(workdir === undefined ? {} : { workdir }) });
99
+ }
100
+ catch (error) {
101
+ return { kind: 'error', text: error instanceof Error ? error.message : String(error) };
102
+ }
103
+ agent.followup(createUserMessage({
104
+ content: [{ type: 'text', text }],
105
+ source: { kind: 'plugin', plugin: 'dsh-agent-plugins-market' }
106
+ }));
66
107
  return { kind: 'success', text: this.t('commandAcknowledged', { command: spec.name }) };
67
108
  }
68
109
  });
@@ -7,22 +7,48 @@
7
7
  * browser authorization the user is completing must not be abandoned just
8
8
  * because nobody is waiting for it any more.
9
9
  *
10
+ * The timer and its reason come from the host's own deadline primitive, which
11
+ * only notifies. This module adds the one thing that primitive does not answer
12
+ * and these callers need: whether the work settled at all.
13
+ *
10
14
  * @module runtime/deadline
11
15
  */
16
+ import { deadline } from '@deepseek-ai/dsh-timeout';
17
+ /** Identifies this plugin's bounded waits inside a timeout reason. */
18
+ export const MARKET_DEADLINE_CODE = 'MARKET_DEADLINE';
12
19
  /**
13
20
  * Resolve `true` once `work` settles, or `false` when `deadlineMs` elapses.
14
21
  *
15
22
  * A rejection counts as settled: this helper only reports timing, so callers
16
23
  * that need the failure attach their own handler to `work`.
24
+ *
25
+ * A non-positive wait means "do not wait": the answer is simply whether the
26
+ * work had already settled. That is deliberately not the host primitive's own
27
+ * reading of a non-positive timeout, which is "arm no timer at all" — the floor
28
+ * below keeps the two from collapsing into opposite behaviors.
29
+ * @param work - the work to wait for; it is never cancelled, only stopped being awaited.
30
+ * @param deadlineMs - how long to wait, in milliseconds.
31
+ * @returns whether the work settled before the deadline.
17
32
  */
18
33
  export function settlesWithin(work, deadlineMs) {
34
+ const bound = deadline(undefined, Math.max(1, deadlineMs), MARKET_DEADLINE_CODE);
35
+ let settled = false;
19
36
  return new Promise(resolve => {
20
- const timer = setTimeout(() => resolve(false), deadlineMs);
21
- timer.unref?.();
22
- const settle = () => {
23
- clearTimeout(timer);
24
- resolve(true);
37
+ const settle = (value) => {
38
+ if (settled)
39
+ return;
40
+ settled = true;
41
+ resolve(value);
25
42
  };
26
- void work.then(settle, settle);
43
+ bound.signal.addEventListener('abort', () => {
44
+ settle(false);
45
+ }, { once: true });
46
+ void work.then(() => {
47
+ settle(true);
48
+ }, () => {
49
+ settle(true);
50
+ });
51
+ }).finally(() => {
52
+ bound[Symbol.dispose]();
27
53
  });
28
54
  }
@@ -0,0 +1,140 @@
1
+ /** How long one injected command may run; the executor caps a larger request. */
2
+ export const DYNAMIC_CONTEXT_TIMEOUT_MS = 120_000;
3
+ /** The requested forward cap for one injected command's stdout, in bytes. */
4
+ export const DYNAMIC_CONTEXT_MAX_OUTPUT_BYTES = 32 * 1024;
5
+ /**
6
+ * Resolve the host shell service from any context, whether or not the reading
7
+ * fiber injects `shell`.
8
+ *
9
+ * Cordis resolves `ctx.shell` by walking the reading fiber's ancestors, so a
10
+ * scope that does not inject the service throws `cannot get property "shell"
11
+ * without inject` even when a sibling fiber provides it. `ctx.get` reads the
12
+ * global service store instead and answers `undefined` in a profile that has
13
+ * no shell, which is what keeps the seam optional.
14
+ *
15
+ * @param ctx - the context the dynamic-context pass runs under.
16
+ * @returns the shell seam, or `undefined` when the profile provides none.
17
+ */
18
+ export function shellSeamOf(ctx) {
19
+ return ctx.get('shell');
20
+ }
21
+ /**
22
+ * Commands whose exit code 1 is a result rather than a failure, mirroring the
23
+ * shell consumers the harness already treats this way.
24
+ */
25
+ const BENIGN_EXIT_ONE = new Set(['grep', 'rg', 'egrep', 'fgrep', 'find', 'diff', 'test', '[']);
26
+ const BENIGN_EXIT_ONE_SUBCOMMANDS = new Set(['git diff', 'git grep']);
27
+ const INJECTION = /(^|[ \t])!`([^`\n]+)`|^```!\r?\n([\s\S]*?)^```[ \t]*$/gm;
28
+ /**
29
+ * Run every placeholder's command and return the text the model receives.
30
+ *
31
+ * @param text - author text whose `${...}` variables and arguments are already resolved.
32
+ * @param options - the resolved shell seam, session directory and cancellation signal.
33
+ * @returns the text with each placeholder replaced by its command's output.
34
+ * @throws when a command fails, times out or is aborted, carrying the command and its output.
35
+ */
36
+ export async function injectDynamicContext(text, options) {
37
+ const injections = findInjections(text);
38
+ if (injections.length === 0)
39
+ return text;
40
+ const parts = [];
41
+ let cursor = 0;
42
+ for (const injection of injections) {
43
+ parts.push(text.slice(cursor, injection.start), await runInjection(injection.command, options));
44
+ cursor = injection.end;
45
+ }
46
+ parts.push(text.slice(cursor));
47
+ return parts.join('');
48
+ }
49
+ /** Locate every placeholder in one pass so an inserted output is never re-scanned. */
50
+ function findInjections(text) {
51
+ const found = [];
52
+ for (const match of text.matchAll(INJECTION)) {
53
+ const index = match.index ?? 0;
54
+ const script = match[3];
55
+ if (script !== undefined) {
56
+ // The capture runs to the closing fence line; the block's own framing
57
+ // whitespace is not part of the script.
58
+ found.push({ start: index, end: index + match[0].length, command: script.trim() });
59
+ continue;
60
+ }
61
+ const command = match[2];
62
+ if (command === undefined)
63
+ continue;
64
+ // The captured prefix stays in the text, so the replacement keeps the
65
+ // author's own leading whitespace.
66
+ const prefix = match[1] ?? '';
67
+ found.push({ start: index + prefix.length, end: index + match[0].length, command });
68
+ }
69
+ return found;
70
+ }
71
+ /** Run one placeholder's command and return its output, or throw with the command's own words. */
72
+ async function runInjection(command, options) {
73
+ const spec = options.shell.resolve({
74
+ command,
75
+ ...(options.workdir === undefined ? {} : { workdir: options.workdir }),
76
+ timeoutMs: DYNAMIC_CONTEXT_TIMEOUT_MS,
77
+ stdoutMaxBytes: DYNAMIC_CONTEXT_MAX_OUTPUT_BYTES,
78
+ ...(options.signal === undefined ? {} : { signal: options.signal })
79
+ });
80
+ const result = await options.shell.run(spec);
81
+ if (!benignExit(result.exitCode, command))
82
+ throw injectionFailure(command, result);
83
+ const body = stripTrailingNewline(mergeOutputs(result.stdout.text, result.stderr.text));
84
+ if (!result.stdout.truncated && !result.stderr.truncated)
85
+ return body;
86
+ const notice = truncationNotice(result);
87
+ return body === '' ? notice : `${body}\n${notice}`;
88
+ }
89
+ /** Whether this command's exit status is a result rather than a failure. */
90
+ export function benignExit(exitCode, command) {
91
+ if (exitCode === 0)
92
+ return true;
93
+ if (exitCode !== 1)
94
+ return false;
95
+ const [program = '', subcommand = ''] = stripEnvironmentPrefix(command.trim().replace(/\s+/g, ' ')).split(' ');
96
+ // A path-qualified invocation names the same command as its bare spelling.
97
+ if (BENIGN_EXIT_ONE.has(program.split('/').pop() ?? program))
98
+ return true;
99
+ return program === 'git' && BENIGN_EXIT_ONE_SUBCOMMANDS.has(`git ${subcommand}`);
100
+ }
101
+ /** Skip leading `NAME=value` assignments so `FOO=1 grep x` keeps its benign status. */
102
+ function stripEnvironmentPrefix(command) {
103
+ let rest = command;
104
+ for (;;) {
105
+ const match = /^[A-Za-z_][A-Za-z0-9_]*=\S*\s+/.exec(rest);
106
+ if (match === null)
107
+ return rest;
108
+ rest = rest.slice(match[0].length);
109
+ }
110
+ }
111
+ /** Merge the two captured streams the way the default shell surfaces them. */
112
+ function mergeOutputs(stdout, stderr) {
113
+ if (stderr === '')
114
+ return stdout;
115
+ if (stdout === '')
116
+ return stderr;
117
+ return stdout.endsWith('\n') ? stdout + stderr : `${stdout}\n${stderr}`;
118
+ }
119
+ /** One trailing newline is dropped so the output joins the surrounding text. */
120
+ function stripTrailingNewline(value) {
121
+ return value.endsWith('\n') ? value.slice(0, -1) : value;
122
+ }
123
+ /** What the caller reports when a placeholder's command does not produce a result. */
124
+ function injectionFailure(command, result) {
125
+ const cause = result.timedOut
126
+ ? `timed out after ${DYNAMIC_CONTEXT_TIMEOUT_MS}ms`
127
+ : result.aborted
128
+ ? 'was aborted'
129
+ : `exited with ${result.exitCode === null ? 'a signal' : `code ${result.exitCode}`}`;
130
+ return new Error([
131
+ `Shell command failed for pattern "!\`${command}\`" (${cause})`,
132
+ ...(result.stdout.text === '' ? [] : ['[stdout]', result.stdout.text.trimEnd()]),
133
+ ...(result.stderr.text === '' ? [] : ['[stderr]', result.stderr.text.trimEnd()])
134
+ ].join('\n'));
135
+ }
136
+ /** Tell the model the injected text is only part of the command's output. */
137
+ function truncationNotice(result) {
138
+ const spill = result.stdout.spillPath ?? result.stderr.spillPath;
139
+ return `[output truncated${spill === undefined ? '' : `; full output: ${spill}`}]`;
140
+ }
@@ -24,6 +24,7 @@ import { promisify } from 'node:util';
24
24
  import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
25
25
  import { defineTool } from '@deepseek-ai/dsh-tools';
26
26
  import { platformBrowserOpener } from './mcp-client/oauth.js';
27
+ import { toolsServiceOf } from './tool-registry-observer.js';
27
28
  const execFileAsync = promisify(execFile);
28
29
  /** The GitHub repository experience feedback files issues against. */
29
30
  export const FEEDBACK_REPO_OWNER = 'Sivan757';
@@ -180,10 +181,13 @@ const FEEDBACK_DESCRIPTION = 'File an experience-feedback issue for dsh-agent-pl
180
181
  * on this host.
181
182
  */
182
183
  export function mountFeedbackTool(hostCtx, dataRoot, t) {
183
- const host = hostCtx;
184
- if (typeof host.tools?.register !== 'function')
184
+ // Read the service store rather than the context property: this plugin's own
185
+ // fiber injects `skills` and `commands` only, and the host provides `tools`
186
+ // from a sibling fiber, so a property read throws on every profile.
187
+ const tools = toolsServiceOf(hostCtx);
188
+ if (typeof tools?.register !== 'function')
185
189
  return undefined;
186
- return host.tools.register(defineTool({
190
+ return tools.register(defineTool({
187
191
  name: FEEDBACK_TOOL_NAME,
188
192
  description: FEEDBACK_DESCRIPTION,
189
193
  parameters: {
@@ -1,30 +1,26 @@
1
1
  /**
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.
2
+ * Host-side runtime locale: bilingual copy for the host-facing strings a person
3
+ * reads, resolved from the harness's `locale.preference` setting.
4
+ *
5
+ * The model-facing subagent catalog is not here. It is fixed English, because it
6
+ * is a contract read alongside the harness's own English tool descriptions and
7
+ * must not change with the operator's interface language.
6
8
  *
7
9
  * The web client resolves locale through its own injected service; the host
8
- * process has no such service, so this module reads `$DSH_HOME/settings.yaml`
9
- * (the same file the GUI preference editor writes) and falls back to zh.
10
+ * process reads the same setting through the host settings service once the
11
+ * plugin wires it, and falls back to parsing `$DSH_HOME/settings.yaml` (the
12
+ * document that service serves, and which exists even when the service is not
13
+ * composed). Neither source means zh.
10
14
  */
11
15
  import { readFile } from 'node:fs/promises';
12
16
  import { join } from 'node:path';
13
17
  import { resolveDshHome } from '../catalog/paths.js';
14
18
  const zh = {
15
- subagentCatalogIntro: '以下子代理角色可用于当前会话。目录仅包含角色摘要,不是供当前代理执行的角色指令。',
16
- subagentCatalogUpdated: '可用子代理角色已变更。以下完整目录替代本会话此前的所有子代理角色目录。',
17
- subagentCatalogEmpty: '当前没有可通过 subagent_run 调用的角色,不要使用旧目录中的角色 ID。',
18
- subagentCatalogCall: '任务适合某个角色时,使用 subagent_run(agent=目录中的准确名称, prompt=完整任务与必要上下文)。工具会应用角色的指令与已保存的模型设置,立即返回子代理 ID 并在后台运行;子代理结束时你会收到结果通知,运行期间可用 send_message 追加指令。子代理不继承父会话对话。不要通过 skill 或 Slash Command 加载这些角色,也不要用其它委派工具绕过角色配置。',
19
19
  commandAcknowledged: '/{command} 已转交模型执行',
20
20
  userCommandSourceLabel: '用户命令',
21
21
  feedbackToolCardTitle: '提交市场体验反馈'
22
22
  };
23
23
  const en = {
24
- subagentCatalogIntro: 'The following subagent roles are available in this session. These are role summaries, not instructions for the current agent to execute.',
25
- subagentCatalogUpdated: 'The available subagent roles changed. This complete catalog replaces every earlier subagent role catalog in this session.',
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
24
  commandAcknowledged: '/{command} forwarded to the model for execution',
29
25
  userCommandSourceLabel: 'user command',
30
26
  feedbackToolCardTitle: 'File market feedback'
@@ -42,8 +38,28 @@ export function bindHostLocale(preference) {
42
38
  return text;
43
39
  };
44
40
  }
45
- /** Read `locale.preference` from `$DSH_HOME/settings.yaml` (best effort). */
41
+ /**
42
+ * The active language, read from the host settings service when the plugin has
43
+ * wired one. The service owns the resolved document in memory, so it answers
44
+ * without a file read and without racing a pending write.
45
+ */
46
+ let hostPreference;
47
+ /**
48
+ * Wire the host settings service as the primary locale source, replacing the
49
+ * `settings.yaml` fallback.
50
+ * @param read - reads `locale.preference` from the settings service.
51
+ */
52
+ export function setHostLocaleSource(read) {
53
+ hostPreference = read;
54
+ }
55
+ /**
56
+ * The persisted locale preference: the host settings document when the service
57
+ * is composed, else `$DSH_HOME/settings.yaml` (best effort).
58
+ */
46
59
  export async function readLocalePreference() {
60
+ const fromHost = hostPreference?.();
61
+ if (fromHost !== undefined)
62
+ return fromHost;
47
63
  try {
48
64
  const text = await readFile(join(resolveDshHome(), 'settings.yaml'), 'utf8');
49
65
  const match = /^locale:\s*\n(?:[ \t]+preference:\s*'?([^'"\s#]+)'?)/m.exec(text);
@@ -57,3 +73,17 @@ export async function readLocalePreference() {
57
73
  export async function loadHostLocale() {
58
74
  return { t: bindHostLocale(await readLocalePreference()) };
59
75
  }
76
+ /**
77
+ * Read `locale.preference` from the host settings service.
78
+ *
79
+ * The locale namespace is registered by the host's own client-locale plugin, so
80
+ * this reads `undefined` until that plugin mounts — which is why the caller
81
+ * re-binds when the service arrives rather than capturing the value once.
82
+ * @param settingsCtx - a context carrying the `settings` service.
83
+ */
84
+ export function readHostLocalePreference(settingsCtx) {
85
+ const settings = settingsCtx.settings;
86
+ const section = settings?.get?.('locale');
87
+ const preference = section?.preference;
88
+ return typeof preference === 'string' ? preference : undefined;
89
+ }
@@ -1,7 +1,8 @@
1
1
  import { DIRECT_LSP_SUITE_ID } from './lsp-status.js';
2
2
  import { SerialPassQueue, RetryScheduler } from './mount-lifecycle.js';
3
3
  import { describeLegacySeam, findLegacyLspSeams } from './profile-seam.js';
4
- import { qualifiedSuiteId } from '../catalog/paths.js';
4
+ import { qualifiedSuiteId, suiteDataDir } from '../catalog/paths.js';
5
+ import { expandPluginPaths, pluginRootOf } from '../catalog/plugin-variables.js';
5
6
  import { effectiveSurfaces } from '../model/types.js';
6
7
  /** Build one dsh-lsp-stdio Config.servers entry from a normalized spec. */
7
8
  export function toLspServerConfig(spec) {
@@ -14,6 +15,15 @@ export function toLspServerConfig(spec) {
14
15
  ...(spec.configuration === undefined ? {} : { configuration: spec.configuration })
15
16
  };
16
17
  }
18
+ /** Resolve author-written paths in one declaration before the host spawns the server. */
19
+ export function expandLspServerConfig(config, context) {
20
+ return {
21
+ ...config,
22
+ command: expandPluginPaths(config.command, context),
23
+ args: config.args.map(argument => expandPluginPaths(argument, context)),
24
+ ...(config.env === undefined ? {} : { env: Object.fromEntries(Object.entries(config.env).map(([key, value]) => [key, expandPluginPaths(value, context)])) })
25
+ };
26
+ }
17
27
  /** Import specifiers kept as string literals so the host packages stay dynamically loaded. */
18
28
  const LSP_STDIO_IMPORT = '@deepseek-ai/dsh-lsp-stdio';
19
29
  const LSP_SERVICE_IMPORT = '@deepseek-ai/dsh-lsp';
@@ -62,6 +72,8 @@ export class LspMountRegistry {
62
72
  capabilityFailure;
63
73
  /** Latest diagnostic per suite id; cleared when its suite mounts or leaves the wanted set. */
64
74
  lastDiagnostics = new Map();
75
+ /** Plugin storage root holding each suite's `${PLUGIN_DATA}` directory; unset leaves the variable verbatim. */
76
+ pluginDataRoot;
65
77
  /** Direct (user-configured) server provider; defaults to none. */
66
78
  directProvider = async () => ({});
67
79
  disabledProvider = async () => new Set();
@@ -98,9 +110,14 @@ export class LspMountRegistry {
98
110
  // otherwise shadow each other's mount and diagnostics.
99
111
  const key = qualifiedSuiteId(suite.sourceId, suite.id);
100
112
  const config = {};
113
+ const root = pluginRootOf(suite);
114
+ const context = {
115
+ ...(root === undefined ? {} : { root }),
116
+ ...(root === undefined || this.pluginDataRoot === undefined ? {} : { data: suiteDataDir(this.pluginDataRoot, suite.sourceId, suite.id) })
117
+ };
101
118
  for (const spec of servers)
102
119
  if (!disabled.has(`${key}/${spec.key}`))
103
- config[`${key}/${spec.key}`] = toLspServerConfig(spec);
120
+ config[`${key}/${spec.key}`] = expandLspServerConfig(toLspServerConfig(spec), context);
104
121
  if (Object.keys(config).length > 0)
105
122
  wanted.set(key, { suite, config });
106
123
  }
@@ -257,6 +274,10 @@ export class LspMountRegistry {
257
274
  }
258
275
  }
259
276
  }
277
+ /** Install the plugin storage root per-suite data directories resolve against. */
278
+ setPluginDataRoot(root) {
279
+ this.pluginDataRoot = root;
280
+ }
260
281
  /** Install the direct (user-configured) server provider used at reconcile time. */
261
282
  setDirectProvider(provider) {
262
283
  this.directProvider = provider;
@@ -4,8 +4,8 @@
4
4
  * and legacy SSE) or through the host's `@deepseek-ai/dsh-mcp-client`
5
5
  * (compatibility mode: no OAuth, no SSE, but the host-native implementation).
6
6
  *
7
- * The choice is a host settings namespace (`dsh-agent-plugins-market`,
8
- * `mcpEnhanced: boolean`, default true) registered by the plugin's node half:
7
+ * The choice is the `mcpEnhanced` field of the market's host settings
8
+ * namespace (see `contracts/settings.ts`) registered by the plugin's node half:
9
9
  * the registration is what makes the host 插件配置 tab serve our card, the
10
10
  * client card binds it for state, and the node half watches it to remount
11
11
  * servers when the switch flips. `readMcpBackend` remains only as the
@@ -17,20 +17,19 @@ 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
- /** Settings namespace this plugin registers; the key the plugin-config tab pairs our card by. */
21
- export const MCP_SETTINGS_NAMESPACE = 'dsh-agent-plugins-market';
22
- /** Schema of the market settings namespace: the switches this plugin's config card serves. */
20
+ import { MARKET_SETTINGS_DEFAULTS } from '../contracts/settings.js';
21
+ /**
22
+ * Schema of the market settings namespace: the switches this plugin's config
23
+ * card serves, with the defaults declared to the host. The values themselves
24
+ * belong to {@link MARKET_SETTINGS_DEFAULTS}; this schema states them, it does
25
+ * not own them.
26
+ */
23
27
  export const MarketSettingsSchema = z.object({
24
- /** ON (default) = the built-in bridge with OAuth and SSE; OFF = host client compat mode. */
25
- mcpEnhanced: 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),
28
- /** Download region for GitHub acquisition; `auto` follows the interface language. */
29
- downloadRegion: z.union([z.const('auto'), z.const('global'), z.const('china')]).default('auto'),
30
- /** ON (default) = the `report_market_issue` model tool is registered; OFF = unregistered. */
31
- feedbackEnabled: z.boolean().default(true),
32
- /** ON = refresh every configured source in the background; OFF (default) = never. */
33
- autoUpdateSources: z.boolean().default(false)
28
+ mcpEnhanced: z.boolean().default(MARKET_SETTINGS_DEFAULTS.mcpEnhanced),
29
+ scanProjectLayouts: z.boolean().default(MARKET_SETTINGS_DEFAULTS.scanProjectLayouts),
30
+ downloadRegion: z.union([z.const('auto'), z.const('global'), z.const('china')]).default(MARKET_SETTINGS_DEFAULTS.downloadRegion),
31
+ feedbackEnabled: z.boolean().default(MARKET_SETTINGS_DEFAULTS.feedbackEnabled),
32
+ autoUpdateSources: z.boolean().default(MARKET_SETTINGS_DEFAULTS.autoUpdateSources)
34
33
  });
35
34
  /** Path of the legacy persisted settings file under the plugin data root. */
36
35
  export function marketSettingsPath(dataRoot) {
@@ -10,6 +10,14 @@
10
10
  */
11
11
  /** Default timeout for individual MCP tool calls (ms). */
12
12
  export const DEFAULT_TOOL_CALL_TIMEOUT_MS = 60_000;
13
+ /**
14
+ * How long one connection attempt may spend initializing (ms). A server that
15
+ * never answers would otherwise hold its process, transport and browser leg
16
+ * until the SDK's own 60-second request default expired; a working server
17
+ * handshakes well under a second, and a genuinely slow one can declare its own
18
+ * `startupTimeoutMs`.
19
+ */
20
+ export const DEFAULT_STARTUP_TIMEOUT_MS = 10_000;
13
21
  /** Valid `serverName`, kept below the public tool-name budget. */
14
22
  export const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/;
15
23
  /**
@@ -23,9 +23,11 @@
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 { redactErrorMessage } from '../mcp-redaction.js';
26
27
  import { PLUGIN_NAME, PLUGIN_VERSION } from './plugin-identity.js';
27
28
  import { createTransport } from './transport.js';
28
29
  import { syncTools } from './tools.js';
30
+ import { DEFAULT_STARTUP_TIMEOUT_MS } from './config.js';
29
31
  /** Defaults shared by the config and {@link resolveReconnectPolicy}. */
30
32
  export const RECONNECT_DEFAULTS = Object.freeze({
31
33
  enabled: true,
@@ -89,9 +91,11 @@ export function startConnection(host, config, policy, credentials) {
89
91
  registrationFailure: 'contain',
90
92
  serverName: config.serverName,
91
93
  toolCallTimeoutMs: config.toolCallTimeoutMs,
94
+ // Every attempt is bounded: an unreachable server must not hold its
95
+ // process, transport and browser leg while nothing can arrive.
96
+ startupTimeoutMs: config.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS,
92
97
  ...(config.enabledTools === undefined ? {} : { enabledTools: config.enabledTools }),
93
- ...(config.disabledTools === undefined ? {} : { disabledTools: config.disabledTools }),
94
- ...(config.startupTimeoutMs === undefined ? {} : { startupTimeoutMs: config.startupTimeoutMs })
98
+ ...(config.disabledTools === undefined ? {} : { disabledTools: config.disabledTools })
95
99
  };
96
100
  // The initial sync uses 'throw' when failOnStartupError is configured, so
97
101
  // a registration conflict propagates to the startup-await path. Re-syncs
@@ -226,7 +230,7 @@ export function startConnection(host, config, policy, credentials) {
226
230
  // Fetch-phase failure: the previous generation is still registered
227
231
  // and `disposers` still owns it — keep serving the last good list.
228
232
  if (!disposed)
229
- host.logger.error(`${label}: tool re-sync failed: ${String(error)}`);
233
+ host.logger.error(`${label}: tool re-sync failed: ${redactErrorMessage(String(error))}`);
230
234
  }
231
235
  });
232
236
  /**
@@ -258,10 +262,7 @@ export function startConnection(host, config, policy, credentials) {
258
262
  host.logger.info(message);
259
263
  });
260
264
  try {
261
- if (config.startupTimeoutMs === undefined)
262
- await generation.connect(transport);
263
- else
264
- await generation.connect(transport, { timeout: config.startupTimeoutMs });
265
+ await generation.connect(transport, { timeout: opts.startupTimeoutMs });
265
266
  if (hasClosed()) {
266
267
  attemptSettled = true;
267
268
  generationDown(generation);
@@ -275,7 +276,7 @@ export function startConnection(host, config, policy, credentials) {
275
276
  // Disposal clears current ownership before it closes the generation, so
276
277
  // only a live supervisor reports an attempt failure.
277
278
  if (isCurrent(generation))
278
- host.logger.warn(`${label}: connection attempt failed: ${String(error)}`);
279
+ host.logger.warn(`${label}: connection attempt failed: ${redactErrorMessage(String(error))}`);
279
280
  // A pending browser leg outlives the initialize timeout: the human is
280
281
  // mid-approval. Hold this generation open — transport and loopback
281
282
  // listener stay alive — until the leg settles; abandoning it would