dsh-agent-plugins-market 0.7.2 → 0.8.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 (42) hide show
  1. package/README.md +11 -3
  2. package/README.zh.md +11 -3
  3. package/client/client.js +61 -44
  4. package/client/client.js.map +1 -1
  5. package/lib/application/catalog.js +9 -2
  6. package/lib/catalog/component-files.js +3 -1
  7. package/lib/catalog/native-project.js +1 -1
  8. package/lib/catalog/project-config.js +0 -2
  9. package/lib/catalog/project-hooks.js +12 -7
  10. package/lib/catalog/surfaces.js +17 -10
  11. package/lib/index.js +23 -2
  12. package/lib/model/command-names.js +14 -0
  13. package/lib/model/layouts.js +1 -0
  14. package/lib/runtime/commands-mounts.js +3 -2
  15. package/lib/runtime/hooks-mounts.js +7 -4
  16. package/lib/runtime/mcp-backend.js +3 -2
  17. package/lib/runtime/mcp-direct-config.js +18 -9
  18. package/lib/runtime/plugin-message-source.js +4 -0
  19. package/lib/runtime/project-runtime.js +10 -8
  20. package/lib/runtime/settings-namespace.js +44 -66
  21. package/lib/runtime/user-commands.js +23 -8
  22. package/lib/runtime/user-hooks.js +102 -0
  23. package/lib/runtime/user-panels.js +34 -22
  24. package/lib/runtime/user-store.js +81 -19
  25. package/lib/types/catalog/component-files.d.ts +1 -0
  26. package/lib/types/catalog/project-hooks.d.ts +15 -2
  27. package/lib/types/catalog/surfaces.d.ts +6 -4
  28. package/lib/types/contracts/market.d.ts +5 -1
  29. package/lib/types/index.d.ts +87 -1
  30. package/lib/types/model/command-names.d.ts +12 -0
  31. package/lib/types/model/layouts.d.ts +1 -0
  32. package/lib/types/model/types.d.ts +2 -1
  33. package/lib/types/runtime/mcp-backend.d.ts +20 -13
  34. package/lib/types/runtime/mcp-direct-config.d.ts +1 -5
  35. package/lib/types/runtime/plugin-message-source.d.ts +24 -0
  36. package/lib/types/runtime/settings-namespace.d.ts +41 -31
  37. package/lib/types/runtime/user-commands.d.ts +5 -2
  38. package/lib/types/runtime/user-hooks.d.ts +22 -0
  39. package/lib/types/runtime/user-panels.d.ts +35 -15
  40. package/lib/types/runtime/user-store.d.ts +27 -10
  41. package/package.json +38 -31
  42. package/schemas/com.deepseek.harness/spec.md +1 -1
@@ -12,6 +12,7 @@
12
12
  import { qualifiedSuiteId } from '../catalog/paths.js';
13
13
  import { loadUserMcpSuite } from '../runtime/mcp-direct-config.js';
14
14
  import { loadSuiteOverrides } from '../runtime/mcp-overrides.js';
15
+ import { loadUserHooksSuite } from '../runtime/user-hooks.js';
15
16
  import { applyLspOverrides } from '../runtime/server-config.js';
16
17
  import { buildSuiteDetail, readSkillContent } from './details.js';
17
18
  import { CatalogContext } from './catalog-context.js';
@@ -103,8 +104,14 @@ export class Catalog {
103
104
  .map(([key]) => key.slice(0, key.indexOf('/'))));
104
105
  const sources = state.sources.filter(source => enabledSources.has(source.id));
105
106
  const suites = sources.length === 0 ? [] : (await this.snapshots.build({ ...state, sources }, 'user', this.context.userRoot)).enabledSuites;
106
- const direct = await loadUserMcpSuite(this.context.agentsRoot);
107
- return applyLspOverrides(this.context.dataRoot, Object.keys(direct.mcp.servers).length === 0 ? suites : [...suites, direct]);
107
+ // The user's own Agent layout declarations ride the same mount registries
108
+ // as an installed suite: each direct suite joins the list only when it
109
+ // declares the content its surface count reports, so an absent or empty
110
+ // file costs no mount pass.
111
+ const mcp = await loadUserMcpSuite(this.context.agentsRoot);
112
+ const hooks = await loadUserHooksSuite(this.context.agentsRoot);
113
+ const direct = [...(Object.keys(mcp.mcp.servers).length === 0 ? [] : [mcp]), ...(hooks.surfaces.hooks === 0 ? [] : [hooks])];
114
+ return applyLspOverrides(this.context.dataRoot, direct.length === 0 ? suites : [...suites, ...direct]);
108
115
  }
109
116
  /** The full market overview from one user snapshot. */
