dsh-agent-plugins-market 0.6.0 → 0.6.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 (134) hide show
  1. package/README.md +143 -169
  2. package/README.zh.md +136 -170
  3. package/client/client.js +8202 -684
  4. package/client/client.js.map +1 -1
  5. package/lib/application/catalog.js +236 -47
  6. package/lib/application/details.js +19 -10
  7. package/lib/application/panel-resources.js +95 -0
  8. package/lib/application/project-agent-roles.js +39 -0
  9. package/lib/catalog/codex-mcp.js +114 -0
  10. package/lib/catalog/component-files.js +151 -0
  11. package/lib/catalog/manifests.js +105 -44
  12. package/lib/catalog/native-project.js +44 -23
  13. package/lib/catalog/paths.js +6 -6
  14. package/lib/catalog/project-config.js +96 -0
  15. package/lib/catalog/project-hooks.js +108 -0
  16. package/lib/catalog/scan-pipeline.js +6 -1
  17. package/lib/catalog/scan-resolvers.js +70 -26
  18. package/lib/catalog/source-catalog.js +16 -3
  19. package/lib/catalog/suite-components.js +125 -0
  20. package/lib/catalog/surfaces.js +60 -46
  21. package/lib/catalog/validate.js +16 -11
  22. package/lib/contracts/market.js +16 -1
  23. package/lib/index.js +187 -35
  24. package/lib/model/layouts.js +49 -0
  25. package/lib/routes.js +106 -6
  26. package/lib/runtime/agent-role-router.js +226 -0
  27. package/lib/runtime/commands-mounts.js +27 -67
  28. package/lib/runtime/feedback-tool.js +181 -0
  29. package/lib/runtime/hooks-mounts.js +66 -13
  30. package/lib/runtime/host-locale.js +23 -7
  31. package/lib/runtime/legacy-root-migration.js +12 -0
  32. package/lib/runtime/lsp-direct-config.js +6 -3
  33. package/lib/runtime/lsp-mounts.js +38 -24
  34. package/lib/runtime/lsp-server-state.js +19 -0
  35. package/lib/runtime/lsp-status.js +3 -2
  36. package/lib/runtime/mcp-backend.js +6 -2
  37. package/lib/runtime/mcp-client/config.js +6 -0
  38. package/lib/runtime/mcp-client/connection.js +8 -2
  39. package/lib/runtime/mcp-client/tools.js +6 -3
  40. package/lib/runtime/mcp-config.js +26 -13
  41. package/lib/runtime/mcp-direct-config.js +56 -0
  42. package/lib/runtime/mcp-mounts.js +25 -9
  43. package/lib/runtime/mcp-overrides.js +11 -5
  44. package/lib/runtime/mcp-status.js +10 -7
  45. package/lib/runtime/model-catalog.js +48 -0
  46. package/lib/runtime/project-runtime.js +136 -0
  47. package/lib/runtime/reconciler.js +38 -28
  48. package/lib/runtime/server-config.js +75 -0
  49. package/lib/runtime/skills-provider.js +31 -77
  50. package/lib/runtime/storage-migration.js +169 -0
  51. package/lib/runtime/subagent-catalog.js +129 -0
  52. package/lib/runtime/user-commands.js +86 -0
  53. package/lib/runtime/user-panels.js +176 -0
  54. package/lib/runtime/user-store.js +142 -0
  55. package/lib/types/application/catalog.d.ts +39 -9
  56. package/lib/types/application/panel-resources.d.ts +13 -0
  57. package/lib/types/application/project-agent-roles.d.ts +3 -0
  58. package/lib/types/application/queries.d.ts +17 -1
  59. package/lib/types/catalog/codex-mcp.d.ts +4 -0
  60. package/lib/types/catalog/component-files.d.ts +17 -0
  61. package/lib/types/catalog/manifests.d.ts +14 -9
  62. package/lib/types/catalog/native-project.d.ts +2 -5
  63. package/lib/types/catalog/paths.d.ts +4 -4
  64. package/lib/types/catalog/project-config.d.ts +6 -0
  65. package/lib/types/catalog/project-hooks.d.ts +10 -0
  66. package/lib/types/catalog/scan-pipeline.d.ts +2 -0
  67. package/lib/types/catalog/scan-resolvers.d.ts +4 -3
  68. package/lib/types/catalog/source-catalog.d.ts +1 -1
  69. package/lib/types/catalog/suite-components.d.ts +5 -0
  70. package/lib/types/catalog/surfaces.d.ts +2 -2
  71. package/lib/types/catalog/validate.d.ts +2 -0
  72. package/lib/types/contracts/market.d.ts +60 -0
  73. package/lib/types/contracts/mcp-status.d.ts +5 -1
  74. package/lib/types/index.d.ts +2 -2
  75. package/lib/types/model/layouts.d.ts +102 -0
  76. package/lib/types/model/types.d.ts +51 -6
  77. package/lib/types/routes.d.ts +6 -1
  78. package/lib/types/runtime/agent-role-router.d.ts +95 -0
  79. package/lib/types/runtime/commands-mounts.d.ts +16 -7
  80. package/lib/types/runtime/feedback-tool.d.ts +49 -0
  81. package/lib/types/runtime/hooks-mounts.d.ts +4 -0
  82. package/lib/types/runtime/host-locale.d.ts +1 -1
  83. package/lib/types/runtime/legacy-root-migration.d.ts +2 -0
  84. package/lib/types/runtime/lsp-mounts.d.ts +4 -0
  85. package/lib/types/runtime/lsp-server-state.d.ts +3 -0
  86. package/lib/types/runtime/lsp-status.d.ts +1 -0
  87. package/lib/types/runtime/mcp-backend.d.ts +9 -1
  88. package/lib/types/runtime/mcp-client/config.d.ts +4 -3
  89. package/lib/types/runtime/mcp-client/tools.d.ts +3 -0
  90. package/lib/types/runtime/mcp-config.d.ts +11 -5
  91. package/lib/types/runtime/mcp-direct-config.d.ts +7 -0
  92. package/lib/types/runtime/mcp-mounts.d.ts +2 -1
  93. package/lib/types/runtime/mcp-overrides.d.ts +2 -0
  94. package/lib/types/runtime/mcp-status.d.ts +1 -1
  95. package/lib/types/runtime/model-catalog.d.ts +7 -0
  96. package/lib/types/runtime/project-runtime.d.ts +32 -0
  97. package/lib/types/runtime/reconciler.d.ts +2 -0
  98. package/lib/types/runtime/server-config.d.ts +10 -0
  99. package/lib/types/runtime/skills-provider.d.ts +1 -5
  100. package/lib/types/runtime/storage-migration.d.ts +15 -0
  101. package/lib/types/runtime/subagent-catalog.d.ts +52 -0
  102. package/lib/types/runtime/user-commands.d.ts +24 -0
  103. package/lib/types/runtime/user-panels.d.ts +91 -0
  104. package/lib/types/runtime/user-store.d.ts +51 -0
  105. package/package.json +10 -3
  106. package/schemas/README.md +69 -7
  107. package/schemas/agent-plugins/spec.md +48 -0
  108. package/schemas/claude-code/marketplace.schema.json +143 -0
  109. package/schemas/claude-code/plugin.schema.json +105 -0
  110. package/schemas/claude-code/spec.md +108 -0
  111. package/schemas/codex/marketplace.schema.json +78 -0
  112. package/schemas/codex/plugin.schema.json +77 -0
  113. package/schemas/codex/spec.md +91 -0
  114. package/schemas/cursor/marketplace.schema.json +82 -0
  115. package/schemas/cursor/plugin.schema.json +58 -0
  116. package/schemas/cursor/spec.md +92 -0
  117. package/schemas/github-copilot/marketplace.schema.json +107 -0
  118. package/schemas/github-copilot/plugin.schema.json +89 -0
  119. package/schemas/github-copilot/spec.md +105 -0
  120. package/schemas/kimi/marketplace.schema.json +38 -0
  121. package/schemas/kimi/plugin.schema.json +84 -0
  122. package/schemas/kimi/spec.md +94 -0
  123. package/schemas/qoder/marketplace.schema.json +115 -0
  124. package/schemas/qoder/plugin.schema.json +127 -0
  125. package/schemas/qoder/spec.md +111 -0
  126. package/schemas/skill-collection/spec.md +29 -0
  127. package/schemas/universal/marketplace.schema.json +98 -0
  128. package/schemas/universal/plugin.schema.json +49 -0
  129. package/schemas/universal/spec.md +53 -0
  130. package/schemas/zcode/marketplace.schema.json +161 -0
  131. package/schemas/zcode/plugin.schema.json +89 -0
  132. package/schemas/zcode/spec.md +102 -0
  133. package/lib/catalog/legacy-root-migration.js +0 -51
  134. package/lib/types/catalog/legacy-root-migration.d.ts +0 -14
