dsh-agent-plugins-market 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/README.md +117 -111
  2. package/README.zh.md +112 -98
  3. package/client/client.js +547 -540
  4. package/client/client.js.map +1 -1
  5. package/lib/application/catalog-context.js +181 -0
  6. package/lib/application/catalog.js +217 -1110
  7. package/lib/application/details.js +6 -6
  8. package/lib/application/install-store.js +95 -0
  9. package/lib/application/lsp-service.js +97 -0
  10. package/lib/application/mcp-service.js +246 -0
  11. package/lib/application/panel-resources.js +29 -12
  12. package/lib/application/ports.js +21 -0
  13. package/lib/application/project-agent-roles.js +1 -1
  14. package/lib/application/snapshot-cache.js +160 -0
  15. package/lib/application/source-store.js +521 -0
  16. package/lib/catalog/archive.js +34 -29
  17. package/lib/catalog/component-files.js +14 -6
  18. package/lib/catalog/fs-probes.js +43 -0
  19. package/lib/catalog/manifests.js +53 -92
  20. package/lib/catalog/native-project.js +2 -2
  21. package/lib/catalog/paths.js +66 -55
  22. package/lib/catalog/project-config.js +2 -1
  23. package/lib/catalog/project-hooks.js +9 -1
  24. package/lib/catalog/scan-resolvers.js +29 -45
  25. package/lib/catalog/skills-parse.js +14 -10
  26. package/lib/catalog/source-catalog.js +3 -2
  27. package/lib/catalog/suite-components.js +5 -5
  28. package/lib/catalog/suite-scanner.js +3 -1
  29. package/lib/catalog/surfaces.js +5 -26
  30. package/lib/catalog/validate.js +7 -11
  31. package/lib/contracts/market.js +1 -0
  32. package/lib/index.js +91 -208
  33. package/lib/model/types.js +26 -3
  34. package/lib/routes.js +45 -15
  35. package/lib/runtime/agent-role-names.js +70 -0
  36. package/lib/runtime/agent-role-router.js +116 -102
  37. package/lib/runtime/commands-mounts.js +9 -6
  38. package/lib/runtime/deadline.js +28 -0
  39. package/lib/runtime/feedback-tool.js +94 -43
  40. package/lib/runtime/hooks-mounts.js +9 -11
  41. package/lib/runtime/host-locale.js +10 -21
  42. package/lib/runtime/lsp-direct-config.js +19 -19
  43. package/lib/runtime/lsp-mounts.js +159 -68
  44. package/lib/runtime/lsp-server-state.js +4 -4
  45. package/lib/runtime/lsp-status.js +1 -1
  46. package/lib/runtime/mcp-auth-record.js +42 -0
  47. package/lib/runtime/mcp-backend.js +6 -5
  48. package/lib/runtime/mcp-client/config.js +0 -1
  49. package/lib/runtime/mcp-client/connection.js +32 -37
  50. package/lib/runtime/mcp-client/host-contract.js +10 -0
  51. package/lib/runtime/mcp-client/oauth.js +5 -7
  52. package/lib/runtime/mcp-client/plugin-identity.js +28 -0
  53. package/lib/runtime/mcp-client/projection.js +202 -0
  54. package/lib/runtime/mcp-client/tools.js +4 -189
  55. package/lib/runtime/mcp-config.js +29 -10
  56. package/lib/runtime/mcp-direct-config.js +33 -16
  57. package/lib/runtime/mcp-mounts.js +35 -75
  58. package/lib/runtime/mcp-overrides.js +5 -5
  59. package/lib/runtime/mcp-redaction.js +3 -2
  60. package/lib/runtime/mcp-status.js +7 -8
  61. package/lib/runtime/mount-lifecycle.js +73 -0
  62. package/lib/runtime/profile-seam.js +321 -0
  63. package/lib/runtime/project-runtime.js +11 -8
  64. package/lib/runtime/reconcile-scheduler.js +99 -0
  65. package/lib/runtime/reconciler.js +4 -0
  66. package/lib/runtime/server-config.js +6 -4
  67. package/lib/runtime/settings-namespace.js +136 -0
  68. package/lib/runtime/skills-provider.js +5 -3
  69. package/lib/runtime/source-auto-update.js +70 -0
  70. package/lib/{model/state.js → runtime/state-store.js} +9 -11
  71. package/lib/runtime/storage-migration.js +35 -18
  72. package/lib/runtime/subagent-catalog.js +5 -4
  73. package/lib/runtime/user-commands.js +2 -2
  74. package/lib/runtime/user-panels.js +31 -21
  75. package/lib/runtime/user-store.js +12 -13
  76. package/lib/types/application/catalog-context.d.ts +118 -0
  77. package/lib/types/application/catalog.d.ts +99 -304
  78. package/lib/types/application/install-store.d.ts +21 -0
  79. package/lib/types/application/lsp-service.d.ts +36 -0
  80. package/lib/types/application/mcp-service.d.ts +71 -0
  81. package/lib/types/application/panel-resources.d.ts +8 -0
  82. package/lib/types/application/ports.d.ts +72 -0
  83. package/lib/types/application/queries.d.ts +13 -26
  84. package/lib/types/application/snapshot-cache.d.ts +51 -0
  85. package/lib/types/application/source-store.d.ts +100 -0
  86. package/lib/types/catalog/archive.d.ts +3 -11
  87. package/lib/types/catalog/component-files.d.ts +4 -0
  88. package/lib/types/catalog/fs-probes.d.ts +10 -0
  89. package/lib/types/catalog/manifests.d.ts +17 -16
  90. package/lib/types/catalog/native-project.d.ts +2 -2
  91. package/lib/types/catalog/paths.d.ts +47 -15
  92. package/lib/types/catalog/scan-pipeline.d.ts +3 -3
  93. package/lib/types/catalog/scan-resolvers.d.ts +3 -9
  94. package/lib/types/catalog/skills-parse.d.ts +0 -7
  95. package/lib/types/catalog/source-catalog.d.ts +3 -3
  96. package/lib/types/catalog/suite-scanner.d.ts +8 -6
  97. package/lib/types/catalog/validate.d.ts +0 -2
  98. package/lib/types/contracts/lsp-status.d.ts +35 -0
  99. package/lib/types/contracts/market.d.ts +3 -2
  100. package/lib/types/contracts/mcp-status.d.ts +4 -0
  101. package/lib/types/model/types.d.ts +29 -5
  102. package/lib/types/runtime/agent-role-names.d.ts +6 -0
  103. package/lib/types/runtime/agent-role-router.d.ts +54 -51
  104. package/lib/types/runtime/deadline.d.ts +18 -0
  105. package/lib/types/runtime/feedback-tool.d.ts +33 -11
  106. package/lib/types/runtime/hooks-mounts.d.ts +1 -1
  107. package/lib/types/runtime/host-locale.d.ts +1 -1
  108. package/lib/types/runtime/lsp-direct-config.d.ts +3 -3
  109. package/lib/types/runtime/lsp-mounts.d.ts +62 -19
  110. package/lib/types/runtime/lsp-status.d.ts +2 -1
  111. package/lib/types/runtime/mcp-auth-record.d.ts +29 -0
  112. package/lib/types/runtime/mcp-backend.d.ts +7 -4
  113. package/lib/types/runtime/mcp-client/config.d.ts +0 -2
  114. package/lib/types/runtime/mcp-client/host-contract.d.ts +78 -0
  115. package/lib/types/runtime/mcp-client/oauth.d.ts +1 -1
  116. package/lib/types/runtime/mcp-client/plugin-identity.d.ts +16 -0
  117. package/lib/types/runtime/mcp-client/projection.d.ts +45 -0
  118. package/lib/types/runtime/mcp-client/tools.d.ts +5 -72
  119. package/lib/types/runtime/mcp-config.d.ts +25 -4
  120. package/lib/types/runtime/mcp-direct-config.d.ts +18 -4
  121. package/lib/types/runtime/mcp-mounts.d.ts +10 -15
  122. package/lib/types/runtime/mcp-overrides.d.ts +1 -1
  123. package/lib/types/runtime/mcp-status.d.ts +1 -1
  124. package/lib/types/runtime/mount-lifecycle.d.ts +53 -0
  125. package/lib/types/runtime/profile-seam.d.ts +64 -0
  126. package/lib/types/runtime/reconcile-scheduler.d.ts +45 -0
  127. package/lib/types/runtime/reconciler.d.ts +2 -0
  128. package/lib/types/runtime/settings-namespace.d.ts +76 -0
  129. package/lib/types/runtime/source-auto-update.d.ts +33 -0
  130. package/lib/types/{model/state.d.ts → runtime/state-store.d.ts} +2 -2
  131. package/lib/types/runtime/storage-migration.d.ts +5 -3
  132. package/lib/types/runtime/subagent-catalog.d.ts +2 -0
  133. package/lib/types/runtime/user-panels.d.ts +8 -11
  134. package/lib/types/runtime/user-store.d.ts +2 -4
  135. package/package.json +48 -21
  136. package/schemas/README.md +1 -1
