dsh-agent-plugins-market 0.7.0 → 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 (78) hide show
  1. package/README.md +2 -2
  2. package/README.zh.md +2 -2
  3. package/client/client.js +28048 -1033
  4. package/client/client.js.map +1 -1
  5. package/lib/application/catalog-context.js +8 -1
  6. package/lib/application/catalog.js +13 -0
  7. package/lib/application/details.js +13 -2
  8. package/lib/application/mcp-service.js +17 -0
  9. package/lib/application/panel-resources.js +16 -4
  10. package/lib/application/project-agent-roles.js +7 -1
  11. package/lib/catalog/archive.js +1 -1
  12. package/lib/catalog/paths.js +8 -0
  13. package/lib/catalog/plugin-variables.js +38 -0
  14. package/lib/catalog/project-hooks.js +14 -1
  15. package/lib/catalog/skills-parse.js +47 -0
  16. package/lib/catalog/source-catalog.js +8 -3
  17. package/lib/catalog/suite-components.js +2 -2
  18. package/lib/contracts/market.js +1 -0
  19. package/lib/contracts/settings.js +63 -0
  20. package/lib/index.js +26 -11
  21. package/lib/model/layouts.js +10 -1
  22. package/lib/routes.js +12 -0
  23. package/lib/runtime/agent-role-router.js +28 -6
  24. package/lib/runtime/commands-mounts.js +50 -9
  25. package/lib/runtime/deadline.js +32 -6
  26. package/lib/runtime/dynamic-context.js +140 -0
  27. package/lib/runtime/feedback-tool.js +7 -3
  28. package/lib/runtime/host-locale.js +39 -3
  29. package/lib/runtime/lsp-mounts.js +23 -2
  30. package/lib/runtime/mcp-backend.js +14 -15
  31. package/lib/runtime/mcp-client/config.js +8 -0
  32. package/lib/runtime/mcp-client/connection.js +6 -6
  33. package/lib/runtime/mcp-client/host-seams.js +8 -88
  34. package/lib/runtime/mcp-config.js +7 -4
  35. package/lib/runtime/project-runtime.js +23 -9
  36. package/lib/runtime/reconcile-scheduler.js +6 -8
  37. package/lib/runtime/reconciler.js +2 -1
  38. package/lib/runtime/regions.js +0 -19
  39. package/lib/runtime/settings-namespace.js +21 -13
  40. package/lib/runtime/skills-provider.js +33 -11
  41. package/lib/runtime/source-auto-update.js +10 -14
  42. package/lib/runtime/timer-seat.js +80 -0
  43. package/lib/runtime/tool-registry-observer.js +20 -20
  44. package/lib/runtime/user-commands.js +5 -1
  45. package/lib/runtime/user-panels.js +110 -41
  46. package/lib/runtime/user-store.js +114 -58
  47. package/lib/types/application/catalog-context.d.ts +6 -0
  48. package/lib/types/application/catalog.d.ts +9 -0
  49. package/lib/types/application/mcp-service.d.ts +6 -0
  50. package/lib/types/application/ports.d.ts +1 -1
  51. package/lib/types/application/queries.d.ts +2 -0
  52. package/lib/types/catalog/paths.d.ts +6 -0
  53. package/lib/types/catalog/plugin-variables.d.ts +16 -0
  54. package/lib/types/catalog/skills-parse.d.ts +13 -0
  55. package/lib/types/catalog/source-catalog.d.ts +7 -2
  56. package/lib/types/contracts/market.d.ts +10 -0
  57. package/lib/types/contracts/settings.d.ts +59 -0
  58. package/lib/types/model/layouts.d.ts +10 -0
  59. package/lib/types/runtime/agent-role-router.d.ts +6 -2
  60. package/lib/types/runtime/commands-mounts.d.ts +15 -7
  61. package/lib/types/runtime/deadline.d.ts +10 -11
  62. package/lib/types/runtime/dynamic-context.d.ts +81 -0
  63. package/lib/types/runtime/host-locale.d.ts +19 -1
  64. package/lib/types/runtime/lsp-mounts.d.ts +7 -0
  65. package/lib/types/runtime/mcp-backend.d.ts +8 -15
  66. package/lib/types/runtime/mcp-client/config.d.ts +8 -0
  67. package/lib/types/runtime/mcp-client/host-seams.d.ts +20 -64
  68. package/lib/types/runtime/project-runtime.d.ts +10 -3
  69. package/lib/types/runtime/reconcile-scheduler.d.ts +1 -1
  70. package/lib/types/runtime/regions.d.ts +1 -4
  71. package/lib/types/runtime/settings-namespace.d.ts +10 -10
  72. package/lib/types/runtime/skills-provider.d.ts +11 -2
  73. package/lib/types/runtime/source-auto-update.d.ts +3 -4
  74. package/lib/types/runtime/timer-seat.d.ts +51 -0
  75. package/lib/types/runtime/tool-registry-observer.d.ts +12 -6
  76. package/lib/types/runtime/user-panels.d.ts +65 -12
  77. package/lib/types/runtime/user-store.d.ts +65 -16
  78. package/package.json +21 -4
