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
package/lib/index.js CHANGED
@@ -1,21 +1,23 @@
1
1
  import { Catalog } from './application/catalog.js';
2
+ import { settlesWithin } from './runtime/deadline.js';
2
3
  import { RuntimeReconciler } from './runtime/reconciler.js';
4
+ import { ReconcileScheduler } from './runtime/reconcile-scheduler.js';
5
+ import { MarketSettingsNamespace } from './runtime/settings-namespace.js';
6
+ import { deleteMcpAuthGrant } from './runtime/mcp-auth-record.js';
3
7
  import { inspectToolRegistry } from './runtime/tool-registry-observer.js';
4
8
  import { migratePluginStorage } from './runtime/storage-migration.js';
5
9
  import { mountAgentRoleTool } from './runtime/agent-role-router.js';
6
10
  import { projectAgentRoles } from './application/project-agent-roles.js';
7
11
  import { mountProjectCommands, mountProjectMcp, mountProjectHooks, mountSuiteInstructions } from './runtime/project-runtime.js';
8
12
  import { createPanelResources } from './application/panel-resources.js';
9
- import { resolveDataRoot, resolveUserRoot } from './catalog/paths.js';
13
+ import { resolveAgentsRoot, resolveDataRoot, resolveUserRoot } from './catalog/paths.js';
10
14
  import { mountSuiteRoutes } from './routes.js';
11
- import { MCP_SETTINGS_NAMESPACE, MarketSettingsSchema, readMcpBackend } from './runtime/mcp-backend.js';
12
- import { narrowDownloadRegion } from './runtime/regions.js';
13
15
  import { SuiteSkillProvider } from './runtime/skills-provider.js';
14
16
  import { loadLspServers } from './runtime/lsp-direct-config.js';
15
17
  import { loadDisabledLspServers } from './runtime/lsp-server-state.js';
16
18
  import { bindHostLocale, loadHostLocale } from './runtime/host-locale.js';
17
- import { mountFeedbackTool } from './runtime/feedback-tool.js';
18
19
  import { createUserPanelStores } from './runtime/user-panels.js';
20
+ import { SourceAutoUpdater } from './runtime/source-auto-update.js';
19
21
  import { UserPanelSkillProvider } from './runtime/user-panels.js';
20
22
  import { UserCommandMountRegistry } from './runtime/user-commands.js';
21
23
  export const name = 'dsh-agent-plugins-market';