@@ -0,0 +1,75 @@
1
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { MCP_SCHEMA_ID, validateAgainstSchema, validateMcpJson } from '../catalog/validate.js';
4
+ import { parseLspServers } from '../catalog/lsp-spec.js';
5
+ import { qualifiedSuiteId } from '../catalog/paths.js';
6
+ import { redactMcpConfig } from './mcp-redaction.js';
7
+ /** Restore only unchanged redacted leaves, rejecting invented masked values. */
8
+ export function restoreRedactedConfig(input, original, redacted = redactMcpConfig(original)) {
9
+ if (typeof input === 'string' && input.includes('[redacted]')) {
10
+ if (input !== redacted)
11
+ throw new Error('masked values must remain unchanged or be replaced');
12
+ return original;
13
+ }
14
+ if (Array.isArray(input))
15
+ return input.map((value, index) => restoreRedactedConfig(value, Array.isArray(original) ? original[index] : undefined, Array.isArray(redacted) ? redacted[index] : undefined));
16
+ if (typeof input === 'object' && input !== null) {
17
+ const source = typeof original === 'object' && original !== null ? original : {};
18
+ const safe = typeof redacted === 'object' && redacted !== null ? redacted : {};
19
+ return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, restoreRedactedConfig(value, source[key], safe[key])]));
20
+ }
21
+ return input;
22
+ }
23
+ export async function validateServerMcp(root, key, config) {
24
+ const document = { $schema: MCP_SCHEMA_ID, mcpServers: { [key]: config } };
25
+ const errors = await validateAgainstSchema(MCP_SCHEMA_ID, document);
26
+ if (errors.length > 0)
27
+ throw new Error(`invalid MCP configuration: ${errors.join('; ')}`);
28
+ const result = await validateMcpJson(root, document);
29
+ const server = result.config?.servers[key];
30
+ if (server === undefined || result.errors.length > 0)
31
+ throw new Error(`invalid MCP configuration: ${result.errors.join('; ')}`);
32
+ return server;
33
+ }
34
+ export function validateServerLsp(key, config) {
35
+ const errors = [];
36
+ const server = parseLspServers({ [key]: config }, errors)[key];
37
+ if (server === undefined || errors.length > 0)
38
+ throw new Error(`invalid LSP configuration: ${errors.join('; ')}`);
39
+ return server;
40
+ }
41
+ /** Public configuration excludes the parser's derived identity. */
42
+ export function lspConfig(spec) {
43
+ return Object.fromEntries(Object.entries(spec).filter(([key]) => key !== 'key'));
44
+ }
45
+ export async function loadLspOverrides(root) {
46
+ try {
47
+ const raw = JSON.parse(await readFile(join(root, 'lsp-overrides.json'), 'utf8'));
48
+ return Object.fromEntries(Object.entries(raw).map(([id, config]) => [id, validateServerLsp(id.slice(id.lastIndexOf('/') + 1), config)]));
49
+ }
50
+ catch (error) {
51
+ if (error.code === 'ENOENT')
52
+ return {};
53
+ throw error;
54
+ }
55
+ }
56
+ export async function saveLspOverride(root, id, config) {
57
+ const overrides = await loadLspOverrides(root);
58
+ overrides[id] = config;
59
+ await mkdir(root, { recursive: true });
60
+ const path = join(root, 'lsp-overrides.json');
61
+ await writeFile(`${path}.tmp`, JSON.stringify(Object.fromEntries(Object.entries(overrides).map(([key, value]) => [key, lspConfig(value)])), null, 2), { mode: 0o600 });
62
+ await rename(`${path}.tmp`, path);
63
+ }
64
+ export async function applyLspOverrides(root, suites) {
65
+ const overrides = await loadLspOverrides(root);
66
+ return suites.map(suite => suite.lsp === undefined
67
+ ? suite
68
+ : {
69
+ ...suite,
70
+ lsp: {
71
+ ...suite.lsp,
72
+ servers: Object.fromEntries(Object.entries(suite.lsp.servers).map(([key, spec]) => [key, overrides[`${qualifiedSuiteId(suite.sourceId, suite.id)}/${key}`] ?? spec]))
73
+ }
74
+ });
75
+ }
@@ -12,21 +12,19 @@
12
12
  * resource base points at the skill directory, so CC-authored skills work
