coding-tool-x 3.8.6 → 3.8.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/dist/web/assets/{AgentsDrawer-Db9wAyaZ.js → AgentsDrawer-DRQuwi0m.js} +1 -1
  2. package/dist/web/assets/Analytics-Cf5KwEdj.js +1 -0
  3. package/dist/web/assets/AssetPathField-DyniM0pF.js +1 -0
  4. package/dist/web/assets/{ChatHistoryDrawer-CwzJHyNP.js → ChatHistoryDrawer-CrfS0KYV.js} +1 -1
  5. package/dist/web/assets/{CollapseItem-CTCfSiue.js → CollapseItem-ClMsJuUu.js} +1 -1
  6. package/dist/web/assets/{CommandsDrawer-vDghB1U9.js → CommandsDrawer-CzHelf56.js} +1 -1
  7. package/dist/web/assets/ConfigExportDrawer-BEZ8wTby.js +1 -0
  8. package/dist/web/assets/ConfigTemplates-Crg3xWe5.js +1 -0
  9. package/dist/web/assets/{ConfigTemplatesDrawer-CbsJp76X.js → ConfigTemplatesDrawer-BqM32Im7.js} +1 -1
  10. package/dist/web/assets/{ConfigTemplatesPanel-liMSTh7s.js → ConfigTemplatesPanel-lTdutC8H.js} +1 -1
  11. package/dist/web/assets/{Divider-D28g1gwj.js → Divider-DsdbkHrG.js} +1 -1
  12. package/dist/web/assets/{FavoritesDrawer-D6QSiYaL.js → FavoritesDrawer-DDyWUBJR.js} +1 -1
  13. package/dist/web/assets/Forward-DLCL7aMY.js +1 -0
  14. package/dist/web/assets/{GatewayConvertDrawer-RvET6iNG.js → GatewayConvertDrawer-Rigtt_5J.js} +1 -1
  15. package/dist/web/assets/Home-tCSKmSHV.js +1 -0
  16. package/dist/web/assets/{MarkdownEditor-CYmLGYju.js → MarkdownEditor-BMMUufSc.js} +1 -1
  17. package/dist/web/assets/McpDrawer-CmE-d7VW.js +1 -0
  18. package/dist/web/assets/{McpFormDrawer-BcTlgSpN.js → McpFormDrawer-ByvS18od.js} +1 -1
  19. package/dist/web/assets/{PluginManager-Dh2o0xkZ.js → PluginManager-C4kJlpVX.js} +1 -1
  20. package/dist/web/assets/{PluginsDrawer-deKQ5u3I.js → PluginsDrawer-05R4k15x.js} +1 -1
  21. package/dist/web/assets/PluginsPanel-BCoEs7Og.js +1 -0
  22. package/dist/web/assets/PluginsPanel-CzGMK-Ap.css +1 -0
  23. package/dist/web/assets/ProjectList-BJt1OIaI.js +1 -0
  24. package/dist/web/assets/ProjectList-DG3yu8xZ.css +1 -0
  25. package/dist/web/assets/PromptsDrawer-DnwBf_oW.js +1 -0
  26. package/dist/web/assets/RecentSessionsDrawer-B7d-38wH.js +1 -0
  27. package/dist/web/assets/RecentSessionsDrawer-yrAD4p1A.css +1 -0
  28. package/dist/web/assets/SessionCard-BUgjHQwX.js +1 -0
  29. package/dist/web/assets/SessionList-B-bXZRFk.js +1 -0
  30. package/dist/web/assets/SessionList-qG_XpPBJ.css +1 -0
  31. package/dist/web/assets/{SettingsDrawer-u_vwUuPS.js → SettingsDrawer-BITzjQLl.js} +1 -1
  32. package/dist/web/assets/SkillManager-D9jkNuRM.js +1 -0
  33. package/dist/web/assets/{SkillsDrawer-CkF3BpyC.js → SkillsDrawer-0Z4IefxC.js} +1 -1
  34. package/dist/web/assets/SkillsPanel-BKIVOj7n.js +1 -0
  35. package/dist/web/assets/{SkillsPanel-B3Nm5j32.css → SkillsPanel-C4ke56nF.css} +1 -1
  36. package/dist/web/assets/{SpeedTestDrawer-BgfG1Ked.js → SpeedTestDrawer-Bx1bYf5i.js} +1 -1
  37. package/dist/web/assets/Upload-CzC4rGI4.js +1 -0
  38. package/dist/web/assets/{WorkspaceDrawer-Br5CeCzL.js → WorkspaceDrawer-eujtWpoq.js} +1 -1
  39. package/dist/web/assets/{WorkspaceManager-B2O8-qIw.js → WorkspaceManager-CQ-8Ej68.js} +1 -1
  40. package/dist/web/assets/{WorkspacePanel-5oDKit3c.js → WorkspacePanel-Cql5qE2R.js} +1 -1
  41. package/dist/web/assets/headers-BqDmkMjy.js +1 -0
  42. package/dist/web/assets/{icons-B2Vg-IOC.js → icons-DmFe4b5j.js} +1 -1
  43. package/dist/web/assets/index-Cmmwj3Fx.js +2 -0
  44. package/dist/web/assets/{index-Bi1MQHYX.css → index-Cnbe696C.css} +1 -1
  45. package/dist/web/assets/{light-Cp0t7v32.js → light-0eavxNvt.js} +1 -1
  46. package/dist/web/assets/platforms-BmzzwnSw.js +1 -0
  47. package/dist/web/assets/platforms-DWXgmy2w.js +1 -0
  48. package/dist/web/assets/session-visibility-BAmxh3cK.js +1 -0
  49. package/dist/web/assets/sessions-D4yQtfKb.js +1 -0
  50. package/dist/web/assets/sessions-DqlbocLJ.js +1 -0
  51. package/dist/web/assets/use-message-DqCwefG6.js +1 -0
  52. package/dist/web/assets/{useResponsiveDrawer-jbmqZuJs.js → useResponsiveDrawer-DZd5rNk5.js} +1 -1
  53. package/dist/web/index.html +7 -7
  54. package/package.json +1 -1
  55. package/src/commands/search.js +17 -2
  56. package/src/config/paths.js +2 -0
  57. package/src/platforms/driver-registry.js +9 -1
  58. package/src/platforms/drivers/claude/session-history-adapter.js +72 -1
  59. package/src/platforms/drivers/claude/sessions-implementation.js +49 -7
  60. package/src/platforms/drivers/claude/sessions.js +30 -6
  61. package/src/platforms/drivers/codex/parser.js +57 -1
  62. package/src/platforms/drivers/codex/session-history-adapter.js +8 -2
  63. package/src/platforms/drivers/codex/sessions-implementation.js +25 -3
  64. package/src/platforms/drivers/codex/sessions.js +18 -1
  65. package/src/platforms/drivers/dsh/api-operations.js +396 -0
  66. package/src/platforms/drivers/dsh/channels-implementation.js +458 -0
  67. package/src/platforms/drivers/dsh/channels.js +191 -0
  68. package/src/platforms/drivers/dsh/common.js +571 -0
  69. package/src/platforms/drivers/dsh/native-config.js +93 -0
  70. package/src/platforms/drivers/dsh/native-logs.js +116 -0
  71. package/src/platforms/drivers/dsh/plugin-manager.js +117 -0
  72. package/src/platforms/drivers/dsh/projects.js +23 -0
  73. package/src/platforms/drivers/dsh/proxy-implementation.js +260 -0
  74. package/src/platforms/drivers/dsh/proxy.js +23 -0
  75. package/src/platforms/drivers/dsh/resources.js +346 -0
  76. package/src/platforms/drivers/dsh/sessions.js +587 -0
  77. package/src/platforms/drivers/dsh/statistics-implementation.js +238 -0
  78. package/src/platforms/drivers/dsh/statistics.js +24 -0
  79. package/src/platforms/drivers/gemini/session-history-adapter.js +49 -1
  80. package/src/platforms/drivers/gemini/sessions-implementation.js +46 -1
  81. package/src/platforms/drivers/gemini/sessions.js +2 -1
  82. package/src/platforms/drivers/omp/session-history-adapter.js +42 -1
  83. package/src/platforms/drivers/omp/sessions-implementation.js +51 -3
  84. package/src/platforms/drivers/omp/sessions.js +33 -2
  85. package/src/platforms/drivers/opencode/sessions-implementation.js +89 -9
  86. package/src/platforms/drivers/opencode/sessions.js +37 -1
  87. package/src/platforms/manifest-schema.js +43 -3
  88. package/src/platforms/manifests/dsh.json +542 -0
  89. package/src/platforms/path-resolver.js +2 -0
  90. package/src/platforms/platform-path-context.js +12 -2
  91. package/src/platforms/registry.js +16 -1
  92. package/src/platforms/runtime.js +5 -0
  93. package/src/platforms/session-history-adapters.js +1 -0
  94. package/src/server/api/platform-api-config.js +23 -2
  95. package/src/server/api/platform-route-factory.js +9 -2
  96. package/src/server/api/platforms.js +19 -1
  97. package/src/server/api/plugins.js +108 -13
  98. package/src/server/services/config-registry-service.js +10 -1
  99. package/src/server/services/config-templates-service.js +1 -0
  100. package/src/server/services/plugins-service.js +474 -103
  101. package/src/server/services/session-history-index.js +900 -140
  102. package/src/server/services/session-history-worker.js +3 -1
  103. package/src/server/services/skill-service.js +214 -7
  104. package/src/server/services/usage-log-utils.js +1 -0
  105. package/src/server/services/workspace-service.js +39 -10
  106. package/src/shared/channel-driver.js +80 -5
  107. package/src/shared/driver-factories/api.js +22 -6
  108. package/src/shared/driver-factories/projects.js +8 -0
  109. package/dist/web/assets/Analytics-C4gMzv7g.js +0 -1
  110. package/dist/web/assets/AssetPathField-Bd_nMFu3.js +0 -1
  111. package/dist/web/assets/ConfigExportDrawer-C-XDvlHc.js +0 -1
  112. package/dist/web/assets/ConfigTemplates-CYY8XRED.js +0 -1
  113. package/dist/web/assets/Home-P8eseZcf.js +0 -1
  114. package/dist/web/assets/McpDrawer-Ch3bkKIq.js +0 -1
  115. package/dist/web/assets/PluginsPanel-BofsgQgj.js +0 -1
  116. package/dist/web/assets/PluginsPanel-D6ZqITcR.css +0 -1
  117. package/dist/web/assets/ProjectList-677RLZ8O.css +0 -1
  118. package/dist/web/assets/ProjectList-DhSWJHTA.js +0 -1
  119. package/dist/web/assets/PromptsDrawer-Cfb2FeeU.js +0 -1
  120. package/dist/web/assets/RecentSessionsDrawer-3eXamJhr.js +0 -1
  121. package/dist/web/assets/RecentSessionsDrawer-CKFsOYDM.css +0 -1
  122. package/dist/web/assets/SessionCard-CW0eHURI.js +0 -1
  123. package/dist/web/assets/SessionList-BpYRz1cX.css +0 -1
  124. package/dist/web/assets/SessionList-DQfyDGWU.js +0 -1
  125. package/dist/web/assets/SkillManager-DHMprJLA.js +0 -1
  126. package/dist/web/assets/SkillsPanel-DWE4pFGx.js +0 -1
  127. package/dist/web/assets/Upload-CNwaGOws.js +0 -1
  128. package/dist/web/assets/headers-cf-QapUq.js +0 -1
  129. package/dist/web/assets/index-BMAUPq5K.js +0 -2
  130. package/dist/web/assets/platforms-CBkB45JP.js +0 -1
  131. package/dist/web/assets/platforms-FTpjxAT6.js +0 -1
  132. package/dist/web/assets/sessions-Cmy31slD.js +0 -1
  133. package/dist/web/assets/sessions-yC_oVVh8.js +0 -1
  134. package/dist/web/assets/use-message-DoLM8C1C.js +0 -1