@@ -23,6 +25,7 @@ export const inject = ['skills', 'commands'];
23
25
  export async function apply(ctx, config = {}) {
24
26
  const userRoot = resolveUserRoot(config.userRoot);
25
27
  const dataRoot = resolveDataRoot(config.dataRoot, userRoot);
28
+ const agentsRoot = resolveAgentsRoot();
26
29
  const migration = await migratePluginStorage(config);
27
30
  if (migration.conflicts.length > 0)
28
31
  throw new Error(`Plugin storage migration conflicts (original files retained): ${migration.conflicts.join(', ')}`);
@@ -30,240 +33,126 @@ export async function apply(ctx, config = {}) {
30
33
  let userPanelControl;
31
34
  // Host runtime copy resolves from the harness `locale.preference` setting;
32
35
  // the async settings read lands before the first session starts in practice.
33
- const t = bindHostLocale(undefined);
36
+ const hostLocale = { t: bindHostLocale(undefined) };
34
37
  void loadHostLocale().then(locale => {
35
38
  hostLocale.t = locale.t;
36
39
  providerControl?.invalidate();
37
40
  userPanelControl?.invalidate();
38
41
  });
39
- const hostLocale = { t };
40
- /** Settings watchers to release at teardown (backend watch + feedback switch). */
41
- const settingsWatchers = [];
42
42
  const runtime = new RuntimeReconciler(ctx, dataRoot, key => hostLocale.t(key));
43
43
  // User panel stores (skills / commands / agent personas) and their runtime
44
44
  // contributions: one extra skill provider plus one command mount registry.
45
- const panels = createUserPanelStores(userRoot);
45
+ const panels = createUserPanelStores(agentsRoot);
46
46
  const userCommands = new UserCommandMountRegistry(ctx, panels.commands, key => hostLocale.t(key));
47
47
  let disposed = false;
48
48
  let projectCommands;
49
49
  let projectMcp;
50
50
  let projectHooks;
51
51
  let suitePrompts;
52
- let reconcileRequested = false;
53
- let reconciliation;
54
- const reconcileOnce = async () => {
55
- try {
56
- const suites = await catalog.enabledUserSuites();
57
- if (disposed)
58
- return;
59
- const diagnostics = await runtime.reconcile(suites);
60
- catalog.mcpDiagnostics = diagnostics.mcp;
61
- for (const diagnostic of diagnostics.mcp) {
62
- ctx.logger?.warn(`[dsh-agent-plugins-market] suite "${diagnostic.suiteId}" mcp server "${diagnostic.serverKey}": ${diagnostic.reason}`);
63
- }
64
- for (const diagnostic of diagnostics.lsp) {
65
- ctx.logger?.warn(`[dsh-agent-plugins-market] suite "${diagnostic.suiteId}" lsp server "${diagnostic.serverKey}": ${diagnostic.reason}`);
66
- }
67
- for (const diagnostic of diagnostics.commands) {
68
- if (diagnostic.reason !== '')
69
- ctx.logger?.warn(`[dsh-agent-plugins-market] suite "${diagnostic.suiteId}" command "${diagnostic.command}": ${diagnostic.reason}`);
70
- }
71
- for (const diagnostic of diagnostics.hooks) {
72
- ctx.logger?.warn(`[dsh-agent-plugins-market] suite "${diagnostic.suiteId}" hooks: ${diagnostic.reason}`);
73
- }
74
- for (const error of diagnostics.errors) {
75
- ctx.logger?.warn(`[dsh-agent-plugins-market] ${error.surface} reconcile failed: ${error.reason}`);
76
- }
52
+ const scheduler = new ReconcileScheduler(ctx, runtime, {
53
+ enabledSuites: () => catalog.enabledUserSuites(),
54
+ publishMcpDiagnostics: diagnostics => {
55
+ catalog.mcpDiagnostics = diagnostics;
77
56
  }
78
- catch (error) {
79
- ctx.logger?.warn(`[dsh-agent-plugins-market] runtime reconcile failed: ${error instanceof Error ? error.message : String(error)}`);
80
- throw error;
57
+ });
58
+ /** Reconcile the user command mounts and report each failure once. */
59
+ const reconcileUserCommands = async () => {
60
+ const diagnostics = await userCommands.reconcile().catch(() => []);
61
+ for (const reason of diagnostics) {
62
+ ctx.logger?.warn(`[dsh-agent-plugins-market] user commands: ${reason}`);
81
63
  }
82
64
  };
83
- // Settings, credential and catalog events can arrive during a slow MCP
84
- // connection. Keep one current pass and one fresh follow-up, not a queue
85
- // of stale snapshots that all reconnect the same servers.
86
- const reconcileMounts = () => {
87
- if (disposed)
88
- return Promise.resolve();
89
- reconcileRequested = true;
90
- reconciliation ??= (async () => {
91
- try {
92
- do {
93
- reconcileRequested = false;
94
- await reconcileOnce();
95
- } while (reconcileRequested && !disposed);
96
- }
97
- finally {
98
- reconciliation = undefined;
99
- }
100
- })();
101
- return reconciliation;
102
- };
103
65
  /**
104
- * Coalesce credential updates.
105
- *
106
- * `runtime.usesCredential(ref)` reflects the last completed pass, so an event
107
- * arriving while the first reconcile is still running would be filtered out
108
- * and the remount lost. Pending refs are therefore kept until the next pass
109
- * observes them, and bursts are collapsed into one reconcile.
66
+ * How long the change pipeline holds the queue on one stage. A mount that
67
+ * stops responding — a stdio server that never finishes its handshake, a
68
+ * browser authorization nobody completed — delays its own surface only; the
69
+ * stage keeps running while the pipeline moves on to the next change.
110
70
  */
111
- const pendingCredentialRefs = new Set();
112
- let credentialFlush;
113
- const flushCredentialUpdates = () => {
114
- credentialFlush = undefined;
115
- const refs = [...pendingCredentialRefs];
116
- pendingCredentialRefs.clear();
117
- // Re-read the catalog first: an unknown ref means the snapshot predates
118
- // this change, so reconcile anyway to refresh the known reference set.
119
- void reconcileMounts().catch(() => { });
120
- if (refs.length > 0) {
121
- ctx.logger?.info?.(`[dsh-agent-plugins-market] reconciling after credential update: ${refs.join(', ')}`);
71
+ const CHANGE_STAGE_DEADLINE_MS = 20_000;
72
+ /** Run one pipeline stage, logging its failure or its overrun instead of holding the pipeline. */
73
+ const runStage = async (stage, work) => {
74
+ const running = (async () => await work())();
75
+ void running.catch(error => {
76
+ ctx.logger?.warn(`[dsh-agent-plugins-market] ${stage} failed: ${error instanceof Error ? error.message : String(error)}`);
77
+ });
78
+ if (!(await settlesWithin(running, CHANGE_STAGE_DEADLINE_MS))) {
79
+ ctx.logger?.warn(`[dsh-agent-plugins-market] ${stage} is still running after ${CHANGE_STAGE_DEADLINE_MS}ms; the change pipeline continues without waiting for it`);
122
80
  }
123
81
  };
82
+ /**
83
+ * Catalog change pipeline: invalidate the derived skill and command
84
+ * surfaces, refresh the project mounts, then reconcile every runtime mount.
85
+ * `CatalogContext` schedules it in the background, so a stage that overruns
86
+ * costs freshness, never the mutation that asked for it.
87
+ */
124
88
  const onChanged = async () => {
125
89
  if (disposed)
126
90
  return;
127
91
  providerControl?.invalidate();
128
92
  userPanelControl?.invalidate();
129
- await projectCommands?.refresh();
130
- await suitePrompts?.refresh();
131
- await Promise.all([projectMcp?.refresh(), projectHooks?.refresh()]);
132
- const userCommandDiagnostics = await userCommands.reconcile().catch(() => []);
133
- for (const reason of userCommandDiagnostics) {
134
- ctx.logger?.warn(`[dsh-agent-plugins-market] user commands: ${reason}`);
135
- }
136
- await reconcileMounts();
93
+ await runStage('project commands', () => projectCommands?.refresh());
94
+ await runStage('suite instructions', () => suitePrompts?.refresh());
95
+ await runStage('project MCP and hooks', () => Promise.all([projectMcp?.refresh(), projectHooks?.refresh()]));
96
+ await runStage('user commands', () => reconcileUserCommands());
97
+ await runStage('runtime mounts', () => scheduler.request());
98
+ };
99
+ // Background source updates: off until the settings switch says otherwise.
100
+ const autoUpdate = new SourceAutoUpdater(ctx, () => catalog.refreshSource());
101
+ const settings = new MarketSettingsNamespace(ctx, dataRoot, hostLocale, {
102
+ setScanProjectLayouts: enabled => catalog.setScanProjectLayouts(enabled),
103
+ refreshMcpMounts: () => {
104
+ void Promise.all([scheduler.request(), projectMcp?.refresh()]).catch(() => { });
105
+ },
106
+ setAutoUpdateSources: enabled => autoUpdate.setEnabled(enabled)
107
+ });
108
+ // The credentials store powers the MCP re-authorize action (dropping a grant
109
+ // record forces the next mount through a fresh browser authorization). Every
110
+ // port below is read at call time: this plugin's apply may run before the
111
+ // credentials, tools and settings services provision, and a snapshot taken
112
+ // here would be permanently undefined even after the service is live.
113
+ let toolsRegistry;
114
+ const ports = {
115
+ mcpToolSnapshot: () => (toolsRegistry === undefined ? [] : inspectToolRegistry(toolsRegistry)),
116
+ credentialsStore: {
117
+ deleteGrantRecord: async (serverName) => {
118
+ const store = ctx.get?.('credentials');
119
+ await deleteMcpAuthGrant(store, serverName);
120
+ }
121
+ },
122
+ // Re-authorize must rebuild the live bridge, not just drop the grant: the
123
+ // registry flags the mount and the following reconcile tears it down and
124
+ // remounts, so the server's 401 restarts the browser authorization.
125
+ mcpRemount: (suiteId, serverKey) => runtime.forceMcpRemount(suiteId, serverKey),
126
+ mcpRemountAll: () => runtime.forceMcpRemountAll(),
127
+ mcpServerOwner: serverName => runtime.mcpServerOwner(serverName),
128
+ lspStatusSource: runtime.lsp,
129
+ mcpBackend: () => settings.backend(),
130
+ setMcpBackend: backend => settings.setBackend(backend),
131
+ downloadRegion: () => settings.downloadRegion()
137
132
  };
138
- // The credential store powers the MCP re-authorize action (dropping a grant
139
- // record forces the next mount through a fresh browser authorization).
140
- // Resolved lazily at call time: this plugin's apply may run before the
141
- // credentials plugin provisions, and a snapshot taken here would be
142
- // permanently undefined even after the service is live.
143
- const catalog = new Catalog({ userRoot, dataRoot, onChanged, ...(config.git === undefined ? {} : { git: config.git }) });
133
+ const catalog = new Catalog({ userRoot, dataRoot, agentsRoot, onChanged, ports, ...(config.git === undefined ? {} : { git: config.git }) });
144
134
  await catalog.load();
145
135
  await catalog.mergeSources(config.sources ?? []);
146
136
  const resources = createPanelResources(catalog, panels);
147
- // Mirror oauth.ts's `credentialIdFor`: the record is stored under the folded
148
- // serverName, so the delete must fold the same way or it misses the record.
149
- const credentialIdFor = (serverName) => serverName
150
- .toLowerCase()
151
- .replace(/[^a-z0-9-]+/g, '-')
152
- .replace(/^-+|-+$/g, '') || 'server';
153
- catalog.setCredentialsStore({
154
- deleteGrantRecord: async (serverName) => {
155
- const store = ctx.get?.('credentials');
156
- if (store?.deleteRecord === undefined)
157
- throw new Error('credentials service is not mounted');
158
- await store.deleteRecord(`mcp-auth/${credentialIdFor(serverName)}`);
159
- }
160
- });
161
137
  runtime.setMcpOverridesProvider(async () => catalog.allMcpOverrides(await catalog.enabledUserSuites()));
162
- // Re-authorize must rebuild the live bridge, not just drop the grant: the
163
- // registry flags the mount and the following reconcile tears it down and
164
- // remounts, so the server's 401 restarts the browser authorization.
165
- catalog.setMcpRemountHook((suiteId, serverKey) => runtime.forceMcpRemount(suiteId, serverKey), serverName => runtime.mcpServerOwner(serverName));
166
- catalog.setLspStatusSource(runtime.lsp);
167
- runtime.lsp.setDirectProvider(async () => (await loadLspServers(dataRoot)).servers);
138
+ runtime.lsp.setDirectProvider(async () => (await loadLspServers(agentsRoot)).servers);
168
139
  runtime.lsp.setDisabledProvider(() => loadDisabledLspServers(dataRoot));
169
- // The MCP backend choice is a host settings namespace (the registration is
170
- // also what makes the plugin-config tab serve our card). The node half owns
171
- // it: the provider derives the mount backend from the scope, the writer
172
- // flips the switch, and the watcher remounts servers when it flips. The
173
- // same single registration also gates the experience-feedback tool —
174
- // `settings.register` throws on a duplicate namespace, so there is exactly
175
- // one inject block and one `register` call.
176
- //
177
- // The whole callback is failure-contained on purpose: a throw here is
178
- // INVISIBLE in the UI (the inject resolves asynchronously and cordis only
179
- // logs), and it silently removes the namespace from settings.describe —
180
- // which is exactly what hides the plugin-config card. Every failure mode
181
- // lands in the logger with a loud prefix instead.
182
- let feedbackDisposer;
183
- ctx.inject(['settings'], settingsCtx => {
184
- try {
185
- ctx.logger?.info?.('[dsh-agent-plugins-market] settings inject resolved — registering namespace');
186
- const settings = settingsCtx.settings;
187
- const scope = settings.register(MCP_SETTINGS_NAMESPACE, MarketSettingsSchema, {
188
- base: { mcpEnhanced: true, downloadRegion: 'auto', feedbackEnabled: true, scanProjectLayouts: true }
189
- });
190
- ctx.logger?.info?.('[dsh-agent-plugins-market] settings namespace registered — plugin-config card will serve');
191
- catalog.setMcpBackendProvider(async () => (scope.get().mcpEnhanced === false ? 'host' : 'builtin'));
192
- catalog.setMcpBackendWriter(async (backend) => {
193
- await scope.update({ mcpEnhanced: backend !== 'host' });
194
- });
195
- catalog.setDownloadRegionProvider(async () => narrowDownloadRegion(scope.get().downloadRegion));
196
- const syncProjectLayouts = () => {
197
- void catalog.setScanProjectLayouts(scope.get().scanProjectLayouts !== false).catch(error => {
198
- ctx.logger?.error?.(`[dsh-agent-plugins-market] project layout reconciliation failed: ${String(error)}`);
199
- });
200
- };
201
- syncProjectLayouts();
202
- settingsWatchers.push(scope.watch(syncProjectLayouts));
203
- // One-time migration from the earlier data-root settings.json choice.
204
- void readMcpBackend(dataRoot).then(backend => {
205
- if (backend === 'host')
206
- void scope.update({ mcpEnhanced: false }).catch(() => { });
207
- });
208
- let previousBackend = scope.get().mcpEnhanced !== false;
209
- settingsWatchers.push(scope.watch(() => {
210
- const backend = scope.get().mcpEnhanced !== false;
211
- if (backend === previousBackend)
212
- return;
213
- previousBackend = backend;
214
- void Promise.all([reconcileMounts(), projectMcp?.refresh()]).catch(() => { });
215
- }));
216
- // The experience-feedback model tool: gated by the namespace's
217
- // `feedbackEnabled` field (default on); the switch unregisters it. The
218
- // tool mount is doubly contained — its failure must never take the
219
- // settings namespace (and with it the config card) down with it.
220
- const syncFeedbackTool = () => {
221
- try {
222
- const state = scope.get();
223
- const wanted = state.feedbackEnabled !== false;
224
- if (wanted && feedbackDisposer === undefined) {
225
- feedbackDisposer = mountFeedbackTool(ctx, dataRoot, (key, params) => hostLocale.t(key, params)) ?? undefined;
226
- }
227
- else if (!wanted && feedbackDisposer !== undefined) {
228
- feedbackDisposer();
229
- feedbackDisposer = undefined;
230
- }
231
- }
232
- catch (error) {
233
- ctx.logger?.error?.(`[dsh-agent-plugins-market] feedback tool mount failed: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
234
- }
235
- };
236
- syncFeedbackTool();
237
- settingsWatchers.push(scope.watch(() => syncFeedbackTool()));
238
- }
239
- catch (error) {
240
- 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)}`);
241
- }
242
- });
243
140
  runtime.setMcpBackendProvider(() => catalog.mcpBackend());
244
- const eventHost = ctx;
245
- const disposeCredentialUpdates = eventHost.on?.('credentials/reference-updated', ref => {
246
- pendingCredentialRefs.add(ref);
247
- if (credentialFlush !== undefined)
248
- clearTimeout(credentialFlush);
249
- credentialFlush = setTimeout(flushCredentialUpdates, 150);
250
- credentialFlush.unref?.();
251
- }) ?? (() => { });
141
+ // The namespace must be registered after the catalog exists: the host
142
+ // resolves the inject callback synchronously when the settings service is
143
+ // already mounted, and the registration reads back the project-layout switch.
144
+ settings.mount();
252
145
  ctx.inject(['tools'], toolsCtx => {
253
- const registry = toolsCtx.tools;
254
- catalog.setMcpToolSnapshotProvider(() => inspectToolRegistry(registry));
146
+ toolsRegistry = toolsCtx.tools;
255
147
  // Foreign-namespace guard: a native host MCP client (or another plugin)
256
148
  // owning `mcp__<serverName>__` names makes the mount registry skip its
257
149
  // own server with a clear diagnostic instead of failing mid-registration.
258
- runtime.setMcpToolNamesProvider(() => inspectToolRegistry(registry).map(tool => tool.name));
150
+ runtime.setMcpToolNamesProvider(() => inspectToolRegistry(toolsRegistry).map(tool => tool.name));
259
151
  });
260
152
  void Promise.resolve()
261
153
  .then(async () => {
262
- const userCommandDiagnostics = await userCommands.reconcile().catch(() => []);
263
- for (const reason of userCommandDiagnostics) {
264
- ctx.logger?.warn(`[dsh-agent-plugins-market] user commands: ${reason}`);
265
- }
266
- await reconcileMounts();
154
+ await reconcileUserCommands();
155
+ await scheduler.request();
267
156
  })
268
157
  .catch(() => { });
269
158
  ctx.skills.registerProvider(control => {
@@ -274,7 +163,7 @@ export async function apply(ctx, config = {}) {
274
163
  // edit invalidates only its own catalog contribution.
275
164
  ctx.skills.registerProvider(control => {
276
165
  userPanelControl = control;
277
- return new UserPanelSkillProvider(panels.skills, key => hostLocale.t(key));
166
+ return new UserPanelSkillProvider(panels.skills);
278
167
  });
279
168
  ctx.inject(['tools', 'llm', 'subagents', 'agents'], hostCtx => {
280
169
  hostCtx.effect(() => mountAgentRoleTool(hostCtx, async (parent) => [
@@ -310,15 +199,9 @@ export async function apply(ctx, config = {}) {
310
199
  });
311
200
  ctx.effect(() => () => {
312
201
  disposed = true;
313
- disposeCredentialUpdates();
314
- if (credentialFlush !== undefined)
315
- clearTimeout(credentialFlush);
316
- credentialFlush = undefined;
317
- pendingCredentialRefs.clear();
318
- for (const dispose of settingsWatchers.splice(0))
319
- dispose();
320
- feedbackDisposer?.();
321
- feedbackDisposer = undefined;
202
+ scheduler.dispose();
203
+ autoUpdate.dispose();
204
+ settings.dispose();
322
205
  userCommands.disposeAll();
323
206
  void runtime.dispose();
324
207
  }, 'dsh-agent-plugins-market: lifecycle');
@@ -7,11 +7,34 @@
7
7
  * Codex `.codex-plugin/plugin.json`). Discovery maps every layout onto this
8
8
  * internal shape; runtime injection consumes only the internal shape.
9
9
  */
10
- /** Archive URL extensions the archive acquisition path understands. */
11
- const ARCHIVE_URL_PATTERN = /\.(zip|tgz|tar\.gz|tar)$/i;
10
+ /**
11
+ * Archive URL suffixes with the payload kind each names, longest suffix first
12
+ * so `.tar.gz` wins over `.tar`. Source-kind inference, source-id derivation,
13
+ * and archive extraction all read this one table.
14
+ */
15
+ export const ARCHIVE_SUFFIXES = [
16
+ ['.tar.gz', 'targz'],
17
+ ['.tgz', 'targz'],
18
+ ['.zip', 'zip'],
19
+ ['.tar', 'tar']
20
+ ];
21
+ /** The archive suffix `url` ends with, or undefined when it names no known payload. */
22
+ function matchArchiveSuffix(url) {
23
+ const clean = url.trim().toLowerCase();
24
+ return ARCHIVE_SUFFIXES.find(([suffix]) => clean.endsWith(suffix));
25
+ }
26
+ /** Classify an archive URL by extension; undefined when unsupported. */
27
+ export function archiveFormatOf(url) {
28
+ return matchArchiveSuffix(url)?.[1];
29
+ }
12
30
  /** Whether a URL points at a downloadable archive. */
13
31
  export function isArchiveUrl(url) {
14
- return ARCHIVE_URL_PATTERN.test(url.trim());
32
+ return matchArchiveSuffix(url) !== undefined;
33
+ }
34
+ /** Drop a trailing archive suffix (`plugin-0.1.zip` → `plugin-0.1`); other values pass through. */
35
+ export function stripArchiveSuffix(value) {
36
+ const suffix = matchArchiveSuffix(value)?.[0];
37
+ return suffix === undefined ? value : value.slice(0, -suffix.length);
15
38
  }
16
39
  /**
17
40
  * Effective acquisition kind of a source: the explicit `kind` wins, the
package/lib/routes.js CHANGED
@@ -130,7 +130,7 @@ export function mountSuiteRoutes(hostCtx, manager, panels) {
130
130
  }
131
131
  });
132
132
  post(MARKET_ROUTES.addSource, async (body) => {
133
- const url = String(body['url'] ?? '').trim();
133
+ const url = textField(body['url'] ?? '', 'source url').trim();
134
134
  if (url === '')
135
135
  throw new Error('missing source url');
136
136
  const local = body['local'] === true;
@@ -145,11 +145,11 @@ export function mountSuiteRoutes(hostCtx, manager, panels) {
145
145
  if (!url.startsWith('~/') && url !== '~' && !isAbsolute(expanded))
146
146
  throw new Error('local source url must be an absolute path or start with ~/');
147
147
  }
148
- const branch = body['branch'];
148
+ const branch = body['branch'] === undefined ? undefined : textField(body['branch'], 'branch');
149
149
  const sha256 = parseSha256(body['sha256']);
150
150
  const source = await manager.addSource({
151
151
  url: local || kind === 'local' ? expandHome(url) : url,
152
- ...(typeof branch === 'string' && branch.trim() !== '' ? { branch: branch.trim() } : {}),
152
+ ...(branch !== undefined && branch.trim() !== '' ? { branch: branch.trim() } : {}),
153
153
  ...(local ? { local: true } : {}),
154
154
  ...(kind === undefined ? {} : { kind }),
155
155
  ...(sha256 === undefined ? {} : { sha256 })
@@ -162,13 +162,13 @@ export function mountSuiteRoutes(hostCtx, manager, panels) {
162
162
  throw new Error('missing source id');
163
163
  const patch = {};
164
164
  if (body['url'] !== undefined) {
165
- const url = String(body['url']).trim();
165
+ const url = textField(body['url'], 'source url').trim();
166
166
  if (url === '')
167
167
  throw new Error('missing source url');
168
168
  patch.url = url;
169
169
  }
170
170
  if (body['branch'] !== undefined)
171
- patch.branch = String(body['branch']).trim();
171
+ patch.branch = textField(body['branch'], 'branch').trim();
172
172
  if (body['local'] !== undefined)
173
173
  patch.local = body['local'] === true;
174
174
  const kind = parseSourceKind(body['kind']);
@@ -197,8 +197,8 @@ export function mountSuiteRoutes(hostCtx, manager, panels) {
197
197
  return {};
198
198
  });
199
199
  post(MARKET_ROUTES.refreshSource, async (body) => {
200
- const id = body['id'];
201
- await manager.refreshSource(typeof id === 'string' && id !== '' ? id : undefined);
200
+ const id = body['id'] === undefined ? undefined : textField(body['id'], 'source id');
201
+ await manager.refreshSource(id === '' ? undefined : id);
202
202
  return {};
203
203
  });
204
204
  post(MARKET_ROUTES.install, async (body) => {
@@ -299,12 +299,24 @@ export function mountSuiteRoutes(hostCtx, manager, panels) {
299
299
  return {};
300
300
  });
301
301
  post(`${MARKET_ROUTES.lspServers}/enabled`, async (body) => {
302
- const id = String(body['id'] ?? '');
302
+ const id = textField(body['id'] ?? '', 'LSP server id');
303
303
  if (id === '')
304
304
  throw new Error('missing LSP server id');
305
- await manager.setLspServerEnabled(id, body['enabled'] !== false);
305
+ const enabled = body['enabled'];
306
+ if (typeof enabled !== 'boolean')
307
+ throw new Error('missing boolean enabled');
308
+ await manager.setLspServerEnabled(id, enabled);
306
309
  return {};
307
310
  });
311
+ // Upgrade repair: drop the hand-written profile layer an older release told
312
+ // the user to add for LSP. It edits a file the user owns, so the profile
313
+ // name is matched against what is on disk and nothing else is touched.
314
+ post(MARKET_ROUTES.migrateLspSeam, async (body) => {
315
+ const profile = textField(body['profile'] ?? '', 'profile name').trim();
316
+ if (profile === '')
317
+ throw new Error('missing profile name');
318
+ return { migration: await manager.migrateLegacyLspSeam(profile) };
319
+ });
308
320
  // User panel CRUD (skills / commands / agent personas). The host web
309
321
  // server matches exact pathnames, so the entry name rides the `name` query
310
322
  // parameter (GET read, POST replace, POST .../delete remove). The routes
@@ -327,8 +339,8 @@ export function mountSuiteRoutes(hostCtx, manager, panels) {
327
339
  sendJson(response, 200, { entry });
328
340
  });
329
341
  post(`${userPanelRoute(kind)}/create`, async (body) => {
330
- const name = String(body['name'] ?? '').trim();
331
- const text = String(body['text'] ?? '');
342
+ const name = textField(body['name'] ?? '', 'entry name').trim();
343
+ const text = textField(body['text'] ?? '', 'entry text');
332
344
  if (name === '')
333
345
  throw new Error('missing entry name');
334
346
  const entry = await storeOf(kind).create(name, text);
@@ -337,7 +349,7 @@ export function mountSuiteRoutes(hostCtx, manager, panels) {
337
349
  });
338
350
  post(`${userPanelRoute(kind)}/update`, async (body, request) => {
339
351
  const name = queryOf(request).get('name') ?? '';
340
- const text = String(body['text'] ?? '');
352
+ const text = textField(body['text'] ?? '', 'entry text');
341
353
  if (name === '')
342
354
  throw new Error('missing entry name');
343
355
  await storeOf(kind).update(name, text);
@@ -345,7 +357,7 @@ export function mountSuiteRoutes(hostCtx, manager, panels) {
345
357
  return {};
346
358
  });
347
359
  post(`${userPanelRoute(kind)}/delete`, async (body) => {
348
- const name = String(body['name'] ?? '');
360
+ const name = textField(body['name'] ?? '', 'entry name');
349
361
  if (name === '')
350
362
  throw new Error('missing entry name');
351
363
  await storeOf(kind).remove(name);
@@ -359,6 +371,23 @@ export function mountSuiteRoutes(hostCtx, manager, panels) {
359
371
  dispose();
360
372
  };
361
373
  }
374
+ /**
375
+ * Read a request field this route documents as a string. Bodies are arbitrary
376
+ * JSON, so the field may arrive as any value; anything but a string is
377
+ * rejected rather than coerced, because `String({})` is `"[object Object]"` —
378
+ * a non-empty value that every check downstream would accept and store as a
379
+ * source url, a branch name, or an entry name. Empty stays empty so each
380
+ * caller keeps owning whether that means "missing".
381
+ */
382
+ function textField(value, label) {
383
+ if (typeof value !== 'string')
384
+ throw new Error(`${label} must be a string`);
385
+ return value;
386
+ }
387
+ /** Render an untrusted field for a diagnostic message. Never a value reader — see `textField`. */
388
+ function describe(value) {
389
+ return String(value);
390
+ }
362
391
  function parseTarget(body) {
363
392
  const sourceId = body['sourceId'];
364
393
  const suiteId = body['suiteId'];
@@ -374,13 +403,13 @@ function parseSourceKind(raw) {
374
403
  return undefined;
375
404
  if (raw === 'git' || raw === 'local' || raw === 'archive')
376
405
  return raw;
377
- throw new Error(`invalid source kind "${String(raw)}"`);
406
+ throw new Error(`invalid source kind "${describe(raw)}"`);
378
407
  }
379
408
  /** Parse an optional SHA-256 hex digest; rejects malformed values. */
380
409
  function parseSha256(raw) {
381
410
  if (raw === undefined || raw === null || raw === '')
382
411
  return undefined;
383
- const value = String(raw).trim().toLowerCase();
412
+ const value = textField(raw, 'sha256').trim().toLowerCase();
384
413
  if (!/^[0-9a-f]{64}$/.test(value))
385
414
  throw new Error('sha256 must be a 64-character hex digest');
386
415
  return value;
@@ -396,6 +425,7 @@ function sameOrigin(request) {
396
425
  return false;
397
426
  }
398
427
  }
428
+ /** Parses the request body; `undefined` for an oversized, unparsable, or failed read. */
399
429
  function readJsonBody(request) {
400
430
  return new Promise(resolve => {
401
431
  let size = 0;
@@ -0,0 +1,70 @@
1
+ import { createHash } from 'node:crypto';
2
+ /**
3
+ * The `[source, suite, kind, name]` identity the host encodes into a discovery
4
+ * name, or `undefined` for a name that is not one — a user-authored role name
5
+ * is a plain string, and anything else carries no identity.
6
+ */
7
+ function roleIdentity(name) {
8
+ let parsed;
9
+ try {
10
+ parsed = JSON.parse(name);
11
+ }
12
+ catch {
13
+ return undefined;
14
+ }
15
+ if (!Array.isArray(parsed))
16
+ return undefined;
17
+ const parts = parsed;
18
+ if (parts.length !== 4)
19
+ return undefined;
20
+ const [source, suite, kind, id] = parts;
21
+ if (typeof source !== 'string' || typeof suite !== 'string' || typeof kind !== 'string' || typeof id !== 'string')
22
+ return undefined;
23
+ return [source, suite, kind, id];
24
+ }
25
+ /** Internal IDs stay opaque to callers; only colliding resource names need qualification. */
26
+ export function namedAgentRoles(entries) {
27
+ const roles = entries.map(entry => {
28
+ const identity = roleIdentity(entry.name);
29
+ const base = identity === undefined ? entry.name : identity[3].replace(/\.agent$/, '');
30
+ return { entry, base, suite: identity?.[1] ?? 'user', source: identity?.[0] ?? 'user', level: 0 };
31
+ });
32
+ const baseCounts = new Map();
33
+ for (const role of roles)
34
+ baseCounts.set(role.base, (baseCounts.get(role.base) ?? 0) + 1);
35
+ for (const role of roles)
36
+ if ((baseCounts.get(role.base) ?? 0) > 1)
37
+ role.level = 1;
38
+ const candidate = (role) => {
39
+ if (role.level === 0)
40
+ return role.base;
41
+ const qualified = `${role.suite}/${role.base}`;
42
+ const fullyQualified = `${role.source}/${role.suite}/${role.base}`;
43
+ if (role.level === 1)
44
+ return qualified;
45
+ if (role.level === 2)
46
+ return fullyQualified;
47
+ return `${fullyQualified}~${createHash('sha256').update(role.entry.name).digest('hex')}`;
48
+ };
49
+ // Recheck all names: a qualified collision may overlap another role's literal name.
50
+ for (;;) {
51
+ const groups = new Map();
52
+ for (const role of roles) {
53
+ const key = candidate(role);
54
+ groups.set(key, [...(groups.get(key) ?? []), role]);
55
+ }
56
+ let changed = false;
57
+ for (const group of groups.values()) {
58
+ if (group.length < 2)
59
+ continue;
60
+ for (const role of group) {
61
+ if (role.level < 3) {
62
+ role.level++;
63
+ changed = true;
64
+ }
65
+ }
66
+ }
67
+ if (!changed)
68
+ return roles.map(role => ({ ...role.entry, callName: candidate(role) }));
69
+ }
70
+ }