dsh-agent-plugins-market 0.6.2 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.md +117 -111
  2. package/README.zh.md +112 -98
  3. package/client/client.js +28533 -1511
  4. package/client/client.js.map +1 -1
  5. package/lib/application/catalog-context.js +188 -0
  6. package/lib/application/catalog.js +229 -1109
  7. package/lib/application/details.js +19 -8
  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 +263 -0
  11. package/lib/application/panel-resources.js +43 -14
  12. package/lib/application/ports.js +21 -0
  13. package/lib/application/project-agent-roles.js +8 -2
  14. package/lib/application/snapshot-cache.js +160 -0
  15. package/lib/application/source-store.js +521 -0
  16. package/lib/catalog/archive.js +35 -30
  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 +74 -55
  22. package/lib/catalog/plugin-variables.js +38 -0
  23. package/lib/catalog/project-config.js +2 -1
  24. package/lib/catalog/project-hooks.js +23 -2
  25. package/lib/catalog/scan-resolvers.js +29 -45
  26. package/lib/catalog/skills-parse.js +61 -10
  27. package/lib/catalog/source-catalog.js +10 -4
  28. package/lib/catalog/suite-components.js +7 -7
  29. package/lib/catalog/suite-scanner.js +3 -1
  30. package/lib/catalog/surfaces.js +5 -26
  31. package/lib/catalog/validate.js +7 -11
  32. package/lib/contracts/market.js +2 -0
  33. package/lib/contracts/settings.js +63 -0
  34. package/lib/index.js +115 -217
  35. package/lib/model/layouts.js +10 -1
  36. package/lib/model/types.js +26 -3
  37. package/lib/routes.js +57 -15
  38. package/lib/runtime/agent-role-names.js +70 -0
  39. package/lib/runtime/agent-role-router.js +142 -106
  40. package/lib/runtime/commands-mounts.js +55 -11
  41. package/lib/runtime/deadline.js +54 -0
  42. package/lib/runtime/dynamic-context.js +140 -0
  43. package/lib/runtime/feedback-tool.js +101 -46
  44. package/lib/runtime/hooks-mounts.js +9 -11
  45. package/lib/runtime/host-locale.js +49 -24
  46. package/lib/runtime/lsp-direct-config.js +19 -19
  47. package/lib/runtime/lsp-mounts.js +182 -70
  48. package/lib/runtime/lsp-server-state.js +4 -4
  49. package/lib/runtime/lsp-status.js +1 -1
  50. package/lib/runtime/mcp-auth-record.js +42 -0
  51. package/lib/runtime/mcp-backend.js +14 -14
  52. package/lib/runtime/mcp-client/config.js +8 -1
  53. package/lib/runtime/mcp-client/connection.js +38 -43
  54. package/lib/runtime/mcp-client/host-contract.js +10 -0
  55. package/lib/runtime/mcp-client/host-seams.js +8 -88
  56. package/lib/runtime/mcp-client/oauth.js +5 -7
  57. package/lib/runtime/mcp-client/plugin-identity.js +28 -0
  58. package/lib/runtime/mcp-client/projection.js +202 -0
  59. package/lib/runtime/mcp-client/tools.js +4 -189
  60. package/lib/runtime/mcp-config.js +36 -14
  61. package/lib/runtime/mcp-direct-config.js +33 -16
  62. package/lib/runtime/mcp-mounts.js +35 -75
  63. package/lib/runtime/mcp-overrides.js +5 -5
  64. package/lib/runtime/mcp-redaction.js +3 -2
  65. package/lib/runtime/mcp-status.js +7 -8
  66. package/lib/runtime/mount-lifecycle.js +73 -0
  67. package/lib/runtime/profile-seam.js +321 -0
  68. package/lib/runtime/project-runtime.js +34 -17
  69. package/lib/runtime/reconcile-scheduler.js +97 -0
  70. package/lib/runtime/reconciler.js +6 -1
  71. package/lib/runtime/regions.js +0 -19
  72. package/lib/runtime/server-config.js +6 -4
  73. package/lib/runtime/settings-namespace.js +144 -0
  74. package/lib/runtime/skills-provider.js +38 -14
  75. package/lib/runtime/source-auto-update.js +66 -0
  76. package/lib/{model/state.js → runtime/state-store.js} +9 -11
  77. package/lib/runtime/storage-migration.js +35 -18
  78. package/lib/runtime/subagent-catalog.js +5 -4
  79. package/lib/runtime/timer-seat.js +80 -0
  80. package/lib/runtime/tool-registry-observer.js +20 -20
  81. package/lib/runtime/user-commands.js +7 -3
  82. package/lib/runtime/user-panels.js +125 -46
  83. package/lib/runtime/user-store.js +124 -69
  84. package/lib/types/application/catalog-context.d.ts +124 -0
  85. package/lib/types/application/catalog.d.ts +108 -304
  86. package/lib/types/application/install-store.d.ts +21 -0
  87. package/lib/types/application/lsp-service.d.ts +36 -0
  88. package/lib/types/application/mcp-service.d.ts +77 -0
  89. package/lib/types/application/panel-resources.d.ts +8 -0
  90. package/lib/types/application/ports.d.ts +72 -0
  91. package/lib/types/application/queries.d.ts +15 -26
  92. package/lib/types/application/snapshot-cache.d.ts +51 -0
  93. package/lib/types/application/source-store.d.ts +100 -0
  94. package/lib/types/catalog/archive.d.ts +3 -11
  95. package/lib/types/catalog/component-files.d.ts +4 -0
  96. package/lib/types/catalog/fs-probes.d.ts +10 -0
  97. package/lib/types/catalog/manifests.d.ts +17 -16
  98. package/lib/types/catalog/native-project.d.ts +2 -2
  99. package/lib/types/catalog/paths.d.ts +53 -15
  100. package/lib/types/catalog/plugin-variables.d.ts +16 -0
  101. package/lib/types/catalog/scan-pipeline.d.ts +3 -3
  102. package/lib/types/catalog/scan-resolvers.d.ts +3 -9
  103. package/lib/types/catalog/skills-parse.d.ts +13 -7
  104. package/lib/types/catalog/source-catalog.d.ts +10 -5
  105. package/lib/types/catalog/suite-scanner.d.ts +8 -6
  106. package/lib/types/catalog/validate.d.ts +0 -2
  107. package/lib/types/contracts/lsp-status.d.ts +35 -0
  108. package/lib/types/contracts/market.d.ts +13 -2
  109. package/lib/types/contracts/mcp-status.d.ts +4 -0
  110. package/lib/types/contracts/settings.d.ts +59 -0
  111. package/lib/types/model/layouts.d.ts +10 -0
  112. package/lib/types/model/types.d.ts +29 -5
  113. package/lib/types/runtime/agent-role-names.d.ts +6 -0
  114. package/lib/types/runtime/agent-role-router.d.ts +59 -52
  115. package/lib/types/runtime/commands-mounts.d.ts +15 -7
  116. package/lib/types/runtime/deadline.d.ts +17 -0
  117. package/lib/types/runtime/dynamic-context.d.ts +81 -0
  118. package/lib/types/runtime/feedback-tool.d.ts +33 -11
  119. package/lib/types/runtime/hooks-mounts.d.ts +1 -1
  120. package/lib/types/runtime/host-locale.d.ts +20 -2
  121. package/lib/types/runtime/lsp-direct-config.d.ts +3 -3
  122. package/lib/types/runtime/lsp-mounts.d.ts +69 -19
  123. package/lib/types/runtime/lsp-status.d.ts +2 -1
  124. package/lib/types/runtime/mcp-auth-record.d.ts +29 -0
  125. package/lib/types/runtime/mcp-backend.d.ts +10 -14
  126. package/lib/types/runtime/mcp-client/config.d.ts +8 -2
  127. package/lib/types/runtime/mcp-client/host-contract.d.ts +78 -0
  128. package/lib/types/runtime/mcp-client/host-seams.d.ts +20 -64
  129. package/lib/types/runtime/mcp-client/oauth.d.ts +1 -1
  130. package/lib/types/runtime/mcp-client/plugin-identity.d.ts +16 -0
  131. package/lib/types/runtime/mcp-client/projection.d.ts +45 -0
  132. package/lib/types/runtime/mcp-client/tools.d.ts +5 -72
  133. package/lib/types/runtime/mcp-config.d.ts +25 -4
  134. package/lib/types/runtime/mcp-direct-config.d.ts +18 -4
  135. package/lib/types/runtime/mcp-mounts.d.ts +10 -15
  136. package/lib/types/runtime/mcp-overrides.d.ts +1 -1
  137. package/lib/types/runtime/mcp-status.d.ts +1 -1
  138. package/lib/types/runtime/mount-lifecycle.d.ts +53 -0
  139. package/lib/types/runtime/profile-seam.d.ts +64 -0
  140. package/lib/types/runtime/project-runtime.d.ts +10 -3
  141. package/lib/types/runtime/reconcile-scheduler.d.ts +45 -0
  142. package/lib/types/runtime/reconciler.d.ts +2 -0
  143. package/lib/types/runtime/regions.d.ts +1 -4
  144. package/lib/types/runtime/settings-namespace.d.ts +76 -0
  145. package/lib/types/runtime/skills-provider.d.ts +11 -2
  146. package/lib/types/runtime/source-auto-update.d.ts +32 -0
  147. package/lib/types/{model/state.d.ts → runtime/state-store.d.ts} +2 -2
  148. package/lib/types/runtime/storage-migration.d.ts +5 -3
  149. package/lib/types/runtime/subagent-catalog.d.ts +2 -0
  150. package/lib/types/runtime/timer-seat.d.ts +51 -0
  151. package/lib/types/runtime/tool-registry-observer.d.ts +12 -6
  152. package/lib/types/runtime/user-panels.d.ts +69 -19
  153. package/lib/types/runtime/user-store.d.ts +67 -20
  154. package/package.json +68 -24
  155. package/schemas/README.md +1 -1
