coding-tool-x 3.8.9 → 3.9.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 (130) hide show
  1. package/README.md +59 -387
  2. package/dist/web/assets/AgentsDrawer-DooV5Qhx.js +1 -0
  3. package/dist/web/assets/Analytics-BXRwqC-P.js +1 -0
  4. package/dist/web/assets/AssetPathField-B-LHvOxq.js +1 -0
  5. package/dist/web/assets/{ChatHistoryDrawer-BTmQ1aQr.js → ChatHistoryDrawer-CoHlGAxb.js} +1 -1
  6. package/dist/web/assets/{CollapseItem-CwqJk3bE.js → CollapseItem-kJkiHxFl.js} +1 -1
  7. package/dist/web/assets/CommandsDrawer-DLQrikcw.js +1 -0
  8. package/dist/web/assets/ConfigExportDrawer-BItwo0FQ.js +1 -0
  9. package/dist/web/assets/{ConfigTemplates-gyERg9iC.js → ConfigTemplates-R3YzzBT3.js} +1 -1
  10. package/dist/web/assets/{ConfigTemplatesDrawer-B17RqFi4.js → ConfigTemplatesDrawer-C_nPKrhx.js} +1 -1
  11. package/dist/web/assets/ConfigTemplatesPanel-BiwrNyYN.js +1 -0
  12. package/dist/web/assets/{Divider-DsdbkHrG.js → Divider-BLLhMTSt.js} +1 -1
  13. package/dist/web/assets/FavoritesDrawer-BAJ-AOpN.js +1 -0
  14. package/dist/web/assets/GatewayConvertDrawer-Av3yzwhP.js +1 -0
  15. package/dist/web/assets/Home-B01aL_SR.js +1 -0
  16. package/dist/web/assets/{MarkdownEditor-9TTwTS-U.js → MarkdownEditor-D75GGt5X.js} +1 -1
  17. package/dist/web/assets/McpDrawer-xNzJtT4X.js +1 -0
  18. package/dist/web/assets/McpFormDrawer-B240mJ5y.js +1 -0
  19. package/dist/web/assets/{PluginManager-BuRcWuBj.js → PluginManager-CgLjBdlr.js} +1 -1
  20. package/dist/web/assets/{PluginsDrawer-_zZENaQn.js → PluginsDrawer-DvJdwMXx.js} +1 -1
  21. package/dist/web/assets/PluginsPanel-kX14E_DT.js +1 -0
  22. package/dist/web/assets/ProjectList-CRtc3Hhx.js +1 -0
  23. package/dist/web/assets/PromptsDrawer-CvT9dA2N.js +1 -0
  24. package/dist/web/assets/RecentSessionsDrawer-DM1gf584.js +1 -0
  25. package/dist/web/assets/SessionCard-cZR2V4Br.js +1 -0
  26. package/dist/web/assets/SessionList-D7-P4Zme.js +1 -0
  27. package/dist/web/assets/SettingsDrawer-BFzaxbgC.css +1 -0
  28. package/dist/web/assets/SettingsDrawer-gW8kh-S8.js +1 -0
  29. package/dist/web/assets/{SkillManager-S698Hns8.js → SkillManager-D0BlI7CG.js} +1 -1
  30. package/dist/web/assets/{SkillsDrawer-Ms4lPCow.js → SkillsDrawer-BMB6uzqP.js} +1 -1
  31. package/dist/web/assets/SkillsPanel-BzR828De.js +1 -0
  32. package/dist/web/assets/Upload-3fNyOQrx.js +1 -0
  33. package/dist/web/assets/{WorkspaceDrawer-L7HJRc6u.js → WorkspaceDrawer-DvtBA58Q.js} +1 -1
  34. package/dist/web/assets/{WorkspaceManager-DsoNg2z8.js → WorkspaceManager-BW5KtsHO.js} +1 -1
  35. package/dist/web/assets/WorkspacePanel-BC_xpAEZ.js +1 -0
  36. package/dist/web/assets/client-BgcNg4Bt.js +1 -0
  37. package/dist/web/assets/{headers-B5JWGn3v.js → headers-DbowkmZJ.js} +1 -1
  38. package/dist/web/assets/{icons-DmFe4b5j.js → icons-B-AzZoeE.js} +1 -1
  39. package/dist/web/assets/index-BFqnddoI.js +2 -0
  40. package/dist/web/assets/index-Bn8BUnwP.css +1 -0
  41. package/dist/web/assets/{light-0eavxNvt.js → light-C2X7hegj.js} +1 -1
  42. package/dist/web/assets/platforms-DrzAtnjY.js +1 -0
  43. package/dist/web/assets/platforms-mqqe_82d.js +1 -0
  44. package/dist/web/assets/project-config-BVt5s-pH.js +1 -0
  45. package/dist/web/assets/sessions-53-Fr68H.js +1 -0
  46. package/dist/web/assets/{sessions-D4yQtfKb.js → sessions-CaWUaysM.js} +1 -1
  47. package/dist/web/assets/use-message-DBsCm7FJ.js +1 -0
  48. package/dist/web/assets/{useResponsiveDrawer-V_onl9NV.js → useResponsiveDrawer-Ckgj760z.js} +1 -1
  49. package/dist/web/assets/{workspaces-mjyPVGS8.js → workspaces-DQuZd4qw.js} +1 -1
  50. package/dist/web/index.html +8 -8
  51. package/docs/analytics.png +0 -0
  52. package/docs/home.png +0 -0
  53. package/package.json +4 -2
  54. package/src/commands/export-config.js +17 -211
  55. package/src/config/model-metadata.json +180 -16
  56. package/src/platforms/channel-auth-service.js +1 -1
  57. package/src/platforms/commands-service.js +28 -0
  58. package/src/platforms/driver-registry.js +1 -0
  59. package/src/platforms/drivers/claude/channels-implementation.js +79 -90
  60. package/src/platforms/drivers/claude/native-config-implementation.js +3 -4
  61. package/src/platforms/drivers/codex/channels-implementation.js +34 -49
  62. package/src/platforms/drivers/codex/native-config-implementation.js +3 -51
  63. package/src/platforms/drivers/dsh/api-operations.js +1 -1
  64. package/src/platforms/drivers/dsh/channels-implementation.js +28 -8
  65. package/src/platforms/drivers/dsh/channels.js +0 -18
  66. package/src/platforms/drivers/dsh/common.js +7 -0
  67. package/src/platforms/drivers/dsh/native-config.js +7 -2
  68. package/src/platforms/drivers/gemini/channels-implementation.js +47 -55
  69. package/src/platforms/drivers/gemini/native-config-implementation.js +8 -20
  70. package/src/platforms/drivers/generic-native-config.js +20 -0
  71. package/src/platforms/drivers/native-config-snapshot.js +30 -4
  72. package/src/platforms/drivers/omp/channels-implementation.js +51 -17
  73. package/src/platforms/drivers/omp/native-config-implementation.js +10 -17
  74. package/src/platforms/drivers/opencode/channels-implementation.js +6 -0
  75. package/src/platforms/drivers/opencode/native-config-implementation.js +3 -67
  76. package/src/platforms/manifest-schema.js +35 -2
  77. package/src/platforms/manifests/claude.json +0 -8
  78. package/src/platforms/manifests/codex.json +0 -8
  79. package/src/platforms/manifests/dsh.json +6 -9
  80. package/src/platforms/manifests/gemini.json +0 -8
  81. package/src/platforms/manifests/omp.json +0 -8
  82. package/src/platforms/manifests/opencode.json +0 -8
  83. package/src/platforms/native-oauth-adapters.js +33 -33
  84. package/src/platforms/remote-notification-providers.js +5 -5
  85. package/src/server/api/settings.js +6 -5
  86. package/src/server/index.js +2 -0
  87. package/src/server/services/bounded-content.js +60 -0
  88. package/src/server/services/channel-balance.js +10 -4
  89. package/src/server/services/config-export-service.js +2 -2
  90. package/src/server/services/config-templates-service.js +5 -1
  91. package/src/server/services/plugins-service.js +162 -85
  92. package/src/server/services/session-history-maintenance-worker.js +71 -0
  93. package/src/server/services/session-history-maintenance.js +166 -0
  94. package/src/server/services/skill-service.js +23 -3
  95. package/src/server/services/speed-test.js +0 -72
  96. package/src/shared/channel-driver.js +16 -0
  97. package/src/shared/driver-factories/api.js +1 -0
  98. package/src/utils/native-config-patcher.js +474 -0
  99. package/dist/web/assets/AgentsDrawer-Cj5CJ4d9.js +0 -1
  100. package/dist/web/assets/Analytics-1doS-8uC.js +0 -1
  101. package/dist/web/assets/AssetPathField-C9TOPl7o.js +0 -1
  102. package/dist/web/assets/CommandsDrawer-CaZWDI_G.js +0 -1
  103. package/dist/web/assets/ConfigExportDrawer-C3vSZEsS.js +0 -1
  104. package/dist/web/assets/ConfigTemplatesPanel-DKIC_FqG.js +0 -1
  105. package/dist/web/assets/FavoritesDrawer-DaziQmBp.js +0 -1
  106. package/dist/web/assets/GatewayConvertDrawer-OMgWUUSc.js +0 -1
  107. package/dist/web/assets/Home-DOSUTfrE.js +0 -1
  108. package/dist/web/assets/McpDrawer-I8rl8lGH.js +0 -1
  109. package/dist/web/assets/McpFormDrawer-DEP3C7ut.js +0 -1
  110. package/dist/web/assets/PluginsPanel-Bs5qZ-TK.js +0 -1
  111. package/dist/web/assets/ProjectList-BSxuL2td.js +0 -1
  112. package/dist/web/assets/PromptsDrawer-CoZ-ZtsY.js +0 -1
  113. package/dist/web/assets/RecentSessionsDrawer-B_17aJBG.js +0 -1
  114. package/dist/web/assets/SessionCard-BL_x0d3t.js +0 -1
  115. package/dist/web/assets/SessionList-DhRYZ6vl.js +0 -1
  116. package/dist/web/assets/SettingsDrawer-Dw5NUgHC.css +0 -1
  117. package/dist/web/assets/SettingsDrawer-fYb0DU7X.js +0 -1
  118. package/dist/web/assets/SkillsPanel-rqnqAOGL.js +0 -1
  119. package/dist/web/assets/SpeedTestDrawer-Bbn38SfV.js +0 -1
  120. package/dist/web/assets/SpeedTestDrawer-Dc0WToJ_.css +0 -1
  121. package/dist/web/assets/Upload-CdyVHxCI.js +0 -1
  122. package/dist/web/assets/WorkspacePanel-Be8PVZah.js +0 -1
  123. package/dist/web/assets/client-UDwXBP9i.js +0 -1
  124. package/dist/web/assets/index-3Lp6z8TQ.css +0 -1
  125. package/dist/web/assets/index-BKlAWTH-.js +0 -2
  126. package/dist/web/assets/platforms-BmzzwnSw.js +0 -1
  127. package/dist/web/assets/platforms-DWXgmy2w.js +0 -1
  128. package/dist/web/assets/project-config-B2baPzYL.js +0 -1
  129. package/dist/web/assets/sessions-CC0j-KH_.js +0 -1
  130. package/dist/web/assets/use-message-DqCwefG6.js +0 -1
