cc-viewer 1.9.8 → 1.9.10

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 (141) hide show
  1. package/README.md +1 -1
  2. package/cli.js +5 -3
  3. package/dist/assets/App-Ci9GYs24.js +2 -0
  4. package/dist/assets/App-D06hBGII.css +1 -0
  5. package/dist/assets/{MdxEditorPanel-BnbZpgDp.js → MdxEditorPanel-8fwlNHMz.js} +1 -1
  6. package/dist/assets/Mobile-DngSOqYr.js +1 -0
  7. package/dist/assets/ProxyStatsModal-CWSIELLj.js +1 -0
  8. package/dist/assets/{_baseUniq-CwQykUuc.js → _baseUniq-6ZLcdMmL.js} +1 -1
  9. package/dist/assets/{arc-EdUdBxtG.js → arc-Dirf6EZP.js} +1 -1
  10. package/dist/assets/{architectureDiagram-Q4EWVU46-DtBIdElY.js → architectureDiagram-Q4EWVU46-BfIhmu_I.js} +1 -1
  11. package/dist/assets/{blockDiagram-DXYQGD6D-guNZIKkZ.js → blockDiagram-DXYQGD6D-D0y4ALi1.js} +1 -1
  12. package/dist/assets/{c4Diagram-AHTNJAMY-xV5vB-Z9.js → c4Diagram-AHTNJAMY-Dlfp4SUK.js} +1 -1
  13. package/dist/assets/{channel-DYEktNap.js → channel-CaNcU8SI.js} +1 -1
  14. package/dist/assets/{chunk-4BX2VUAB-ZTkItpvj.js → chunk-4BX2VUAB-ki0vHNS-.js} +1 -1
  15. package/dist/assets/{chunk-4TB4RGXK-dYIewyyR.js → chunk-4TB4RGXK-D8yO8Iuv.js} +1 -1
  16. package/dist/assets/{chunk-55IACEB6-CSsPNgPX.js → chunk-55IACEB6-BvIgaNPO.js} +1 -1
  17. package/dist/assets/{chunk-EDXVE4YY-CnMiuqzQ.js → chunk-EDXVE4YY-DF2Of_VO.js} +1 -1
  18. package/dist/assets/{chunk-FMBD7UC4-CVpATPZ3.js → chunk-FMBD7UC4-BIw7SXs-.js} +1 -1
  19. package/dist/assets/{chunk-OYMX7WX6-Dpq4s8dW.js → chunk-OYMX7WX6-AB76s0A0.js} +1 -1
  20. package/dist/assets/{chunk-QZHKN3VN-Bnojh6X6.js → chunk-QZHKN3VN-CuN2LCFc.js} +1 -1
  21. package/dist/assets/{chunk-YZCP3GAM-BYqK4Dsa.js → chunk-YZCP3GAM-D4DLBzBN.js} +1 -1
  22. package/dist/assets/classDiagram-6PBFFD2Q-DLJzWgYm.js +1 -0
  23. package/dist/assets/classDiagram-v2-HSJHXN6E-DLJzWgYm.js +1 -0
  24. package/dist/assets/clone-H_VGsHn9.js +1 -0
  25. package/dist/assets/{cose-bilkent-S5V4N54A-OJ7mmWyV.js → cose-bilkent-S5V4N54A-Bn485PX9.js} +1 -1
  26. package/dist/assets/{dagre-KV5264BT-B1Kwd_Rk.js → dagre-KV5264BT-e4QVCf17.js} +1 -1
  27. package/dist/assets/{diagram-5BDNPKRD-D8qrw8sp.js → diagram-5BDNPKRD-_SBFGgnf.js} +1 -1
  28. package/dist/assets/{diagram-G4DWMVQ6-ip-N4ho8.js → diagram-G4DWMVQ6-Bz1ovxLs.js} +1 -1
  29. package/dist/assets/{diagram-MMDJMWI5-2PZaskUF.js → diagram-MMDJMWI5-B-oojW8K.js} +1 -1
  30. package/dist/assets/{diagram-TYMM5635-CeAOQUZP.js → diagram-TYMM5635-CTAyNaOj.js} +1 -1
  31. package/dist/assets/{erDiagram-SMLLAGMA-BCBaEYEb.js → erDiagram-SMLLAGMA-BfvKAqq2.js} +1 -1
  32. package/dist/assets/{flowDiagram-DWJPFMVM-CsGyIqcX.js → flowDiagram-DWJPFMVM-BpTEZEAe.js} +1 -1
  33. package/dist/assets/{ganttDiagram-T4ZO3ILL-COsj3FZ-.js → ganttDiagram-T4ZO3ILL-DUD_vkyE.js} +1 -1
  34. package/dist/assets/{gitGraphDiagram-UUTBAWPF-DvQVQGJG.js → gitGraphDiagram-UUTBAWPF-DnlXcTud.js} +1 -1
  35. package/dist/assets/{graph-BSB9fDSP.js → graph-D4xsUKSn.js} +1 -1
  36. package/dist/assets/{index-DKufsAzx.js → index-84-BjpGl.js} +1 -1
  37. package/dist/assets/index-BDKwOnE3.js +2 -0
  38. package/dist/assets/{index-Bo-12bug.js → index-BniBLRtZ.js} +1 -1
  39. package/dist/assets/{index-BRw4Iz2O.js → index-C2Zo3KOg.js} +1 -1
  40. package/dist/assets/{index-BuVComcY.js → index-CWZoKT7e.js} +1 -1
  41. package/dist/assets/{index-eM2ih5n7.js → index-D-4EsFub.js} +1 -1
  42. package/dist/assets/{index-CNNAYjkF.js → index-D3KzRmkP.js} +1 -1
  43. package/dist/assets/{index-DXBfwPYD.js → index-NGgdum2i.js} +1 -1
  44. package/dist/assets/{infoDiagram-42DDH7IO-eNucS4iW.js → infoDiagram-42DDH7IO-BbdvmCDt.js} +1 -1
  45. package/dist/assets/{ishikawaDiagram-UXIWVN3A-Dxxj-gUX.js → ishikawaDiagram-UXIWVN3A-oZBOPoTO.js} +1 -1
  46. package/dist/assets/{journeyDiagram-VCZTEJTY-yJJzVEBb.js → journeyDiagram-VCZTEJTY-DWoH0hs9.js} +1 -1
  47. package/dist/assets/{jszip.min-ByI0ds5u.js → jszip.min-I5y9RZhq.js} +1 -1
  48. package/dist/assets/{kanban-definition-6JOO6SKY-2rfTi2zw.js → kanban-definition-6JOO6SKY-BLHtjKsS.js} +1 -1
  49. package/dist/assets/{layout-DTmHj0Wm.js → layout-C116SNcQ.js} +1 -1
  50. package/dist/assets/{linear-BOA3a9FE.js → linear-Br08WCkF.js} +1 -1
  51. package/dist/assets/{mermaid.core-5H-NR79H.js → mermaid.core-D-4dTrvI.js} +2 -2
  52. package/dist/assets/{min-CpjFTEec.js → min-khs74HbH.js} +1 -1
  53. package/dist/assets/{mindmap-definition-QFDTVHPH-B8KCjFgB.js → mindmap-definition-QFDTVHPH-C3_yItFF.js} +1 -1
  54. package/dist/assets/{pieDiagram-DEJITSTG-BARy96g5.js → pieDiagram-DEJITSTG-ByCwM2_P.js} +1 -1
  55. package/dist/assets/{quadrantDiagram-34T5L4WZ-DYoTapuI.js → quadrantDiagram-34T5L4WZ-CjV65X1r.js} +1 -1
  56. package/dist/assets/{requirementDiagram-MS252O5E-EeANLLK2.js → requirementDiagram-MS252O5E-DFJ_dbtV.js} +1 -1
  57. package/dist/assets/{sankeyDiagram-XADWPNL6-DmQ-YVPj.js → sankeyDiagram-XADWPNL6-BBvXoBfj.js} +1 -1
  58. package/dist/assets/seqResourceLoaders-C34z9uOa.css +41 -0
  59. package/dist/assets/seqResourceLoaders-JJzgZZzU.js +2 -0
  60. package/dist/assets/{sequenceDiagram-FGHM5R23-SZtW0rYC.js → sequenceDiagram-FGHM5R23-BJM04Y3N.js} +1 -1
  61. package/dist/assets/{stateDiagram-FHFEXIEX-CEygsIld.js → stateDiagram-FHFEXIEX-DI2q_Kkn.js} +1 -1
  62. package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BU1XPhBe.js → stateDiagram-v2-QKLJ7IA2-CrGC6kEY.js} +1 -1
  63. package/dist/assets/{timeline-definition-GMOUNBTQ-DhPBaQBt.js → timeline-definition-GMOUNBTQ-JcfRUsjD.js} +1 -1
  64. package/dist/assets/vendor-antd-DxEVqVhD.js +17 -0
  65. package/dist/assets/vendor-codemirror-CgNlkZva.js +1 -0
  66. package/dist/assets/{vendor-mdxeditor-Cz09a1_I.js → vendor-mdxeditor-DoreQf4A.js} +2 -2
  67. package/dist/assets/{vendor-qrcode-qbbntD6-.js → vendor-qrcode-q1CKtOyX.js} +1 -1
  68. package/dist/assets/{vendor-virtuoso-Sk8fQ4Hx.js → vendor-virtuoso-BXGe9q7E.js} +1 -1
  69. package/dist/assets/{vennDiagram-DHZGUBPP-NniTHxDw.js → vennDiagram-DHZGUBPP-mAHR1Ugj.js} +1 -1
  70. package/dist/assets/{wardley-RL74JXVD-D9SNOrDw.js → wardley-RL74JXVD-mlTq_P-4.js} +1 -1
  71. package/dist/assets/{wardleyDiagram-NUSXRM2D-DvHXKNza.js → wardleyDiagram-NUSXRM2D-BDGfajRb.js} +1 -1
  72. package/dist/assets/{xychartDiagram-5P7HB3ND-CaiwPTQB.js → xychartDiagram-5P7HB3ND-Cf2pR1mV.js} +1 -1
  73. package/dist/index.html +4 -4
  74. package/package.json +1 -1
  75. package/plugins/README.md +9 -1
  76. package/server/interceptor.js +1 -1
  77. package/server/lib/builtin-model-prompts.js +1 -1
  78. package/server/lib/chat-queue.js +1 -1
  79. package/server/lib/credential-access.js +1 -1
  80. package/server/lib/credential-migrate.js +1 -1
  81. package/server/lib/error-report.js +26 -0
  82. package/server/lib/json-store.js +1 -1
  83. package/server/lib/launch-config.js +1 -1
  84. package/server/lib/log-watcher.js +1 -1
  85. package/server/lib/plugin-host.mjs +198 -0
  86. package/server/lib/plugin-loader.js +346 -90
  87. package/server/lib/plugin-manager.js +46 -2
  88. package/server/lib/plugin-manifest.js +329 -0
  89. package/server/lib/plugin-panels.js +183 -0
  90. package/server/lib/plugin-proxy.js +408 -0
  91. package/server/lib/plugin-runtime.js +775 -0
  92. package/server/lib/plugin-service-manager.js +463 -0
  93. package/server/lib/proxy/proxy-retry.js +1 -1
  94. package/server/lib/proxy/proxy-stats.js +1 -1
  95. package/server/lib/resume-watchdog.js +1 -1
  96. package/server/lib/same-origin.js +13 -3
  97. package/server/lib/sdk-manager.js +117 -23
  98. package/server/lib/sdk-session-index.js +1 -1
  99. package/server/lib/stats-worker.js +1 -1
  100. package/server/lib/system-prompt-files.js +1 -1
  101. package/server/lib/system-prompt-live.js +1 -1
  102. package/server/lib/system-prompt-snapshots.js +1 -1
  103. package/server/lib/v2/adapter.js +1 -1
  104. package/server/lib/v2/convert.js +1 -1
  105. package/server/lib/v2/jsonl-read.js +1 -1
  106. package/server/lib/v2/live-feed.js +1 -1
  107. package/server/lib/v2/meta-rows.js +1 -1
  108. package/server/lib/v2/resume-list.js +1 -1
  109. package/server/lib/v2/session-list.js +1 -1
  110. package/server/lib/v2/session-owner.js +1 -1
  111. package/server/lib/v2/v2-writer.js +1 -1
  112. package/server/lib/wire-compress.js +1 -1
  113. package/server/lib/ws-mux.js +1 -1
  114. package/server/proxy.js +1 -1
  115. package/server/pty-manager.js +1 -1
  116. package/server/routes/ask-perm.js +1 -1
  117. package/server/routes/events.js +1 -1
  118. package/server/routes/expert.js +1 -1
  119. package/server/routes/files-content.js +1 -1
  120. package/server/routes/files-fs.js +4 -2
  121. package/server/routes/misc.js +11 -0
  122. package/server/routes/plugin-services.js +104 -0
  123. package/server/routes/plugins.js +145 -17
  124. package/server/routes/preferences.js +1 -1
  125. package/server/routes/resume.js +1 -1
  126. package/server/routes/sdk-context.js +1 -1
  127. package/server/routes/sdk-mcp-status.js +1 -1
  128. package/server/routes/workspaces.js +1 -1
  129. package/server/server.js +370 -173
  130. package/dist/assets/App-D2zrtS4P.js +0 -2
  131. package/dist/assets/App-DuDeDOuX.css +0 -1
  132. package/dist/assets/Mobile-CU-g7cTn.js +0 -1
  133. package/dist/assets/ProxyStatsModal-CavWQfBA.js +0 -1
  134. package/dist/assets/classDiagram-6PBFFD2Q-ClGF33bF.js +0 -1
  135. package/dist/assets/classDiagram-v2-HSJHXN6E-ClGF33bF.js +0 -1
  136. package/dist/assets/clone-CFj55h49.js +0 -1
  137. package/dist/assets/index-D2maXxkC.js +0 -2
  138. package/dist/assets/seqResourceLoaders-DZF6ofB7.css +0 -41
  139. package/dist/assets/seqResourceLoaders-jOSz1x7F.js +0 -2
  140. package/dist/assets/vendor-antd-CS_S2mBN.js +0 -17
  141. package/dist/assets/vendor-codemirror-B5l-VbBO.js +0 -1