@@ -9,6 +9,7 @@ const os = require('os');
9
9
  const http = require('http');
10
10
  const https = require('https');
11
11
  const path = require('path');
12
+ const crypto = require('crypto');
12
13
  const { execFileSync } = require('child_process');
13
14
  const toml = require('toml');
14
15
  const tomlStringify = require('@iarna/toml').stringify;
@@ -48,7 +49,6 @@ const OPENCODE_CONFIG_DIR = NATIVE_PATHS.opencode.config;
48
49
  const REPO_SOURCE_META_FILE = '.cc-tool-plugin-source.json';
49
50
  const SUPPORTED_REPO_PROVIDERS = ['github', 'gitlab', 'local'];
50
51
  const PLUGIN_LIST_TTL_MS = 1000;
51
- const MARKET_CACHE_TTL_MS = 5 * 60 * 1000;
52
52
  const DEFAULT_GITHUB_HOST = 'https://github.com';
53
53
  const DEFAULT_GITLAB_HOST = 'https://gitlab.com';
54
54
  const DEFAULT_REPOS_BY_PLATFORM = {
@@ -56,7 +56,8 @@ const DEFAULT_REPOS_BY_PLATFORM = {
56
56
  codex: [],
57
57
  gemini: [],
58
58
  opencode: [],
59
- omp: []
59
+ omp: [],
60
+ dsh: []
60
61
  };
61
62
  const PLATFORM_CAPABILITIES = {
62
63
  claude: {
@@ -73,12 +74,12 @@ const PLATFORM_CAPABILITIES = {
73
74
  },
74
75
  codex: {
75
76
  platform: 'codex',
76
- supportsPlugins: true,
77
- repositories: true,
78
- market: true,
79
- install: true,
80
- uninstall: true,
81
- toggle: true,
77
+ supportsPlugins: false,
78
+ repositories: false,
79
+ market: false,
80
+ install: false,
81
+ uninstall: false,
82
+ toggle: false,
82
83
  config: false,
83
84
  import: false,
84
85
  syncRepos: false
@@ -124,6 +125,19 @@ const PLATFORM_CAPABILITIES = {
124
125
  repositoryToggle: false,
125
126
  repositoryAuth: false,
126
127
  installMetadataMode: 'omp'
128
+ },
129
+ dsh: {
130
+ platform: 'dsh',
131
+ supportsPlugins: true,
132
+ repositories: true,
133
+ market: false,
134
+ install: false,
135
+ uninstall: false,
136
+ toggle: false,
137
+ config: false,
138
+ import: false,
139
+ syncRepos: false,
140
+ disabledReason: 'DSH 插件由 profile 管理,当前面板仅提供只读查看'
127
141
  }
128
142
  };
129
143
 
@@ -442,7 +456,7 @@ function stripJsonComments(input = '') {
442
456
  }
443
457
 
444
458
  class PluginsService {
445
- constructor(platform = 'claude', { registry } = {}) {
459
+ constructor(platform = 'claude', { registry, runtime } = {}) {
446
460
  this.platform = resolveManagedPlatform(platform).platform;
447
461
  this.registry = registry || (() => {
448
462
  try {
@@ -451,6 +465,13 @@ class PluginsService {
451
465
  return null;
452
466
  }
453
467
  })();
468
+ this.runtime = runtime || (() => {
469
+ try {
470
+ return require('../platform-context').getPlatformContext().runtime;
471
+ } catch {
472
+ return null;
473
+ }
474
+ })();
454
475
  this.pathContext = null;
455
476
  try {
456
477
  if (typeof this.registry?.resolvePathContext === 'function') {
@@ -486,6 +507,10 @@ class PluginsService {
486
507
  this.opencodePluginsDir = path.join(this.opencodeConfigDir, 'plugins');
487
508
  this.opencodeLegacyPluginsDir = path.join(this.opencodeConfigDir, 'plugin');
488
509
  this.marketCachePath = getPlatformStatePath('pluginMarketCache', this.platform);
510
+ this.pluginContentCacheRoot = path.join(
511
+ path.dirname(this.marketCachePath),
512
+ `${this.platform}-repo-content`
513
+ );
489
514
  this._marketCache = null;
490
515
  this._marketCacheByKey = new Map();
491
516
  this._pluginListCache = new Map();
@@ -494,6 +519,9 @@ class PluginsService {
494
519
  this._marketFetchedAt = 0;
495
520
  this._marketRepoFingerprint = null;
496
521
  this._marketLastUsedStale = false;
522
+ this._marketReadmeCache = new Map();
523
+ this._marketReadmeCacheLoaded = false;
524
+ this._lastFetchedRepoTrees = new Map();
497
525
  this.ompNativeAdapter = this.platform === 'omp'
498
526
  ? new OmpNativePluginAdapter({ pathContext: this.pathContext })
499
527
  : null;
@@ -706,6 +734,8 @@ class PluginsService {
706
734
  this._marketCacheByKey.clear();
707
735
  this._marketFetchedAt = 0;
708
736
  this._marketRepoFingerprint = null;
737
+ this._marketReadmeCache.clear();
738
+ this._marketReadmeCacheLoaded = false;
709
739
  this._invalidatePluginList();
710
740
  if (removeFile) {
711
741
  try {
@@ -723,13 +753,14 @@ class PluginsService {
723
753
  if (!fs.existsSync(this.marketCachePath)) return null;
724
754
  const data = JSON.parse(fs.readFileSync(this.marketCachePath, 'utf-8'));
725
755
  if (Array.isArray(data)) {
726
- return { plugins: data, fetchedAt: 0, reposFingerprint: null };
756
+ return { plugins: data, fetchedAt: 0, reposFingerprint: null, readmes: {} };
727
757
  }
728
758
  if (Array.isArray(data?.plugins)) {
729
759
  return {
730
760
  plugins: data.plugins,
731
761
  fetchedAt: Number(data.fetchedAt || data.time || 0) || 0,
732
- reposFingerprint: typeof data.reposFingerprint === 'string' ? data.reposFingerprint : null
762
+ reposFingerprint: typeof data.reposFingerprint === 'string' ? data.reposFingerprint : null,
763
+ readmes: data.readmes && typeof data.readmes === 'object' ? data.readmes : {}
733
764
  };
734
765
  }
735
766
  } catch (err) {
@@ -740,22 +771,55 @@ class PluginsService {
740
771
 
741
772
  loadMarketCacheFromFile() {
742
773
  const cached = this.loadMarketCacheEnvelope();
743
- return cached?.fetchedAt && Date.now() - cached.fetchedAt < MARKET_CACHE_TTL_MS
744
- ? cached.plugins
745
- : null;
774
+ return Array.isArray(cached?.plugins) ? cached.plugins : null;
775
+ }
776
+
777
+ _pluginReadmeCacheKey(plugin = {}) {
778
+ return [
779
+ plugin.name || '',
780
+ plugin.repoId || '',
781
+ plugin.repoProvider || '',
782
+ plugin.repoHost || '',
783
+ plugin.repoOwner || '',
784
+ plugin.repoName || '',
785
+ plugin.repoBranch || '',
786
+ plugin.repoProjectPath || '',
787
+ plugin.repoLocalPath || '',
788
+ plugin.directory || '',
789
+ plugin.source || ''
790
+ ].join('::');
791
+ }
792
+
793
+ _loadMarketReadmeCache() {
794
+ if (this._marketReadmeCacheLoaded) return this._marketReadmeCache;
795
+ const envelope = this.loadMarketCacheEnvelope();
796
+ this._marketReadmeCache = new Map(Object.entries(envelope?.readmes || {}));
797
+ this._marketReadmeCacheLoaded = true;
798
+ return this._marketReadmeCache;
746
799
  }
747
800
 
748
- saveMarketCacheToFile(plugins, fetchedAt = Date.now(), reposFingerprint = null) {
801
+ saveMarketCacheToFile(plugins, fetchedAt = Date.now(), reposFingerprint = null, readmes = null) {
749
802
  try {
750
803
  this._ensureDir(path.dirname(this.marketCachePath));
751
- fs.writeFileSync(this.marketCachePath, JSON.stringify({
804
+ const payload = {
752
805
  fetchedAt,
753
806
  time: fetchedAt,
754
807
  reposFingerprint,
755
- plugins
756
- }), 'utf-8');
808
+ plugins,
809
+ readmes: readmes || Object.fromEntries(this._loadMarketReadmeCache())
810
+ };
811
+ const tempPath = `${this.marketCachePath}.tmp-${process.pid}-${Date.now()}`;
812
+ try {
813
+ fs.writeFileSync(tempPath, JSON.stringify(payload), 'utf-8');
814
+ fs.renameSync(tempPath, this.marketCachePath);
815
+ } catch (error) {
816
+ try {
817
+ if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
818
+ } catch (_) {}
819
+ throw error;
820
+ }
757
821
  } catch (err) {
758
- // ignore cache write errors
822
+ // ignore cache write errors, but do not leave a partial cache behind
759
823
  }
760
824
  }
761
825
 
@@ -784,26 +848,166 @@ class PluginsService {
784
848
  ].join('::');
785
849
  if (seen.has(key)) continue;
786
850
  seen.add(key);
787
- deduped.push({
851
+ deduped.push(this.sanitizePluginListItem({
788
852
  ...plugin,
789
853
  isInstalled: installedNames.has(plugin.name)
790
- });
854
+ }));
791
855
  }
792
856
 
793
857
  deduped.sort((a, b) => (a.name || '').toLowerCase().localeCompare((b.name || '').toLowerCase()));
794
858
  return deduped;
795
859
  }
796
860
 
861
+ sanitizePluginListItem(plugin = {}) {
862
+ const sanitized = { ...plugin };
863
+ for (const key of Object.keys(sanitized)) {
864
+ const normalizedKey = key.toLowerCase();
865
+ if (
866
+ normalizedKey === 'content'
867
+ || normalizedKey === 'fullcontent'
868
+ || normalizedKey === 'manifest'
869
+ || normalizedKey === 'rawmanifest'
870
+ || normalizedKey === 'files'
871
+ || normalizedKey === 'filecontents'
872
+ || (normalizedKey.includes('readme') && normalizedKey !== 'readmeurl')
873
+ ) {
874
+ delete sanitized[key];
875
+ }
876
+ }
877
+ return sanitized;
878
+ }
879
+
880
+ sanitizePluginList(plugins = []) {
881
+ return (Array.isArray(plugins) ? plugins : []).map(plugin => this.sanitizePluginListItem(plugin));
882
+ }
883
+
797
884
  _ensureDir(dirPath) {
798
885
  if (!fs.existsSync(dirPath)) {
799
886
  fs.mkdirSync(dirPath, { recursive: true });
800
887
  }
801
888
  }
802
889
 
890
+ _pluginContentScopeKey(options = {}) {
891
+ if (options.scope !== 'project') return 'user';
892
+ const cwd = options.cwd ? path.resolve(options.cwd) : '';
893
+ if (!cwd) return 'project-unknown';
894
+ return `project-${crypto.createHash('sha256').update(cwd).digest('hex').slice(0, 24)}`;
895
+ }
896
+
897
+ _pluginContentRepoKey(repo = {}) {
898
+ const identity = JSON.stringify({
899
+ provider: repo.provider || '',
900
+ host: repo.host || '',
901
+ owner: repo.owner || '',
902
+ name: repo.name || '',
903
+ projectPath: repo.projectPath || '',
904
+ localPath: repo.localPath || '',
905
+ branch: repo.branch || 'main'
906
+ });
907
+ return crypto.createHash('sha256').update(identity).digest('hex').slice(0, 32);
908
+ }
909
+
910
+ _pluginContentCacheDir(repo, options = {}) {
911
+ return path.join(
912
+ this.pluginContentCacheRoot,
913
+ this._pluginContentScopeKey(options),
914
+ this._pluginContentRepoKey(repo)
915
+ );
916
+ }
917
+
918
+ _readCachedRepoFile(repo, filePath, options = {}) {
919
+ try {
920
+ const normalizedPath = normalizePluginRepoDirectory(filePath, 'Plugin repository file path');
921
+ if (!normalizedPath) return null;
922
+ const cacheDir = this._pluginContentCacheDir(repo, options);
923
+ const metaPath = path.join(cacheDir, 'index.json');
924
+ if (!fs.existsSync(metaPath)) return null;
925
+ const metadata = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
926
+ if (!metadata.ready || !metadata.files?.includes(normalizedPath)) return null;
927
+ const cachedPath = resolveInsideRoot(
928
+ cacheDir,
929
+ normalizedPath,
930
+ 'Cached plugin repository file path',
931
+ { allowHiddenSegments: true }
932
+ );
933
+ if (!fs.existsSync(cachedPath)) return null;
934
+ return fs.readFileSync(cachedPath, 'utf8');
935
+ } catch {
936
+ return null;
937
+ }
938
+ }
939
+
940
+ async _cacheRemoteRepoContent(repo, tree, options = {}) {
941
+ if (repo.provider === 'local') return { fetchedFiles: 0 };
942
+ const files = (Array.isArray(tree) ? tree : [])
943
+ .filter(item => item?.type === 'blob' && item.path)
944
+ .map(item => ({ ...item, path: normalizePluginRepoDirectory(item.path, 'Plugin repository file path') }))
945
+ .filter(item => item.path);
946
+ const uniqueFiles = Array.from(new Map(files.map(file => [file.path, file])).values());
947
+ const cacheDir = this._pluginContentCacheDir(repo, options);
948
+ const tempDir = `${cacheDir}.tmp-${process.pid}-${Date.now()}`;
949
+ let backupDir = null;
950
+
951
+ this._ensureDir(path.dirname(cacheDir));
952
+ this._ensureDir(tempDir);
953
+ try {
954
+ let nextFileIndex = 0;
955
+ const fetchWorker = async () => {
956
+ while (nextFileIndex < uniqueFiles.length) {
957
+ const file = uniqueFiles[nextFileIndex++];
958
+ const content = await this.fetchRepoFileContent(repo, file.path, file);
959
+ const targetPath = resolveInsideRoot(
960
+ tempDir,
961
+ file.path,
962
+ 'Cached plugin repository file path',
963
+ { allowHiddenSegments: true }
964
+ );
965
+ this._ensureDir(path.dirname(targetPath));
966
+ fs.writeFileSync(targetPath, content, 'utf8');
967
+ }
968
+ };
969
+ await Promise.all(Array.from({ length: Math.min(4, Math.max(uniqueFiles.length, 1)) }, fetchWorker));
970
+
971
+ fs.writeFileSync(
972
+ path.join(tempDir, 'index.json'),
973
+ JSON.stringify({
974
+ ready: true,
975
+ repoId: repo.id || '',
976
+ branch: repo.branch || 'main',
977
+ fetchedAt: Date.now(),
978
+ files: uniqueFiles.map(file => file.path)
979
+ }),
980
+ 'utf8'
981
+ );
982
+
983
+ if (fs.existsSync(cacheDir)) {
984
+ backupDir = `${cacheDir}.backup-${process.pid}-${Date.now()}`;
985
+ fs.renameSync(cacheDir, backupDir);
986
+ }
987
+ fs.renameSync(tempDir, cacheDir);
988
+ if (backupDir) fs.rmSync(backupDir, { recursive: true, force: true });
989
+ return { fetchedFiles: uniqueFiles.length };
990
+ } catch (error) {
991
+ try {
992
+ if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
993
+ } catch (_) {}
994
+ if (backupDir && !fs.existsSync(cacheDir) && fs.existsSync(backupDir)) {
995
+ try {
996
+ fs.renameSync(backupDir, cacheDir);
997
+ } catch (_) {}
998
+ }
999
+ throw error;
1000
+ }
1001
+ }
1002
+
803
1003
  _isOpenCode() {
804
1004
  return this.platform === 'opencode';
805
1005
  }
806
1006
 
1007
+ _isDsh() {
1008
+ return this.platform === 'dsh';
1009
+ }
1010
+
807
1011
  _isOmp() {
808
1012
  return this.platform === 'omp';
809
1013
  }
@@ -1337,19 +1541,58 @@ class PluginsService {
1337
1541
  return this._clone(value);
1338
1542
  }
1339
1543
 
1544
+ _listDshPlugins(options = {}) {
1545
+ const driver = this.runtime?.getDriver?.('dsh', 'api');
1546
+ if (!driver?.listPlugins) return { plugins: [] };
1547
+ const request = { query: { ...(options.cwd ? { cwd: options.cwd } : {}) } };
1548
+ const result = driver.listPlugins(request);
1549
+ if (result && typeof result.then === 'function') {
1550
+ // The DSH Driver is normally synchronous for this operation. Keep the
1551
+ // service contract synchronous, matching the existing plugin API.
1552
+ return { plugins: [] };
1553
+ }
1554
+ if (result?.status !== 'ok') return { plugins: [] };
1555
+ const profiles = Array.isArray(result.data?.profiles) ? result.data.profiles : [];
1556
+ const plugins = [];
1557
+ for (const profile of profiles) {
1558
+ for (const plugin of Array.isArray(profile?.plugins) ? profile.plugins : []) {
1559
+ const name = String(plugin.name || '').trim();
1560
+ if (!name) continue;
1561
+ const profileName = String(profile.profile || plugin.profile || '').trim();
1562
+ const key = `dsh:${profileName}:${name}`;
1563
+ plugins.push({
1564
+ ...plugin,
1565
+ key,
1566
+ pluginId: key,
1567
+ name,
1568
+ profile: profileName,
1569
+ source: 'dsh-profile',
1570
+ pluginType: 'dsh-profile',
1571
+ enabled: true,
1572
+ readonly: true,
1573
+ description: plugin.description || '',
1574
+ directory: plugin.name || name
1575
+ });
1576
+ }
1577
+ }
1578
+ return { plugins };
1579
+ }
1580
+
1340
1581
  /**
1341
1582
  * List all installed plugins with their status
1342
1583
  * Reads from Claude Code's native installed_plugins.json
1343
1584
  * @returns {Object} { plugins: Array }
1344
1585
  */
1345
1586
  _listPluginsUncached(options = {}) {
1346
- if (!this._pluginsSupported()) {
1347
- return { plugins: [] };
1587
+ if (this._isDsh()) {
1588
+ return this._listDshPlugins(options);
1348
1589
  }
1349
-
1350
1590
  if (this._isCodex()) {
1351
1591
  return { plugins: this._listCodexCachedPlugins() };
1352
1592
  }
1593
+ if (!this._pluginsSupported()) {
1594
+ return { plugins: [] };
1595
+ }
1353
1596
 
1354
1597
  if (this._isOpenCode()) {
1355
1598
  const plugins = [];
@@ -1518,6 +1761,11 @@ class PluginsService {
1518
1761
  * @returns {Object|null} Plugin details or null
1519
1762
  */
1520
1763
  getPlugin(name, options = {}) {
1764
+ if (this._isDsh()) {
1765
+ return this.listPlugins(options).plugins.find(plugin => (
1766
+ plugin.key === name || plugin.pluginId === name || plugin.name === name
1767
+ )) || null;
1768
+ }
1521
1769
  if (this._isCodex()) {
1522
1770
  const plugin = this.listPlugins().plugins.find(p => p.name === name || `${p.name}@${p.marketplace}` === name);
1523
1771
  if (!plugin) return null;
@@ -1583,19 +1831,24 @@ class PluginsService {
1583
1831
  * @returns {Promise<Object>} Installation result
1584
1832
  */
1585
1833
  async installPlugin(source, repoInfo = null, options = {}) {
1834
+ const installFromRepo = (repo, installOptions = {}) => (
1835
+ Object.keys(installOptions || {}).length > 0
1836
+ ? this._installFromRepoDirectory(repo, installOptions)
1837
+ : this._installFromRepoDirectory(repo)
1838
+ );
1586
1839
  if (this._isCodex()) {
1587
1840
  if (hasRepoInstallInfo(repoInfo)) {
1588
- return this._installFromRepoDirectory(repoInfo);
1841
+ return installFromRepo(repoInfo, options);
1589
1842
  }
1590
1843
 
1591
1844
  const parsedSource = this.parseRepoTreeSource(source);
1592
1845
  if (parsedSource) {
1593
- return this._installFromRepoDirectory(parsedSource);
1846
+ return installFromRepo(parsedSource, options);
1594
1847
  }
1595
1848
 
1596
1849
  const parsedRepo = this._repoFromGitUrl(source, 'main');
1597
1850
  if (parsedRepo) {
1598
- return this._installFromRepoDirectory({ ...parsedRepo, directory: '' });
1851
+ return installFromRepo({ ...parsedRepo, directory: '' }, options);
1599
1852
  }
1600
1853
 
1601
1854
  return {
@@ -1604,6 +1857,13 @@ class PluginsService {
1604
1857
  };
1605
1858
  }
1606
1859
 
1860
+ if (!this.getCapabilities().install) {
1861
+ return {
1862
+ success: false,
1863
+ error: `${this.platform} plugin installation is not supported`
1864
+ };
1865
+ }
1866
+
1607
1867
  if (!this._pluginsSupported()) {
1608
1868
  return {
1609
1869
  success: false,
@@ -1627,17 +1887,17 @@ class PluginsService {
1627
1887
 
1628
1888
  if (this._isOpenCode()) {
1629
1889
  if (hasRepoInstallInfo(repoInfo)) {
1630
- return this._installFromRepoDirectory(repoInfo, { installRoot: this._getOpenCodePluginsDir() });
1890
+ return installFromRepo(repoInfo, { ...options, installRoot: this._getOpenCodePluginsDir() });
1631
1891
  }
1632
1892
 
1633
1893
  const parsedSource = this.parseRepoTreeSource(source);
1634
1894
  if (parsedSource) {
1635
- return this._installFromRepoDirectory(parsedSource, { installRoot: this._getOpenCodePluginsDir() });
1895
+ return installFromRepo(parsedSource, { ...options, installRoot: this._getOpenCodePluginsDir() });
1636
1896
  }
1637
1897
 
1638
1898
  const parsedRepo = this._repoFromGitUrl(source, 'main');
1639
1899
  if (parsedRepo) {
1640
- return this._installFromRepoDirectory({ ...parsedRepo, directory: '' }, { installRoot: this._getOpenCodePluginsDir() });
1900
+ return installFromRepo({ ...parsedRepo, directory: '' }, { ...options, installRoot: this._getOpenCodePluginsDir() });
1641
1901
  }
1642
1902
 
1643
1903
  // OpenCode 原生支持 npm 包名,通过 opencode.json 的 plugin 数组管理
@@ -1660,17 +1920,17 @@ class PluginsService {
1660
1920
  }
1661
1921
 
1662
1922
  if (hasRepoInstallInfo(repoInfo)) {
1663
- return await this._installFromRepoDirectory(repoInfo);
1923
+ return await installFromRepo(repoInfo, options);
1664
1924
  }
1665
1925
 
1666
1926
  const parsedSource = this.parseRepoTreeSource(source);
1667
1927
  if (parsedSource) {
1668
- return await this._installFromRepoDirectory(parsedSource);
1928
+ return await installFromRepo(parsedSource, options);
1669
1929
  }
1670
1930
 
1671
1931
  const parsedRepo = this._repoFromGitUrl(source, 'main');
1672
1932
  if (parsedRepo) {
1673
- return await this._installFromRepoDirectory({ ...parsedRepo, directory: '' });
1933
+ return await installFromRepo({ ...parsedRepo, directory: '' }, options);
1674
1934
  }
1675
1935
 
1676
1936
  // Fallback to original git clone method
@@ -1688,12 +1948,25 @@ class PluginsService {
1688
1948
  const manifestCandidates = this._getManifestCandidates(directory);
1689
1949
 
1690
1950
  try {
1951
+ if (normalizedRepo.provider !== 'local') {
1952
+ const cacheIndexPath = path.join(this._pluginContentCacheDir(normalizedRepo, options), 'index.json');
1953
+ if (!fs.existsSync(cacheIndexPath)) {
1954
+ throw new Error('Plugin repository content is not cached; click refresh before installing');
1955
+ }
1956
+ }
1957
+
1691
1958
  let manifest;
1692
1959
  let manifestPath = '';
1693
1960
  for (const candidate of manifestCandidates) {
1694
1961
  try {
1695
1962
  manifestPath = joinRepoPath(directory, candidate);
1696
- manifest = await this.fetchRepoJson(normalizedRepo, manifestPath);
1963
+ if (normalizedRepo.provider === 'local') {
1964
+ manifest = await this.fetchRepoJson(normalizedRepo, manifestPath);
1965
+ } else {
1966
+ const cachedManifest = this._readCachedRepoFile(normalizedRepo, manifestPath, options);
1967
+ if (cachedManifest === null) throw new Error(`File not found in cached repository: ${manifestPath}`);
1968
+ manifest = JSON.parse(stripJsonComments(cachedManifest));
1969
+ }
1697
1970
  break;
1698
1971
  } catch {
1699
1972
  manifestPath = '';
@@ -1730,52 +2003,14 @@ class PluginsService {
1730
2003
  }
1731
2004
  this.copyDirRecursive(sourceDir, pluginDir);
1732
2005
  } else {
1733
- const tempDir = path.join(os.tmpdir(), `plugin-${Date.now()}`);
1734
- const zipPath = path.join(tempDir, 'repo.zip');
1735
- fs.mkdirSync(tempDir, { recursive: true });
1736
-
1737
- try {
1738
- let zipUrl = '';
1739
- let zipHeaders = {};
1740
-
1741
- if (normalizedRepo.provider === 'gitlab') {
1742
- const projectId = encodeURIComponent(normalizedRepo.projectPath);
1743
- zipUrl = `${normalizedRepo.host}/api/v4/projects/${projectId}/repository/archive.zip?sha=${encodeURIComponent(normalizedRepo.branch)}`;
1744
- const token = this.getGitLabToken(normalizedRepo);
1745
- if (token) {
1746
- zipHeaders['PRIVATE-TOKEN'] = token;
1747
- }
1748
- } else {
1749
- zipUrl = `https://api.github.com/repos/${normalizedRepo.owner}/${normalizedRepo.name}/zipball/${encodeURIComponent(normalizedRepo.branch)}`;
1750
- const token = this.getGitHubToken(normalizedRepo);
1751
- zipHeaders.Accept = 'application/vnd.github+json';
1752
- if (token) {
1753
- zipHeaders.Authorization = `token ${token}`;
1754
- }
1755
- }
1756
-
1757
- await this.downloadFile(zipUrl, zipPath, zipHeaders);
1758
- const zip = new AdmZip(zipPath);
1759
- zip.extractAllTo(tempDir, true);
1760
-
1761
- const extractedDir = fs.readdirSync(tempDir).find(item =>
1762
- fs.statSync(path.join(tempDir, item)).isDirectory()
1763
- );
1764
- if (!extractedDir) {
1765
- throw new Error('Empty archive');
1766
- }
1767
-
1768
- const extractedRoot = path.join(tempDir, extractedDir);
1769
- const sourceDir = directory
1770
- ? resolveInsideRoot(extractedRoot, directory, 'Plugin directory', { allowHiddenSegments: true })
1771
- : extractedRoot;
1772
- if (!fs.existsSync(sourceDir)) {
1773
- throw new Error(`Plugin directory not found: ${directory}`);
1774
- }
1775
- this.copyDirRecursive(sourceDir, pluginDir);
1776
- } finally {
1777
- fs.rmSync(tempDir, { recursive: true, force: true });
2006
+ const cachedRepoDir = this._pluginContentCacheDir(normalizedRepo, options);
2007
+ const sourceDir = directory
2008
+ ? resolveInsideRoot(cachedRepoDir, directory, 'Plugin directory', { allowHiddenSegments: true })
2009
+ : cachedRepoDir;
2010
+ if (!fs.existsSync(sourceDir)) {
2011
+ throw new Error(`Plugin directory not found in cached repository: ${directory}`);
1778
2012
  }
2013
+ this.copyDirRecursive(sourceDir, pluginDir, { excludeNames: directory ? [] : ['index.json'] });
1779
2014
  }
1780
2015
 
1781
2016
  // Write plugin.json if not exists
@@ -2042,6 +2277,13 @@ class PluginsService {
2042
2277
  };
2043
2278
  }
2044
2279
 
2280
+ if (!this.getCapabilities().uninstall) {
2281
+ return {
2282
+ success: false,
2283
+ error: `${this.platform} plugin uninstallation is not supported`
2284
+ };
2285
+ }
2286
+
2045
2287
  if (!this._pluginsSupported()) {
2046
2288
  return {
2047
2289
  success: false,
@@ -2183,6 +2425,10 @@ class PluginsService {
2183
2425
  };
2184
2426
  }
2185
2427
 
2428
+ if (!this.getCapabilities().toggle) {
2429
+ throw new Error(`${this.platform} plugin toggles are not supported`);
2430
+ }
2431
+
2186
2432
  if (!this._pluginsSupported()) {
2187
2433
  throw new Error(`${this.platform} plugin management is not supported`);
2188
2434
  }
@@ -2457,6 +2703,7 @@ class PluginsService {
2457
2703
  * @returns {Array} Updated repos list
2458
2704
  */
2459
2705
  addRepo(repo, options = {}) {
2706
+ if (!this.getCapabilities().repositories) return [];
2460
2707
  if (this._isOmp()) {
2461
2708
  const source = repo.source || repo.sourceUri || repo.repoUrl || repo.url
2462
2709
  || repo.localPath || [repo.owner, repo.name].filter(Boolean).join('/');
@@ -2488,6 +2735,7 @@ class PluginsService {
2488
2735
  * @returns {Array} Updated repos list
2489
2736
  */
2490
2737
  removeRepo(owner, name, repoId = '', options = {}) {
2738
+ if (!this.getCapabilities().repositories) return [];
2491
2739
  if (this._isOmp()) {
2492
2740
  return this.ompNativeAdapter.removeMarketplace(repoId || name, options);
2493
2741
  }
@@ -2511,6 +2759,7 @@ class PluginsService {
2511
2759
  * @returns {Array} Updated repos list
2512
2760
  */
2513
2761
  toggleRepo(owner, name, enabled, repoId = '', options = {}) {
2762
+ if (!this.getCapabilities().repositories) return [];
2514
2763
  if (this._isOmp()) {
2515
2764
  throw new Error('OMP native marketplaces do not support repository toggles');
2516
2765
  }
@@ -2529,6 +2778,7 @@ class PluginsService {
2529
2778
  }
2530
2779
 
2531
2780
  updateRepoAuth(owner, name, token = '', clearToken = false, repoId = '') {
2781
+ if (!this.getCapabilities().repositories) return [];
2532
2782
  if (this._isOmp()) {
2533
2783
  throw new Error('OMP native marketplaces do not support per-repository authentication');
2534
2784
  }
@@ -3029,7 +3279,13 @@ class PluginsService {
3029
3279
  * @param {Object} plugin - Plugin object with name, repoUrl, source, or repoInfo
3030
3280
  * @returns {Promise<string>} README content or empty string
3031
3281
  */
3032
- async getPluginReadme(plugin) {
3282
+ async getPluginReadme(plugin, {
3283
+ allowRemote = false,
3284
+ cacheRemote = false,
3285
+ forceRemote = false,
3286
+ scope = 'user',
3287
+ cwd = null
3288
+ } = {}) {
3033
3289
  try {
3034
3290
  const normalizedDirectory = normalizeRepoPath(plugin.directory || '');
3035
3291
  const readmeCandidates = [];
@@ -3058,6 +3314,10 @@ class PluginsService {
3058
3314
  }
3059
3315
  }
3060
3316
 
3317
+ const readmeCache = this._loadMarketReadmeCache();
3318
+ const cachedReadme = readmeCache.get(this._pluginReadmeCacheKey(plugin));
3319
+ if (!forceRemote && typeof cachedReadme === 'string') return cachedReadme;
3320
+
3061
3321
  let repo = null;
3062
3322
  if (plugin.repoProvider || plugin.repoLocalPath || plugin.repoProjectPath || plugin.repoOwner) {
3063
3323
  try {
@@ -3098,9 +3358,23 @@ class PluginsService {
3098
3358
 
3099
3359
  if (!repo) return '';
3100
3360
 
3361
+ for (const candidate of readmeCandidates) {
3362
+ const cachedReadme = this._readCachedRepoFile(repo, candidate, { scope, cwd });
3363
+ if (cachedReadme !== null) {
3364
+ if (cacheRemote) readmeCache.set(this._pluginReadmeCacheKey(plugin), cachedReadme);
3365
+ return cachedReadme;
3366
+ }
3367
+ }
3368
+
3369
+ if (!allowRemote) return '';
3370
+
3101
3371
  for (const candidate of readmeCandidates) {
3102
3372
  try {
3103
- return await this.fetchRepoFileContent(repo, candidate);
3373
+ const readme = await this.fetchRepoFileContent(repo, candidate);
3374
+ if (cacheRemote && typeof readme === 'string') {
3375
+ readmeCache.set(this._pluginReadmeCacheKey(plugin), readme);
3376
+ }
3377
+ return readme;
3104
3378
  } catch {
3105
3379
  // try next candidate
3106
3380
  }
@@ -3424,36 +3698,31 @@ class PluginsService {
3424
3698
  const key = this._marketKey(options);
3425
3699
  const repos = this._isOmp() ? [] : this.getRepos(options).filter(repo => repo.enabled);
3426
3700
  const reposFingerprint = this._marketReposFingerprint(repos);
3427
- const now = Date.now();
3428
3701
  const cached = this._marketCacheByKey.get(key);
3429
- const memoryPlugins = Array.isArray(cached?.plugins)
3430
- ? cached.plugins
3431
- : (Array.isArray(this._marketCache) ? this._marketCache : null);
3432
- if (!forceRefresh && cached && now - cached.fetchedAt < MARKET_CACHE_TTL_MS
3433
- && cached.reposFingerprint === reposFingerprint) {
3702
+ if (!forceRefresh && cached && Array.isArray(cached.plugins)) {
3434
3703
  return this._clone(cached.plugins);
3435
3704
  }
3436
3705
 
3437
3706
  const disk = this.loadMarketCacheEnvelope();
3438
3707
  if (!forceRefresh && Array.isArray(disk?.plugins)) {
3439
3708
  const diskPlugins = this.prepareMarketPlugins(disk.plugins);
3440
- const diskIsFresh = disk.fetchedAt
3441
- && now - disk.fetchedAt < MARKET_CACHE_TTL_MS
3442
- && (!disk.reposFingerprint || disk.reposFingerprint === reposFingerprint);
3443
- const diskIsMoreComplete = memoryPlugins && diskPlugins.length > memoryPlugins.length;
3444
- if (diskIsFresh || diskIsMoreComplete) {
3445
- this._marketCacheByKey.set(key, {
3446
- plugins: diskPlugins,
3447
- fetchedAt: disk.fetchedAt,
3448
- reposFingerprint: disk.reposFingerprint || reposFingerprint
3449
- });
3450
- this._marketCache = diskPlugins;
3451
- this._marketFetchedAt = disk.fetchedAt;
3452
- this._marketRepoFingerprint = disk.reposFingerprint || reposFingerprint;
3453
- return this._clone(diskPlugins);
3454
- }
3709
+ this._marketCacheByKey.set(key, {
3710
+ plugins: diskPlugins,
3711
+ fetchedAt: disk.fetchedAt,
3712
+ reposFingerprint: disk.reposFingerprint || reposFingerprint
3713
+ });
3714
+ this._marketCache = diskPlugins;
3715
+ this._marketFetchedAt = disk.fetchedAt;
3716
+ this._marketRepoFingerprint = disk.reposFingerprint || reposFingerprint;
3717
+ this._marketReadmeCache = new Map(Object.entries(disk.readmes || {}));
3718
+ this._marketReadmeCacheLoaded = true;
3719
+ return this._clone(diskPlugins);
3455
3720
  }
3456
3721
 
3722
+ // A normal read is deliberately local-only. Missing or stale cache must
3723
+ // never fall through to repository/network discovery.
3724
+ if (!forceRefresh) return [];
3725
+
3457
3726
  if (this._marketRefreshPromise) return this._marketRefreshPromise;
3458
3727
  const promise = this._getMarketPluginsUncached(forceRefresh, options, { reposFingerprint })
3459
3728
  .then(plugins => {
@@ -3477,6 +3746,106 @@ class PluginsService {
3477
3746
  return promise;
3478
3747
  }
3479
3748
 
3749
+ /**
3750
+ * Explicitly refresh every remote resource currently exposed by the plugin
3751
+ * UI, then persist the market index and README content for offline reads.
3752
+ */
3753
+ async refreshRemotePlugins({ platform = this.platform, scope = 'user', projectPath = null } = {}) {
3754
+ if (platform !== this.platform) throw new Error(`PluginsService platform mismatch: ${platform}`);
3755
+ if (!this.getCapabilities().market) {
3756
+ return { status: 'succeeded', fetchedPlugins: 0, fetchedReadmes: 0, failedReadmes: [] };
3757
+ }
3758
+
3759
+ const refreshOptions = {
3760
+ scope,
3761
+ ...(projectPath ? { cwd: projectPath } : {})
3762
+ };
3763
+ const previousEnvelope = this.loadMarketCacheEnvelope();
3764
+ const plugins = await this.getMarketPlugins(true, refreshOptions);
3765
+ const readmes = this._loadMarketReadmeCache();
3766
+ const failedReadmes = [];
3767
+ const failedRepositories = [];
3768
+ let fetchedFiles = 0;
3769
+
3770
+ const repos = this._isOmp() ? [] : this.getRepos(refreshOptions).filter(repo => repo.enabled);
3771
+ for (const repo of repos.filter(item => item.provider !== 'local')) {
3772
+ let fetched = this._lastFetchedRepoTrees.get(repo.id);
3773
+ try {
3774
+ if (!fetched) {
3775
+ throw new Error('Repository tree was not fetched during market refresh');
3776
+ }
3777
+ const result = await this._cacheRemoteRepoContent(repo, fetched.tree, refreshOptions);
3778
+ fetchedFiles += result.fetchedFiles;
3779
+ } catch (error) {
3780
+ failedRepositories.push({ repoId: repo.id, error: error.message });
3781
+ }
3782
+ }
3783
+
3784
+ let fetchedReadmes = 0;
3785
+
3786
+ for (const plugin of plugins) {
3787
+ try {
3788
+ const readme = await this.getPluginReadme(plugin, {
3789
+ allowRemote: true,
3790
+ cacheRemote: true,
3791
+ forceRemote: true
3792
+ });
3793
+ if (readme) fetchedReadmes += 1;
3794
+ } catch (error) {
3795
+ failedReadmes.push({
3796
+ pluginId: plugin.pluginId || plugin.id || plugin.name,
3797
+ error: error.message
3798
+ });
3799
+ }
3800
+ }
3801
+
3802
+ const failed = failedRepositories.length > 0 || failedReadmes.length > 0;
3803
+ const status = failed ? 'failed' : 'succeeded';
3804
+ if (failed) {
3805
+ // The market envelope is the read boundary for normal reads. Restore the
3806
+ // previous snapshot when any part of a full refresh failed so the UI
3807
+ // never observes a half-updated market index.
3808
+ if (previousEnvelope?.plugins) {
3809
+ const previousPlugins = this.prepareMarketPlugins(previousEnvelope.plugins);
3810
+ this._marketCache = previousPlugins;
3811
+ this._marketCacheByKey.clear();
3812
+ this._marketCacheByKey.set(this._marketKey(refreshOptions), {
3813
+ plugins: previousPlugins,
3814
+ fetchedAt: previousEnvelope.fetchedAt || 0,
3815
+ reposFingerprint: previousEnvelope.reposFingerprint || null
3816
+ });
3817
+ this._marketFetchedAt = previousEnvelope.fetchedAt || 0;
3818
+ this._marketRepoFingerprint = previousEnvelope.reposFingerprint || null;
3819
+ this._marketReadmeCache = new Map(Object.entries(previousEnvelope.readmes || {}));
3820
+ this._marketReadmeCacheLoaded = true;
3821
+ this.saveMarketCacheToFile(
3822
+ previousEnvelope.plugins,
3823
+ previousEnvelope.fetchedAt,
3824
+ previousEnvelope.reposFingerprint,
3825
+ previousEnvelope.readmes || {}
3826
+ );
3827
+ } else {
3828
+ this.clearMarketCache();
3829
+ }
3830
+ }
3831
+
3832
+ this.saveMarketCacheToFile(
3833
+ failed ? (previousEnvelope?.plugins || []) : plugins,
3834
+ failed ? (previousEnvelope?.fetchedAt || 0) : Date.now(),
3835
+ failed ? (previousEnvelope?.reposFingerprint || null) : this._marketReposFingerprint(repos),
3836
+ failed ? (previousEnvelope?.readmes || {}) : Object.fromEntries(readmes)
3837
+ );
3838
+
3839
+ return {
3840
+ status,
3841
+ fetchedPlugins: failed ? 0 : plugins.length,
3842
+ fetchedFiles,
3843
+ fetchedReadmes,
3844
+ failedReadmes,
3845
+ failedRepositories
3846
+ };
3847
+ }
3848
+
3480
3849
  /**
3481
3850
  * Get market plugins from configured repositories
3482
3851
  * @returns {Promise<Array>} List of available market plugins
@@ -3486,6 +3855,7 @@ class PluginsService {
3486
3855
  return [];
3487
3856
  }
3488
3857
  this._marketLastUsedStale = false;
3858
+ if (forceRefresh) this._lastFetchedRepoTrees.clear();
3489
3859
  if (this._isOmp()) {
3490
3860
  if (forceRefresh) {
3491
3861
  this.ompNativeAdapter.updateMarketplaces('', options);
@@ -3499,7 +3869,7 @@ class PluginsService {
3499
3869
 
3500
3870
  const fileCache = this.loadMarketCacheEnvelope()?.plugins || null;
3501
3871
 
3502
- const repos = this.getRepos().filter(r => r.enabled);
3872
+ const repos = this.getRepos(options).filter(r => r.enabled);
3503
3873
  const marketPlugins = [];
3504
3874
  let repoFailureCount = 0;
3505
3875
 
@@ -3511,6 +3881,7 @@ class PluginsService {
3511
3881
  const pluginsBefore = marketPlugins.length;
3512
3882
  try {
3513
3883
  const tree = await this.fetchRepoTree(repo);
3884
+ this._lastFetchedRepoTrees.set(repo.id, { repo, tree });
3514
3885
  const files = tree.filter(item => item.type === 'blob');
3515
3886
  const fileMap = new Map(files.map(file => [normalizeRepoPath(file.path), file]));
3516
3887
  const readJson = async (filePath) => {