110
117
  async overview() {
@@ -1,6 +1,7 @@
1
1
  /** Resolve declared component files once; all consumers use the same contained resource paths. */
2
2
  import { readFile, readdir, realpath, stat } from 'node:fs/promises';
3
3
  import { extname, join, relative, resolve } from 'node:path';
4
+ import { commandCallName } from '../model/command-names.js';
4
5
  import { PLUGIN_ROOT_VARIABLES } from '../model/layouts.js';
5
6
  import { isFile } from './fs-probes.js';
6
7
  import { isWithin } from './paths.js';
@@ -154,6 +155,7 @@ export async function defaultMarkdownResources(root, component) {
154
155
  export async function resourceText(resource) {
155
156
  return resource.content ?? (await readFile(resource.file, 'utf8'));
156
157
  }
158
+ /** Registration name of a command resource; one rule governs suite, project-native, and user-panel commands. */
157
159
  export function resourceCommandName(name) {
158
- return name.replaceAll('/', '-').replaceAll('\\', '-').toLowerCase();
160
+ return commandCallName(name);
159
161
  }
@@ -47,7 +47,7 @@ export async function discoverNativeProjectSuites(projectRoot, dimension) {
47
47
  lspFiles.push('lsp.json');
48
48
  for (const path of lspFiles) {
49
49
  if (await isFile(join(projectRoot, path))) {
50
- errors.push(`${path}: project LSP configuration is not mounted; the host LSP registry does not isolate projects`);
50
+ errors.push(`${path.replace(/\\/g, '/')}: project LSP configuration is not mounted; the host LSP registry does not isolate projects`);
51
51
  }
52
52
  }
53
53
  // Skills live in `<dir>/skills/<name>/SKILL.md` — the same shape as the
@@ -47,8 +47,6 @@ export async function discoverProjectMcp(projectRoot, files, errors, format = 'm
47
47
  found = true;
48
48
  Object.assign(servers, table);
49
49
  }
50
- if (format === 'zcode' && Object.keys(servers).length === 0)
51
- return discoverProjectMcp(projectRoot, ['.agents/mcp.json'], errors);
52
50
  if (!found)
53
51
  return undefined;
54
52
  for (const [key, server] of Object.entries(servers)) {
@@ -16,9 +16,10 @@ function declarationText(value) {
16
16
  * a standalone hook file may carry the bare event table instead of a `hooks`
17
17
  * key. A recognized top-level event name is the marker — a document with no
18
18
  * event name at the top stays verbatim, so an ordinary settings file missing
19
- * `hooks` is still not accepted as a bare event table.
19
+ * `hooks` is still not accepted as a bare event table. Exported because the
20
+ * user Agent layout root reads the same standalone file shapes.
20
21
  */
21
- function settingsDocument(settings, format) {
22
+ export function standaloneHookDocument(settings, format = 'claude') {
22
23
  if (format !== 'claude' || settings.hooks !== undefined)
23
24
  return settings;
24
25
  const isEventTable = Object.keys(settings).some(key => EVENTS.has(key));
@@ -32,11 +33,15 @@ export async function discoverProjectHooks(projectRoot, files, errors, format =
32
33
  if (settings === null)
33
34
  return undefined;
34
35
  if (settings !== undefined)
35
- documents.push({ file, settings: settingsDocument(settings, format) });
36
+ documents.push({ file, settings: standaloneHookDocument(settings, format) });
36
37
  }
37
38
  return normalizeHookDocuments(projectRoot, documents, errors, format);
38
39
  }
39
- /** Pure validation shared by native settings and plugin file/inline declarations. */
40
+ /**
41
+ * Pure validation shared by native settings and plugin file/inline
42
+ * declarations. `projectRoot` is absent for hooks declared in the user Agent
43
+ * layout root, which belong to no project.
44
+ */
40
45
  export function normalizeHookDocuments(projectRoot, documents, errors, format = 'claude') {
41
46
  const events = Object.create(null);
42
47
  const seen = new Set();
@@ -64,7 +69,7 @@ export function normalizeHookDocuments(projectRoot, documents, errors, format =
64
69
  }
65
70
  for (const [event, groups] of Object.entries(eventTable)) {
66
71
  if (!EVENTS.has(event)) {
67
- errors.push(`${file}: unsupported project hook event ${event}`);
72
+ errors.push(`${file}: unsupported hook event ${event}`);
68
73
  continue;
69
74
  }
70
75
  if (!Array.isArray(groups)) {
@@ -105,7 +110,7 @@ export function normalizeHookDocuments(projectRoot, documents, errors, format =
105
110
  return undefined;
106
111
  }
107
112
  if (hook.async === true || hook.asyncRewake === true) {
108
- errors.push(`${file}: asynchronous project hooks are unsupported`);
113
+ errors.push(`${file}: asynchronous hooks are unsupported`);
109
114
  continue;
110
115
  }
111
116
  const key = JSON.stringify([event, matcher, hook.command]);
@@ -125,5 +130,5 @@ export function normalizeHookDocuments(projectRoot, documents, errors, format =
125
130
  }
126
131
  }
127
132
  }
128
- return !disabled && Object.keys(events).length > 0 ? { projectRoot, events } : undefined;
133
+ return !disabled && Object.keys(events).length > 0 ? { ...(projectRoot === undefined ? {} : { projectRoot }), events } : undefined;
129
134
  }
@@ -4,7 +4,8 @@
4
4
  *
5
5
  * Skills honor any path the winning manifest declares (`skills` may be a
6
6
  * string, an array, or absent), scan up to three levels of nesting, and
7
- * dedupe by frontmatter name. MCP reads both `mcp.json` and the dot-prefixed
7
+ * dedupe by frontmatter name. A path carrying its own `SKILL.md` is one skill
8
+ * and nothing else inside it is read. MCP reads both `mcp.json` and the dot-prefixed
8
9
  * `.mcp.json`, tolerating unknown transports per server. Every read is
9
10
  * fail-closed: broken files produce a diagnostic and are skipped, never a
10
11
  * thrown discovery.
@@ -40,10 +41,12 @@ async function declaredSkillDirs(root, declared, errors) {
40
41
  }
41
42
  /**
42
43
  * Discover SKILL.md files under the suite's skills directory, up to 3 levels
43
- * deep. Portable v1 suites (§7.1) instead discover exactly one level of
44
- * `skills/` subdirectories each carrying a `SKILL.md` — the spec forbids
45
- * recursive deeper search and root-level or flat skill files are outside the
46
- * portable discovery shape.
44
+ * deep. A path that carries `SKILL.md` is one skill directory — a manifest may
45
+ * list those individually — and only that document is read from it. Portable
46
+ * v1 suites (§7.1) instead discover exactly one level of `skills/`
47
+ * subdirectories each carrying a `SKILL.md` — the spec forbids recursive
48
+ * deeper search and root-level or flat skill files are outside the portable
49
+ * discovery shape.
47
50
  */
48
51
  export async function discoverSkills(root, errors, declared, portable = false) {
49
52
  const skills = [];
@@ -96,16 +99,20 @@ export async function discoverSkills(root, errors, declared, portable = false) {
96
99
  pushUnique(await parseOneSkill(skillsDir, dirname(skillsDir), '', errors));
97
100
  continue;
98
101
  }
99
- for (const name of await listMdFiles(skillsDir)) {
100
- pushUnique(await parseOneSkill(join(skillsDir, name), skillsDir, name.replace(/\.md$/, ''), errors));
101
- }
102
- // A declared path may be one skill directory (a manifest listing
103
- // individual skills, e.g. mattpocock) or a container of skills.
102
+ // A directory carrying its own `SKILL.md` is one skill, whether a manifest
103
+ // declared it individually (mattpocock lists every skill directory) or the
104
+ // conventional container happens to be one. Everything beside that
105
+ // `SKILL.md` — `references/`, `scripts/`, the notes and formats a skill
106
+ // attaches — belongs to it, so those documents are neither parsed as flat
107
+ // skills nor reported as malformed ones.
104
108
  if (await isFile(join(skillsDir, 'SKILL.md'))) {
105
109
  const name = skillsDir.split(/[\\/]/).at(-1) ?? '';
106
110
  pushUnique(await parseOneSkill(join(skillsDir, 'SKILL.md'), skillsDir, name, errors));
107
111
  continue;
108
112
  }
113
+ for (const name of await listMdFiles(skillsDir)) {
114
+ pushUnique(await parseOneSkill(join(skillsDir, name), skillsDir, name.replace(/\.md$/, ''), errors));
115
+ }
109
116
  for (const child of await listChildDirs(skillsDir)) {
110
117
  const name = child.split(/[\\/]/).at(-1) ?? '';
111
118
  pushUnique(await parseOneSkill(join(child, 'SKILL.md'), child, name, errors));
package/lib/index.js CHANGED
@@ -1,3 +1,5 @@
1
+ import z from '@deepseek-ai/schemastery';
2
+ import { MarketSettingsFields } from './runtime/mcp-backend.js';
1
3
  import { Catalog } from './application/catalog.js';
2
4
  import { settlesWithin } from './runtime/deadline.js';
3
5
  import { RuntimeReconciler } from './runtime/reconciler.js';
@@ -24,7 +26,26 @@ import { UserCommandMountRegistry } from './runtime/user-commands.js';
24
26
  import { presetSourceRef } from './model/preset-source.js';
25
27
  export const name = 'dsh-agent-plugins-market';
26
28
  export const inject = ['skills', 'commands'];
27
- export async function apply(ctx, config = {}) {
29
+ export const Config = z.object({
30
+ userRoot: z.union([z.string(), z.const(undefined)]),
31
+ dataRoot: z.union([z.string(), z.const(undefined)]),
32
+ sources: z.any(),
33
+ git: z.object({
34
+ proxy: z.string(),
35
+ insteadOf: z.dict(z.string(), z.any()),
36
+ timeoutMs: z.natural(),
37
+ cloneRetry: z.boolean(),
38
+ fallbackTarball: z.boolean(),
39
+ allowHttpArchives: z.boolean()
40
+ }),
41
+ mcpEnhanced: MarketSettingsFields.mcpEnhanced.volatile(),
42
+ scanProjectLayouts: MarketSettingsFields.scanProjectLayouts.volatile(),
43
+ downloadRegion: MarketSettingsFields.downloadRegion.volatile(),
44
+ feedbackEnabled: MarketSettingsFields.feedbackEnabled.volatile(),
45
+ autoUpdateSources: MarketSettingsFields.autoUpdateSources.volatile()
46
+ });
47
+ const undefinedRef = { get: () => undefined };
48
+ export async function apply(ctx, config = { mcpEnhanced: undefinedRef, scanProjectLayouts: undefinedRef, downloadRegion: undefinedRef, feedbackEnabled: undefinedRef, autoUpdateSources: undefinedRef }) {
28
49
  const userRoot = resolveUserRoot(config.userRoot);
29
50
  const dataRoot = resolveDataRoot(config.dataRoot, userRoot);
30
51
  const agentsRoot = resolveAgentsRoot();
@@ -110,7 +131,7 @@ export async function apply(ctx, config = {}) {
110
131
  };
111
132
  // Background source updates: off until the settings switch says otherwise.
112
133
  const autoUpdate = new SourceAutoUpdater(ctx, () => catalog.refreshSource());
113
- const settings = new MarketSettingsNamespace(ctx, dataRoot, hostLocale, {
134
+ const settings = new MarketSettingsNamespace(ctx, config, dataRoot, hostLocale, {
114
135
  setScanProjectLayouts: enabled => catalog.setScanProjectLayouts(enabled),
115
136
  refreshMcpMounts: () => {
116
137
  void Promise.all([scheduler.request(), projectMcp?.refresh()]).catch(() => { });
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The call name a command resource registers under.
3
+ *
4
+ * A command resource may live at any depth under its `commands/` directory,
5
+ * but the host's slash-command grammar accepts single-segment names only, so
6
+ * registration flattens the path separators: `git/commit` registers as
7
+ * `git-commit`. The user panel renders the same call name, so a card never
8
+ * advertises a slash name the host cannot accept.
9
+ * @module model/command-names
10
+ */
11
+ /** Flatten a resource path into the single-segment name a slash command registers under. */
12
+ export function commandCallName(name) {
13
+ return name.replaceAll('/', '-').replaceAll('\\', '-').toLowerCase();
14
+ }
@@ -47,6 +47,7 @@ export const PROJECT_LAYOUTS = [
47
47
  dirName: '.agents',
48
48
  label: 'agents',
49
49
  subdirs: ['skills', 'agents', 'commands'],
50
+ mcpFiles: ['.agents/mcp.json'],
50
51
  hookFiles: ['.agents/hooks/hooks.json', '.agents/hooks.json']
51
52
  },
52
53
  { dirName: '.codex', label: 'Codex', subdirs: ['skills'], mcpFiles: ['.codex/config.toml'], mcpFormat: 'codex' },
@@ -7,6 +7,7 @@ import { expandPluginPaths, pluginRootOf } from '../catalog/plugin-variables.js'
7
7
  import { defaultMarkdownResources, resourceText, resourceCommandName } from '../catalog/component-files.js';
8
8
  import { bindHostLocale } from './host-locale.js';
9
9
  import { injectDynamicContext, shellSeamOf } from './dynamic-context.js';
10
+ import { pluginMarketSource } from './plugin-message-source.js';
10
11
  const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/;
11
12
  export class CommandMountRegistry {
12
13
  ctx;
@@ -89,7 +90,7 @@ export class CommandMountRegistry {
89
90
  if (shell === undefined) {
90
91
  agent.followup(createUserMessage({
91
92
  content: [{ type: 'text', text: body }],
92
- source: { kind: 'plugin', plugin: 'dsh-agent-plugins-market' }
93
+ source: pluginMarketSource()
93
94
  }));
94
95
  return { kind: 'success', text: this.t('commandAcknowledged', { command: spec.name }) };
95
96
  }
@@ -102,7 +103,7 @@ export class CommandMountRegistry {
102
103
  }
103
104
  agent.followup(createUserMessage({
104
105
  content: [{ type: 'text', text }],
105
- source: { kind: 'plugin', plugin: 'dsh-agent-plugins-market' }
106
+ source: pluginMarketSource()
106
107
  }));
107
108
  return { kind: 'success', text: this.t('commandAcknowledged', { command: spec.name }) };
108
109
  }
@@ -6,9 +6,12 @@
6
6
  *
7
7
  * The bridge is a cordis function plugin (`inject: ['shell']`) whose config
8
8
  * is a single `configPath` plus `pluginRoot` for `${CLAUDE_PLUGIN_ROOT}`
9
- * substitution — exactly the shape a suite hook file needs. Mounts reconcile
10
- * on every enable/disable/install/uninstall; a missing bridge package, a
11
- * broken hook file, or a mount failure is contained per suite.
9
+ * substitution — exactly the shape a suite hook file needs. `projectDir` is
10
+ * forwarded only for hooks declared by a project layout; user Agent layout
11
+ * hooks carry none, so the bridge defaults `${CLAUDE_PROJECT_DIR}` to the
12
+ * calling session's workspace. Mounts reconcile on every
13
+ * enable/disable/install/uninstall; a missing bridge package, a broken hook
14
+ * file, or a mount failure is contained per suite.
12
15
  */
13
16
  import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
14
17
  import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
@@ -96,7 +99,7 @@ export class HooksMountRegistry {
96
99
  handle = mountCtx.plugin(bridge, {
97
100
  configPath,
98
101
  pluginRoot: suite.root,
99
- ...(suite.hooks === undefined ? {} : { projectDir: suite.hooks.projectRoot })
102
+ ...(suite.hooks?.projectRoot === undefined ? {} : { projectDir: suite.hooks.projectRoot })
100
103
  });
101
104
  await handle.await();
102
105
  this.live.set(key, handle);
@@ -24,13 +24,14 @@ import { MARKET_SETTINGS_DEFAULTS } from '../contracts/settings.js';
24
24
  * belong to {@link MARKET_SETTINGS_DEFAULTS}; this schema states them, it does
25
25
  * not own them.
26
26
  */
27
- export const MarketSettingsSchema = z.object({
27
+ export const MarketSettingsFields = {
28
28
  mcpEnhanced: z.boolean().default(MARKET_SETTINGS_DEFAULTS.mcpEnhanced),
29
29
  scanProjectLayouts: z.boolean().default(MARKET_SETTINGS_DEFAULTS.scanProjectLayouts),
30
30
  downloadRegion: z.union([z.const('auto'), z.const('global'), z.const('china')]).default(MARKET_SETTINGS_DEFAULTS.downloadRegion),
31
31
  feedbackEnabled: z.boolean().default(MARKET_SETTINGS_DEFAULTS.feedbackEnabled),
32
32
  autoUpdateSources: z.boolean().default(MARKET_SETTINGS_DEFAULTS.autoUpdateSources)
33
- });
33
+ };
34
+ export const MarketSettingsSchema = z.object(MarketSettingsFields);
34
35
  /** Path of the legacy persisted settings file under the plugin data root. */
35
36
  export function marketSettingsPath(dataRoot) {
36
37
  return join(dataRoot, 'settings.json');
@@ -6,11 +6,7 @@ import { effectiveSurfaces } from '../model/types.js';
6
6
  export const USER_MCP_SOURCE = '@user-mcp';
7
7
  export const USER_MCP_SUITE = 'user-mcp';
8
8
  /**
9
- * User-created services reuse the owned bridge lifecycle without changing host configuration.
10
- *
11
- * The declarations live in the shared Agent layout root
12
- * (`~/.agents/mcp.json`), so a service the user adds by hand is ordinary
13
- * `mcpServers` JSON in the same place other Agent tools look.
9
+ * Load the user's own MCP declaration file.
14
10
  *
15
11
  * The suite always carries an `mcp` document — an absent or malformed
16
12
  * `mcp.json` leaves an empty one — so callers read `.mcp.servers` directly
@@ -37,9 +33,10 @@ export async function loadUserMcpSuite(agentsRoot) {
37
33
  surfaces: { skills: 0, mcp: Object.keys(mcp.servers).length, commands: 0, agents: 0, hooks: 0, lsp: 0 },
38
34
  dimension: 'user',
39
35
  enabled: true,
40
- // A user-created suite has no install entry and no overrides, so every
41
- // surface keeps its enabled default.
42
- activeSurfaces: effectiveSurfaces(undefined),
36
+ // The suite declares MCP only: the other surfaces would otherwise make the
37
+ // command registry read this root a second time beside the user-command
38
+ // panel registry.
39
+ activeSurfaces: effectiveSurfaces({ skills: false, hooks: false, commands: false, agents: false, lsp: false }),
43
40
  installedAt: 'user',
44
41
  errors
45
42
  };
@@ -49,13 +46,25 @@ export async function loadUserMcpSuite(agentsRoot) {
49
46
  * keys this client does not know ride along untouched rather than failing the
50
47
  * whole file, and the closed-set rule the package schema applies to suites
51
48
  * does not apply here.
49
+ *
50
+ * A hand-written file may omit `$schema` entirely, so the baseline identifier
51
+ * is filled in before validation: the document then reads as the baseline
52
+ * release while every shape and required field the client does know is still
53
+ * validated. A rejected document keeps the file's own declarations out of the
54
+ * mount and reports the reason, the same fail-closed rule the write paths
55
+ * apply.
52
56
  */
53
57
  async function validateUserMcp(agentsRoot, raw) {
54
- const result = await validateMcpJson(agentsRoot, raw, { packageRules: false });
58
+ const document = isRecord(raw) && raw['$schema'] === undefined ? { ...raw, $schema: MCP_SCHEMA_ID } : raw;
59
+ const result = await validateMcpJson(agentsRoot, document, { packageRules: false });
55
60
  if (result.config === undefined || result.errors.length > 0)
56
61
  throw new Error(`invalid MCP configuration: ${result.errors.join('; ')}`);
57
62
  return result.config;
58
63
  }
64
+ /** Whether a parsed user document is the JSON object the MCP validator reads. */
65
+ function isRecord(value) {
66
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
67
+ }
59
68
  /** Add one service atomically; reject collisions and malformed config before writing. */
60
69
  export async function addUserMcpServer(agentsRoot, name, server) {
61
70
  if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(name))
@@ -0,0 +1,4 @@
1
+ /** Build the source for one follow-up message; fresh per message, no shared state. */
2
+ export function pluginMarketSource() {
3
+ return { kind: 'plugin-market', form: 'instructions' };
4
+ }
@@ -125,16 +125,19 @@ function mountProjectSurface(ctx, catalog, service, create, withoutProject = fal
125
125
  mounts.delete(agent);
126
126
  await mount?.fiber.dispose();
127
127
  };
128
- const unwatchCreated = ctx.on('agent/created', ({ agent }) => attach(agent));
129
- const unwatchDisposed = ctx.on('agent/disposed', ({ agent }) => {
130
- void detach(agent).catch(warn);
131
- });
132
- const unwatchStart = ctx.on('agent/session-start', ({ agent }) => {
133
- attach(agent);
128
+ // One lifecycle listener covers every startup path: the host merged the old
129
+ // agent/session-start emit into agent/created (which now carries the
130
+ // startup/resume/clear/compact source) and fires it serially.
131
+ const unwatchCreated = ctx.on('agent/created', ({ agent }) => {
132
+ const projectAgent = agent;
133
+ attach(projectAgent);
134
134
  // The refresh chain can reject — its queue body re-reads the project
135
135
  // catalog — and the host emits this event without awaiting the listener,
136
136
  // so an unhandled rejection here would surface at every session start.
137
- void mounts.get(agent)?.refresh().catch(warn);
137
+ void mounts.get(projectAgent)?.refresh().catch(warn);
138
+ });
139
+ const unwatchDisposed = ctx.on('agent/disposed', ({ agent }) => {
140
+ void detach(agent).catch(warn);
138
141
  });
139
142
  for (const agent of host.agents.list())
140
143
  attach(agent);
@@ -146,7 +149,6 @@ function mountProjectSurface(ctx, catalog, service, create, withoutProject = fal
146
149
  disposed = true;
147
150
  unwatchCreated();
148
151
  unwatchDisposed();
149
- unwatchStart();
150
152
  await Promise.all([...mounts.keys()].map(detach));
151
153
  }
152
154
  };
@@ -1,12 +1,11 @@
1
- import { MARKET_SETTINGS_NAMESPACE, resolveMarketSettings } from '../contracts/settings.js';
1
+ import { resolveMarketSettings } from '../contracts/settings.js';
2
2
  import { FEEDBACK_TOOL_NAME, mountFeedbackTool } from './feedback-tool.js';
3
- import { MarketSettingsSchema, readMcpBackend } from './mcp-backend.js';
4
3
  export class MarketSettingsNamespace {
5
4
  ctx;
5
+ refs;
6
6
  dataRoot;
7
7
  locale;
8
8
  host;
9
- scope;
10
9
  watchers = [];
11
10
  feedbackDisposer;
12
11
  /**
@@ -16,87 +15,66 @@ export class MarketSettingsNamespace {
16
15
  * switch, or skipped because the host has no tools registry to register on.
17
16
  */
18
17
  feedbackMountState;
19
- constructor(ctx, dataRoot, locale, host) {
18
+ constructor(ctx, refs, dataRoot, locale, host) {
20
19
  this.ctx = ctx;
20
+ this.refs = refs;
21
21
  this.dataRoot = dataRoot;
22
22
  this.locale = locale;
23
23
  this.host = host;
24
24
  }
25
- /** Register the namespace; the host resolves it whenever the settings service mounts. */
25
+ /** Subscribe the runtime reactions to live settings updates. */
26
26
  mount() {
27
- this.ctx.inject(['settings'], settingsCtx => this.register(settingsCtx));
27
+ this.syncProjectLayouts();
28
+ this.syncAutoUpdateSources();
29
+ this.syncFeedbackTool();
30
+ let previousBackend = this.settings().mcpEnhanced;
31
+ const watcher = this.ctx.on('loader/volatile-update', () => {
32
+ const backend = this.settings().mcpEnhanced;
33
+ if (backend !== previousBackend) {
34
+ previousBackend = backend;
35
+ this.host.refreshMcpMounts();
36
+ }
37
+ this.syncProjectLayouts();
38
+ this.syncAutoUpdateSources();
39
+ this.syncFeedbackTool();
40
+ });
41
+ this.watchers.push(watcher);
42
+ }
43
+ /** Release every watcher and unmount the feedback tool. */
44
+ dispose() {
45
+ for (const release of this.watchers.splice(0))
46
+ release();
47
+ this.feedbackDisposer?.();
48
+ this.feedbackDisposer = undefined;
49
+ }
50
+ /**
51
+ * The resolved settings, from the volatile references the host updates in place.
52
+ */
53
+ settings() {
54
+ return resolveMarketSettings({
55
+ mcpEnhanced: this.refs.mcpEnhanced.get(),
56
+ scanProjectLayouts: this.refs.scanProjectLayouts.get(),
57
+ downloadRegion: this.refs.downloadRegion.get(),
58
+ feedbackEnabled: this.refs.feedbackEnabled.get(),
59
+ autoUpdateSources: this.refs.autoUpdateSources.get()
60
+ });
28
61
  }
29
- /** The persisted MCP backend choice; the built-in client until registration lands. */
62
+ /** The persisted MCP backend choice; the built-in client until the host applies a value. */
30
63
  async backend() {
31
64
  return this.settings().mcpEnhanced ? 'builtin' : 'host';
32
65
  }
33
66
  /**
34
- * Persist a backend choice.
35
- * @throws when the settings service is not mounted.
67
+ * Legacy write path for the backend choice: the value now lives in the host
68
+ * settings document, edited from the Plugins panel's configuration page, so
69
+ * this accepts the request and lets the volatile reference decide.
36
70
  */
37
71
  async setBackend(backend) {
38
- if (this.scope === undefined)
39
- throw new Error('the settings service is not mounted');
40
- await this.scope.update({ mcpEnhanced: backend !== 'host' });
72
+ this.ctx.logger?.info?.(`[dsh-agent-plugins-market] legacy MCP backend write (${backend}) ignored — the value lives in the host settings document`);
41
73
  }
42
74
  /** The persisted download-region setting. */
43
75
  async downloadRegion() {
44
76
  return this.settings().downloadRegion;
45
77
  }
46
- /**
47
- * The resolved settings, whether or not the namespace is registered yet.
48
- *
49
- * Every reader goes through here so "the field is absent" has exactly one
50
- * answer — {@link resolveMarketSettings} — instead of each caller inventing
51
- * its own fallback for the window before registration lands.
52
- */
53
- settings() {
54
- return resolveMarketSettings(this.scope?.get());
55
- }
56
- /** Release every watcher and unmount the feedback tool. */
57
- dispose() {
58
- for (const release of this.watchers.splice(0))
59
- release();
60
- this.feedbackDisposer?.();
61
- this.feedbackDisposer = undefined;
62
- }
63
- register(settingsCtx) {
64
- try {
65
- this.ctx.logger?.info?.('[dsh-agent-plugins-market] settings inject resolved — registering namespace');
66
- const settings = settingsCtx.settings;
67
- const scope = settings.register(MARKET_SETTINGS_NAMESPACE, MarketSettingsSchema);
68
- this.scope = scope;
69
- this.ctx.logger?.info?.('[dsh-agent-plugins-market] settings namespace registered — plugin-config card will serve');
70
- this.syncProjectLayouts();
71
- this.watchers.push(scope.watch(() => this.syncProjectLayouts()));
72
- this.syncAutoUpdateSources();
73
- this.watchers.push(scope.watch(() => this.syncAutoUpdateSources()));
74
- // One-time migration from the earlier data-root settings.json choice.
75
- void readMcpBackend(this.dataRoot).then(backend => {
76
- if (backend === 'host')
77
- void scope.update({ mcpEnhanced: false }).catch(() => { });
78
- });
79
- let previousBackend = this.settings().mcpEnhanced;
80
- this.watchers.push(scope.watch(() => {
81
- const backend = this.settings().mcpEnhanced;
82
- if (backend === previousBackend)
83
- return;
84
- previousBackend = backend;
85
- this.host.refreshMcpMounts();
86
- }));
87
- // The experience-feedback model tool: gated by the namespace's
88
- // `feedbackEnabled` field (default on); the switch unregisters it. The
89
- // tool mount is doubly contained — its failure must never take the
90
- // settings namespace (and with it the config card) down with it. The
91
- // mount outcome is logged on every transition so an absent
92
- // `report_market_issue` in a session is traceable to its cause.
93
- this.syncFeedbackTool();
94
- this.watchers.push(scope.watch(() => this.syncFeedbackTool()));
95
- }
96
- catch (error) {
97
- this.ctx.logger?.error?.(`[dsh-agent-plugins-market] settings namespace registration failed — the plugin-config card will be hidden this boot: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
98
- }
99
- }
100
78
  /** Apply the background source-update switch. */
101
79
  syncAutoUpdateSources() {
102
80
  try {
@@ -2,12 +2,17 @@
2
2
  * User command mounting: every enabled entry of the user commands panel
3
3
  * registers as a dsh slash command, so user-authored quick replies behave
4
4
  * exactly like suite commands — the body with `$ARGUMENTS` substituted rides
5
- * one follow-up message on the receiving agent. Reconciled on every catalog
6
- * change, keyed `user/<name>`.
5
+ * one follow-up message on the receiving agent. A nested entry registers
6
+ * under its flattened call name (`git/commit` → `git-commit`) while the panel
7
+ * keeps addressing it by path; when two entries flatten to one call name the
8
+ * first registers and the other is reported. Reconciled on every catalog
9
+ * change, keyed by the call name.
7
10
  * @module runtime/user-commands
8
11
  */
9
12
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
10
- import { USER_ENTRY_NAME } from './user-store.js';
13
+ import { commandCallName } from '../model/command-names.js';
14
+ import { USER_ENTRY_PATH } from './user-store.js';
15
+ import { pluginMarketSource } from './plugin-message-source.js';
11
16
  /** Register/unregister user-panel commands to match the panel's enabled entries. */
12
17
  export class UserCommandMountRegistry {
13
18
  ctx;
@@ -25,14 +30,24 @@ export class UserCommandMountRegistry {
25
30
  const diagnostics = [];
26
31
  const entries = await this.store.list();
27
32
  const wanted = new Map();
33
+ // Registration is keyed by call name, so two entries that flatten to the
34
+ // same one cannot silently shadow each other.
35
+ const owners = new Map();
28
36
  for (const entry of entries) {
29
37
  if (entry.disabled)
30
38
  continue;
31
- if (!USER_ENTRY_NAME.test(entry.name))
39
+ if (!USER_ENTRY_PATH.test(entry.name))
32
40
  continue;
33
- wanted.set(entry.name, {
34
- name: entry.name,
35
- description: entry.description === '' ? `[${this.t('userCommandSourceLabel')}] ${entry.name}` : `[${this.t('userCommandSourceLabel')}] ${entry.description}`,
41
+ const callName = commandCallName(entry.name);
42
+ const owner = owners.get(callName);
43
+ if (owner !== undefined) {
44
+ diagnostics.push(`command "${callName}": "${entry.name}" is shadowed by "${owner}" — both flatten to the same call name`);
45
+ continue;
46
+ }
47
+ owners.set(callName, entry.name);
48
+ wanted.set(callName, {
49
+ name: callName,
50
+ description: entry.description === '' ? `[${this.t('userCommandSourceLabel')}] ${callName}` : `[${this.t('userCommandSourceLabel')}] ${entry.description}`,
36
51
  ...(typeof entry.metadata['argument-hint'] === 'string' && entry.metadata['argument-hint'] !== '' ? { hint: entry.metadata['argument-hint'] } : {}),
37
52
  body: entry.content
38
53
  });
@@ -66,7 +81,7 @@ export class UserCommandMountRegistry {
66
81
  const text = spec.body.replaceAll('$ARGUMENTS', invocation.rawInput.trim());
67
82
  agent.followup(createUserMessage({
68
83
  content: [{ type: 'text', text }],
69
- source: { kind: 'plugin', plugin: 'dsh-agent-plugins-market' }
84
+ source: pluginMarketSource()
70
85
  }));
71
86
  return { kind: 'success', text: this.t('commandAcknowledged', { command: spec.name }) };
72
87
  }