@@ -36,6 +36,7 @@ const {
36
36
  normalizeSafeRelativePath,
37
37
  resolveInsideRoot
38
38
  } = require('../../shared/config-artifact-paths');
39
+ const { readTextFileLimited, truncateUtf8 } = require('./bounded-content');
39
40
 
40
41
  const CLAUDE_PLUGINS_DIR = NATIVE_PATHS.claude.plugins
41
42
  || path.join(NATIVE_PATHS.claude.dir || path.dirname(NATIVE_PATHS.claude.settings), 'plugins');
@@ -49,6 +50,13 @@ const OPENCODE_CONFIG_DIR = NATIVE_PATHS.opencode.config;
49
50
  const REPO_SOURCE_META_FILE = '.cc-tool-plugin-source.json';
50
51
  const SUPPORTED_REPO_PROVIDERS = ['github', 'gitlab', 'local'];
51
52
  const PLUGIN_LIST_TTL_MS = 1000;
53
+ const MAX_PLUGIN_DETAIL_BYTES = 128 * 1024;
54
+ const MAX_PLUGIN_MANIFEST_BYTES = 256 * 1024;
55
+ const MAX_PLUGIN_STATE_BYTES = 4 * 1024 * 1024;
56
+ const MAX_PLUGIN_LIST_CACHE_ENTRIES = 32;
57
+ const MAX_MARKET_CACHE_ENTRIES = 32;
58
+ const MAX_MARKET_README_CACHE_ENTRIES = 32;
59
+ const MAX_MARKET_CACHE_FILE_BYTES = 8 * 1024 * 1024;
52
60
  const DEFAULT_GITHUB_HOST = 'https://github.com';