@@ -79,7 +79,9 @@ export class SuiteSkillProvider {
79
79
  candidateFor(entry) {
80
80
  return {
81
81
  name: entry.skill.name,
82
- description: `[${entry.suite.manifest.name}] ${entry.skill.description}`,
82
+ // The author's description verbatim — the harness renders it into the
83
+ // model's skill catalog, where a suite-name prefix is our packaging.
84
+ description: entry.skill.description,
83
85
  ...(entry.skill.whenToUse === undefined ? {} : { whenToUse: entry.skill.whenToUse }),
84
86
  invocation: entry.skill.invocation,
85
87
  source: entry.source,
@@ -100,7 +102,7 @@ export class SuiteSkillProvider {
100
102
  const located = [];
101
103
  const userSuites = await this.manager.enabledUserSuites();
102
104
  for (const suite of userSuites) {
103
- for (const skill of suite.activeSurfaces?.skills === false ? [] : suite.skills) {
105
+ for (const skill of suite.activeSurfaces.skills === false ? [] : suite.skills) {
104
106
  try {
105
107
  if (parseFrontmatterRecord(await readFile(skill.file, 'utf8')).disabled === true)
106
108
  continue;
@@ -120,7 +122,7 @@ export class SuiteSkillProvider {
120
122
  const snapshot = await this.manager.readProjectCatalog(cwd);
121
123
  const located = [];
122
124
  for (const suite of snapshot.enabledSuites) {
123
- for (const skill of suite.activeSurfaces?.skills === false ? [] : suite.skills) {
125
+ for (const skill of suite.activeSurfaces.skills === false ? [] : suite.skills) {
124
126
  try {
125
127
  if (parseFrontmatterRecord(await readFile(skill.file, 'utf8')).disabled === true)
126
128
  continue;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Optional background refresh of every configured market source.
3
+ *
4
+ * The `autoUpdateSources` setting (default off) turns this on from the
5
+ * plugin-config card. While enabled the updater runs one refresh pass every
6
+ * {@link AUTO_UPDATE_INTERVAL_MS} and then waits again; the first pass runs a
7
+ * full interval after the switch flips, so enabling the feature never starts
8
+ * network traffic as a surprise. A pass that is still running when the next
9
+ * tick arrives is skipped rather than queued, and the timer is unref'd so it
10
+ * never keeps the DSH process alive on its own.
11
+ * @module runtime/source-auto-update
12
+ */
13
+ /** How often the background updater refreshes every source. */
14
+ export const AUTO_UPDATE_INTERVAL_MS = 6 * 60 * 60 * 1000;
15
+ export class SourceAutoUpdater {
16
+ ctx;
17
+ refresh;
18
+ intervalMs;
19
+ timer;
20
+ inFlight = false;
21
+ constructor(ctx, refresh, intervalMs = AUTO_UPDATE_INTERVAL_MS) {
22
+ this.ctx = ctx;
23
+ this.refresh = refresh;
24
+ this.intervalMs = intervalMs;
25
+ }
26
+ /** Whether a refresh timer is currently armed. */
27
+ get enabled() {
28
+ return this.timer !== undefined;
29
+ }
30
+ /** Arm or disarm the interval; repeated calls with the same state do nothing. */
31
+ setEnabled(enabled) {
32
+ if (enabled === this.enabled)
33
+ return;
34
+ if (!enabled) {
35
+ this.clearTimer();
36
+ this.ctx.logger?.info?.('[dsh-agent-plugins-market] background source updates off');
37
+ return;
38
+ }
39
+ this.timer = setInterval(() => void this.run(), this.intervalMs);
40
+ this.timer.unref?.();
41
+ this.ctx.logger?.info?.(`[dsh-agent-plugins-market] background source updates on: every ${Math.round(this.intervalMs / 60_000)} minutes`);
42
+ }
43
+ /** Disarm the interval; an in-flight pass finishes on its own. */
44
+ dispose() {
45
+ this.clearTimer();
46
+ }
47
+ clearTimer() {
48
+ if (this.timer === undefined)
49
+ return;
50
+ clearInterval(this.timer);
51
+ this.timer = undefined;
52
+ }
53
+ async run() {
54
+ if (this.inFlight) {
55
+ this.ctx.logger?.warn?.('[dsh-agent-plugins-market] background source update skipped: the previous pass is still running');
56
+ return;
57
+ }
58
+ this.inFlight = true;
59
+ try {
60
+ await this.refresh();
61
+ this.ctx.logger?.info?.('[dsh-agent-plugins-market] background source update finished');
62
+ }
63
+ catch (error) {
64
+ this.ctx.logger?.warn?.(`[dsh-agent-plugins-market] background source update failed: ${error instanceof Error ? error.message : String(error)}`);
65
+ }
66
+ finally {
67
+ this.inFlight = false;
68
+ }
69
+ }
70
+ }
@@ -2,13 +2,14 @@
2
2
  * Persisted suite state: configured sources and per-suite install entries.
3
3
  *
4
4
  * State is a plain JSON file at `<dimensionRoot>/state.json`. The host is the
5
- * only writer (through manager actions and the HTTP routes); the manager
6
- * re-reads the file whenever a mutation races, keeping the on-disk copy
7
- * authoritative like the market profile state this pattern follows.
5
+ * only writer (through catalog actions and the HTTP routes), and it re-reads
6
+ * the file whenever a mutation races, keeping the on-disk copy authoritative
7
+ * like the market profile state this pattern follows. The record shapes live
8
+ * in `src/model/types.ts`; only the reading and writing live here.
8
9
  */
9
- import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
10
- import { dirname } from 'node:path';
11
- import { SUITE_SURFACE_KEYS } from './types.js';
10
+ import { readFile } from 'node:fs/promises';
11
+ import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
12
+ import { SUITE_SURFACE_KEYS } from '../model/types.js';
12
13
  export const EMPTY_STATE = { version: 1, sources: [], installed: {} };
13
14
  /** Parse persisted state; unreadable or wrong-version files yield a contained empty state. */
14
15
  export async function loadState(statePath) {
@@ -86,10 +87,7 @@ function parseSurfaceOverrides(raw) {
86
87
  }
87
88
  return hasAny ? { surfaces: overrides } : {};
88
89
  }
89
- /** Persist state atomically through a sibling-temp rename. */
90
+ /** Persist state atomically through the harness's atomic-write helper. */
90
91
  export async function saveState(statePath, state) {
91
- await mkdir(dirname(statePath), { recursive: true });
92
- const temp = `${statePath}.${process.pid}.tmp`;
93
- await writeFile(temp, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
94
- await rename(temp, statePath);
92
+ await writeFileAtomic(statePath, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600, dirMode: 0o700 });
95
93
  }
@@ -1,8 +1,9 @@
1
1
  /** Consolidate plugin-owned storage before stores, routes, or providers are exposed. */
2
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';
3
+ import { copyFile, lstat, mkdir, readdir, readFile, rmdir, unlink } from 'node:fs/promises';
4
+ import { dirname, join, relative, resolve } from 'node:path';
5
+ import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
6
+ import { expandHome, isWithin, resolveAgentsRoot, resolveDataRoot, resolveDshHome, resolveUserRoot } from '../catalog/paths.js';
6
7
  async function info(path) {
7
8
  try {
8
9
  return await lstat(path);
@@ -54,20 +55,24 @@ export async function mergeStorageTree(from, to, result) {
54
55
  }
55
56
  await unlink(from);
56
57
  }
57
- function contains(parent, child) {
58
- const path = relative(parent, child);
59
- return path === '' || (!path.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) && path !== '..' && !isAbsolute(path));
60
- }
58
+ /** The three hand-authored resource kinds the market panel owns. */
59
+ const PANEL_KINDS = ['skills', 'commands', 'agents'];
61
60
  /**
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.
61
+ * Migrate former root overrides, data-root panels, and hand-authored
62
+ * resources into their canonical homes: plugin state under the user/data
63
+ * roots, and user content under the shared Agent layout root. Unknown files in
64
+ * override directories remain untouched. Conflicts remain at the old path and
65
+ * are returned to block activation; reruns are safe.
65
66
  */
66
67
  export async function migratePluginStorage(config = {}) {
67
68
  const userRoot = resolveUserRoot();
68
69
  const dataRoot = resolveDataRoot();
70
+ const agentsRoot = resolveAgentsRoot();
69
71
  const result = { conflicts: [] };
70
- for (const path of [resolveDshHome(), userRoot, dataRoot, join(userRoot, 'user'), join(userRoot, '.sources')]) {
72
+ if (isWithin(userRoot, agentsRoot) || isWithin(agentsRoot, userRoot) || isWithin(dataRoot, agentsRoot) || isWithin(agentsRoot, dataRoot)) {
73
+ throw new Error('The Agent layout root overlaps canonical plugin storage');
74
+ }
75
+ for (const path of [resolveDshHome(), userRoot, dataRoot, agentsRoot, join(userRoot, 'user'), join(userRoot, '.sources')]) {
71
76
  if ((await info(path))?.isSymbolicLink() === true)
72
77
  throw new Error(`Plugin storage cannot use a symbolic-link directory: ${path}`);
73
78
  }
@@ -78,7 +83,7 @@ export async function migratePluginStorage(config = {}) {
78
83
  throw new Error(`Plugin storage migration cannot traverse a symbolic-link root: ${path}`);
79
84
  }
80
85
  if (legacyUserRoot !== userRoot) {
81
- if (contains(legacyUserRoot, userRoot) || contains(userRoot, legacyUserRoot))
86
+ if (isWithin(legacyUserRoot, userRoot) || isWithin(userRoot, legacyUserRoot))
82
87
  throw new Error('Legacy userRoot overlaps canonical plugin storage');
83
88
  const legacySources = join(legacyUserRoot, '.sources');
84
89
  const sourceInfo = await info(legacySources);
@@ -123,13 +128,27 @@ export async function migratePluginStorage(config = {}) {
123
128
  for (const legacy of new Set([legacyDataRoot, join(resolveDshHome(), 'agent-plugins-data')])) {
124
129
  if (legacy === dataRoot)
125
130
  continue;
126
- if (contains(legacy, dataRoot) || contains(dataRoot, legacy))
131
+ if (isWithin(legacy, dataRoot) || isWithin(dataRoot, legacy))
127
132
  throw new Error('Legacy dataRoot overlaps canonical plugin data storage');
128
133
  for (const entry of ['data', 'overrides', 'user', 'settings.json', 'lsp-servers.json', 'feedback']) {
129
134
  await mergeStorageTree(join(legacy, entry), join(dataRoot, entry), result);
130
135
  }
131
136
  }
132
- await mergeStorageTree(join(dataRoot, 'user'), join(userRoot, 'user'), result);
137
+ // Hand-authored resources moved out of plugin storage into the shared Agent
138
+ // layout root; every former `<root>/user/<kind>` layout maps onto `<kind>`.
139
+ // Each source is moved straight to its final location so a conflict is
140
+ // reported at the file the user actually has, not at a hop along the way.
141
+ for (const kind of PANEL_KINDS) {
142
+ await mergeStorageTree(join(dataRoot, 'user', kind), join(agentsRoot, kind), result);
143
+ await mergeStorageTree(join(userRoot, 'user', kind), join(agentsRoot, kind), result);
144
+ }
145
+ // Drop the emptied former panel directories; rmdir refuses a non-empty one,
146
+ // so files retained by a conflict (or an unrecognized kind) keep their home.
147
+ await rmdir(join(dataRoot, 'user')).catch(() => { });
148
+ await rmdir(join(userRoot, 'user')).catch(() => { });
149
+ // Hand-written service declarations moved from data files to Agent-layout files.
150
+ await mergeStorageTree(join(dataRoot, 'mcp-servers.json'), join(agentsRoot, 'mcp.json'), result);
151
+ await mergeStorageTree(join(dataRoot, 'lsp-servers.json'), join(agentsRoot, 'lsp.json'), result);
133
152
  return result;
134
153
  }
135
154
  /** Retain all state fields while rebasing adopted checkouts moved with a root. */
@@ -145,15 +164,13 @@ async function relocateManagedSourceUrls(userRoot, legacyUserRoot) {
145
164
  if (typeof source !== 'object' || source === null || !('local' in source) || source.local !== true || !('url' in source) || typeof source.url !== 'string')
146
165
  continue;
147
166
  const oldCheckout = join(legacyUserRoot, '.sources');
148
- if (!contains(oldCheckout, resolve(expandHome(source.url))))
167
+ if (!isWithin(oldCheckout, resolve(expandHome(source.url))))
149
168
  continue;
150
169
  source.url = join(userRoot, '.sources', relative(oldCheckout, resolve(expandHome(source.url))));
151
170
  changed = true;
152
171
  }
153
172
  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);
173
+ await writeFileAtomic(path, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600, dirMode: 0o700 });
157
174
  }
158
175
  }
159
176
  async function collectUnsupportedEntries(path, conflicts) {
@@ -51,8 +51,8 @@ function escapeText(value) {
51
51
  }
52
52
  function renderCatalog(entries, update, t) {
53
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>`;
54
+ const config = [entry.provider, entry.model, entry.reasoningEffort].filter(value => value !== undefined).map(escapeText);
55
+ return `- ${escapeText(entry.name)}: ${escapeText(entry.description)}${config.length === 0 ? '' : ` (${config.join(' / ')})`}`;
56
56
  });
57
57
  return createUserMessage({
58
58
  content: [
@@ -74,7 +74,7 @@ function renderCatalog(entries, update, t) {
74
74
  }
75
75
  function digestEntries(entries) {
76
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'))
77
+ .update(entries.map(entry => JSON.stringify([entry.name, entry.roleId, entry.title, entry.description, entry.provider, entry.model, entry.reasoningEffort])).join('\n'))
78
78
  .digest('hex');
79
79
  }
80
80
  /** Resumed and forked seeds may carry unvalidated source fields; ignore unreadable catalogs. */
@@ -88,10 +88,11 @@ function readEntries(source) {
88
88
  const entry = raw;
89
89
  if (typeof entry.name !== 'string' || entry.name === '' || typeof entry.title !== 'string' || typeof entry.description !== 'string')
90
90
  return undefined;
91
- if (['provider', 'model', 'reasoningEffort'].some(key => entry[key] !== undefined && (typeof entry[key] !== 'string' || entry[key] === '')))
91
+ if (['roleId', 'provider', 'model', 'reasoningEffort'].some(key => entry[key] !== undefined && (typeof entry[key] !== 'string' || entry[key] === '')))
92
92
  return undefined;
93
93
  entries.push({
94
94
  name: entry.name,
95
+ ...(entry.roleId === undefined ? {} : { roleId: entry.roleId }),
95
96
  title: entry.title,
96
97
  description: entry.description,
97
98
  ...(entry.provider === undefined ? {} : { provider: entry.provider }),
@@ -62,9 +62,9 @@ export class UserCommandMountRegistry {
62
62
  ...(spec.hint === undefined ? {} : { input: { hint: spec.hint } }),
63
63
  handler: invocation => {
64
64
  const agent = invocation.agent;
65
- const text = [this.t('userCommandForwardTitle', { command: spec.name }), '', spec.body.replaceAll('$ARGUMENTS', invocation.rawInput.trim())].join('\n');
65
+ const text = spec.body.replaceAll('$ARGUMENTS', invocation.rawInput.trim());
66
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 }) };
67
+ return { kind: 'success', text: this.t('commandAcknowledged', { command: spec.name }) };
68
68
  }
69
69
  });
70
70
  this.live.set(key, disposer);
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * User panel storage: the persistence layer behind the skills / commands /
3
3
  * agent-personas panels. Each surface owns one directory of Markdown files
4
- * under `<userRoot>/user/<kind>/`, so user-authored entries survive restarts,
5
- * are trivially hand-editable, and stay outside suite checkouts.
4
+ * under the shared Agent layout root (`~/.agents/<kind>/`), so user-authored
5
+ * entries survive restarts, are trivially hand-editable, and stay outside
6
+ * suite checkouts.
6
7
  *
7
8
  * Skills entries follow the SKILL.md frontmatter grammar (`name`,
8
9
  * `description`, optional `whenToUse`, invocation controls) plus a
@@ -16,27 +17,35 @@ import { join } from 'node:path';
16
17
  import { deleteEntryFile, entryExists, listEntryFiles, readEntryFile, USER_ENTRY_NAME, userEntryDir, writeEntryFile } from './user-store.js';
17
18
  /** The skill-source label user-panel entries carry into the skill registry. */
18
19
  export const USER_PANEL_SKILL_SOURCE = 'user-panel';
19
- /** Ranks sit behind every shipped root so real files always win a name clash. */
20
- const USER_PANEL_RANK = 600;
20
+ /**
21
+ * Panel skills outrank both the harness's reader of this directory and the
22
+ * suite skills an install adds.
23
+ *
24
+ * `dsh-skill-filesystem` maps `~/.agents/skills` as its `user-agents` root at
25
+ * rank 500, and a lower rank wins a duplicated skill name. Sitting behind it
26
+ * handed every entry to that reader, which knows neither this panel's
27
+ * `disabled` frontmatter nor its localized description, so a skill the user
28
+ * disabled stayed loaded. At 440 the panel keeps its own controls; project
29
+ * roots (100-300) and the user's `~/.dsh` skills (400) still outrank it, while
30
+ * a skill the user wrote by hand now beats one an installed suite ships under
31
+ * the same name (the suite user rank is 450).
32
+ */
33
+ const USER_PANEL_RANK = 440;
21
34
  /** Throwing CRUD over one panel directory, shared by the three panels. */
22
35
  export class UserPanelStore {
23
- dataRoot;
36
+ agentsRoot;
24
37
  kind;
25
38
  extraNameCheck;
26
- constructor(dataRoot, kind,
39
+ constructor(agentsRoot, kind,
27
40
  /** Extra name grammar for this panel (runs after USER_ENTRY_NAME). */
28
41
  extraNameCheck = () => true) {
29
- this.dataRoot = dataRoot;
42
+ this.agentsRoot = agentsRoot;
30
43
  this.kind = kind;
31
44
  this.extraNameCheck = extraNameCheck;
32
45
  }
33
- /** The panel's directory under the data root. */
46
+ /** The panel's directory under the Agent layout root. */
34
47
  dirPath() {
35
- return userEntryDir(this.dataRoot, this.kind);
36
- }
37
- /** The plugin data root the panel lives under. */
38
- root() {
39
- return this.dataRoot;
48
+ return userEntryDir(this.agentsRoot, this.kind);
40
49
  }
41
50
  get dir() {
42
51
  return this.dirPath();
@@ -102,11 +111,11 @@ export class UserPanelStore {
102
111
  }
103
112
  }
104
113
  /** Locate the three stores; constructed once per plugin activation. */
105
- export function createUserPanelStores(dataRoot) {
114
+ export function createUserPanelStores(agentsRoot) {
106
115
  return {
107
- skills: new UserPanelStore(dataRoot, 'skills', isUserSkillEntryName),
108
- commands: new UserPanelStore(dataRoot, 'commands'),
109
- agents: new UserPanelStore(dataRoot, 'agents', isUserSkillEntryName)
116
+ skills: new UserPanelStore(agentsRoot, 'skills', isUserSkillEntryName),
117
+ commands: new UserPanelStore(agentsRoot, 'commands'),
118
+ agents: new UserPanelStore(agentsRoot, 'agents', isUserSkillEntryName)
110
119
  };
111
120
  }
112
121
  /**
@@ -125,11 +134,9 @@ export function isUserSkillEntryName(name) {
125
134
  */
126
135
  export class UserPanelSkillProvider {
127
136
  skills;
128
- t;
129
137
  name = 'user-panel';
130
- constructor(skills, t) {
138
+ constructor(skills) {
131
139
  this.skills = skills;
132
- this.t = t;
133
140
  }
134
141
  async list(_options) {
135
142
  const candidates = [];
@@ -139,7 +146,10 @@ export class UserPanelSkillProvider {
139
146
  continue;
140
147
  candidates.push({
141
148
  name: entry.name,
142
- description: this.t('userSkillDescription', { description: entry.description === '' ? entry.name : entry.description }),
149
+ // The entry's own description verbatim; a user-entry label is our
150
+ // packaging, and this description reaches the same model-facing
151
+ // catalog a suite skill does.
152
+ description: entry.description === '' ? entry.name : entry.description,
143
153
  ...(typeof entry.metadata['whenToUse'] === 'string' && entry.metadata['whenToUse'] !== '' ? { whenToUse: entry.metadata['whenToUse'] } : {}),
144
154
  invocation: {
145
155
  modelInvocable: entry.metadata['disable-model-invocation'] !== true,
@@ -6,29 +6,29 @@
6
6
  * the runtime mounts.
7
7
  * @module runtime/user-store
8
8
  */
9
- import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
9
+ import { readdir, readFile, rm, stat } from 'node:fs/promises';
10
10
  import { join } from 'node:path';
11
+ import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
11
12
  import { parseDocument, stringify } from 'yaml';
12
13
  import { parseSkillFrontmatter, stripFrontmatter } from '../catalog/skills-parse.js';
13
- /** Where user panel entries persist: `<userRoot>/user/<kind>/`. */
14
- export function userEntryDir(dataRoot, kind) {
15
- return join(dataRoot, 'user', kind);
14
+ /** Where user panel entries persist: `<agentsRoot>/<kind>/`. */
15
+ export function userEntryDir(agentsRoot, kind) {
16
+ return join(agentsRoot, kind);
16
17
  }
17
18
  /** `[a-z][a-z0-9_-]*` — the grammar every panel entry name must satisfy. */
18
19
  export const USER_ENTRY_NAME = /^[a-z][a-z0-9_-]*$/;
19
- /** The enablement frontmatter key shared by skills and commands. */
20
- export const DISABLED_KEY = 'disabled';
21
20
  /** Parse full YAML metadata without flattening arrays, mappings, or multiline strings. */
22
21
  export function parseFrontmatterRecord(text) {
23
- const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text);
24
- if (match === null) {
22
+ const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text)?.[1];
23
+ if (frontmatter === undefined) {
25
24
  if (/^---(?:\r?\n|$)/.test(text))
26
25
  throw new Error('Invalid frontmatter: missing closing delimiter');
27
26
  return {};
28
27
  }
29
- const doc = parseDocument(match[1]);
30
- if (doc.errors.length > 0)
31
- throw new Error(`Invalid frontmatter: ${doc.errors[0].message}`);
28
+ const doc = parseDocument(frontmatter);
29
+ const error = doc.errors[0];
30
+ if (error !== undefined)
31
+ throw new Error(`Invalid frontmatter: ${error.message}`);
32
32
  const value = doc.toJS({ maxAliasCount: 100 });
33
33
  if (value === null)
34
34
  return {};
@@ -104,8 +104,7 @@ export async function listEntryFiles(dir, strict = false) {
104
104
  export async function writeEntryFile(dir, name, text) {
105
105
  assertEntryName(name);
106
106
  parseFrontmatterRecord(text);
107
- await mkdir(dir, { recursive: true });
108
- await writeFile(join(dir, `${name}.md`), text, 'utf8');
107
+ await writeFileAtomic(join(dir, `${name}.md`), text, { mode: 0o644, dirMode: 0o700 });
109
108
  }
110
109
  /**
111
110
  * Enforce the entry-name grammar at every filesystem choke point. The name
@@ -0,0 +1,118 @@
1
+ import type { GitOptions } from '../catalog/git.js';
2
+ import { type DiscoveredSuite, type InstalledEntry, type Suite, type SuiteDimension, type SuiteState } from '../model/types.js';
3
+ import type { CatalogPortsOverride } from './ports.js';
4
+ import { SnapshotCache, type SnapshotHost } from './snapshot-cache.js';
5
+ /**
6
+ * Git network tuning, plumbed from the host config to every remote-touching
7
+ * invocation (proxy, `insteadOf` mirrors, timeout, retry). `fallbackTarball`
8
+ * additionally lets a failed GitHub clone fall back to a codeload tarball
9
+ * download through the archive pipeline.
10
+ */
11
+ export interface CatalogGitOptions extends GitOptions {
12
+ /** Retry a failed GitHub clone as a codeload tarball download; default false. */
13
+ fallbackTarball?: boolean;
14
+ /** Allow plain-http archive downloads (intranet mirrors); default false. */
15
+ allowHttpArchives?: boolean;
16
+ }
17
+ /** Dependencies and host callback used by the catalog application module. */
18
+ export interface CatalogOptions {
19
+ userRoot: string;
20
+ dataRoot: string;
21
+ /** Shared Agent layout root holding the user's hand-authored resources and service declarations. */
22
+ agentsRoot: string;
23
+ onChanged: () => void | Promise<void>;
24
+ /** Git/archive acquisition tuning. */
25
+ git?: CatalogGitOptions;
26
+ /** Host seams; unwired members fall back to the defaults in `ports.ts`. */
27
+ ports?: CatalogPortsOverride;
28
+ /**
29
+ * Freshness window for cached project-dimension snapshots. The project
30
+ * catalog sits on the skill-list hot path (every provider `list()` call),
31
+ * and its inputs — the project's own files — change only through editor
32
+ * saves; a short window keeps listing cheap without going stale. Defaults
33
+ * to 5 seconds; 0 disables caching.
34
+ */
35
+ projectSnapshotTtlMs?: number;
36
+ /**
37
+ * Freshness window for the cached user-dimension snapshot (defaults to the
38
+ * scan-cache TTL). It bounds how long out-of-band edits to local sources
39
+ * stay invisible; 0 disables snapshot caching. Tests pass a small value so
40
+ * staleness regressions need no real sleeps.
41
+ */
42
+ userSnapshotTtlMs?: number;
43
+ }
44
+ /** Key of one install entry in the persisted `installed` record. */
45
+ export declare function installKey(sourceId: string, suiteId: string): string;
46
+ /**
47
+ * How long an explicit caller waits for the derived refresh before answering
48
+ * anyway. The refresh keeps running; only the wait ends.
49
+ */
50
+ export declare const DERIVED_REFRESH_WAIT_MS = 10000;
51
+ export declare class CatalogContext implements SnapshotHost {
52
+ private currentState;
53
+ private mutationQueue;
54
+ private currentRevision;
55
+ private currentScanGeneration;
56
+ private scanProjectLayoutsEnabled;
57
+ /** The derived refresh in flight, shared by every change that lands while it runs. */
58
+ private refreshPass;
59
+ /** Whether a change landed after the running pass started; it earns one more pass. */
60
+ private refreshQueued;
61
+ readonly statePath: string;
62
+ readonly userRoot: string;
63
+ readonly dataRoot: string;
64
+ readonly agentsRoot: string;
65
+ readonly git: CatalogGitOptions;
66
+ /** Discovery scans and dimension snapshots derived from {@link state}. */
67
+ readonly snapshots: SnapshotCache;
68
+ private readonly onChanged;
69
+ constructor(options: CatalogOptions);
70
+ get state(): SuiteState;
71
+ /** Bumped by every mutation: a snapshot built at an older revision is never published. */
72
+ get revision(): number;
73
+ /** Bumped by every content-changing mutation: a scan started earlier never repopulates its cache. */
74
+ get scanGeneration(): number;
75
+ /** Whether native project layouts (`.claude/`, `.agents/`) take part in discovery. */
76
+ get scanProjectLayouts(): boolean;
77
+ /** One install entry, or undefined when the suite is not installed. */
78
+ installed(sourceId: string, suiteId: string): InstalledEntry | undefined;
79
+ /** Load persisted user state once at plugin activation. */
80
+ load(): Promise<void>;
81
+ /** Replace the persisted state and write it out. */
82
+ commit(next: SuiteState): Promise<void>;
83
+ /** Apply the host's project-layout switch and invalidate every project snapshot. */
84
+ setScanProjectLayouts(enabled: boolean): Promise<void>;
85
+ /**
86
+ * Apply the install state to freshly discovered suites: enablement, the
87
+ * effective surface toggles, and the lock/install timestamps the market
88
+ * cards report.
89
+ */
90
+ project(discovered: readonly DiscoveredSuite[], state: SuiteState, dimension: SuiteDimension): Suite[];
91
+ /**
92
+ * Invalidate snapshots and schedule the change pipeline. `keepScanCache`
93
+ * marks state-only mutations (install, enable, surface toggles, MCP
94
+ * overrides) whose inputs leave every checkout untouched, so the next
95
+ * snapshot re-derives from cached discovery instead of rescanning the
96
+ * filesystem.
97
+ *
98
+ * The invalidation is synchronous and the refresh is coalesced in the
99
+ * background: resolving here means the state is durable, never that every
100
+ * mount has finished. Callers that must observe the refreshed surfaces await
101
+ * {@link refreshSettled}, whose wait is bounded so one stuck mount cannot
102
+ * hold a request — or the next mutation behind it — open.
103
+ */
104
+ notifyChanged(keepScanCache?: boolean): Promise<void>;
105
+ /**
106
+ * Resolve once the queued derived refresh finishes, `false` when the deadline
107
+ * elapses first. The refresh itself is never cancelled.
108
+ */
109
+ refreshSettled(deadlineMs?: number): Promise<boolean>;
110
+ /** Queue one derived refresh; a pass already in flight absorbs it. */
111
+ private scheduleRefresh;
112
+ /** Drop every cached dimension snapshot; `increment` advances the revision. */
113
+ invalidateSnapshot(increment: boolean): void;
114
+ /** Drop cached discovery and advance the generation a scan is validated against. */
115
+ invalidateScans(): void;
116
+ /** Serialize one mutation behind every mutation queued before it. */
117
+ enqueue<T>(operation: () => Promise<T>): Promise<T>;
118
+ }