@@ -1,24 +1,5 @@
1
- /**
2
- * Download regions for GitHub acquisition, modeled on dshmarket's regions.js:
3
- * one setting answers "where are you" instead of a row of mirror toggles.
4
- *
5
- * The China route proxies github.com clones through a public prefix proxy —
6
- * `git clone https://gh-proxy.com/https://github.com/owner/repo` — rather
7
- * than rewriting the hostname, which is what lets one prefix cover clone,
8
- * fetch, and release downloads without a mapping table per service. Public
9
- * proxies come and go, so the route carries an environment escape hatch
10
- * (`DSH_MARKET_GITHUB_PROXY`), and the region itself is a setting the user
11
- * can flip at any time; sources already cloned keep their proxied origin and
12
- * follow it on refresh.
13
- *
14
- * @module runtime/regions
15
- */
16
1
  /** The China-route GitHub prefix proxy, no trailing slash. */
17
2
  const DEFAULT_GITHUB_PROXY = 'https://gh-proxy.com';
18
- /** Narrow an untrusted stored value to a setting; anything else reads as auto. */
19
- export function narrowDownloadRegion(value) {
20
- return value === 'global' || value === 'china' ? value : 'auto';
21
- }
22
3
  /**
23
4
  * Resolve the effective region: an explicit choice wins; `auto` follows the
24
5
  * interface language — a Chinese-language user defaults to the China route.
@@ -1,5 +1,6 @@
1
- import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
1
+ import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
+ import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
3
4
  import { MCP_SCHEMA_ID, validateAgainstSchema, validateMcpJson } from '../catalog/validate.js';
4
5
  import { parseLspServers } from '../catalog/lsp-spec.js';
5
6
  import { qualifiedSuiteId } from '../catalog/paths.js';
@@ -56,10 +57,11 @@ export async function loadLspOverrides(root) {
56
57
  export async function saveLspOverride(root, id, config) {
57
58
  const overrides = await loadLspOverrides(root);
58
59
  overrides[id] = config;
59
- await mkdir(root, { recursive: true });
60
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);
61
+ await writeFileAtomic(path, JSON.stringify(Object.fromEntries(Object.entries(overrides).map(([key, value]) => [key, lspConfig(value)])), null, 2), {
62
+ mode: 0o600,
63
+ dirMode: 0o700
64
+ });
63
65
  }
64
66
  export async function applyLspOverrides(root, suites) {
65
67
  const overrides = await loadLspOverrides(root);
@@ -0,0 +1,144 @@
1
+ import { MARKET_SETTINGS_NAMESPACE, resolveMarketSettings } from '../contracts/settings.js';
2
+ import { FEEDBACK_TOOL_NAME, mountFeedbackTool } from './feedback-tool.js';
3
+ import { MarketSettingsSchema, readMcpBackend } from './mcp-backend.js';
4
+ export class MarketSettingsNamespace {
5
+ ctx;
6
+ dataRoot;
7
+ locale;
8
+ host;
9
+ scope;
10
+ watchers = [];
11
+ feedbackDisposer;
12
+ /**
13
+ * The feedback tool's last reported mount state. A settings change that does
14
+ * not move it (region, backend, project layouts) must not re-log the same
15
+ * line, and every other state is exactly one line: mounted, skipped by the
16
+ * switch, or skipped because the host has no tools registry to register on.
17
+ */
18
+ feedbackMountState;
19
+ constructor(ctx, dataRoot, locale, host) {
20
+ this.ctx = ctx;
21
+ this.dataRoot = dataRoot;
22
+ this.locale = locale;
23
+ this.host = host;
24
+ }
25
+ /** Register the namespace; the host resolves it whenever the settings service mounts. */
26
+ mount() {
27
+ this.ctx.inject(['settings'], settingsCtx => this.register(settingsCtx));
28
+ }
29
+ /** The persisted MCP backend choice; the built-in client until registration lands. */
30
+ async backend() {
31
+ return this.settings().mcpEnhanced ? 'builtin' : 'host';
32
+ }
33
+ /**
34
+ * Persist a backend choice.
35
+ * @throws when the settings service is not mounted.
36
+ */
37
+ 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' });
41
+ }
42
+ /** The persisted download-region setting. */
43
+ async downloadRegion() {
44
+ return this.settings().downloadRegion;
45
+ }
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
+ /** Apply the background source-update switch. */
101
+ syncAutoUpdateSources() {
102
+ try {
103
+ this.host.setAutoUpdateSources(this.settings().autoUpdateSources);
104
+ }
105
+ catch (error) {
106
+ this.ctx.logger?.error?.(`[dsh-agent-plugins-market] background source update switch failed: ${String(error)}`);
107
+ }
108
+ }
109
+ syncProjectLayouts() {
110
+ void this.host.setScanProjectLayouts(this.settings().scanProjectLayouts).catch(error => {
111
+ this.ctx.logger?.error?.(`[dsh-agent-plugins-market] project layout reconciliation failed: ${String(error)}`);
112
+ });
113
+ }
114
+ syncFeedbackTool() {
115
+ try {
116
+ const wanted = this.settings().feedbackEnabled;
117
+ if (!wanted) {
118
+ if (this.feedbackDisposer !== undefined) {
119
+ this.feedbackDisposer();
120
+ this.feedbackDisposer = undefined;
121
+ }
122
+ }
123
+ else if (this.feedbackDisposer === undefined) {
124
+ this.feedbackDisposer = mountFeedbackTool(this.ctx, this.dataRoot, (key, params) => this.locale.t(key, params)) ?? undefined;
125
+ }
126
+ const state = !wanted ? 'off' : this.feedbackDisposer === undefined ? 'unavailable' : 'mounted';
127
+ if (state === this.feedbackMountState)
128
+ return;
129
+ this.feedbackMountState = state;
130
+ if (state === 'mounted') {
131
+ this.ctx.logger?.info?.(`[dsh-agent-plugins-market] ${FEEDBACK_TOOL_NAME} mounted on the host tools registry`);
132
+ }
133
+ else if (state === 'off') {
134
+ this.ctx.logger?.info?.(`[dsh-agent-plugins-market] ${FEEDBACK_TOOL_NAME} not mounted: feedbackEnabled is off`);
135
+ }
136
+ else {
137
+ this.ctx.logger?.warn?.(`[dsh-agent-plugins-market] ${FEEDBACK_TOOL_NAME} not mounted: the host exposes no tools registry`);
138
+ }
139
+ }
140
+ catch (error) {
141
+ this.ctx.logger?.error?.(`[dsh-agent-plugins-market] feedback tool mount failed: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
142
+ }
143
+ }
144
+ }
@@ -7,24 +7,29 @@
7
7
  * `.agents/skills` (200) but beat custom (300); user suites (450) lose to
8
8
  * the user's own `~/.dsh/skills` (400) and beat `~/.agents/skills` (500).
9
9
  *
10
- * Bodies are rewritten on load: `${CLAUDE_PLUGIN_ROOT}` (which Claude Code
11
- * authors write into skill prose) is substituted with the suite root, and the
12
- * resource base points at the skill directory, so CC-authored skills work
13
- * verbatim under the harness.
10
+ * Bodies are rewritten on load: the path variables Claude Code authors write
11
+ * into skill prose (`${CLAUDE_PLUGIN_ROOT}`, `${CLAUDE_PLUGIN_DATA}`,
12
+ * `${CLAUDE_PROJECT_DIR}`, `${CLAUDE_SKILL_DIR}`) are substituted, `` !`cmd` ``
13
+ * dynamic-context placeholders run, and the resource base points at the skill
14
+ * directory, so CC-authored skills work verbatim under the harness.
14
15
  */
15
16
  import { readFile } from 'node:fs/promises';
16
17
  import { parseSkillFrontmatter, stripFrontmatter } from '../catalog/skills-parse.js';
17
18
  import { parseFrontmatterRecord } from './user-store.js';
18
- import { PLUGIN_ROOT_VARIABLES } from '../model/layouts.js';
19
+ import { expandPluginPaths, pluginRootOf } from '../catalog/plugin-variables.js';
20
+ import { suiteDataDir } from '../catalog/paths.js';
21
+ import { injectDynamicContext } from './dynamic-context.js';
19
22
  export const SUITE_PROJECT_SOURCE = 'agent-plugin-project';
20
23
  export const SUITE_USER_SOURCE = 'agent-plugin-user';
21
24
  const PROJECT_RANK = 250;
22
25
  const USER_RANK = 450;
23
26
  export class SuiteSkillProvider {
24
27
  manager;
28
+ options;
25
29
  name = 'agent-plugin';
26
- constructor(manager) {
30
+ constructor(manager, options = {}) {
27
31
  this.manager = manager;
32
+ this.options = options;
28
33
  }
29
34
  async list(options) {
30
35
  const located = await this.locate(options.cwd);
@@ -42,7 +47,7 @@ export class SuiteSkillProvider {
42
47
  }
43
48
  return unique.map(entry => this.candidateFor(entry));
44
49
  }
45
- async get(candidate, _options) {
50
+ async get(candidate, options) {
46
51
  const locator = candidate.locator;
47
52
  let text;
48
53
  try {
@@ -61,9 +66,21 @@ export class SuiteSkillProvider {
61
66
  const parsed = parseSkillFrontmatter(text, candidate.name);
62
67
  if (typeof parsed === 'string')
63
68
  return undefined;
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));
69
+ const authored = [stripFrontmatter(text), ...(locator.skillInstructions === undefined ? [] : [locator.skillInstructions])].join('\n\n');
70
+ const withPaths = expandPluginPaths(authored, {
71
+ ...(locator.suiteRoot === undefined ? {} : { root: locator.suiteRoot }),
72
+ ...(locator.data === undefined ? {} : { data: locator.data }),
73
+ skillDir: locator.directory,
74
+ ...(options.cwd === undefined ? {} : { projectDir: options.cwd })
75
+ });
76
+ const shell = this.options.shell?.();
77
+ const content = shell === undefined
78
+ ? withPaths
79
+ : await injectDynamicContext(withPaths, {
80
+ shell,
81
+ ...(options.cwd === undefined ? {} : { workdir: options.cwd }),
82
+ ...(options.signal === undefined ? {} : { signal: options.signal })
83
+ });
67
84
  return {
68
85
  name: parsed.name,
69
86
  description: candidate.description,
@@ -79,7 +96,9 @@ export class SuiteSkillProvider {
79
96
  candidateFor(entry) {
80
97
  return {
81
98
  name: entry.skill.name,
82
- description: `[${entry.suite.manifest.name}] ${entry.skill.description}`,
99
+ // The author's description verbatim — the harness renders it into the
100
+ // model's skill catalog, where a suite-name prefix is our packaging.
101
+ description: entry.skill.description,
83
102
  ...(entry.skill.whenToUse === undefined ? {} : { whenToUse: entry.skill.whenToUse }),
84
103
  invocation: entry.skill.invocation,
85
104
  source: entry.source,
@@ -90,7 +109,12 @@ export class SuiteSkillProvider {
90
109
  ...(entry.suite.manifest.skillInstructions === undefined ? {} : { skillInstructions: entry.suite.manifest.skillInstructions }),
91
110
  file: entry.skill.file,
92
111
  directory: entry.skill.directory,
93
- suiteRoot: entry.suite.root
112
+ ...(pluginRootOf(entry.suite) === undefined
113
+ ? {}
114
+ : {
115
+ suiteRoot: entry.suite.root,
116
+ ...(this.options.dataRoot === undefined ? {} : { data: suiteDataDir(this.options.dataRoot, entry.suite.sourceId, entry.suite.id) })
117
+ })
94
118
  },
95
119
  path: entry.skill.file,
96
120
  resourceBase: { kind: 'directory', path: entry.skill.directory }
@@ -100,7 +124,7 @@ export class SuiteSkillProvider {
100
124
  const located = [];
101
125
  const userSuites = await this.manager.enabledUserSuites();
102
126
  for (const suite of userSuites) {
103
- for (const skill of suite.activeSurfaces?.skills === false ? [] : suite.skills) {
127
+ for (const skill of suite.activeSurfaces.skills === false ? [] : suite.skills) {
104
128
  try {
105
129
  if (parseFrontmatterRecord(await readFile(skill.file, 'utf8')).disabled === true)
106
130
  continue;
@@ -120,7 +144,7 @@ export class SuiteSkillProvider {
120
144
  const snapshot = await this.manager.readProjectCatalog(cwd);
121
145
  const located = [];
122
146
  for (const suite of snapshot.enabledSuites) {
123
- for (const skill of suite.activeSurfaces?.skills === false ? [] : suite.skills) {
147
+ for (const skill of suite.activeSurfaces.skills === false ? [] : suite.skills) {
124
148
  try {
125
149
  if (parseFrontmatterRecord(await readFile(skill.file, 'utf8')).disabled === true)
126
150
  continue;
@@ -0,0 +1,66 @@
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. The repetition rides the fiber's
10
+ * timer seat, so it dies with this plugin.
11
+ * @module runtime/source-auto-update
12
+ */
13
+ import { repeat } from './timer-seat.js';
14
+ /** How often the background updater refreshes every source. */
15
+ export const AUTO_UPDATE_INTERVAL_MS = 6 * 60 * 60 * 1000;
16
+ export class SourceAutoUpdater {
17
+ ctx;
18
+ refresh;
19
+ intervalMs;
20
+ stop;
21
+ inFlight = false;
22
+ constructor(ctx, refresh, intervalMs = AUTO_UPDATE_INTERVAL_MS) {
23
+ this.ctx = ctx;
24
+ this.refresh = refresh;
25
+ this.intervalMs = intervalMs;
26
+ }
27
+ /** Whether a refresh timer is currently armed. */
28
+ get enabled() {
29
+ return this.stop !== undefined;
30
+ }
31
+ /** Arm or disarm the interval; repeated calls with the same state do nothing. */
32
+ setEnabled(enabled) {
33
+ if (enabled === this.enabled)
34
+ return;
35
+ if (!enabled) {
36
+ this.stop?.();
37
+ this.stop = undefined;
38
+ this.ctx.logger?.info?.('[dsh-agent-plugins-market] background source updates off');
39
+ return;
40
+ }
41
+ this.stop = repeat(this.ctx, () => void this.run(), this.intervalMs);
42
+ this.ctx.logger?.info?.(`[dsh-agent-plugins-market] background source updates on: every ${Math.round(this.intervalMs / 60_000)} minutes`);
43
+ }
44
+ /** Disarm the interval; an in-flight pass finishes on its own. */
45
+ dispose() {
46
+ this.stop?.();
47
+ this.stop = undefined;
48
+ }
49
+ async run() {
50
+ if (this.inFlight) {
51
+ this.ctx.logger?.warn?.('[dsh-agent-plugins-market] background source update skipped: the previous pass is still running');
52
+ return;
53
+ }
54
+ this.inFlight = true;
55
+ try {
56
+ await this.refresh();
57
+ this.ctx.logger?.info?.('[dsh-agent-plugins-market] background source update finished');
58
+ }
59
+ catch (error) {
60
+ this.ctx.logger?.warn?.(`[dsh-agent-plugins-market] background source update failed: ${error instanceof Error ? error.message : String(error)}`);
61
+ }
62
+ finally {
63
+ this.inFlight = false;
64
+ }
65
+ }
66
+ }
@@ -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 }),
@@ -0,0 +1,80 @@
1
+ function seatOf(ctx) {
2
+ const seat = ctx.get('timer');
3
+ if (seat === undefined)
4
+ return undefined;
5
+ return typeof seat.interval === 'function' && typeof seat.timeout === 'function' && typeof seat.debounce === 'function' ? seat : undefined;
6
+ }
7
+ /**
8
+ * Repeat `callback` every `delayMs` until the returned disposer runs or the
9
+ * fiber disposes.
10
+ * @param ctx - the owning plugin context.
11
+ * @param callback - the repeating work.
12
+ * @param delayMs - the period.
13
+ * @returns the disposer that stops the repetition.
14
+ */
15
+ export function repeat(ctx, callback, delayMs) {
16
+ const seat = seatOf(ctx);
17
+ if (seat !== undefined)
18
+ return seat.interval(callback, delayMs);
19
+ const timer = setInterval(callback, delayMs);
20
+ timer.unref?.();
21
+ return () => {
22
+ clearInterval(timer);
23
+ };
24
+ }
25
+ /**
26
+ * Run `callback` once after `delayMs`, unless the returned disposer runs first
27
+ * or the fiber disposes.
28
+ * @param ctx - the owning plugin context.
29
+ * @param callback - the deferred work.
30
+ * @param delayMs - the delay.
31
+ * @returns the disposer that cancels the pending run.
32
+ */
33
+ export function defer(ctx, callback, delayMs) {
34
+ const seat = seatOf(ctx);
35
+ if (seat !== undefined)
36
+ return seat.timeout(callback, delayMs);
37
+ const timer = setTimeout(callback, delayMs);
38
+ timer.unref?.();
39
+ return () => {
40
+ clearTimeout(timer);
41
+ };
42
+ }
43
+ /**
44
+ * Collapse a burst of calls into one run of `callback`, `delayMs` after the
45
+ * last call, until the trigger is disposed or the fiber disposes.
46
+ * @param ctx - the owning plugin context.
47
+ * @param callback - the coalesced work.
48
+ * @param delayMs - the quiet period.
49
+ * @returns the trigger to call per event and to dispose at teardown.
50
+ */
51
+ export function coalesce(ctx, callback, delayMs) {
52
+ const seat = seatOf(ctx);
53
+ if (seat !== undefined) {
54
+ const debounced = seat.debounce(callback, delayMs);
55
+ // The seat returns the debounced function itself, carrying its own
56
+ // disposer; capture that disposer before this wrapper replaces the field.
57
+ const stopSeat = debounced.dispose;
58
+ const trigger = debounced;
59
+ trigger.dispose = () => {
60
+ stopSeat?.call(debounced);
61
+ };
62
+ return trigger;
63
+ }
64
+ let timer;
65
+ const trigger = (() => {
66
+ if (timer !== undefined)
67
+ clearTimeout(timer);
68
+ timer = setTimeout(() => {
69
+ timer = undefined;
70
+ callback();
71
+ }, delayMs);
72
+ timer.unref?.();
73
+ });
74
+ trigger.dispose = () => {
75
+ if (timer !== undefined)
76
+ clearTimeout(timer);
77
+ timer = undefined;
78
+ };
79
+ return trigger;
80
+ }