@@ -30,7 +30,7 @@ import { ASK_TIMEOUT_MS } from './ask/ask-constants.js';
30
30
  import { withDefaultThinkingDisplay, resolveLaunchSystemPrompt, launchArgsToExtraArgs } from './launch-config.js';
31
31
  import { evaluateImDeny } from './im-deny.js';
32
32
  import { APPROVAL_TOOLS, isOverTrustDir, isPublishCommand } from './approval-policy.js';
33
- import { reportSwallowed } from '@ccv/core/error-report';
33
+ import { reportSwallowed } from '../lib/error-report.js';
34
34
  import { resolve as _pathResolve, dirname as _pathDirname, sep as _pathSep } from 'node:path';
35
35
  import { createHash } from 'node:crypto';
36
36
  import { registerSdkSessionLister } from './sdk-session-index.js';
@@ -147,7 +147,7 @@ const MAX_QUEUE_ITEMS = 50;
147
147
  // Callbacks registered by cli.js (one wiring for the whole process; every session
148
148
  // reuses the same callbacks — they broadcast to the shared terminal WS).
149
149
  let _onTurnEnd = null; // SDK mode has no Stop hook (ensureHooks() skipped) — fire turnEnd directly when the SDK 'result' message arrives
150
- let _onQueryError = null; // query-level failure → (message: string) => void; cli.js forwards to a WS toast
150
+ let _onQueryError = null; // query-level failure → (message: string, meta: {cwd, sessionId, instanceKey}) => void; cli.js forwards it as a scoped sdk-error WS frame (dialogue row + toast)
151
151
  let _broadcastWs = null;