@@ -5,6 +5,7 @@ import { parse as parseYaml } from 'yaml';
5
5
  import { mountSubagentCatalog } from './subagent-catalog.js';
6
6
  import { bindHostLocale } from './host-locale.js';
7
7
  import { namedAgentRoles } from './agent-role-names.js';
8
+ import { expandPluginPaths } from '../catalog/plugin-variables.js';
8
9
  export const AGENT_ROLE_TOOL_NAME = 'subagent_run';
9
10
  /**
10
11
  * Absolute delegation-depth cap applied to every role child. The host default is
@@ -113,7 +114,7 @@ export async function resolveAgentOptions(policy, parent, llm, signal, subject,
113
114
  };
114
115
  }
115
116
  /** Stable summaries; invalid declarations are excluded and transient read failures abort publication. */
116
- export async function agentRoleCatalog(entries, signal, diagnose = () => { }) {
117
+ export async function agentRoleCatalog(entries, signal, diagnose = () => { }, projectDir) {
117
118
  const summaries = [];
118
119
  const counts = new Map();
119
120
  for (const entry of entries)
@@ -137,7 +138,7 @@ export async function agentRoleCatalog(entries, signal, diagnose = () => { }) {
137
138
  }
138
139
  let policy;
139
140
  try {
140
- policy = parseAgentRole(text);
141
+ policy = expandPolicyPaths(parseAgentRole(text), entry, projectDir);
141
142
  }
142
143
  catch (error) {
143
144
  diagnose(`${entry.path}: ${String(error)}`);
@@ -160,9 +161,30 @@ export async function agentRoleCatalog(entries, signal, diagnose = () => { }) {
160
161
  signal.throwIfAborted();
161
162
  return summaries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
162
163
  }
164
+ /** A suite card carries the same path variables as every other surface its author wrote for. */
165
+ function expandPolicyPaths(policy, entry, projectDir) {
166
+ if (entry.suiteRoot === undefined && entry.suiteData === undefined && projectDir === undefined)
167
+ return policy;
168
+ const context = {
169
+ ...(entry.suiteRoot === undefined ? {} : { root: entry.suiteRoot }),
170
+ ...(entry.suiteData === undefined ? {} : { data: entry.suiteData }),
171
+ ...(projectDir === undefined ? {} : { projectDir })
172
+ };
173
+ return {
174
+ ...policy,
175
+ content: expandPluginPaths(policy.content, context),
176
+ ...(policy.title === undefined ? {} : { title: expandPluginPaths(policy.title, context) }),
177
+ ...(policy.description === undefined ? {} : { description: expandPluginPaths(policy.description, context) })
178
+ };
179
+ }
163
180
  /** Read the same declaration for catalog summaries and execution, including inline resources. */
164
- export async function readAgentRole(entry) {
165
- return parseAgentRole(entry.rawText ?? (await readFile(entry.path, 'utf8')));
181
+ export async function readAgentRole(entry, projectDir) {
182
+ return expandPolicyPaths(parseAgentRole(entry.rawText ?? (await readFile(entry.path, 'utf8'))), entry, projectDir);
183
+ }
184
+ /** The calling session's directory, which is what a card's `${CLAUDE_PROJECT_DIR}` names. */
185
+ function sessionCwd(agent) {
186
+ const cwd = agent?.session?.header?.cwd;
187
+ return typeof cwd === 'string' && cwd !== '' ? cwd : undefined;
166
188
  }
167
189
  /**
168
190
  * Start one role child and return its durable id without waiting for the result.
@@ -180,7 +202,7 @@ export async function executeAgentRole(host, listRoles, agentName, prompt, paren
180
202
  throw new Error(`agent "${agentName}" is unavailable or ambiguous`);
181
203
  if (entry.disabled)
182
204
  throw new Error(`agent "${agentName}" is disabled`);
183
- const policy = await readAgentRole(entry);
205
+ const policy = await readAgentRole(entry, sessionCwd(parent));
184
206
  if (policy.disabled)
185
207
  throw new Error(`agent "${agentName}" is disabled`);
186
208
  const agentOptions = await resolveAgentOptions(policy, parent, host.llm, signal, agentName, diagnose);
@@ -227,7 +249,7 @@ export function mountAgentRoleTool(ctx, listRoles, t = bindHostLocale(undefined)
227
249
  const disposeTool = host.tools.register(tool);
228
250
  let disposeCatalog;
229
251
  try {
230
- disposeCatalog = mountSubagentCatalog(ctx, tool, async (agent, signal) => agentRoleCatalog(await listRoles(agent), signal, message => ctx.logger?.warn(message)), t);
252
+ disposeCatalog = mountSubagentCatalog(ctx, tool, async (agent, signal) => agentRoleCatalog(await listRoles(agent), signal, message => ctx.logger?.warn(message), sessionCwd(agent)), t);
231
253
  }
232
254
  catch (error) {
233
255
  disposeTool();
@@ -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: {
@@ -5,8 +5,10 @@
5
5
  * `locale.preference` setting.
6
6
  *
7
7
  * 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.
8
+ * process reads the same setting through the host settings service once the
9
+ * plugin wires it, and falls back to parsing `$DSH_HOME/settings.yaml` (the
10
+ * document that service serves, and which exists even when the service is not
11
+ * composed). Neither source means zh.
10
12
  */
11
13
  import { readFile } from 'node:fs/promises';
12
14
  import { join } from 'node:path';
@@ -42,8 +44,28 @@ export function bindHostLocale(preference) {
42
44
  return text;
43
45
  };
44
46
  }
45
- /** Read `locale.preference` from `$DSH_HOME/settings.yaml` (best effort). */
47
+ /**
48
+ * The active language, read from the host settings service when the plugin has
49
+ * wired one. The service owns the resolved document in memory, so it answers
50
+ * without a file read and without racing a pending write.
51
+ */
52
+ let hostPreference;
53
+ /**
54
+ * Wire the host settings service as the primary locale source, replacing the
55
+ * `settings.yaml` fallback.
56
+ * @param read - reads `locale.preference` from the settings service.
57
+ */
58
+ export function setHostLocaleSource(read) {
59
+ hostPreference = read;
60
+ }
61
+ /**
62
+ * The persisted locale preference: the host settings document when the service
63
+ * is composed, else `$DSH_HOME/settings.yaml` (best effort).
64
+ */
46
65
  export async function readLocalePreference() {
66
+ const fromHost = hostPreference?.();
67
+ if (fromHost !== undefined)
68
+ return fromHost;
47
69
  try {
48
70
  const text = await readFile(join(resolveDshHome(), 'settings.yaml'), 'utf8');
49
71
  const match = /^locale:\s*\n(?:[ \t]+preference:\s*'?([^'"\s#]+)'?)/m.exec(text);
@@ -57,3 +79,17 @@ export async function readLocalePreference() {
57
79
  export async function loadHostLocale() {
58
80
  return { t: bindHostLocale(await readLocalePreference()) };
59
81
  }
82
+ /**
83
+ * Read `locale.preference` from the host settings service.
84
+ *
85
+ * The locale namespace is registered by the host's own client-locale plugin, so
86
+ * this reads `undefined` until that plugin mounts — which is why the caller
87
+ * re-binds when the service arrives rather than capturing the value once.
88
+ * @param settingsCtx - a context carrying the `settings` service.
89
+ */
90
+ export function readHostLocalePreference(settingsCtx) {
91
+ const settings = settingsCtx.settings;
92
+ const section = settings?.get?.('locale');
93
+ const preference = section?.preference;
94
+ return typeof preference === 'string' ? preference : undefined;
95
+ }
@@ -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
  /**
@@ -26,6 +26,7 @@ import { MAX_TIMER_DELAY_MS } from './host-seams.js';
26
26
  import { PLUGIN_NAME, PLUGIN_VERSION } from './plugin-identity.js';
27
27
  import { createTransport } from './transport.js';
28
28
  import { syncTools } from './tools.js';
29
+ import { DEFAULT_STARTUP_TIMEOUT_MS } from './config.js';
29
30
  /** Defaults shared by the config and {@link resolveReconnectPolicy}. */
30
31
  export const RECONNECT_DEFAULTS = Object.freeze({
31
32
  enabled: true,
@@ -89,9 +90,11 @@ export function startConnection(host, config, policy, credentials) {
89
90
  registrationFailure: 'contain',
90
91
  serverName: config.serverName,
91
92
  toolCallTimeoutMs: config.toolCallTimeoutMs,
93
+ // Every attempt is bounded: an unreachable server must not hold its
94
+ // process, transport and browser leg while nothing can arrive.
95
+ startupTimeoutMs: config.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS,
92
96
  ...(config.enabledTools === undefined ? {} : { enabledTools: config.enabledTools }),
93
- ...(config.disabledTools === undefined ? {} : { disabledTools: config.disabledTools }),
94
- ...(config.startupTimeoutMs === undefined ? {} : { startupTimeoutMs: config.startupTimeoutMs })
97
+ ...(config.disabledTools === undefined ? {} : { disabledTools: config.disabledTools })
95
98
  };
96
99
  // The initial sync uses 'throw' when failOnStartupError is configured, so
97
100
  // a registration conflict propagates to the startup-await path. Re-syncs
@@ -258,10 +261,7 @@ export function startConnection(host, config, policy, credentials) {
258
261
  host.logger.info(message);
259
262
  });
260
263
  try {
261
- if (config.startupTimeoutMs === undefined)
262
- await generation.connect(transport);
263
- else
264
- await generation.connect(transport, { timeout: config.startupTimeoutMs });
264
+ await generation.connect(transport, { timeout: opts.startupTimeoutMs });
265
265
  if (hasClosed()) {
266
266
  attemptSettled = true;
267
267
  generationDown(generation);