53
61
  const DEFAULT_GITLAB_HOST = 'https://gitlab.com';
54
62
  const DEFAULT_REPOS_BY_PLATFORM = {
@@ -522,6 +530,7 @@ class PluginsService {
522
530
  this._marketReadmeCache = new Map();
523
531
  this._marketReadmeCacheLoaded = false;
524
532
  this._lastFetchedRepoTrees = new Map();
533
+ this._claudeInstalledPluginIndex = null;
525
534
  this.ompNativeAdapter = this.platform === 'omp'
526
535
  ? new OmpNativePluginAdapter({ pathContext: this.pathContext })
527
536
  : null;
@@ -542,6 +551,47 @@ class PluginsService {
542
551
  }
543
552
  }
544
553
 
554
+ _rememberPluginList(key, value, cachedAt = Date.now()) {
555
+ this._pluginListCache.delete(key);
556
+ this._pluginListCache.set(key, {
557
+ value: this._clone(value),
558
+ cachedAt,
559
+ generation: this._pluginListGeneration
560
+ });
561
+ while (this._pluginListCache.size > MAX_PLUGIN_LIST_CACHE_ENTRIES) {
562
+ this._pluginListCache.delete(this._pluginListCache.keys().next().value);
563
+ }
564
+ }
565
+
566
+ _rememberMarketPlugins(key, plugins, fetchedAt, reposFingerprint) {
567
+ this._marketCacheByKey.delete(key);
568
+ this._marketCacheByKey.set(key, { plugins, fetchedAt, reposFingerprint });
569
+ while (this._marketCacheByKey.size > MAX_MARKET_CACHE_ENTRIES) {
570
+ this._marketCacheByKey.delete(this._marketCacheByKey.keys().next().value);
571
+ }
572
+ }
573
+
574
+ _setMarketReadme(key, value) {
575
+ if (typeof value !== 'string') return '';
576
+ const bounded = truncateUtf8(value, MAX_PLUGIN_DETAIL_BYTES);
577
+ this._marketReadmeCache.delete(key);
578
+ this._marketReadmeCache.set(key, bounded.text);
579
+ while (this._marketReadmeCache.size > MAX_MARKET_README_CACHE_ENTRIES) {
580
+ this._marketReadmeCache.delete(this._marketReadmeCache.keys().next().value);
581
+ }
582
+ return bounded.text;
583
+ }
584
+
585
+ _loadMarketReadmes(readmes = {}) {
586
+ this._marketReadmeCache.clear();
587
+ for (const [key, value] of Object.entries(readmes || {})) {
588
+ this._setMarketReadme(key, value);
589
+ if (this._marketReadmeCache.size >= MAX_MARKET_README_CACHE_ENTRIES) break;
590
+ }
591
+ this._marketReadmeCacheLoaded = true;
592
+ return this._marketReadmeCache;
593
+ }
594
+
545
595
  _invalidatePluginList(options = null) {
546
596
  this._pluginListGeneration++;
547
597
  if (!options) {
@@ -751,7 +801,9 @@ class PluginsService {
751
801
  loadMarketCacheEnvelope() {
752
802
  try {
753
803
  if (!fs.existsSync(this.marketCachePath)) return null;
754
- const data = JSON.parse(fs.readFileSync(this.marketCachePath, 'utf-8'));
804
+ const boundedCache = readTextFileLimited(this.marketCachePath, MAX_MARKET_CACHE_FILE_BYTES);
805
+ if (boundedCache.truncated) return null;
806
+ const data = JSON.parse(boundedCache.text);
755
807
  if (Array.isArray(data)) {
756
808
  return { plugins: data, fetchedAt: 0, reposFingerprint: null, readmes: {} };
757
809
  }
@@ -793,20 +845,25 @@ class PluginsService {
793
845
  _loadMarketReadmeCache() {
794
846
  if (this._marketReadmeCacheLoaded) return this._marketReadmeCache;
795
847
  const envelope = this.loadMarketCacheEnvelope();
796
- this._marketReadmeCache = new Map(Object.entries(envelope?.readmes || {}));
797
- this._marketReadmeCacheLoaded = true;
798
- return this._marketReadmeCache;
848
+ return this._loadMarketReadmes(envelope?.readmes || {});
799
849
  }
800
850
 
801
851
  saveMarketCacheToFile(plugins, fetchedAt = Date.now(), reposFingerprint = null, readmes = null) {
802
852
  try {
803
853
  this._ensureDir(path.dirname(this.marketCachePath));
854
+ const readmeSource = readmes || Object.fromEntries(this._loadMarketReadmeCache());
855
+ const boundedReadmes = {};
856
+ for (const [key, value] of Object.entries(readmeSource || {})) {
857
+ if (Object.keys(boundedReadmes).length >= MAX_MARKET_README_CACHE_ENTRIES) break;
858
+ if (typeof value !== 'string') continue;
859
+ boundedReadmes[key] = truncateUtf8(value, MAX_PLUGIN_DETAIL_BYTES).text;
860
+ }
804
861
  const payload = {
805
862
  fetchedAt,
806
863
  time: fetchedAt,
807
864
  reposFingerprint,
808
865
  plugins,
809
- readmes: readmes || Object.fromEntries(this._loadMarketReadmeCache())
866
+ readmes: boundedReadmes
810
867
  };
811
868
  const tempPath = `${this.marketCachePath}.tmp-${process.pid}-${Date.now()}`;
812
869
  try {
@@ -881,6 +938,25 @@ class PluginsService {
881
938
  return (Array.isArray(plugins) ? plugins : []).map(plugin => this.sanitizePluginListItem(plugin));
882
939
  }
883
940
 
941
+ sanitizePluginDetail(plugin = {}) {
942
+ const sanitized = { ...plugin };
943
+ for (const key of Object.keys(sanitized)) {
944
+ const normalizedKey = key.toLowerCase();
945
+ if (
946
+ normalizedKey === 'content'
947
+ || normalizedKey === 'fullcontent'
948
+ || normalizedKey === 'manifest'
949
+ || normalizedKey === 'rawmanifest'
950
+ || normalizedKey === 'files'
951
+ || normalizedKey === 'filecontents'
952
+ || (normalizedKey.includes('readme') && normalizedKey !== 'readmeurl')
953
+ ) {
954
+ delete sanitized[key];
955
+ }
956
+ }
957
+ return sanitized;
958
+ }
959
+
884
960
  _ensureDir(dirPath) {
885
961
  if (!fs.existsSync(dirPath)) {
886
962
  fs.mkdirSync(dirPath, { recursive: true });
@@ -931,7 +1007,7 @@ class PluginsService {
931
1007
  { allowHiddenSegments: true }
932
1008
  );
933
1009
  if (!fs.existsSync(cachedPath)) return null;
934
- return fs.readFileSync(cachedPath, 'utf8');
1010
+ return readTextFileLimited(cachedPath, MAX_PLUGIN_DETAIL_BYTES).text;
935
1011
  } catch {
936
1012
  return null;
937
1013
  }
@@ -1137,7 +1213,7 @@ class PluginsService {
1137
1213
  _readFirstMarkdownParagraph(filePath) {
1138
1214
  if (!fs.existsSync(filePath)) return '';
1139
1215
  try {
1140
- const content = fs.readFileSync(filePath, 'utf8');
1216
+ const content = readTextFileLimited(filePath, MAX_PLUGIN_DETAIL_BYTES).text;
1141
1217
  const lines = content
1142
1218
  .split(/\r?\n/)
1143
1219
  .map(line => line.trim())
@@ -1207,7 +1283,9 @@ class PluginsService {
1207
1283
  const manifestPath = path.join(rootDir, candidate);
1208
1284
  if (!fs.existsSync(manifestPath)) continue;
1209
1285
  try {
1210
- return JSON.parse(stripJsonComments(fs.readFileSync(manifestPath, 'utf8')));
1286
+ const bounded = readTextFileLimited(manifestPath, MAX_PLUGIN_MANIFEST_BYTES);
1287
+ if (bounded.truncated) continue;
1288
+ return JSON.parse(stripJsonComments(bounded.text));
1211
1289
  } catch {
1212
1290
  // try next candidate
1213
1291
  }
@@ -1254,6 +1332,32 @@ class PluginsService {
1254
1332
  }
1255
1333
  }
1256
1334
 
1335
+ _readClaudeInstalledPluginIndex() {
1336
+ if (!fs.existsSync(this.claudeInstalledFile)) return [];
1337
+ try {
1338
+ const stat = fs.statSync(this.claudeInstalledFile);
1339
+ const signature = `${stat.size}:${stat.mtimeMs}`;
1340
+ if (this._claudeInstalledPluginIndex?.signature === signature) {
1341
+ return this._claudeInstalledPluginIndex.entries;
1342
+ }
1343
+ const boundedState = readTextFileLimited(this.claudeInstalledFile, MAX_PLUGIN_STATE_BYTES);
1344
+ if (boundedState.truncated) return [];
1345
+ const data = JSON.parse(boundedState.text);
1346
+ const entries = Object.keys(data.plugins || {}).map(key => {
1347
+ const parsed = splitPluginMarketplaceKey(key);
1348
+ return { key, name: parsed.name, marketplace: parsed.marketplace || '' };
1349
+ });
1350
+ this._claudeInstalledPluginIndex = { signature, entries };
1351
+ return entries;
1352
+ } catch {
1353
+ return [];
1354
+ }
1355
+ }
1356
+
1357
+ _clearClaudeInstalledPluginIndex() {
1358
+ this._claudeInstalledPluginIndex = null;
1359
+ }
1360
+
1257
1361
  _writeClaudeSettings(settings) {
1258
1362
  const filePath = this.pathContext?.customized
1259
1363
  ? (this.pathContext.native?.settings || NATIVE_PATHS.claude.settings)
@@ -1455,6 +1559,7 @@ class PluginsService {
1455
1559
  ...(installData.repoSourceMeta || {})
1456
1560
  }];
1457
1561
  fs.writeFileSync(this.claudeInstalledFile, JSON.stringify(nativeData, null, 2), 'utf8');
1562
+ this._clearClaudeInstalledPluginIndex();
1458
1563
  this._invalidatePluginList();
1459
1564
  this._setClaudePluginEnabled(name, marketplace, true);
1460
1565
  }
@@ -1533,11 +1638,7 @@ class PluginsService {
1533
1638
  }
1534
1639
 
1535
1640
  const value = this._listPluginsUncached(options);
1536
- this._pluginListCache.set(key, {
1537
- value: this._clone(value),
1538
- cachedAt: now,
1539
- generation: this._pluginListGeneration
1540
- });
1641
+ this._rememberPluginList(key, value, now);
1541
1642
  return this._clone(value);
1542
1643
  }
1543
1644
 
@@ -1675,15 +1776,12 @@ class PluginsService {
1675
1776
  let repoId = install.repoId || '';
1676
1777
 
1677
1778
  if (install.installPath && fs.existsSync(install.installPath)) {
1678
- const manifestPath = path.join(install.installPath, 'plugin.json');
1679
- if (fs.existsSync(manifestPath)) {
1680
- try {
1681
- const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
1682
- description = manifest.description || '';
1683
- } catch (err) {
1684
- // Ignore parse errors
1685
- }
1686
- }
1779
+ const manifest = this._readLocalManifest(install.installPath, [
1780
+ 'plugin.json',
1781
+ '.claude-plugin/plugin.json',
1782
+ 'package.json'
1783
+ ]);
1784
+ description = manifest?.description || '';
1687
1785
 
1688
1786
  const repoSourceMeta = this.readRepoSourceMeta(install.installPath) || {};
1689
1787
  repoUrl = repoUrl || repoSourceMeta.repoUrl || '';
@@ -1762,9 +1860,10 @@ class PluginsService {
1762
1860
  */
1763
1861
  getPlugin(name, options = {}) {
1764
1862
  if (this._isDsh()) {
1765
- return this.listPlugins(options).plugins.find(plugin => (
1863
+ const plugin = this.listPlugins(options).plugins.find(plugin => (
1766
1864
  plugin.key === name || plugin.pluginId === name || plugin.name === name
1767
- )) || null;
1865
+ ));
1866
+ return plugin ? this.sanitizePluginDetail(plugin) : null;
1768
1867
  }
1769
1868
  if (this._isCodex()) {
1770
1869
  const plugin = this.listPlugins().plugins.find(p => p.name === name || `${p.name}@${p.marketplace}` === name);
@@ -1785,7 +1884,7 @@ class PluginsService {
1785
1884
  if (this._isOpenCode()) {
1786
1885
  const plugin = this.listPlugins().plugins.find(p => p.name === name || p.directory === name);
1787
1886
  if (!plugin) return null;
1788
- return plugin;
1887
+ return this.sanitizePluginDetail(plugin);
1789
1888
  }
1790
1889
 
1791
1890
  if (this._isOmp()) {
@@ -1793,7 +1892,7 @@ class PluginsService {
1793
1892
  p.pluginId === name || p.id === name || p.name === name
1794
1893
  );
1795
1894
  if (!plugin) return null;
1796
- return plugin;
1895
+ return this.sanitizePluginDetail(plugin);
1797
1896
  }
1798
1897
 
1799
1898
  const plugin = getPlugin(name);
@@ -1806,16 +1905,12 @@ class PluginsService {
1806
1905
 
1807
1906
  let manifest = null;
1808
1907
  if (fs.existsSync(manifestPath)) {
1809
- try {
1810
- manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
1811
- } catch (err) {
1812
- // Ignore parse errors
1813
- }
1908
+ manifest = this._readLocalManifest(pluginDir, ['plugin.json']);
1814
1909
  }
1815
1910
 
1816
1911
  return {
1817
1912
  name,
1818
- ...plugin,
1913
+ ...this.sanitizePluginDetail(plugin),
1819
1914
  description: manifest?.description || '',
1820
1915
  author: manifest?.author || '',
1821
1916
  commands: manifest?.commands || [],
@@ -2374,6 +2469,7 @@ class PluginsService {
2374
2469
  delete data.plugins[key];
2375
2470
  }
2376
2471
  fs.writeFileSync(this.claudeInstalledFile, JSON.stringify(data, null, 2), 'utf8');
2472
+ this._clearClaudeInstalledPluginIndex();
2377
2473
  this._invalidatePluginList();
2378
2474
  removed = true;
2379
2475
  }
@@ -2457,22 +2553,12 @@ class PluginsService {
2457
2553
 
2458
2554
  // Claude: store enabled state in CTX registry
2459
2555
  // First check if plugin exists in native installed_plugins.json
2460
- let pluginExists = false;
2461
2556
  const baseName = name.split('/').pop();
2462
- if (fs.existsSync(this.claudeInstalledFile)) {
2463
- try {
2464
- const data = JSON.parse(fs.readFileSync(this.claudeInstalledFile, 'utf8'));
2465
- if (data.plugins) {
2466
- for (const key of Object.keys(data.plugins)) {
2467
- const { name: pluginName } = splitPluginMarketplaceKey(key);
2468
- if (pluginName === name || key === name || pluginName === baseName) {
2469
- pluginExists = true;
2470
- break;
2471
- }
2472
- }
2473
- }
2474
- } catch (e) { /* ignore */ }
2475
- }
2557
+ const installedEntries = this._readClaudeInstalledPluginIndex();
2558
+ const installedMatch = installedEntries.find(entry => (
2559
+ entry.name === name || entry.key === name || entry.name === baseName
2560
+ ));
2561
+ let pluginExists = Boolean(installedMatch);
2476
2562
 
2477
2563
  // Also check legacy registry
2478
2564
  const legacyPlugin = getPlugin(name);
@@ -2494,20 +2580,7 @@ class PluginsService {
2494
2580
  } catch (e) {
2495
2581
  console.warn('[PluginsService] Failed to update plugin registry:', e.message);
2496
2582
  }
2497
- if (fs.existsSync(this.claudeInstalledFile)) {
2498
- try {
2499
- const data = JSON.parse(fs.readFileSync(this.claudeInstalledFile, 'utf8'));
2500
- for (const key of Object.keys(data.plugins || {})) {
2501
- const parsed = splitPluginMarketplaceKey(key);
2502
- if (parsed.name === name || key === name || parsed.name === baseName) {
2503
- matchedMarketplace = parsed.marketplace || '';
2504
- break;
2505
- }
2506
- }
2507
- } catch {
2508
- // ignore
2509
- }
2510
- }
2583
+ matchedMarketplace = installedMatch?.marketplace || '';
2511
2584
  this._setClaudePluginEnabled(baseName || name, matchedMarketplace, enabled);
2512
2585
 
2513
2586
  return {
@@ -3309,7 +3382,7 @@ class PluginsService {
3309
3382
  : readmeCandidates.map(candidate => path.join(plugin.installPath, candidate));
3310
3383
  for (const candidatePath of localCandidates) {
3311
3384
  if (fs.existsSync(candidatePath)) {
3312
- return fs.readFileSync(candidatePath, 'utf8');
3385
+ return readTextFileLimited(candidatePath, MAX_PLUGIN_DETAIL_BYTES).text;
3313
3386
  }
3314
3387
  }
3315
3388
  }
@@ -3361,7 +3434,7 @@ class PluginsService {
3361
3434
  for (const candidate of readmeCandidates) {
3362
3435
  const cachedReadme = this._readCachedRepoFile(repo, candidate, { scope, cwd });
3363
3436
  if (cachedReadme !== null) {
3364
- if (cacheRemote) readmeCache.set(this._pluginReadmeCacheKey(plugin), cachedReadme);
3437
+ if (cacheRemote) this._setMarketReadme(this._pluginReadmeCacheKey(plugin), cachedReadme);
3365
3438
  return cachedReadme;
3366
3439
  }
3367
3440
  }
@@ -3371,10 +3444,11 @@ class PluginsService {
3371
3444
  for (const candidate of readmeCandidates) {
3372
3445
  try {
3373
3446
  const readme = await this.fetchRepoFileContent(repo, candidate);
3447
+ const boundedReadme = truncateUtf8(readme, MAX_PLUGIN_DETAIL_BYTES).text;
3374
3448
  if (cacheRemote && typeof readme === 'string') {
3375
- readmeCache.set(this._pluginReadmeCacheKey(plugin), readme);
3449
+ this._setMarketReadme(this._pluginReadmeCacheKey(plugin), boundedReadme);
3376
3450
  }
3377
- return readme;
3451
+ return boundedReadme;
3378
3452
  } catch {
3379
3453
  // try next candidate
3380
3454
  }
@@ -3706,16 +3780,16 @@ class PluginsService {
3706
3780
  const disk = this.loadMarketCacheEnvelope();
3707
3781
  if (!forceRefresh && Array.isArray(disk?.plugins)) {
3708
3782
  const diskPlugins = this.prepareMarketPlugins(disk.plugins);
3709
- this._marketCacheByKey.set(key, {
3710
- plugins: diskPlugins,
3711
- fetchedAt: disk.fetchedAt,
3712
- reposFingerprint: disk.reposFingerprint || reposFingerprint
3713
- });
3783
+ this._rememberMarketPlugins(
3784
+ key,
3785
+ diskPlugins,
3786
+ disk.fetchedAt,
3787
+ disk.reposFingerprint || reposFingerprint
3788
+ );
3714
3789
  this._marketCache = diskPlugins;
3715
3790
  this._marketFetchedAt = disk.fetchedAt;
3716
3791
  this._marketRepoFingerprint = disk.reposFingerprint || reposFingerprint;
3717
- this._marketReadmeCache = new Map(Object.entries(disk.readmes || {}));
3718
- this._marketReadmeCacheLoaded = true;
3792
+ this._loadMarketReadmes(disk.readmes || {});
3719
3793
  return this._clone(diskPlugins);
3720
3794
  }
3721
3795
 
@@ -3729,11 +3803,7 @@ class PluginsService {
3729
3803
  this._marketCache = Array.isArray(plugins) ? plugins : [];
3730
3804
  this._marketFetchedAt = this._marketLastUsedStale ? 0 : Date.now();
3731
3805
  this._marketRepoFingerprint = reposFingerprint;
3732
- this._marketCacheByKey.set(key, {
3733
- plugins: this._marketCache,
3734
- fetchedAt: this._marketFetchedAt,
3735
- reposFingerprint
3736
- });
3806
+ this._rememberMarketPlugins(key, this._marketCache, this._marketFetchedAt, reposFingerprint);
3737
3807
  if (!this._marketLastUsedStale) {
3738
3808
  this.saveMarketCacheToFile(this._marketCache, this._marketFetchedAt, reposFingerprint);
3739
3809
  }
@@ -3809,15 +3879,15 @@ class PluginsService {
3809
3879
  const previousPlugins = this.prepareMarketPlugins(previousEnvelope.plugins);
3810
3880
  this._marketCache = previousPlugins;
3811
3881
  this._marketCacheByKey.clear();
3812
- this._marketCacheByKey.set(this._marketKey(refreshOptions), {
3813
- plugins: previousPlugins,
3814
- fetchedAt: previousEnvelope.fetchedAt || 0,
3815
- reposFingerprint: previousEnvelope.reposFingerprint || null
3816
- });
3882
+ this._rememberMarketPlugins(
3883
+ this._marketKey(refreshOptions),
3884
+ previousPlugins,
3885
+ previousEnvelope.fetchedAt || 0,
3886
+ previousEnvelope.reposFingerprint || null
3887
+ );
3817
3888
  this._marketFetchedAt = previousEnvelope.fetchedAt || 0;
3818
3889
  this._marketRepoFingerprint = previousEnvelope.reposFingerprint || null;
3819
- this._marketReadmeCache = new Map(Object.entries(previousEnvelope.readmes || {}));
3820
- this._marketReadmeCacheLoaded = true;
3890
+ this._loadMarketReadmes(previousEnvelope.readmes || {});
3821
3891
  this.saveMarketCacheToFile(
3822
3892
  previousEnvelope.plugins,
3823
3893
  previousEnvelope.fetchedAt,
@@ -4033,4 +4103,11 @@ class PluginsService {
4033
4103
  }
4034
4104
  }
4035
4105
 
4036
- module.exports = { PluginsService };
4106
+ module.exports = {
4107
+ PluginsService,
4108
+ MAX_PLUGIN_DETAIL_BYTES,
4109
+ MAX_PLUGIN_MANIFEST_BYTES,
4110
+ MAX_PLUGIN_LIST_CACHE_ENTRIES,
4111
+ MAX_MARKET_CACHE_ENTRIES,
4112
+ MAX_MARKET_README_CACHE_ENTRIES
4113
+ };
@@ -0,0 +1,71 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { parentPort, workerData } = require('worker_threads');
6
+ const { DatabaseSync } = require('node:sqlite');
7
+
8
+ const MIN_FREE_BYTES = 256 * 1024 * 1024;
9
+ const MIN_FREE_RATIO = 0.2;
10
+
11
+ function readPragmaNumber(db, name) {
12
+ return Number(db.prepare(`PRAGMA ${name}`).get()?.[name]) || 0;
13
+ }
14
+
15
+ function inspectDatabase(dbPath, db) {
16
+ db.exec('PRAGMA busy_timeout = 5000; PRAGMA cache_size = -16384;');
17
+ const pageSize = readPragmaNumber(db, 'page_size');
18
+ const pageCount = readPragmaNumber(db, 'page_count');
19
+ const freePageCount = readPragmaNumber(db, 'freelist_count');
20
+ const fileBytes = fs.statSync(dbPath).size;
21
+ const freeBytes = pageSize * freePageCount;
22
+ const pageBytes = pageSize * pageCount;
23
+
24
+ if (freeBytes < MIN_FREE_BYTES || freeBytes < pageBytes * MIN_FREE_RATIO) {
25
+ return { status: 'below-threshold', beforeBytes: fileBytes, freeBytes };
26
+ }
27
+
28
+ const disk = fs.statfsSync(path.dirname(dbPath));
29
+ const availableBytes = Number(disk.bavail) * Number(disk.bsize);
30
+ if (!Number.isFinite(availableBytes) || availableBytes < fileBytes) {
31
+ return { status: 'insufficient-space', beforeBytes: fileBytes, freeBytes };
32
+ }
33
+
34
+ const checkpoint = db.prepare('PRAGMA wal_checkpoint(TRUNCATE)').get();
35
+ if (Number(checkpoint?.busy) > 0) {
36
+ return { status: 'database-busy', beforeBytes: fileBytes, freeBytes };
37
+ }
38
+
39
+ return { status: 'ready', beforeBytes: fileBytes, freeBytes };
40
+ }
41
+
42
+ function compactDatabase(dbPath) {
43
+ if (!dbPath || !fs.existsSync(dbPath)) return { status: 'missing' };
44
+ const db = new DatabaseSync(dbPath, { timeout: 5000 });
45
+ try {
46
+ const inspection = inspectDatabase(dbPath, db);
47
+ if (inspection.status !== 'ready') return inspection;
48
+
49
+ db.exec('VACUUM;');
50
+ const checkpoint = db.prepare('PRAGMA wal_checkpoint(TRUNCATE)').get();
51
+ const afterBytes = fs.statSync(dbPath).size;
52
+ return {
53
+ status: 'compacted',
54
+ beforeBytes: inspection.beforeBytes,
55
+ afterBytes,
56
+ reclaimedBytes: Math.max(0, inspection.beforeBytes - afterBytes),
57
+ checkpointBusy: Number(checkpoint?.busy) > 0
58
+ };
59
+ } finally {
60
+ db.close();
61
+ }
62
+ }
63
+
64
+ try {
65
+ const result = compactDatabase(workerData?.dbPath);
66
+ parentPort.postMessage({ type: 'done', result });
67
+ parentPort.close();
68
+ } catch (error) {
69
+ parentPort.postMessage({ type: 'error', error: error?.message || String(error) });
70
+ parentPort.close();
71
+ }
@@ -0,0 +1,166 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { Worker } = require('worker_threads');
6
+ const { PATHS } = require('../../config/paths');
7
+
8
+ const MAINTENANCE_LOCK_STALE_MS = 30 * 60 * 1000;
9
+ const INITIAL_MAINTENANCE_DELAY_MS = 60 * 1000;
10
+ const RETRY_MAINTENANCE_DELAY_MS = 5 * 60 * 1000;
11
+ const MAINTENANCE_WORKER_PATH = path.join(__dirname, 'session-history-maintenance-worker.js');
12
+
13
+ const scheduledMaintenances = new Map();
14
+
15
+ function isProcessAlive(pid) {
16
+ if (!Number.isInteger(pid) || pid <= 0) return false;
17
+ try {
18
+ process.kill(pid, 0);
19
+ return true;
20
+ } catch (error) {
21
+ return error?.code === 'EPERM';
22
+ }
23
+ }
24
+
25
+ function acquireMaintenanceLock(dbPath) {
26
+ const lockPath = `${path.resolve(dbPath)}.maintenance.lock`;
27
+
28
+ for (let attempt = 0; attempt < 2; attempt += 1) {
29
+ let fd;
30
+ try {
31
+ fd = fs.openSync(lockPath, 'wx', 0o600);
32
+ fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, startedAt: Date.now() }), 'utf8');
33
+ return { fd, lockPath };
34
+ } catch (error) {
35
+ if (fd !== undefined) {
36
+ try { fs.closeSync(fd); } catch (_) {}
37
+ try { fs.unlinkSync(lockPath); } catch (_) {}
38
+ }
39
+ if (error?.code !== 'EEXIST') throw error;
40
+ }
41
+
42
+ let owner = null;
43
+ let ageMs = 0;
44
+ try {
45
+ owner = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
46
+ ageMs = Math.max(0, Date.now() - fs.statSync(lockPath).mtimeMs);
47
+ } catch (error) {
48
+ if (error?.code === 'ENOENT') continue;
49
+ }
50
+
51
+ const ownerPid = Number(owner?.pid);
52
+ if (isProcessAlive(ownerPid)) return null;
53
+ if ((!Number.isInteger(ownerPid) || ownerPid <= 0) && ageMs < MAINTENANCE_LOCK_STALE_MS) {
54
+ return null;
55
+ }
56
+
57
+ try {
58
+ fs.unlinkSync(lockPath);
59
+ } catch (error) {
60
+ if (error?.code !== 'ENOENT') return null;
61
+ }
62
+ }
63
+
64
+ return null;
65
+ }
66
+
67
+ function releaseMaintenanceLock(lock) {
68
+ if (!lock) return;
69
+ try { fs.closeSync(lock.fd); } catch (_) {}
70
+ try { fs.unlinkSync(lock.lockPath); } catch (_) {}
71
+ }
72
+
73
+ function runMaintenanceWorker(dbPath) {
74
+ return new Promise((resolve, reject) => {
75
+ const worker = new Worker(MAINTENANCE_WORKER_PATH, { workerData: { dbPath } });
76
+ let settled = false;
77
+ worker.unref();
78
+
79
+ const finish = (error, result) => {
80
+ if (settled) return;
81
+ settled = true;
82
+ if (error) reject(error);
83
+ else resolve(result);
84
+ };
85
+
86
+ worker.on('message', (message) => {
87
+ if (message?.type === 'done') {
88
+ finish(null, message.result);
89
+ } else if (message?.type === 'error') {
90
+ finish(new Error(message.error || '会话索引维护线程失败'));
91
+ }
92
+ });
93
+
94
+ worker.once('error', (error) => finish(error));
95
+ worker.once('exit', (code) => {
96
+ if (!settled && code !== 0) {
97
+ finish(new Error(`会话索引维护线程异常退出(代码 ${code})`));
98
+ } else if (!settled) {
99
+ finish(null, { status: 'skipped' });
100
+ }
101
+ });
102
+ });
103
+ }
104
+
105
+ function isRetryableMaintenanceError(error) {
106
+ return /SQLITE_BUSY|database is locked|database table is locked/i.test(error?.message || '');
107
+ }
108
+
109
+ async function runScheduledMaintenance(dbPath) {
110
+ let lock = null;
111
+ try {
112
+ if (!dbPath || !fs.existsSync(dbPath)) return false;
113
+
114
+ lock = acquireMaintenanceLock(dbPath);
115
+ if (!lock) return true;
116
+
117
+ const lockState = require('./session-history-index').cleanupStaleInventoryLocks(dbPath);
118
+ if (lockState.retained.length > 0) return true;
119
+
120
+ const result = await runMaintenanceWorker(dbPath);
121
+ return result.status === 'database-busy';
122
+ } catch (error) {
123
+ return isRetryableMaintenanceError(error);
124
+ } finally {
125
+ releaseMaintenanceLock(lock);
126
+ }
127
+ }
128
+
129
+ function scheduleMaintenanceAttempt(dbPath, delayMs) {
130
+ const key = path.resolve(dbPath);
131
+ const state = scheduledMaintenances.get(key);
132
+ if (!state || state.timer || state.running) return;
133
+
134
+ state.timer = setTimeout(async () => {
135
+ state.timer = null;
136
+ state.running = true;
137
+ let retry = false;
138
+ try {
139
+ retry = await runScheduledMaintenance(key);
140
+ } catch (_) {
141
+ retry = false;
142
+ } finally {
143
+ state.running = false;
144
+ if (retry) {
145
+ scheduleMaintenanceAttempt(key, RETRY_MAINTENANCE_DELAY_MS);
146
+ } else if (scheduledMaintenances.get(key) === state) {
147
+ scheduledMaintenances.delete(key);
148
+ }
149
+ }
150
+ }, delayMs);
151
+ state.timer.unref();
152
+ }
153
+
154
+ function scheduleSessionHistoryMaintenance(dbPath = PATHS.sessionHistoryIndex) {
155
+ if (process.env.NODE_ENV === 'test' || !dbPath) return false;
156
+
157
+ const key = path.resolve(dbPath);
158
+ if (scheduledMaintenances.has(key)) return false;
159
+
160
+ const state = { timer: null, running: false };
161
+ scheduledMaintenances.set(key, state);
162
+ scheduleMaintenanceAttempt(key, INITIAL_MAINTENANCE_DELAY_MS);
163
+ return true;
164
+ }
165
+
166
+ module.exports = { scheduleSessionHistoryMaintenance };