13
13
  * verbatim under the harness.
14
14
  */
15
- import { readFile, readdir, stat } from 'node:fs/promises';
16
- import { join } from 'node:path';
15
+ import { readFile } from 'node:fs/promises';
17
16
  import { parseSkillFrontmatter, stripFrontmatter } from '../catalog/skills-parse.js';
18
- import { bindHostLocale } from './host-locale.js';
17
+ import { parseFrontmatterRecord } from './user-store.js';
18
+ import { PLUGIN_ROOT_VARIABLES } from '../model/layouts.js';
19
19
  export const SUITE_PROJECT_SOURCE = 'agent-plugin-project';
20
20
  export const SUITE_USER_SOURCE = 'agent-plugin-user';
21
21
  const PROJECT_RANK = 250;
22
22
  const USER_RANK = 450;
23
23
  export class SuiteSkillProvider {
24
24
  manager;
25
- t;
26
25
  name = 'agent-plugin';
27
- constructor(manager, t = bindHostLocale(undefined)) {
26
+ constructor(manager) {
28
27
  this.manager = manager;
29
- this.t = t;
30
28
  }
31
29
  async list(options) {
32
30
  const located = await this.locate(options.cwd);
@@ -44,85 +42,28 @@ export class SuiteSkillProvider {
44
42
  }
45
43
  return unique.map(entry => this.candidateFor(entry));
46
44
  }
47
- /** Parse `agents/*.md` of one suite into agent-definition candidates. */
48
- async agentsOf(suite) {
49
- if (suite.activeSurfaces?.agents === false)
50
- return [];
51
- const dir = join(suite.root, 'agents');
52
- let entries;
53
- try {
54
- entries = await readdir(dir);
55
- }
56
- catch {
57
- return [];
58
- }
59
- const located = [];
60
- for (const entry of entries) {
61
- if (!entry.endsWith('.md'))
62
- continue;
63
- const file = join(dir, entry);
64
- try {
65
- const info = await stat(file);
66
- if (!info.isFile())
67
- continue;
68
- }
69
- catch {
70
- continue;
71
- }
72
- const agentName = entry.slice(0, -3);
73
- let text;
74
- try {
75
- text = await readFile(file, 'utf8');
76
- }
77
- catch {
78
- continue;
79
- }
80
- const parsed = parseSkillFrontmatter(text, agentName);
81
- const description = typeof parsed === 'string' ? (suite.manifest.description ?? agentName) : parsed.description;
82
- const skill = {
83
- name: `agent-${agentName}`,
84
- directory: dir,
85
- file,
86
- description,
87
- ...(typeof parsed !== 'string' && parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {}),
88
- invocation: typeof parsed === 'string' ? { modelInvocable: true, userInvocable: false } : parsed.invocation
89
- };
90
- located.push({
91
- rank: suite.dimension === 'project' ? PROJECT_RANK : USER_RANK,
92
- source: suite.dimension === 'project' ? SUITE_PROJECT_SOURCE : SUITE_USER_SOURCE,
93
- suite,
94
- skill
95
- });
96
- }
97
- return located;
98
- }
99
45
  async get(candidate, _options) {
100
46
  const locator = candidate.locator;
101
47
  let text;
102
48
  try {
103
- text = await readFile(locator.file, 'utf8');
49
+ text = locator.content ?? (await readFile(locator.file, 'utf8'));
104
50
  }
105
51
  catch {
106
52
  return undefined;
107
53
  }
108
- if (locator.kind === 'agent') {
109
- const suiteName = locator.suiteRoot.split(/[\\/]/).at(-1) ?? 'suite';
110
- const content = [this.t('agentDefinitionTitle', { suite: suiteName }), '', this.t('agentDefinitionIntro'), '', '```markdown', text, '```'].join('\n');
111
- return {
112
- name: candidate.name,
113
- description: candidate.description,
114
- invocation: candidate.invocation,
115
- source: candidate.source,
116
- provider: this.name,
117
- resourceBase: { kind: 'directory', path: locator.directory },
118
- path: locator.file,
119
- content
120
- };
54
+ try {
55
+ if (parseFrontmatterRecord(text).disabled === true)
56
+ return undefined;
57
+ }
58
+ catch {
59
+ return undefined;
121
60
  }
122
61
  const parsed = parseSkillFrontmatter(text, candidate.name);
123
62
  if (typeof parsed === 'string')
124
63
  return undefined;
125
- const content = stripFrontmatter(text).replaceAll('${CLAUDE_PLUGIN_ROOT}', locator.suiteRoot);
64
+ const content = [stripFrontmatter(text), ...(locator.skillInstructions === undefined ? [] : [locator.skillInstructions])]
65
+ .join('\n\n')
66
+ .replace(/\$\{([A-Z_]+)\}/g, (match, name) => (PLUGIN_ROOT_VARIABLES.has(name) ? locator.suiteRoot : match));
126
67
  return {
127
68
  name: parsed.name,
128
69
  description: candidate.description,
@@ -145,10 +86,11 @@ export class SuiteSkillProvider {
145
86
  provider: this.name,
146
87
  rank: entry.rank,
147
88
  locator: {
89
+ ...(entry.content === undefined ? {} : { content: entry.content }),
90
+ ...(entry.suite.manifest.skillInstructions === undefined ? {} : { skillInstructions: entry.suite.manifest.skillInstructions }),
148
91
  file: entry.skill.file,
149
92
  directory: entry.skill.directory,
150
- suiteRoot: entry.suite.root,
151
- kind: entry.skill.name.startsWith('agent-') ? 'agent' : 'skill'
93
+ suiteRoot: entry.suite.root
152
94
  },
153
95
  path: entry.skill.file,
154
96
  resourceBase: { kind: 'directory', path: entry.skill.directory }
@@ -159,9 +101,15 @@ export class SuiteSkillProvider {
159
101
  const userSuites = await this.manager.enabledUserSuites();
160
102
  for (const suite of userSuites) {
161
103
  for (const skill of suite.activeSurfaces?.skills === false ? [] : suite.skills) {
104
+ try {
105
+ if (parseFrontmatterRecord(await readFile(skill.file, 'utf8')).disabled === true)
106
+ continue;
107
+ }
108
+ catch {
109
+ continue;
110
+ }
162
111
  located.push({ rank: USER_RANK, source: SUITE_USER_SOURCE, suite, skill });
163
112
  }
164
- located.push(...(await this.agentsOf(suite)));
165
113
  }
166
114
  if (cwd !== undefined) {
167
115
  located.push(...(await this.locateProject(cwd)));
@@ -173,9 +121,15 @@ export class SuiteSkillProvider {
173
121
  const located = [];
174
122
  for (const suite of snapshot.enabledSuites) {
175
123
  for (const skill of suite.activeSurfaces?.skills === false ? [] : suite.skills) {
124
+ try {
125
+ if (parseFrontmatterRecord(await readFile(skill.file, 'utf8')).disabled === true)
126
+ continue;
127
+ }
128
+ catch {
129
+ continue;
130
+ }
176
131
  located.push({ rank: PROJECT_RANK, source: SUITE_PROJECT_SOURCE, suite, skill });
177
132
  }
178
- located.push(...(await this.agentsOf(suite)));
179
133
  }
180
134
  return located;
181
135
  }
@@ -0,0 +1,169 @@
1
+ /** Consolidate plugin-owned storage before stores, routes, or providers are exposed. */
2
+ import { constants } from 'node:fs';
3
+ import { copyFile, lstat, mkdir, readdir, readFile, rename, rmdir, unlink, writeFile } from 'node:fs/promises';
4
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
5
+ import { expandHome, resolveDataRoot, resolveDshHome, resolveUserRoot } from '../catalog/paths.js';
6
+ async function info(path) {
7
+ try {
8
+ return await lstat(path);
9
+ }
10
+ catch (error) {
11
+ if (error.code === 'ENOENT')
12
+ return undefined;
13
+ throw error;
14
+ }
15
+ }
16
+ /** Move only successfully copied entries; never traverse links or overwrite a destination. */
17
+ export async function mergeStorageTree(from, to, result) {
18
+ if (resolve(from) === resolve(to))
19
+ return;
20
+ const source = await info(from);
21
+ if (source === undefined)
22
+ return;
23
+ const destination = await info(to);
24
+ if (source.isSymbolicLink() || destination?.isSymbolicLink() === true) {
25
+ result.conflicts.push(from);
26
+ return;
27
+ }
28
+ if (source.isDirectory()) {
29
+ if (destination !== undefined && !destination.isDirectory()) {
30
+ result.conflicts.push(from);
31
+ return;
32
+ }
33
+ await mkdir(to, { recursive: true });
34
+ for (const name of await readdir(from))
35
+ await mergeStorageTree(join(from, name), join(to, name), result);
36
+ if ((await readdir(from)).length === 0)
37
+ await rmdir(from);
38
+ return;
39
+ }
40
+ if (!source.isFile() || (destination !== undefined && !destination.isFile())) {
41
+ result.conflicts.push(from);
42
+ return;
43
+ }
44
+ if (destination !== undefined) {
45
+ if (!(await readFile(from)).equals(await readFile(to))) {
46
+ result.conflicts.push(from);
47
+ return;
48
+ }
49
+ }
50
+ else {
51
+ await mkdir(dirname(to), { recursive: true });
52
+ // COPYFILE_EXCL also protects against a destination created during migration.
53
+ await copyFile(from, to, constants.COPYFILE_EXCL);
54
+ }
55
+ await unlink(from);
56
+ }
57
+ function contains(parent, child) {
58
+ const path = relative(parent, child);
59
+ return path === '' || (!path.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) && path !== '..' && !isAbsolute(path));
60
+ }
61
+ /**
62
+ * Migrate former root overrides and data/user panels into the canonical root.
63
+ * Unknown files in override directories remain untouched. Conflicts remain at
64
+ * the old path and are returned to block activation; reruns are safe.
65
+ */
66
+ export async function migratePluginStorage(config = {}) {
67
+ const userRoot = resolveUserRoot();
68
+ const dataRoot = resolveDataRoot();
69
+ const result = { conflicts: [] };
70
+ for (const path of [resolveDshHome(), userRoot, dataRoot, join(userRoot, 'user'), join(userRoot, '.sources')]) {
71
+ if ((await info(path))?.isSymbolicLink() === true)
72
+ throw new Error(`Plugin storage cannot use a symbolic-link directory: ${path}`);
73
+ }
74
+ const legacyUserRoot = resolve(expandHome(config.userRoot ?? userRoot));
75
+ const legacyDataRoot = resolve(expandHome(config.dataRoot ?? join(legacyUserRoot, 'data')));
76
+ for (const path of new Set([legacyUserRoot, legacyDataRoot, join(resolveDshHome(), 'agent-plugins-data')])) {
77
+ if ((await info(path))?.isSymbolicLink() === true)
78
+ throw new Error(`Plugin storage migration cannot traverse a symbolic-link root: ${path}`);
79
+ }
80
+ if (legacyUserRoot !== userRoot) {
81
+ if (contains(legacyUserRoot, userRoot) || contains(userRoot, legacyUserRoot))
82
+ throw new Error('Legacy userRoot overlaps canonical plugin storage');
83
+ const legacySources = join(legacyUserRoot, '.sources');
84
+ const sourceInfo = await info(legacySources);
85
+ const sourceNames = sourceInfo?.isDirectory() === true ? await readdir(legacySources) : [];
86
+ const rootConflicts = [];
87
+ if (sourceInfo !== undefined && !sourceInfo.isDirectory())
88
+ rootConflicts.push(legacySources);
89
+ for (const name of sourceNames) {
90
+ if ((await info(join(userRoot, '.sources', name))) !== undefined)
91
+ rootConflicts.push(join(legacySources, name));
92
+ await collectUnsupportedEntries(join(legacySources, name), rootConflicts);
93
+ }
94
+ const legacyState = join(legacyUserRoot, 'state.json');
95
+ const currentState = join(userRoot, 'state.json');
96
+ const previous = await info(legacyState);
97
+ const current = await info(currentState);
98
+ // Validate before relocating any checkouts: malformed state must remain
99
+ // beside its original sources so recovery does not require finding them.
100
+ if (previous?.isFile() === true)
101
+ JSON.parse(await readFile(legacyState, 'utf8'));
102
+ if (previous !== undefined && (!previous.isFile() || (current !== undefined && (!current.isFile() || !(await readFile(legacyState)).equals(await readFile(currentState)))))) {
103
+ rootConflicts.push(legacyState);
104
+ }
105
+ if (rootConflicts.length > 0) {
106
+ // The install state and its checkouts are one ownership unit. Moving only
107
+ // one half would orphan installs or make the retained state unusable.
108
+ result.conflicts.push(...rootConflicts);
109
+ }
110
+ else {
111
+ if (sourceInfo?.isDirectory() === true) {
112
+ for (const name of sourceNames)
113
+ await mergeStorageTree(join(legacySources, name), join(userRoot, '.sources', name), result);
114
+ if ((await readdir(legacySources)).length === 0)
115
+ await rmdir(legacySources);
116
+ }
117
+ await mergeStorageTree(legacyState, currentState, result);
118
+ await relocateManagedSourceUrls(userRoot, legacyUserRoot);
119
+ }
120
+ for (const entry of ['data', 'user'])
121
+ await mergeStorageTree(join(legacyUserRoot, entry), join(userRoot, entry), result);
122
+ }
123
+ for (const legacy of new Set([legacyDataRoot, join(resolveDshHome(), 'agent-plugins-data')])) {
124
+ if (legacy === dataRoot)
125
+ continue;
126
+ if (contains(legacy, dataRoot) || contains(dataRoot, legacy))
127
+ throw new Error('Legacy dataRoot overlaps canonical plugin data storage');
128
+ for (const entry of ['data', 'overrides', 'user', 'settings.json', 'lsp-servers.json', 'feedback']) {
129
+ await mergeStorageTree(join(legacy, entry), join(dataRoot, entry), result);
130
+ }
131
+ }
132
+ await mergeStorageTree(join(dataRoot, 'user'), join(userRoot, 'user'), result);
133
+ return result;
134
+ }
135
+ /** Retain all state fields while rebasing adopted checkouts moved with a root. */
136
+ async function relocateManagedSourceUrls(userRoot, legacyUserRoot) {
137
+ const path = join(userRoot, 'state.json');
138
+ if ((await info(path)) === undefined)
139
+ return;
140
+ const state = JSON.parse(await readFile(path, 'utf8'));
141
+ if (typeof state !== 'object' || state === null || !('sources' in state) || !Array.isArray(state.sources))
142
+ return;
143
+ let changed = false;
144
+ for (const source of state.sources) {
145
+ if (typeof source !== 'object' || source === null || !('local' in source) || source.local !== true || !('url' in source) || typeof source.url !== 'string')
146
+ continue;
147
+ const oldCheckout = join(legacyUserRoot, '.sources');
148
+ if (!contains(oldCheckout, resolve(expandHome(source.url))))
149
+ continue;
150
+ source.url = join(userRoot, '.sources', relative(oldCheckout, resolve(expandHome(source.url))));
151
+ changed = true;
152
+ }
153
+ if (changed) {
154
+ const temp = `${path}.migration-${process.pid}.tmp`;
155
+ await writeFile(temp, `${JSON.stringify(state, null, 2)}\n`, { flag: 'wx' });
156
+ await rename(temp, path);
157
+ }
158
+ }
159
+ async function collectUnsupportedEntries(path, conflicts) {
160
+ const entry = await info(path);
161
+ if (entry === undefined)
162
+ return;
163
+ if (entry.isDirectory()) {
164
+ for (const name of await readdir(path))
165
+ await collectUnsupportedEntries(join(path, name), conflicts);
166
+ }
167
+ else if (!entry.isFile())
168
+ conflicts.push(path);
169
+ }
@@ -0,0 +1,129 @@
1
+ /** Durable role catalogs follow DSH tool-skill's pre-step publication and session-surface recovery. */
2
+ import { createHash } from 'node:crypto';
3
+ import { createUserMessage } from '@deepseek-ai/dsh-llm';
4
+ /** Register after the exact tool definition; teardown removes guidance before execution. */
5
+ export function mountSubagentCatalog(ctx, tool, snapshot, t) {
6
+ const host = ctx;
7
+ let disposed = false;
8
+ const dispose = host.on('agent/pre-step', async ({ agent, signal }, next) => {
9
+ const decision = await next();
10
+ if (decision.kind === 'reject' || disposed)
11
+ return decision;
12
+ signal.throwIfAborted();
13
+ let entries;
14
+ try {
15
+ entries = host.tools.get(tool.name, agent) === tool ? await snapshot(agent, signal) : [];
16
+ }
17
+ catch (error) {
18
+ signal.throwIfAborted();
19
+ host.logger?.warn(`subagent catalog snapshot incomplete: ${String(error)}`);
20
+ return decision;
21
+ }
22
+ signal.throwIfAborted();
23
+ if (disposed)
24
+ return decision;
25
+ if (host.tools.get(tool.name, agent) !== tool)
26
+ entries = [];
27
+ const digest = digestEntries(entries);
28
+ const history = catalogHistory(agent);
29
+ const existing = catalogMessage(decision.messages);
30
+ if (history.visibleDigest === digest) {
31
+ return existing === undefined ? decision : { ...decision, messages: decision.messages.filter(message => message.id !== existing.message.id) };
32
+ }
33
+ if (existing !== undefined && digestEntries(existing.entries) === digest)
34
+ return decision;
35
+ if (!history.published && entries.length === 0) {
36
+ return existing === undefined ? decision : { ...decision, messages: decision.messages.filter(message => message.id !== existing.message.id) };
37
+ }
38
+ const message = renderCatalog(entries, history.published, t);
39
+ return {
40
+ ...decision,
41
+ messages: existing === undefined ? [...decision.messages, message] : decision.messages.map(item => (item.id === existing.message.id ? message : item))
42
+ };
43
+ });
44
+ return () => {
45
+ disposed = true;
46
+ dispose();
47
+ };
48
+ }
49
+ function escapeText(value) {
50
+ return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&apos;');
51
+ }
52
+ function renderCatalog(entries, update, t) {
53
+ const lines = entries.map(entry => {
54
+ const route = [entry.provider, entry.model].filter(value => value !== undefined).join('/') || t('subagentCatalogInherit');
55
+ return `<subagent id="${escapeText(entry.name)}" name="${escapeText(entry.title)}" model="${escapeText(route)}" reasoning_effort="${escapeText(entry.reasoningEffort ?? t('subagentCatalogDefaultEffort'))}">${escapeText(entry.description)}</subagent>`;
56
+ });
57
+ return createUserMessage({
58
+ content: [
59
+ {
60
+ type: 'text',
61
+ text: [
62
+ '<system-reminder>',
63
+ t(update ? 'subagentCatalogUpdated' : 'subagentCatalogIntro'),
64
+ '<available_subagents>',
65
+ ...lines,
66
+ '</available_subagents>',
67
+ t(entries.length === 0 ? 'subagentCatalogEmpty' : 'subagentCatalogCall'),
68
+ '</system-reminder>'
69
+ ].join('\n')
70
+ }
71
+ ],
72
+ source: { kind: 'subagent-catalog', form: 'catalog', ...(update ? { update: true } : {}), entries }
73
+ });
74
+ }
75
+ function digestEntries(entries) {
76
+ return createHash('sha256')
77
+ .update(entries.map(entry => JSON.stringify([entry.name, entry.title, entry.description, entry.provider, entry.model, entry.reasoningEffort])).join('\n'))
78
+ .digest('hex');
79
+ }
80
+ /** Resumed and forked seeds may carry unvalidated source fields; ignore unreadable catalogs. */
81
+ function readEntries(source) {
82
+ if (typeof source !== 'object' || source === null || !('kind' in source) || source.kind !== 'subagent-catalog' || !('entries' in source) || !Array.isArray(source.entries))
83
+ return undefined;
84
+ const entries = [];
85
+ for (const raw of source.entries) {
86
+ if (typeof raw !== 'object' || raw === null)
87
+ return undefined;
88
+ const entry = raw;
89
+ if (typeof entry.name !== 'string' || entry.name === '' || typeof entry.title !== 'string' || typeof entry.description !== 'string')
90
+ return undefined;
91
+ if (['provider', 'model', 'reasoningEffort'].some(key => entry[key] !== undefined && (typeof entry[key] !== 'string' || entry[key] === '')))
92
+ return undefined;
93
+ entries.push({
94
+ name: entry.name,
95
+ title: entry.title,
96
+ description: entry.description,
97
+ ...(entry.provider === undefined ? {} : { provider: entry.provider }),
98
+ ...(entry.model === undefined ? {} : { model: entry.model }),
99
+ ...(entry.reasoningEffort === undefined ? {} : { reasoningEffort: entry.reasoningEffort })
100
+ });
101
+ }
102
+ return entries;
103
+ }
104
+ function catalogHistory(agent) {
105
+ const visible = new Set(agent.session.surface.nodes);
106
+ let published = false;
107
+ for (let index = agent.session.seq - 1; index >= 0; index--) {
108
+ const event = agent.session.eventAt(index);
109
+ if (event === undefined)
110
+ throw new Error(`subagent catalog cannot read seq ${index} below the current Session length`);
111
+ if (event.type !== 'user/message')
112
+ continue;
113
+ const entries = readEntries(event.data.source);
114
+ if (entries === undefined)
115
+ continue;
116
+ published = true;
117
+ if (visible.has(event.seq))
118
+ return { visibleDigest: digestEntries(entries), published };
119
+ }
120
+ return { published };
121
+ }
122
+ function catalogMessage(messages) {
123
+ for (const message of messages) {
124
+ const entries = readEntries(message.source);
125
+ if (entries !== undefined)
126
+ return { message, entries };
127
+ }
128
+ return undefined;
129
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * User command mounting: every enabled entry of the user commands panel
3
+ * registers as a dsh slash command, so user-authored quick replies behave
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>`.
7
+ * @module runtime/user-commands
8
+ */
9
+ import { USER_ENTRY_NAME } from './user-store.js';
10
+ /** Register/unregister user-panel commands to match the panel's enabled entries. */
11
+ export class UserCommandMountRegistry {
12
+ ctx;
13
+ store;
14
+ t;
15
+ fingerprints = new Map();
16
+ live = new Map();
17
+ constructor(ctx, store, t) {
18
+ this.ctx = ctx;
19
+ this.store = store;
20
+ this.t = t;
21
+ }
22
+ /** Sync the live registrations with the panel's enabled entries. */
23
+ async reconcile() {
24
+ const diagnostics = [];
25
+ const entries = await this.store.list();
26
+ const wanted = new Map();
27
+ for (const entry of entries) {
28
+ if (entry.disabled)
29
+ continue;
30
+ if (!USER_ENTRY_NAME.test(entry.name))
31
+ continue;
32
+ wanted.set(entry.name, {
33
+ name: entry.name,
34
+ description: entry.description === '' ? `[${this.t('userCommandSourceLabel')}] ${entry.name}` : `[${this.t('userCommandSourceLabel')}] ${entry.description}`,
35
+ ...(typeof entry.metadata['argument-hint'] === 'string' && entry.metadata['argument-hint'] !== '' ? { hint: entry.metadata['argument-hint'] } : {}),
36
+ body: entry.content
37
+ });
38
+ }
39
+ for (const [key, disposer] of [...this.live]) {
40
+ if (!wanted.has(key)) {
41
+ disposer();
42
+ this.live.delete(key);
43
+ }
44
+ }
45
+ const host = this.ctx;
46
+ if (typeof host.commands?.register !== 'function') {
47
+ if (wanted.size > 0)
48
+ diagnostics.push('ctx.commands is not available in this profile; user commands stay unregistered');
49
+ return diagnostics;
50
+ }
51
+ for (const [key, spec] of wanted) {
52
+ const fingerprint = JSON.stringify(spec);
53
+ if (this.live.has(key) && this.fingerprints.get(key) === fingerprint)
54
+ continue;
55
+ this.live.get(key)?.();
56
+ this.live.delete(key);
57
+ this.fingerprints.delete(key);
58
+ try {
59
+ const disposer = host.commands.register({
60
+ name: spec.name,
61
+ description: spec.description,
62
+ ...(spec.hint === undefined ? {} : { input: { hint: spec.hint } }),
63
+ handler: invocation => {
64
+ const agent = invocation.agent;
65
+ const text = [this.t('userCommandForwardTitle', { command: spec.name }), '', spec.body.replaceAll('$ARGUMENTS', invocation.rawInput.trim())].join('\n');
66
+ agent.followup({ content: [{ type: 'text', text }], source: { kind: 'plugin', plugin: 'dsh-agent-plugins-market' } });
67
+ return { kind: 'success', text: this.t('userCommandAcknowledged', { command: spec.name }) };
68
+ }
69
+ });
70
+ this.live.set(key, disposer);
71
+ this.fingerprints.set(key, fingerprint);
72
+ }
73
+ catch (error) {
74
+ diagnostics.push(`command "${spec.name}": ${error instanceof Error ? error.message : String(error)}`);
75
+ }
76
+ }
77
+ return diagnostics;
78
+ }
79
+ /** Dispose every registration (plugin teardown). */
80
+ disposeAll() {
81
+ for (const disposer of [...this.live.values()])
82
+ disposer();
83
+ this.live.clear();
84
+ this.fingerprints.clear();
85
+ }
86
+ }