152
152
  let _runWaterfallHook = null;
153
153
 
@@ -327,6 +327,13 @@ function _createSession(key, cwd) {
327
327
  let _sessionId = null;
328
328
  let _initAnnouncedSid = ''; // sessionId whose slash-command surface was already announced (reset on full reset)
329
329
  let _lastInitSnapshot = null; // last sdk-init payload for WS reconnect replay
330
+ // Last sdk-skills frame (supportedCommands() result) for WS reconnect replay, and the
331
+ // resident query it was loaded from (dedupes the once-per-query-generation fetch).
332
+ let _lastSkillsSnapshot = null;
333
+ let _skillsLoadedQuery = null;
334
+ // init 帧的 skills 白名单(name / plugin:skill / :suffix 形式),用于把 supportedCommands()
335
+ // 的全量命令面收敛为纯 skill 面;null = 该 init 帧未提供(不做过滤,保守放行)。
336
+ let _initSkills = null;
330
337
  // Last announced session state ('idle' | 'running' | 'requires_action' | null) — the
331
338
  // turn-level busy truth for the conversation spinner. Sources, most authoritative
332
339
  // first: CLI-emitted session_state_changed (gated behind CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS,
@@ -690,6 +697,8 @@ function _createSession(key, cwd) {
690
697
  const inputQueue = _inputQueue;
691
698
  _activeQuery = null;
692
699
  _inputQueue = null;
700
+ _skillsLoadedQuery = null; // next resident query re-pulls supportedCommands() on init
701
+ _initSkills = null; // re-captured from the next init frame
693
702
  _clearGraceTimer();
694
703
  _clearTurnWatchdog();
695
704
  _lateResultTombstones = 0;
@@ -701,10 +710,12 @@ function _createSession(key, cwd) {
701
710
  }
702
711
  }
703
712
 
704
- /** Turn-level error toast (callback throw must never break the caller). */
713
+ /** Query-level error signal; the routing envelope always comes from this
714
+ * session's closure (callback throw must never break the caller). */
705
715
  function _notifyQueryError(err) {
706
716
  if (!_onQueryError) return;
707
- try { _onQueryError(String(err?.message || err)); }
717
+ const context = { cwd: _cwd, sessionId: _sessionId || null, instanceKey };
718
+ try { _onQueryError(String(err?.message || err), context); }
708
719
  catch (cbErr) { console.warn('[sdk-manager] onQueryError threw:', cbErr?.message); }
709
720
  }
710
721
 
@@ -748,20 +759,33 @@ function _createSession(key, cwd) {
748
759
  // push/result fallbacks keep the spinner correct at result granularity.
749
760
  if (msg.subtype === 'session_state_changed') {
750
761
  _announceSessionState(msg.state);
751
- } else if (msg.subtype === 'init' && _broadcastWs
752
- && Array.isArray(msg.slash_commands) && msg.slash_commands.length > 0
753
- && msg.session_id !== _initAnnouncedSid) {
754
- _initAnnouncedSid = msg.session_id;
755
- _lastInitSnapshot = {
756
- type: 'sdk-init',
757
- sessionId: msg.session_id,
758
- model: msg.model,
759
- slashCommands: msg.slash_commands,
760
- tools: msg.tools,
761
- cwd: _cwd,
762
- instanceKey,
763
- };
764
- _broadcastWs(_lastInitSnapshot);
762
+ } else if (msg.subtype === 'init') {
763
+ if (_broadcastWs && Array.isArray(msg.slash_commands) && msg.slash_commands.length > 0
764
+ && msg.session_id !== _initAnnouncedSid) {
765
+ _initAnnouncedSid = msg.session_id;
766
+ _lastInitSnapshot = {
767
+ type: 'sdk-init',
768
+ sessionId: msg.session_id,
769
+ model: msg.model,
770
+ slashCommands: msg.slash_commands,
771
+ tools: msg.tools,
772
+ cwd: _cwd,
773
+ instanceKey,
774
+ };
775
+ _broadcastWs(_lastInitSnapshot);
776
+ }
777
+ // Skill menu surface: pull the full supportedCommands() (name + description +
778
+ // argumentHint) once per resident-query generation. Done even when slash_commands
779
+ // is empty/absent — a session with no TUI commands can still expose skills.
780
+ // supportedCommands() returns the whole command surface (incl. /clear, /compact,
781
+ // /model, /usage), so keep ONLY real skills: intersect with the init frame's
782
+ // `skills` allowlist (name / plugin:skill / :suffix forms) to honour the
783
+ // "slash menu indexes skills only" decision.
784
+ if (_broadcastWs && _activeQuery && _skillsLoadedQuery !== _activeQuery) {
785
+ _skillsLoadedQuery = _activeQuery;
786
+ _initSkills = Array.isArray(msg.skills) ? msg.skills : null;
787
+ _loadSupportedCommands();
788
+ }
765
789
  } else if (msg.subtype === 'compact_boundary' && _broadcastWs) {
766
790
  const meta = msg.compact_metadata;
767
791
  _broadcastWs({
@@ -861,7 +885,8 @@ function _createSession(key, cwd) {
861
885
 
862
886
  /**
863
887
  * Surface a failed turn to the Web UI — the wire path persists the failed entry but
864
- * never pushes a toast, so a dead/errored turn would otherwise look like a hang.
888
+ * never signals it, so a dead/errored turn would otherwise look like a hang. The
889
+ * sdk-error frame renders as a persistent dialogue row plus a toast client-side.
865
890
  */
866
891
  function _notifyTurnError(resultMsg) {
867
892
  const isError = resultMsg.is_error === true || (resultMsg.subtype && resultMsg.subtype !== 'success');
@@ -953,6 +978,58 @@ function _createSession(key, cwd) {
953
978
  }
954
979
  }
955
980
 
981
+ /**
982
+ * Pull the SDK's supportedCommands() (skill surface: name + description + argumentHint)
983
+ * once per resident-query generation and broadcast it as an `sdk-skills` frame, retained
984
+ * for WS reconnect replay. Fire-and-forget from the init branch — never awaited. Bounded
985
+ * by CTX_USAGE_TIMEOUT_MS; on timeout/rejection/stale generation it reports and broadcasts
986
+ * nothing (clients fall back to /api/skills). Raw commands are passed through; the web
987
+ * client's normalizeSdkCommands does the safe-name filtering.
988
+ */
989
+ function _loadSupportedCommands() {
990
+ const q = _activeQuery;
991
+ if (!q || typeof q.supportedCommands !== 'function') return;
992
+ let timeoutId;
993
+ const timeout = new Promise((_, reject) => {
994
+ timeoutId = setTimeout(() => reject(new Error('supportedCommands timeout')), CTX_USAGE_TIMEOUT_MS);
995
+ if (timeoutId.unref) timeoutId.unref();
996
+ });
997
+ Promise.race([Promise.resolve().then(() => q.supportedCommands()), timeout]).then((commands) => {
998
+ if (_activeQuery !== q) return; // stale generation (session switch/death) — drop
999
+ // Keep only real skills: supportedCommands() also returns TUI built-ins (/clear,
1000
+ // /compact, /model, /usage). When the init frame supplied a skills allowlist, filter
1001
+ // by it (name, plugin:skill, or :suffix). No allowlist → pass through (conservative).
1002
+ let filtered = Array.isArray(commands) ? commands : [];
1003
+ if (Array.isArray(_initSkills) && _initSkills.length > 0) {
1004
+ const allow = new Set();
1005
+ for (const s of _initSkills) {
1006
+ if (typeof s !== 'string' || !s) continue;
1007
+ allow.add(s); // exact (name or plugin:skill)
1008
+ const idx = s.lastIndexOf(':');
1009
+ if (idx >= 0) allow.add(s.slice(idx + 1)); // :suffix form → bare name
1010
+ }
1011
+ filtered = filtered.filter(c => c && (allow.has(c.name)
1012
+ || (typeof c.name === 'string' && allow.has(c.name.replace(/^plugin.*:/, '')))));
1013
+ }
1014
+ _lastSkillsSnapshot = {
1015
+ type: 'sdk-skills',
1016
+ sessionId: _sessionId || null,
1017
+ ts: Date.now(),
1018
+ cwd: _cwd,
1019
+ instanceKey,
1020
+ commands: filtered,
1021
+ };
1022
+ if (_broadcastWs) {
1023
+ try { _broadcastWs(_lastSkillsSnapshot); }
1024
+ catch (err) { reportSwallowed('sdk-manager.skills-broadcast', err); }
1025
+ }
1026
+ }).catch((err) => {
1027
+ reportSwallowed('sdk-manager.supported-commands', err);
1028
+ }).finally(() => {
1029
+ if (timeoutId) clearTimeout(timeoutId);
1030
+ });
1031
+ }
1032
+
956
1033
  /**
957
1034
  * Collect an MCP-server-status snapshot via the Query control surface (`mcp_status`).
958
1035
  * Returns { ok:true, snapshot } | { ok:false, reason:'session-inactive'|'control-failed' }.
@@ -1464,6 +1541,7 @@ function _createSession(key, cwd) {
1464
1541
  _launchResume = null;
1465
1542
  _initAnnouncedSid = '';
1466
1543
  _lastInitSnapshot = null; // don't replay the OLD session's sdk-init to the new one
1544
+ _lastSkillsSnapshot = null; // same for the skill surface
1467
1545
  _lastSessionState = null; // same for the session-state snapshot — new session starts idle
1468
1546
  _clearContextUsage('session-switched');
1469
1547
  _clearMcpStatus('session-switched');
@@ -1485,6 +1563,7 @@ function _createSession(key, cwd) {
1485
1563
  _turnInFlight = false;
1486
1564
  _initAnnouncedSid = '';
1487
1565
  _lastInitSnapshot = null;
1566
+ _lastSkillsSnapshot = null;
1488
1567
  // Tell clients BEFORE nulling the snapshot: a re-init/launch mid-turn would
1489
1568
  // otherwise leave the spinner lit with no frame ever clearing it (the new
1490
1569
  // session starts idle and the state-change guard would swallow the first
@@ -1546,6 +1625,7 @@ function _createSession(key, cwd) {
1546
1625
  },
1547
1626
  getContextUsageSnapshot: () => _lastContextUsage,
1548
1627
  getMcpStatusSnapshot: () => _lastMcpStatus,
1628
+ getSkillsSnapshot: () => _lastSkillsSnapshot,
1549
1629
  fetchContextUsage: async () => {
1550
1630
  // On-demand fetch for the HTTP route: refresh the snapshot, broadcast to all clients,
1551
1631
  // then reply. Unlike _refreshContextUsage this bypasses the in-flight gate (an explicit
@@ -1597,9 +1677,9 @@ export function isSdkAvailable() {
1597
1677
  * @param {string} projectName
1598
1678
  * @param {object} deps
1599
1679
  * @param {function} deps.onTurnEnd — ({sessionId, ts}) => void, fired on SDK 'result'
1600
- * @param {function} [deps.onQueryError] — (message: string) => void, fired when the
1601
- * query itself fails (spawn/protocol/iterator errors) — until this existed the only
1602
- * surfacing was console.error, invisible in the Web UI
1680
+ * @param {function} [deps.onQueryError] — (message: string, meta: {cwd, sessionId, instanceKey}) => void,
1681
+ * fired when the query itself fails (spawn/protocol/iterator errors); meta carries the session's
1682
+ * routing envelope so the sdk-error frame scopes to the owning workspace's viewers
1603
1683
  * @param {function} deps.broadcastWs — (msg) => void, terminal-WS broadcast for approvals
1604
1684
  * @param {string} [deps.permissionMode]
1605
1685
  * @param {function} [deps.runWaterfallHook] — plugin waterfall (onPlanRequest/onAskRequest/onPermRequest)
@@ -1650,7 +1730,11 @@ export async function sendUserMessage(text, anchor) {
1650
1730
  console.warn(`[sdk-manager] no SDK session registered for "${targetName}" — message dropped`);
1651
1731
  reportSwallowed('sdk-manager.route', new Error(`no SDK session for ${targetName}`));
1652
1732
  if (_onQueryError) {
1653
- try { _onQueryError(`Workspace "${targetName}" is no longer available — message not sent.`); }
1733
+ // Module-level fallback has no session closure. Anchors never carry cwd
1734
+ // (server.js builds {project, instanceKey} only) — pass the project key so
1735
+ // broadcastWsMessage can scope the frame; instanceKey narrows further when present.
1736
+ const meta = { cwd: anchor.cwd || null, sessionId: null, instanceKey: anchor.instanceKey || null, project: anchor.project || null };
1737
+ try { _onQueryError(`Workspace "${targetName}" is no longer available — message not sent.`, meta); }
1654
1738
  catch (err) { console.warn('[sdk-manager] onQueryError threw:', err?.message); }
1655
1739
  }
1656
1740
  return;
@@ -1900,6 +1984,16 @@ export function getSdkInitSnapshot(anchor) {
1900
1984
  return sess ? sess.getSdkInitSnapshot() : null;
1901
1985
  }
1902
1986
 
1987
+ /**
1988
+ * Last sdk-skills frame (supportedCommands() result) for WS reconnect replay. Null until
1989
+ * the first init-driven pull resolves; an absent frame leaves the client on its /api/skills
1990
+ * fallback. No anchor → active session.
1991
+ */
1992
+ export function getSkillsSnapshot(anchor) {
1993
+ const sess = anchor ? _sessionForAnchor(anchor) : _active();
1994
+ return sess ? sess.getSkillsSnapshot() : null;
1995
+ }
1996
+
1903
1997
  /**
1904
1998
  * Last announced session-state frame for WS reconnect replay (sdk-init snapshot
1905
1999
  * parity). Null until the first state transition — a fresh session is idle, and an
@@ -1,6 +1,6 @@
1
1
  // Synchronous read-only projection of the SDK session registry. Generic view
2
2
  // routing must not import sdk-manager (and eagerly load the Agent SDK) in PTY mode.
3
- import { reportSwallowed } from '@ccv/core/error-report';
3
+ import { reportSwallowed } from '../lib/error-report.js';
4
4
 
5
5
  let _listSessions = null;
6
6
 
@@ -10,7 +10,7 @@ import { join, basename } from 'node:path';
10
10
  import { readJsonlTolerant, listSessionIds } from './v2/replay.js';
11
11
  import { dirSizeSync } from './v2/layout.js';
12
12
  import { isDiscardableSession } from './v2/session-select.js';
13
- import { reportSwallowed } from '@ccv/core/error-report';
13
+ import { reportSwallowed } from '../lib/error-report.js';
14
14
  import {
15
15
  INTER_SESSION_TYPES, isSystemText, extractUserTexts, isSuggestionMode,
16
16
  collectPromptsFromEvents, sortEpochFiles,
@@ -3,7 +3,7 @@ import { join } from 'node:path';
3
3
  import { MODEL_PROMPT_DIR, matchModelPrompt } from './model-system-prompts.js';
4
4
  import { isBuiltinDisabled, matchBuiltinModelPrompt, materializeBuiltinPrompt } from './builtin-model-prompts.js';
5
5
  import { isNonEmptyFile } from './file-api.js';
6
- import { reportSwallowed } from '@ccv/core/error-report';
6
+ import { reportSwallowed } from '../lib/error-report.js';
7
7
 
8
8
  // isNonEmptyFile lives in file-api.js (shared leaf, cycle break:
9
9
  // model-system-prompts.js must not import this module). Re-exported here so
@@ -19,7 +19,7 @@
19
19
  import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, readdirSync, statSync } from 'node:fs';
20
20
  import { join } from 'node:path';
21
21
  import { randomBytes } from 'node:crypto';
22
- import { reportSwallowed } from '@ccv/core/error-report';
22
+ import { reportSwallowed } from '../lib/error-report.js';
23
23
  import { LOG_DIR } from '../../findcc.js';
24
24
  import { renameSyncWithRetry } from './file-api.js';
25
25
  import { matchModelPrompt, MODEL_PROMPT_DIR, readModelPrompt } from './model-system-prompts.js';
@@ -33,7 +33,7 @@
33
33
  import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, readdirSync, statSync, realpathSync, openSync, readSync, closeSync } from 'node:fs';
34
34
  import { join, basename, dirname } from 'node:path';
35
35
  import { randomBytes } from 'node:crypto';
36
- import { reportSwallowed } from '@ccv/core/error-report';
36
+ import { reportSwallowed } from '../lib/error-report.js';
37
37
  import { LOG_DIR, getClaudeConfigDir } from '../../findcc.js';
38
38
  import { renameSyncWithRetry } from './file-api.js';
39
39
 
@@ -29,7 +29,7 @@
29
29
 
30
30
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
31
31
  import { join, dirname, basename } from 'node:path';
32
- import { reportSwallowed } from '@ccv/core/error-report';
32
+ import { reportSwallowed } from '../../lib/error-report.js';
33
33
  import { isMainAgentRequest } from '../interceptor-core.js';
34
34
  import { readSession } from './replay.js';
35
35
  import { iterateJsonlLines } from './jsonl-read.js';
@@ -28,7 +28,7 @@ import { join, basename } from 'node:path';
28
28
  import { iterateRawEntriesAsync } from '../log-stream.js';
29
29
  import { createIncrementalReconstructor } from '@ccv/core/delta-reconstructor';
30
30
  import { isLogFileName, parseLogTs } from '../log-file-utils.js';
31
- import { reportSwallowed } from '@ccv/core/error-report';
31
+ import { reportSwallowed } from '../../lib/error-report.js';
32
32
  import { V2Writer } from './v2-writer.js';
33
33
  import { verifyV1File } from './verify.js';
34
34
  import { parseUserId } from './identity.js';
@@ -14,7 +14,7 @@
14
14
  // mirroring readJsonlTolerant's torn-tail tolerance (spec §14).
15
15
 
16
16
  import { existsSync, openSync, readSync, closeSync, statSync } from 'node:fs';
17
- import { reportSwallowed } from '@ccv/core/error-report';
17
+ import { reportSwallowed } from '../../lib/error-report.js';
18
18
 
19
19
  const DEFAULT_CHUNK_BYTES = 8 * 1024 * 1024;
20
20
  // Node's max string length is 0x1fffffe8 (~512MiB) — a line this long can
@@ -24,7 +24,7 @@
24
24
 
25
25
  import { watch, existsSync, readdirSync, readFileSync, statSync, openSync, readSync, closeSync } from 'node:fs';
26
26
  import { join } from 'node:path';
27
- import { reportSwallowed } from '@ccv/core/error-report';
27
+ import { reportSwallowed } from '../../lib/error-report.js';
28
28
  import { createIncrementalReconstructor } from '@ccv/core/delta-reconstructor';
29
29
  import { processWatchedEntry, sendEventToClients, sendEventRawToClients } from '../log-watcher.js';
30
30
  import { filterClientsByInstance } from './view-router.js';
@@ -21,7 +21,7 @@ import { readJsonlTolerant } from './replay.js';
21
21
  import { iterateJsonlLines } from './jsonl-read.js';
22
22
  import { iterateV2Items, findTeammateSessionDirs } from './adapter.js';
23
23
  import { SingleFlight } from './singleflight.js';
24
- import { reportSwallowed } from '@ccv/core/error-report';
24
+ import { reportSwallowed } from '../../lib/error-report.js';
25
25
  import { classifyRequest, withAgentNameSubType } from '@ccv/core/requestType';
26
26
 
27
27
  // KEEP IN SYNC: server/lib/v2/adapter.js itemKey — (sessionId, seq) identity.
@@ -25,7 +25,7 @@ import { summarizeSessionPage } from './session-list.js';
25
25
  import { sessionHasMainOrTeammateReq } from './session-select.js';
26
26
  import { readPin } from '../session-pin-store.js';
27
27
  import { LIVE_SESSION_MTIME_MS } from '../log-file-utils.js';
28
- import { reportSwallowed } from '@ccv/core/error-report';
28
+ import { reportSwallowed } from '../../lib/error-report.js';
29
29
 
30
30
  /**
31
31
  * Liveness heuristic for the "running" placeholder column. A session is
@@ -26,7 +26,7 @@ import { listSessionIds, readJsonlTolerant } from './replay.js';
26
26
  import { readPromptsHead, collectPromptsFromEvents } from '../user-prompt-extract.js';
27
27
  import { isDiscardableSession } from './session-select.js';
28
28
  import { isSupportedWireFormat, dirSizeSync } from './layout.js';
29
- import { reportSwallowed } from '@ccv/core/error-report';
29
+ import { reportSwallowed } from '../../lib/error-report.js';
30
30
 
31
31
  const MAX_PROJECTS = 32;
32
32
 
@@ -26,7 +26,7 @@
26
26
  import { readFileSync, writeFileSync, unlinkSync, existsSync } from 'node:fs';
27
27
  import { join } from 'node:path';
28
28
  import { isPidAlive } from '../pid-alive.js';
29
- import { reportSwallowed } from '@ccv/core/error-report';
29
+ import { reportSwallowed } from '../../lib/error-report.js';
30
30
 
31
31
  export const OWNER_LOCK_NAME = 'owner.lock';
32
32
 
@@ -19,7 +19,7 @@
19
19
  import { statfsSync, existsSync, readdirSync, readFileSync } from 'node:fs';
20
20
  import { join, basename, sep } from 'node:path';
21
21
  import { AsyncWriteQueue } from '../async-write-queue.js';
22
- import { reportSwallowed } from '@ccv/core/error-report';
22
+ import { reportSwallowed } from '../../lib/error-report.js';
23
23
  import { ensureSessionDirSync, compactLocalTs14, sanitizePathComponent, normalizeSessionCwd } from './layout.js';
24
24
  import { resolveSessionDirName, latestMainSession } from './session-select.js';
25
25
  import { acquireSessionClaim, releaseSessionClaim, isForeignLiveOwned } from './session-owner.js';
@@ -19,7 +19,7 @@
19
19
  * knee on large sessions; q5 collapses to ~2.5x there).
20
20
  */
21
21
  import zlib from 'node:zlib';
22
- import { reportSwallowed } from '@ccv/core/error-report';
22
+ import { reportSwallowed } from '../lib/error-report.js';
23
23
  import { awaitDrainOrClose } from './sse-backpressure.js';
24
24
 
25
25
  const C = zlib.constants;
@@ -42,7 +42,7 @@
42
42
  */
43
43
  import { randomBytes } from 'node:crypto';
44
44
  import { EventEmitter } from 'node:events';
45
- import { reportSwallowed as defaultReport } from '@ccv/core/error-report';
45
+ import { reportSwallowed as defaultReport } from '../lib/error-report.js';
46
46
  import { createBackpressureGate } from './ws-backpressure.js';
47
47
 
48
48
  const STREAM_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
package/server/proxy.js CHANGED
@@ -13,7 +13,7 @@ import { parseAgentId, findHeader } from './lib/v2/agent-id.js';
13
13
  import { liveSystemPromptEnabled } from './lib/system-prompt-live.js';
14
14
  import { executeRequest, extractModel } from './lib/proxy/proxy-retry.js';
15
15
  import { buildRecord, appendRecord, dailyFilePath, todayStr, emitProxyStatsUpdate } from './lib/proxy/proxy-stats.js';
16
- import { reportSwallowed } from '@ccv/core/error-report';
16
+ import { reportSwallowed } from './lib/error-report.js';
17
17
  import { LOG_DIR } from '../findcc.js';
18
18
 
19
19
  // Setup interceptor to patch fetch
@@ -17,7 +17,7 @@ import { randomBytes } from 'node:crypto';
17
17
  // (shared with the SDK link). Re-exported here for existing consumers/tests.
18
18
  import { withDefaultThinkingDisplay, resolveLaunchSystemPrompt, insertBeforeDashDash } from './lib/launch-config.js';
19
19
  export { withDefaultThinkingDisplay } from './lib/launch-config.js';
20
- import { reportSwallowed } from '@ccv/core/error-report';
20
+ import { reportSwallowed } from './lib/error-report.js';
21
21
  import { t, tFor } from './i18n.js';
22
22
 
23
23
  const __filename = fileURLToPath(import.meta.url);
@@ -6,7 +6,7 @@ import { filterClientsByScope } from '../lib/v2/view-router.js';
6
6
  import { _projectName } from '../interceptor.js';
7
7
  import { readPrefsRaw } from '../lib/prefs-store.js';
8
8
  import { getCurrentProjectKey, hasFork, resolveScoped } from '../lib/project-prefs.js';
9
- import { reportSwallowed } from '@ccv/core/error-report';
9
+ import { reportSwallowed } from '../lib/error-report.js';
10
10
 
11
11
  function pendingAsks(req, res, parsedUrl, isLocal, deps) {
12
12
  try {
@@ -5,7 +5,7 @@ import { _projectName, getLiveLogSource, isContinuedLaunch } from '../intercepto
5
5
  import { LOG_DIR } from '../../findcc.js';
6
6
  import { streamRawEntriesAsync } from '../lib/log-stream.js';
7
7
  import { migrationStatus } from '../lib/v2/migrate-prompt.js';
8
- import { reportSwallowed } from '@ccv/core/error-report';
8
+ import { reportSwallowed } from '../lib/error-report.js';
9
9
  import { sseHead, sseWrite, needsDrain, wireEnd, awaitWireDrain, isWireV3Enabled } from '../lib/wire-compress.js';
10
10
  import { scopeKeyForProducer } from '../lib/scope-router.js';
11
11
  import { readV2ColdBundle } from '../lib/v2/meta-rows.js';
@@ -19,7 +19,7 @@ import {
19
19
  matchBuiltinModelPrompt, isBuiltinDisabled,
20
20
  readBuiltinDisabled, setBuiltinDisabled, listBuiltinModelPrompts,
21
21
  } from '../lib/builtin-model-prompts.js';
22
- import { reportSwallowed } from '@ccv/core/error-report';
22
+ import { reportSwallowed } from '../lib/error-report.js';
23
23
  import { resolveSpawnModel } from '../lib/spawn-model-resolver.js';
24
24
  import { listSystemPromptPresets, groupPresetsByCategory, getSystemPromptVariablesDoc } from '../lib/system-prompt-presets.js';
25
25
  import { LOG_DIR } from '../../findcc.js';
@@ -6,7 +6,7 @@ import { ERROR_STATUS_MAP } from '../lib/file-api.js';
6
6
  import { viewRootOrReply } from '../lib/view-root.js';
7
7
  import { loadWorkspaces } from '../workspace-registry.js';
8
8
  import { listLivePtys } from '../pty-manager.js';
9
- import { reportSwallowed } from '@ccv/core/error-report';
9
+ import { reportSwallowed } from '../lib/error-report.js';
10
10
  import { discoverClaudeMdCandidates, readCandidateById } from '../lib/claude-md-discovery.js';
11
11
  import { getClaudeConfigDir } from '../../findcc.js';
12
12
  import { _projectName } from '../interceptor.js';
@@ -125,8 +125,10 @@ function importFile(req, res, parsedUrl, isLocal, deps) {
125
125
  res.end(JSON.stringify({ error: 'Missing boundary' }));
126
126
  return;
127
127
  }
128
- const importUrl = new URL(req.url, `${deps.protocol}://${req.headers.host}`);
129
- const dir = importUrl.searchParams.get('dir') || '';
128
+ // Reuse the parsedUrl that handleRequest already validated: req.headers.host may be a
129
+ // malformed value llhttp accepts but URL parsing rejects, and a fresh new URL() here
130
+ // would throw synchronously inside dispatch → unhandled rejection.
131
+ const dir = parsedUrl.searchParams.get('dir') || '';
130
132
  // 在 mkdirSync 之前纯字符串校验,防 symlink 副作用目录被创建在项目外
131
133
  const dirCheck = validateImportDir(dir);
132
134
  if (!dirCheck.ok) {
@@ -2,6 +2,8 @@
2
2
  import { getUserProfile } from '../lib/user-profile.js';
3
3
  import { runWaterfallHook } from '../lib/plugin-loader.js';
4
4
  import { normalizeBasePath } from '../lib/base-path.js';
5
+ import { isAdminReq } from '../lib/is-admin.js';
6
+ import { isSameOriginBrowserRequest } from '../lib/same-origin.js';
5
7
 
6
8
  async function userProfile(req, res) {
7
9
  const profile = await getUserProfile();
@@ -10,6 +12,15 @@ async function userProfile(req, res) {
10
12
  }
11
13
 
12
14
  async function localUrl(req, res, parsedUrl, isLocal, deps) {
15
+ // The response embeds the full ACCESS_TOKEN. Without this gate the permissive global
16
+ // CORS prelude would let any cross-site page read the token from a loopback server
17
+ // (loopback skips auth) and then bypass every Origin/admin check end-to-end.
18
+ // CLI/native clients (no Origin header) and same-origin admin pages stay allowed.
19
+ if (!isAdminReq(req, isLocal) || !isSameOriginBrowserRequest(req, parsedUrl)) {
20
+ res.writeHead(403, { 'Content-Type': 'application/json' });
21
+ res.end(JSON.stringify({ error: 'Forbidden: admin and same-origin required' }));
22
+ return;
23
+ }
13
24
  const localIp = deps.getLocalIp();
14
25
  // 反代子路径部署时分享/二维码 URL 也要带前缀,否则扫码绕过代理直连源站端口(与
15
26
  // server.startedNetwork 启动打印保持一致)。未设 CCV_BASE_PATH 时为空串,行为不变。
@@ -0,0 +1,104 @@
1
+ // Routes for service-plugin proxies (/plugins/<name>/*) and panel bundles
2
+ // (/plugin-panels/<name>/*). Registered in server.js's _routes after pluginsRoutes.
3
+ // Unknown plugin names return 404 here — requests must NOT fall through to the SPA
4
+ // fallback (it would serve index.html as JS/JSON).
5
+ import { getPluginEntries } from '../lib/plugin-loader.js';
6
+ import { tryProxyHttp } from '../lib/plugin-runtime.js';
7
+ import { servePanelFile } from '../lib/plugin-panels.js';
8
+ import { reportSwallowed } from '../lib/error-report.js';
9
+ import { isSameOriginBrowserRequest } from '../lib/same-origin.js';
10
+
11
+ function findPanelEntry(name) {
12
+ for (const entry of getPluginEntries()) {
13
+ if (entry.shape === 'dir' && entry.enabled && !entry.loadError && entry.panel && entry.name === name) {
14
+ return entry;
15
+ }
16
+ }
17
+ return null;
18
+ }
19
+
20
+ function pluginProxyHandler(req, res, parsedUrl) {
21
+ try {
22
+ // Plugin services are same-machine capabilities reachable through the main origin.
23
+ // A cross-site browser page must not drive them just because loopback trusts the
24
+ // socket peer; CLI/native callers (no Origin) and same-origin pages pass.
25
+ if (!isSameOriginBrowserRequest(req, parsedUrl)) {
26
+ res.writeHead(403, { 'Content-Type': 'application/json' });
27
+ res.end(JSON.stringify({ error: 'Forbidden: cross-site plugin proxy request' }));
28
+ return;
29
+ }
30
+ const handled = tryProxyHttp(req, res, parsedUrl);
31
+ if (!handled) {
32
+ res.writeHead(404, { 'Content-Type': 'application/json' });
33
+ res.end(JSON.stringify({ error: 'unknown plugin service' }));
34
+ }
35
+ } catch (err) {
36
+ reportSwallowed('plugin-services.proxy-route', err);
37
+ try {
38
+ if (!res.headersSent) {
39
+ res.writeHead(502, { 'Content-Type': 'application/json' });
40
+ res.end(JSON.stringify({ error: 'plugin proxy failure' }));
41
+ } else {
42
+ res.destroy();
43
+ }
44
+ } catch { /* noop */ }
45
+ }
46
+ }
47
+
48
+ function pluginPanelHandler(req, res, parsedUrl) {
49
+ try {
50
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
51
+ res.writeHead(405, { 'Content-Type': 'text/plain; charset=utf-8' });
52
+ res.end('method not allowed');
53
+ return;
54
+ }
55
+ // /plugin-panels/<name>/<rel...>
56
+ const rest = parsedUrl.pathname.slice('/plugin-panels/'.length);
57
+ const slash = rest.indexOf('/');
58
+ const name = slash === -1 ? rest : rest.slice(0, slash);
59
+ const relPath = slash === -1 ? '' : rest.slice(slash + 1);
60
+ let decodedName;
61
+ try {
62
+ decodedName = decodeURIComponent(name);
63
+ } catch {
64
+ res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
65
+ res.end('bad plugin name');
66
+ return;
67
+ }
68
+ const entry = findPanelEntry(decodedName);
69
+ if (!entry) {
70
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
71
+ res.end('unknown plugin panel');
72
+ return;
73
+ }
74
+ if (!relPath) {
75
+ // Bare directory path -> entry bundle (relative rootDir = drop the first segment of entryRel)
76
+ const entryRel = entry.panel.entryRel;
77
+ const rel = entryRel.slice(entryRel.indexOf('/') + 1);
78
+ servePanelFile(req, res, entry, rel);
79
+ return;
80
+ }
81
+ servePanelFile(req, res, entry, relPath);
82
+ } catch (err) {
83
+ reportSwallowed('plugin-services.panel-route', err);
84
+ try {
85
+ if (!res.headersSent) {
86
+ res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
87
+ res.end('panel route failure');
88
+ } else {
89
+ res.destroy();
90
+ }
91
+ } catch { /* noop */ }
92
+ }
93
+ }
94
+
95
+ export const pluginServicesRoutes = [
96
+ {
97
+ predicate: (url) => url === '/plugins' || url.startsWith('/plugins/'),
98
+ handler: pluginProxyHandler,
99
+ },
100
+ {
101
+ predicate: (url) => url === '/plugin-panels' || url.startsWith('/plugin-panels/'),
102
+ handler: pluginPanelHandler,
103
+ },
104
+ ];