tide-commander 1.181.0 → 1.182.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 (151) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +21 -1
  3. package/dist/assets/{AudioPlayer-BAtnEqGO.js → AudioPlayer-BwhOjJ2c.js} +1 -1
  4. package/dist/assets/{BossLogsModal-DKPlKlOF.js → BossLogsModal-DAHZQ1pi.js} +1 -1
  5. package/dist/assets/BossSpawnModal-E7SGuXA2.js +1 -0
  6. package/dist/assets/ControlsModal-bAaYejMT.js +1 -0
  7. package/dist/assets/{DockerLogsModal-hn265l_A.js → DockerLogsModal-BJx8N4kK.js} +1 -1
  8. package/dist/assets/{EmbeddedEditor-Cj5jPNh6.js → EmbeddedEditor-Be-cO4-9.js} +1 -1
  9. package/dist/assets/{FcStdViewer-CFxBBNQq.js → FcStdViewer-DEwQ08Bb.js} +1 -1
  10. package/dist/assets/{GcodeViewer-3z7qkN29.js → GcodeViewer-ncf61wWb.js} +1 -1
  11. package/dist/assets/{GlbViewer-CaOGrb_f.js → GlbViewer-D12gmnGd.js} +1 -1
  12. package/dist/assets/{GmailOAuthSetup-Bk6jecTI.js → GmailOAuthSetup-C-GvDGQm.js} +1 -1
  13. package/dist/assets/{GoogleOAuthSetup-DVeeuuWe.js → GoogleOAuthSetup-BDjIAgur.js} +1 -1
  14. package/dist/assets/{IframeModal-wSJPMjiR.js → IframeModal-CCocGfa7.js} +1 -1
  15. package/dist/assets/{IntegrationsPanel-BorSz1f6.js → IntegrationsPanel-CBpaeiJG.js} +2 -2
  16. package/dist/assets/{LogViewerModal-Bc_8Tjwp.js → LogViewerModal-CaN7XU8z.js} +1 -1
  17. package/dist/assets/{MonitoringModal-_K-xK58O.js → MonitoringModal-I3nCNlbU.js} +1 -1
  18. package/dist/assets/{PM2LogsModal-RP5kh78y.js → PM2LogsModal-Bki_LCvj.js} +1 -1
  19. package/dist/assets/RestoreArchivedAreaModal-Ciy6t7et.js +1 -0
  20. package/dist/assets/{Scene2DCanvas-BD8gC5GG.js → Scene2DCanvas-DR56E5p7.js} +1 -1
  21. package/dist/assets/{SceneManager-B1FWKQrV.js → SceneManager-DKg8ugY_.js} +1 -1
  22. package/dist/assets/{SkillsPanel-B3sV8qgJ.js → SkillsPanel-C_dxzS6F.js} +2 -2
  23. package/dist/assets/{SlackMultiInstanceSetup-By9kD-7V.js → SlackMultiInstanceSetup-BwiTMMvL.js} +1 -1
  24. package/dist/assets/SpawnModal-DUMcIHoz.js +1 -0
  25. package/dist/assets/{StatisticsModal-k-Zow1Lc.js → StatisticsModal-BKXcBthy.js} +1 -1
  26. package/dist/assets/{StlViewer-CvwylgqD.js → StlViewer-DjdLvysP.js} +1 -1
  27. package/dist/assets/{SubordinateAssignmentModal-BgZoihF2.js → SubordinateAssignmentModal-Rl4cdHpe.js} +1 -1
  28. package/dist/assets/{ThreeAreaSelector-CBoLZACl.js → ThreeAreaSelector-C-9JJ1Hh.js} +1 -1
  29. package/dist/assets/{ThreeViewShortcuts-CwWPyQ1I.js → ThreeViewShortcuts-DL0Hmuj0.js} +1 -1
  30. package/dist/assets/{TriggerManagerPanel-8dCnF4zG.js → TriggerManagerPanel-BtCOoX1g.js} +1 -1
  31. package/dist/assets/{VideoPlayer-CFfNly_q.js → VideoPlayer-BoTr9gkT.js} +1 -1
  32. package/dist/assets/{WorkflowEditorPanel-DaoqmmDI.js → WorkflowEditorPanel-DlcSaDPD.js} +1 -1
  33. package/dist/assets/{abnfDiagram-VRR7QNED-AVCJRSGv.js → abnfDiagram-VRR7QNED-CEBRXYsw.js} +1 -1
  34. package/dist/assets/{arc-BozrtqGC.js → arc-COf9R405.js} +1 -1
  35. package/dist/assets/{architectureDiagram-ZJ3FMSHR-DIbqkKKH.js → architectureDiagram-ZJ3FMSHR-DGBRfu4B.js} +1 -1
  36. package/dist/assets/{area-logos-_xssrP4-.js → area-logos-BRuKyQ5J.js} +1 -1
  37. package/dist/assets/{blockDiagram-677ZJIJ3-cUooA8WG.js → blockDiagram-677ZJIJ3-CN0t4U2k.js} +1 -1
  38. package/dist/assets/{c4Diagram-LMCZKHZV-BN-pAlnh.js → c4Diagram-LMCZKHZV-Cq0IftW5.js} +1 -1
  39. package/dist/assets/channel-DF_pUJhw.js +1 -0
  40. package/dist/assets/{chunk-2Q5K7J3B-Df3QaYQM.js → chunk-2Q5K7J3B-adR2VoGA.js} +1 -1
  41. package/dist/assets/{chunk-32BRIVSS-eWsLMUAK.js → chunk-32BRIVSS-B6LCChZq.js} +1 -1
  42. package/dist/assets/{chunk-5VM5RSS4-D8L5MdGs.js → chunk-5VM5RSS4-CcH2c7y_.js} +1 -1
  43. package/dist/assets/{chunk-EX3LRPZG-D9F-m38t.js → chunk-EX3LRPZG-zBWa-mu2.js} +1 -1
  44. package/dist/assets/{chunk-JWPE2WC7-CTPQZilc.js → chunk-JWPE2WC7-DBQlYiWn.js} +1 -1
  45. package/dist/assets/{chunk-MOJQB5TN-BI5XLi89.js → chunk-MOJQB5TN-BjbsCplS.js} +1 -1
  46. package/dist/assets/{chunk-RYQCIY6F-aUw8CA0H.js → chunk-RYQCIY6F-B946ZxlN.js} +1 -1
  47. package/dist/assets/{chunk-V7JOEXUC-Bvn63xfp.js → chunk-V7JOEXUC-DEeYHnRT.js} +1 -1
  48. package/dist/assets/{chunk-VR4S4FIN-DuQCapS-.js → chunk-VR4S4FIN-B_QmrVGt.js} +1 -1
  49. package/dist/assets/{chunk-XXDRQBXY--Q2M75o8.js → chunk-XXDRQBXY-cgYh6YrW.js} +1 -1
  50. package/dist/assets/classDiagram-OUVF2IWQ-CeYNs9YM.js +1 -0
  51. package/dist/assets/classDiagram-v2-EOCWNBFH-CeYNs9YM.js +1 -0
  52. package/dist/assets/{cose-bilkent-JH36ORCC-CnOCRlHl.js → cose-bilkent-JH36ORCC-DJUmw0Ip.js} +1 -1
  53. package/dist/assets/{cynefin-VYW2F7L2-CVOENzFv.js → cynefin-VYW2F7L2-BUxR220i.js} +1 -1
  54. package/dist/assets/{cynefinDiagram-TSTJHNR4-BLe8BxZL.js → cynefinDiagram-TSTJHNR4-nYR1MwE2.js} +1 -1
  55. package/dist/assets/{dagre-VKFMJZFB-xtW7I5Nk.js → dagre-VKFMJZFB-A5VPim2l.js} +1 -1
  56. package/dist/assets/{diagram-FQU43EPY-BIr_QEk-.js → diagram-FQU43EPY-wdgwDoVg.js} +1 -1
  57. package/dist/assets/{diagram-G47NLZAW-BJd55qXW.js → diagram-G47NLZAW-CylyJQBQ.js} +1 -1
  58. package/dist/assets/{diagram-NH7WQ7WH-CZsKPct7.js → diagram-NH7WQ7WH-BOF1uKhH.js} +1 -1
  59. package/dist/assets/{diagram-OA4YK3LP-BaG39QNH.js → diagram-OA4YK3LP-B0B5zmh5.js} +1 -1
  60. package/dist/assets/{diagram-WEI45ONY-ShUVdCXz.js → diagram-WEI45ONY-vKP2IU9q.js} +1 -1
  61. package/dist/assets/{ebnfDiagram-CCIWWBDH-T9Sx10gP.js → ebnfDiagram-CCIWWBDH-DM8hIEJM.js} +1 -1
  62. package/dist/assets/{erDiagram-Q63AITRT-CwT0KHCH.js → erDiagram-Q63AITRT-iuRH3hWX.js} +1 -1
  63. package/dist/assets/{flowDiagram-23GEKE2U-DpfmSS0V.js → flowDiagram-23GEKE2U-CddCUoGB.js} +1 -1
  64. package/dist/assets/{ganttDiagram-NO4QXBWP-DFHqNARc.js → ganttDiagram-NO4QXBWP-CIfvQLcu.js} +1 -1
  65. package/dist/assets/{gitGraphDiagram-IHSO6WYX-DiG1nj1m.js → gitGraphDiagram-IHSO6WYX-CbW0FQci.js} +1 -1
  66. package/dist/assets/index-B4YklnOy.js +1 -0
  67. package/dist/assets/{index-CeUo--0k.js → index-BmD7ycmn.js} +1 -1
  68. package/dist/assets/{index-B8WCuK1N.js → index-CIcFjlRX.js} +2 -2
  69. package/dist/assets/{index-BMINDCB0.js → index-CRhUiTuD.js} +3 -3
  70. package/dist/assets/{index-COqQ_1sL.css → index-D1GfjaTN.css} +1 -1
  71. package/dist/assets/index-DHuT10Pa.js +1 -0
  72. package/dist/assets/{index-BmmzETKL.js → index-DoJdznbY.js} +5 -5
  73. package/dist/assets/index-Du8eK_OX.js +1 -0
  74. package/dist/assets/index-HYcYan1q.js +19 -0
  75. package/dist/assets/index-HhAZ3SIm.js +2 -0
  76. package/dist/assets/{infoDiagram-FWYZ7A6U-CT7RBQSN.js → infoDiagram-FWYZ7A6U-C0it9hLc.js} +1 -1
  77. package/dist/assets/{ishikawaDiagram-FXEZZL3T-Bv_eTPfi.js → ishikawaDiagram-FXEZZL3T-pKZnNgqp.js} +1 -1
  78. package/dist/assets/{journeyDiagram-5HDEW3XC-BwKya-ih.js → journeyDiagram-5HDEW3XC-Ib9FpXdB.js} +1 -1
  79. package/dist/assets/{kanban-definition-HUTT4EX6-VrWxcMn7.js → kanban-definition-HUTT4EX6-aOTfe6f6.js} +1 -1
  80. package/dist/assets/{linear-BsocX-C-.js → linear-B_ipWlyx.js} +1 -1
  81. package/dist/assets/main-Bvn8CMZc.css +1 -0
  82. package/dist/assets/{main-B9DphKOa.js → main-m3NNcAo4.js} +115 -115
  83. package/dist/assets/{mermaid.core-Uz36XO2-.js → mermaid.core-EY9hyXLz.js} +4 -4
  84. package/dist/assets/{mindmap-definition-LN4V7U3C-Bv9DlPAr.js → mindmap-definition-LN4V7U3C-B_p1LOAa.js} +1 -1
  85. package/dist/assets/{pegDiagram-2B236MQR-CRG-2pCa.js → pegDiagram-2B236MQR-BgCd9rWg.js} +1 -1
  86. package/dist/assets/{pieDiagram-ENE6RG2P-dkVFz981.js → pieDiagram-ENE6RG2P-d8ofNooq.js} +1 -1
  87. package/dist/assets/{quadrantDiagram-ABIIQ3AL-D6Y-oPfA.js → quadrantDiagram-ABIIQ3AL-27DPJdZF.js} +1 -1
  88. package/dist/assets/{railroadDiagram-RFXS5EU6-DnBPGcSS.js → railroadDiagram-RFXS5EU6-C7S8pppU.js} +1 -1
  89. package/dist/assets/{requirementDiagram-TGXJPOKE-BfXBQgDX.js → requirementDiagram-TGXJPOKE-BbTkDbLO.js} +1 -1
  90. package/dist/assets/{sankeyDiagram-HTMAVEWB-B2plq2xb.js → sankeyDiagram-HTMAVEWB-lU57KWVw.js} +1 -1
  91. package/dist/assets/{sequenceDiagram-DBY2YBRQ-5HYSSRy3.js → sequenceDiagram-DBY2YBRQ-Bg18P0LT.js} +1 -1
  92. package/dist/assets/{sizeCapture-X5ZJPWSS-Dhd1V1RM.js → sizeCapture-X5ZJPWSS-FDhyxAiu.js} +1 -1
  93. package/dist/assets/{stateDiagram-2N3HPSRC-BawpJoTz.js → stateDiagram-2N3HPSRC-pp9KUH2d.js} +1 -1
  94. package/dist/assets/stateDiagram-v2-6OUMAXLB-BmoS7QDQ.js +1 -0
  95. package/dist/assets/{swimlanes-5IMT3BWC-BYunKpK9.js → swimlanes-5IMT3BWC-BCUZKORl.js} +2 -2
  96. package/dist/assets/swimlanesDiagram-G3AALYLV-Da6QbdiL.js +8 -0
  97. package/dist/assets/{timeline-definition-FHXFAJF6-BA1n8ny7.js → timeline-definition-FHXFAJF6-BaeOwVlH.js} +1 -1
  98. package/dist/assets/{vennDiagram-L72KCM5P-DfSgfnDb.js → vennDiagram-L72KCM5P-BkhqU_hz.js} +1 -1
  99. package/dist/assets/{wardleyDiagram-EHGQE667-Ccum1Sj1.js → wardleyDiagram-EHGQE667-DvPxIC6q.js} +1 -1
  100. package/dist/assets/{web-24bQZmaT.js → web-BhVPxATx.js} +1 -1
  101. package/dist/assets/{web-CDfC-TTs.js → web-CR-app3U.js} +1 -1
  102. package/dist/assets/{web-C4jAxolp.js → web-DOiXCdVs.js} +1 -1
  103. package/dist/assets/{xychartDiagram-FW5EYKEG-CZKJCuVt.js → xychartDiagram-FW5EYKEG-B9MrBAD_.js} +1 -1
  104. package/dist/build-info.json +1 -1
  105. package/dist/index.html +2 -2
  106. package/dist/locales/de/config.json +5 -0
  107. package/dist/locales/de/terminal.json +1 -0
  108. package/dist/locales/en/config.json +5 -0
  109. package/dist/locales/en/terminal.json +2 -0
  110. package/dist/locales/es/config.json +5 -0
  111. package/dist/locales/es/terminal.json +11 -0
  112. package/dist/locales/fr/config.json +5 -0
  113. package/dist/locales/fr/terminal.json +1 -0
  114. package/dist/locales/hi/config.json +5 -0
  115. package/dist/locales/hi/terminal.json +1 -0
  116. package/dist/locales/it/config.json +5 -0
  117. package/dist/locales/it/terminal.json +1 -0
  118. package/dist/locales/ja/config.json +5 -0
  119. package/dist/locales/ja/terminal.json +1 -0
  120. package/dist/locales/pt/config.json +5 -0
  121. package/dist/locales/pt/terminal.json +1 -0
  122. package/dist/locales/ru/config.json +5 -0
  123. package/dist/locales/ru/terminal.json +1 -0
  124. package/dist/locales/zh-CN/config.json +5 -0
  125. package/dist/locales/zh-CN/terminal.json +1 -0
  126. package/dist/src/packages/server/data/builtin-skills/boss-instructions.js +1 -1
  127. package/dist/src/packages/server/grok/backend.js +7 -10
  128. package/dist/src/packages/server/index.js +61 -34
  129. package/dist/src/packages/server/routes/files.js +45 -19
  130. package/dist/src/packages/server/services/global-file-search.js +281 -0
  131. package/dist/src/packages/server/websocket/handler.js +16 -6
  132. package/dist/src/packages/server/websocket/listeners/runtime-listeners.js +7 -0
  133. package/dist/src/packages/shared/agent-types.js +56 -4
  134. package/dist/src/packages/shared/file-search.js +49 -0
  135. package/dist/src/packages/shared/types.js +1 -0
  136. package/package.json +1 -1
  137. package/dist/assets/BossSpawnModal-CvFw8BPQ.js +0 -1
  138. package/dist/assets/ControlsModal-f4JvuTMY.js +0 -1
  139. package/dist/assets/RestoreArchivedAreaModal-C_cQr4Ay.js +0 -1
  140. package/dist/assets/SpawnModal-yPfAItLC.js +0 -1
  141. package/dist/assets/channel-Dz9gemPm.js +0 -1
  142. package/dist/assets/classDiagram-OUVF2IWQ-D29jX9yn.js +0 -1
  143. package/dist/assets/classDiagram-v2-EOCWNBFH-D29jX9yn.js +0 -1
  144. package/dist/assets/index-B5DZivhj.js +0 -19
  145. package/dist/assets/index-B_A7-K2O.js +0 -1
  146. package/dist/assets/index-CzZH0S0F.js +0 -1
  147. package/dist/assets/index-DHN-Bqnw.js +0 -1
  148. package/dist/assets/index-Dhfk8xKP.js +0 -2
  149. package/dist/assets/main-4CZSv3SH.css +0 -1
  150. package/dist/assets/stateDiagram-v2-6OUMAXLB-ChimTXB4.js +0 -1
  151. package/dist/assets/swimlanesDiagram-G3AALYLV-4ZzygVvO.js +0 -8
@@ -889,6 +889,7 @@
889
889
  "agents": "代理",
890
890
  "infrastructure": "基础设施",
891
891
  "areas": "区域",
892
+ "files": "文件",
892
893
  "modifiedFiles": "已修改的文件",
893
894
  "recentActivity": "最近活动",
894
895
  "sessions": "历史会话"
@@ -204,7 +204,7 @@ Spawn ONLY when the user EXPLICITLY requests it ("create an agent", "spawn a deb
204
204
  "customInstructions": "<optional extra system prompt for this agent>",
205
205
  "codexModel": "<optional: only when provider='codex'>",
206
206
  "opencodeModel": "<optional: only when provider='opencode'>",
207
- "grokModel": "<optional: only when provider='grok'>",
207
+ "grokModel": "<optional: only when provider='grok': grok-4.6 | grok-4.5>",
208
208
  "piModel": "<optional: only when provider='pi', 'provider/model' pattern>"
209
209
  }]
210
210
  \\\`\\\`\\\`
@@ -8,6 +8,7 @@ import { execSync } from 'child_process';
8
8
  import * as fs from 'fs';
9
9
  import * as os from 'os';
10
10
  import * as path from 'path';
11
+ import { resolveGrokReasoningEffort } from '../../shared/agent-types.js';
11
12
  import { GrokJsonEventParser } from './json-event-parser.js';
12
13
  import { TIDE_COMMANDER_APPENDED_PROMPT } from '../prompts/tide-commander.js';
13
14
  import { getSystemPrompt, isEchoPromptEnabled } from '../services/system-prompt-service.js';
@@ -114,16 +115,12 @@ export class GrokBackend {
114
115
  args.push('-m', config.model);
115
116
  }
116
117
  if (config.effort) {
117
- // Map Tide effort labels to Grok reasoning-effort values
118
- const effortMap = {
119
- low: 'low',
120
- medium: 'medium',
121
- high: 'high',
122
- xHigh: 'xhigh',
123
- max: 'max',
124
- };
125
- const mapped = effortMap[config.effort] || config.effort.toLowerCase();
126
- args.push('--reasoning-effort', mapped);
118
+ // Map Tide effort labels to Grok reasoning-effort values, clamped to what
119
+ // the selected model supports (grok-4.5 has no xhigh tier; Grok has no max).
120
+ const mapped = resolveGrokReasoningEffort(config.model, config.effort);
121
+ if (mapped) {
122
+ args.push('--reasoning-effort', mapped);
123
+ }
127
124
  }
128
125
  // Session resume / fork
129
126
  if (config.sessionId) {
@@ -26,6 +26,10 @@ const HOST = process.env.HOST || (process.env.LISTEN_ALL_INTERFACES ? '::' : '12
26
26
  const HTTPS_ENABLED = process.env.HTTPS === '1';
27
27
  const TLS_KEY_PATH = process.env.TLS_KEY_PATH;
28
28
  const TLS_CERT_PATH = process.env.TLS_CERT_PATH;
29
+ // When set, TLS is served on this port IN ADDITION to plain HTTP on PORT, so
30
+ // http:// and https:// clients can both connect to the same instance. Takes
31
+ // precedence over HTTPS=1 (which serves TLS on PORT and nothing in the clear).
32
+ const HTTPS_PORT = process.env.HTTPS_PORT ? Number(process.env.HTTPS_PORT) : null;
29
33
  const FORCE_SHUTDOWN_TIMEOUT_MS = 4500;
30
34
  // ============================================================================
31
35
  // Global Error Handlers
@@ -159,26 +163,48 @@ async function main() {
159
163
  initClaudeCredentialKeepAlive();
160
164
  logger.server.log(`Data directory: ${getDataDir()}`);
161
165
  logger.server.log(`Log file: ${getLogFilePath()}`);
162
- // Create Express app and HTTP server
166
+ // Create Express app and HTTP server(s)
163
167
  const app = createApp();
164
- const server = HTTPS_ENABLED
165
- ? createHttpsServer({
166
- key: fs.readFileSync(assertTlsPath(TLS_KEY_PATH, 'TLS_KEY_PATH')),
167
- cert: fs.readFileSync(assertTlsPath(TLS_CERT_PATH, 'TLS_CERT_PATH')),
168
- }, app)
169
- : createServer(app);
168
+ const readTlsOptions = (reason) => ({
169
+ key: fs.readFileSync(assertTlsPath(TLS_KEY_PATH, 'TLS_KEY_PATH', reason)),
170
+ cert: fs.readFileSync(assertTlsPath(TLS_CERT_PATH, 'TLS_CERT_PATH', reason)),
171
+ });
172
+ if (HTTPS_PORT && HTTPS_ENABLED) {
173
+ logger.server.warn('HTTPS=1 and HTTPS_PORT are both set; HTTPS_PORT wins '
174
+ + `(plain HTTP on ${PORT}, TLS on ${HTTPS_PORT})`);
175
+ }
176
+ // Listener set: either a single server (legacy) or HTTP + HTTPS side by side.
177
+ const listeners = HTTPS_PORT
178
+ ? [
179
+ { server: createServer(app), protocol: 'http', port: Number(PORT) },
180
+ { server: createHttpsServer(readTlsOptions('HTTPS_PORT'), app), protocol: 'https', port: HTTPS_PORT },
181
+ ]
182
+ : [{
183
+ server: HTTPS_ENABLED ? createHttpsServer(readTlsOptions('HTTPS=1'), app) : createServer(app),
184
+ protocol: HTTPS_ENABLED ? 'https' : 'http',
185
+ port: Number(PORT),
186
+ }];
187
+ const primary = listeners[0].server;
170
188
  const sockets = new Set();
171
- server.on('connection', (socket) => {
172
- sockets.add(socket);
173
- socket.on('close', () => {
174
- sockets.delete(socket);
189
+ for (const { server: listener } of listeners) {
190
+ listener.on('connection', (socket) => {
191
+ sockets.add(socket);
192
+ socket.on('close', () => {
193
+ sockets.delete(socket);
194
+ });
175
195
  });
176
- });
177
- // Initialize WebSocket
178
- const wss = websocket.init(server);
196
+ }
197
+ // Initialize WebSocket. Every listener shares one WebSocketServer so clients
198
+ // land in the same client set no matter which scheme they arrived over.
199
+ const wss = websocket.init(primary);
200
+ for (const { server: listener } of listeners.slice(1)) {
201
+ websocket.attachServer(listener, wss);
202
+ }
179
203
  // Set up terminal WebSocket proxy for ttyd buildings
180
204
  // (HTTP proxy is set up in app.ts before API routes)
181
- setupTerminalWsProxy(server);
205
+ for (const { server: listener } of listeners) {
206
+ setupTerminalWsProxy(listener);
207
+ }
182
208
  // Set up skill hot-reload (must be after websocket init to have broadcast available)
183
209
  skillService.setupSkillHotReload(agentService, runtimeService, websocket.broadcast);
184
210
  // Start PM2 status polling for buildings
@@ -187,22 +213,23 @@ async function main() {
187
213
  buildingService.startDockerStatusPolling(websocket.broadcast);
188
214
  // Start terminal (ttyd) status polling for buildings
189
215
  buildingService.startTerminalStatusPolling(websocket.broadcast);
190
- // Start server
191
- server.on('error', (err) => {
192
- logger.server.error('Server listen error:', err);
193
- if (err.code === 'EADDRINUSE') {
194
- logger.server.error(`Port ${PORT} is already in use. Exiting.`);
195
- closeFileLogging();
196
- process.exit(1);
197
- }
198
- });
199
- server.listen(Number(PORT), HOST, () => {
200
- const protocol = HTTPS_ENABLED ? 'https' : 'http';
201
- const wsProtocol = HTTPS_ENABLED ? 'wss' : 'ws';
202
- logger.server.log(`Server running on ${protocol}://${HOST}:${PORT}`);
203
- logger.server.log(`WebSocket available at ${wsProtocol}://${HOST}:${PORT}/ws`);
204
- logger.server.log(`API available at ${protocol}://${HOST}:${PORT}/api`);
205
- });
216
+ // Start server(s)
217
+ for (const { server: listener, protocol, port } of listeners) {
218
+ listener.on('error', (err) => {
219
+ logger.server.error(`Server listen error (${protocol}:${port}):`, err);
220
+ if (err.code === 'EADDRINUSE') {
221
+ logger.server.error(`Port ${port} is already in use. Exiting.`);
222
+ closeFileLogging();
223
+ process.exit(1);
224
+ }
225
+ });
226
+ listener.listen(port, HOST, () => {
227
+ const wsProtocol = protocol === 'https' ? 'wss' : 'ws';
228
+ logger.server.log(`Server running on ${protocol}://${HOST}:${port}`);
229
+ logger.server.log(`WebSocket available at ${wsProtocol}://${HOST}:${port}/ws`);
230
+ logger.server.log(`API available at ${protocol}://${HOST}:${port}/api`);
231
+ });
232
+ }
206
233
  let isShuttingDown = false;
207
234
  const gracefulShutdown = async (signal) => {
208
235
  if (isShuttingDown) {
@@ -241,7 +268,7 @@ async function main() {
241
268
  wss.clients.forEach((client) => client.terminate());
242
269
  await new Promise((resolve) => wss.close(() => resolve()));
243
270
  sockets.forEach((socket) => socket.destroy());
244
- await new Promise((resolve) => server.close(() => resolve()));
271
+ await Promise.all(listeners.map(({ server: listener }) => (new Promise((resolve) => listener.close(() => resolve())))));
245
272
  clearTimeout(forceShutdownTimer);
246
273
  closeFileLogging();
247
274
  process.exit(0);
@@ -256,9 +283,9 @@ async function main() {
256
283
  process.on('SIGINT', () => { void gracefulShutdown('SIGINT'); });
257
284
  process.on('SIGTERM', () => { void gracefulShutdown('SIGTERM'); });
258
285
  }
259
- function assertTlsPath(value, envName) {
286
+ function assertTlsPath(value, envName, reason) {
260
287
  if (!value) {
261
- throw new Error(`${envName} is required when HTTPS=1`);
288
+ throw new Error(`${envName} is required when ${reason} is set`);
262
289
  }
263
290
  return value;
264
291
  }
@@ -11,6 +11,8 @@ import { pathToFileURL } from 'url';
11
11
  import { logger } from '../utils/logger.js';
12
12
  import { loadAreas } from '../data/index.js';
13
13
  import { detectRunnerType, mightBeTestFile, mightBeVitestFile, mightBePhpTestFile } from '../services/test-runner-service.js';
14
+ import { DEFAULT_FILE_SEARCH_EXCLUDE_DIRS, parseExcludeDirNames } from '../../shared/file-search.js';
15
+ import { searchFilesGlobal, FILE_SEARCH_MIN_QUERY } from '../services/global-file-search.js';
14
16
  const log = logger.files;
15
17
  // Get or create temp directory for tide-commander uploads. Exported so other
16
18
  // modules (attachment-downloader, attachment-janitor) put their files under the
@@ -1201,18 +1203,20 @@ router.post('/paste', (req, res) => {
1201
1203
  res.status(500).json({ error: err.message });
1202
1204
  }
1203
1205
  });
1204
- // Directories that are always skipped during file/content search. Kept as a
1205
- // Set for O(1) lookups (this is checked once per directory entry during the
1206
- // tree walk). Union of the old filename + content skip lists.
1207
- const SEARCH_SKIP_DIRS = new Set([
1208
- 'node_modules', 'dist', 'build', '.git', '__pycache__',
1209
- 'venv', '.venv', 'target', 'vendor', '.next', '.cache',
1210
- ]);
1206
+ // Directories skipped during file/content search unless the request supplies
1207
+ // `exclude`. Kept as a Set for O(1) lookups (checked once per directory entry).
1208
+ const SEARCH_SKIP_DIRS = new Set(DEFAULT_FILE_SEARCH_EXCLUDE_DIRS);
1211
1209
  const MAX_SEARCH_DEPTH = 10;
1210
+ /** Request `exclude` (comma list or repeated) overrides the default skip set. */
1211
+ function skipDirsFromQuery(req) {
1212
+ if (req.query.exclude === undefined)
1213
+ return SEARCH_SKIP_DIRS;
1214
+ return new Set(parseExcludeDirNames(req.query.exclude));
1215
+ }
1212
1216
  // Helper function to search files recursively (async / non-blocking).
1213
1217
  // Uses fs.promises so a deep tree walk never blocks the event loop, which
1214
1218
  // matters on a shared multi-agent server. `queryLower` must be pre-lowercased.
1215
- async function searchFilesAsync(dirPath, queryLower, results, maxResults, depth = 0) {
1219
+ async function searchFilesAsync(dirPath, queryLower, results, maxResults, depth = 0, skipDirs = SEARCH_SKIP_DIRS) {
1216
1220
  if (results.length >= maxResults || depth > MAX_SEARCH_DEPTH)
1217
1221
  return;
1218
1222
  let entries;
@@ -1227,8 +1231,8 @@ async function searchFilesAsync(dirPath, queryLower, results, maxResults, depth
1227
1231
  if (results.length >= maxResults)
1228
1232
  break;
1229
1233
  const isDir = entry.isDirectory();
1230
- // Skip common non-essential directories
1231
- if (isDir && SEARCH_SKIP_DIRS.has(entry.name))
1234
+ // Skip configured non-essential directories
1235
+ if (isDir && skipDirs.has(entry.name))
1232
1236
  continue;
1233
1237
  const fullPath = path.join(dirPath, entry.name);
1234
1238
  // Check if name matches query (case-insensitive)
@@ -1255,7 +1259,7 @@ async function searchFilesAsync(dirPath, queryLower, results, maxResults, depth
1255
1259
  for (const sub of subdirs) {
1256
1260
  if (results.length >= maxResults)
1257
1261
  break;
1258
- await searchFilesAsync(sub, queryLower, results, maxResults, depth + 1);
1262
+ await searchFilesAsync(sub, queryLower, results, maxResults, depth + 1, skipDirs);
1259
1263
  }
1260
1264
  }
1261
1265
  // GET /api/files/search - Search for files
@@ -1277,7 +1281,7 @@ router.get('/search', async (req, res) => {
1277
1281
  return;
1278
1282
  }
1279
1283
  const results = [];
1280
- await searchFilesAsync(dirPath, query.toLowerCase(), results, maxResults);
1284
+ await searchFilesAsync(dirPath, query.toLowerCase(), results, maxResults, 0, skipDirsFromQuery(req));
1281
1285
  // Sort: files first (more likely what user wants), then by name
1282
1286
  results.sort((a, b) => {
1283
1287
  if (a.isDirectory !== b.isDirectory) {
@@ -1292,6 +1296,27 @@ router.get('/search', async (req, res) => {
1292
1296
  res.status(500).json({ error: err.message });
1293
1297
  }
1294
1298
  });
1299
+ // GET /api/files/search-global - Filename search across every area directory
1300
+ router.get('/search-global', async (req, res) => {
1301
+ try {
1302
+ const query = typeof req.query.q === 'string' ? req.query.q : '';
1303
+ if (query.trim().length < FILE_SEARCH_MIN_QUERY) {
1304
+ res.json({ files: [] });
1305
+ return;
1306
+ }
1307
+ const limit = parseInt(String(req.query.limit ?? ''), 10);
1308
+ const files = await searchFilesGlobal({
1309
+ query,
1310
+ exclude: req.query.exclude,
1311
+ limit: Number.isFinite(limit) ? limit : undefined,
1312
+ });
1313
+ res.json({ files });
1314
+ }
1315
+ catch (err) {
1316
+ log.error(' Global file search failed:', err);
1317
+ res.status(500).json({ error: err.message });
1318
+ }
1319
+ });
1295
1320
  // Text file extensions for content search
1296
1321
  const TEXT_EXTENSIONS = new Set([
1297
1322
  '.txt', '.md', '.markdown', '.json', '.yaml', '.yml', '.xml', '.html', '.htm',
@@ -1311,7 +1336,7 @@ const MAX_CONTENT_FILE_SIZE = 1024 * 1024;
1311
1336
  // a child process (never blocking the server) and is orders of magnitude faster
1312
1337
  // than a JS tree walk. Resolves { ok:false } when rg is missing or errors, so
1313
1338
  // the caller can fall back to the pure-JS walker.
1314
- function searchFileContentsRipgrep(dirPath, query, maxResults) {
1339
+ function searchFileContentsRipgrep(dirPath, query, maxResults, skipDirs = SEARCH_SKIP_DIRS) {
1315
1340
  return new Promise((resolve) => {
1316
1341
  const args = [
1317
1342
  '--json',
@@ -1323,7 +1348,7 @@ function searchFileContentsRipgrep(dirPath, query, maxResults) {
1323
1348
  '--hidden', // include dotfiles (.env, .github, ...)
1324
1349
  '--no-ignore', // don't honor .gitignore (match old behavior)
1325
1350
  '--no-messages', // suppress permission/binary warnings on stderr
1326
- ...[...SEARCH_SKIP_DIRS].flatMap((d) => ['--glob', `!**/${d}/**`]),
1351
+ ...[...skipDirs].flatMap((d) => ['--glob', `!**/${d}/**`]),
1327
1352
  '--',
1328
1353
  query,
1329
1354
  dirPath,
@@ -1400,7 +1425,7 @@ function searchFileContentsRipgrep(dirPath, query, maxResults) {
1400
1425
  // Fallback content search (async / non-blocking) used only when ripgrep is
1401
1426
  // unavailable. Mirrors the old behavior but with fs.promises so it never
1402
1427
  // blocks the event loop.
1403
- async function searchFileContentsAsync(dirPath, queryLower, results, maxResults, depth = 0) {
1428
+ async function searchFileContentsAsync(dirPath, queryLower, results, maxResults, depth = 0, skipDirs = SEARCH_SKIP_DIRS) {
1404
1429
  if (results.length >= maxResults || depth > MAX_SEARCH_DEPTH)
1405
1430
  return;
1406
1431
  let entries;
@@ -1415,7 +1440,7 @@ async function searchFileContentsAsync(dirPath, queryLower, results, maxResults,
1415
1440
  if (results.length >= maxResults)
1416
1441
  break;
1417
1442
  if (entry.isDirectory()) {
1418
- if (!SEARCH_SKIP_DIRS.has(entry.name))
1443
+ if (!skipDirs.has(entry.name))
1419
1444
  subdirs.push(path.join(dirPath, entry.name));
1420
1445
  continue;
1421
1446
  }
@@ -1451,7 +1476,7 @@ async function searchFileContentsAsync(dirPath, queryLower, results, maxResults,
1451
1476
  for (const sub of subdirs) {
1452
1477
  if (results.length >= maxResults)
1453
1478
  break;
1454
- await searchFileContentsAsync(sub, queryLower, results, maxResults, depth + 1);
1479
+ await searchFileContentsAsync(sub, queryLower, results, maxResults, depth + 1, skipDirs);
1455
1480
  }
1456
1481
  }
1457
1482
  // GET /api/files/search-content - Search file contents
@@ -1477,13 +1502,14 @@ router.get('/search-content', async (req, res) => {
1477
1502
  return;
1478
1503
  }
1479
1504
  let results = [];
1480
- const rg = await searchFileContentsRipgrep(dirPath, query, maxResults);
1505
+ const skipDirs = skipDirsFromQuery(req);
1506
+ const rg = await searchFileContentsRipgrep(dirPath, query, maxResults, skipDirs);
1481
1507
  if (rg.ok) {
1482
1508
  results = rg.results;
1483
1509
  }
1484
1510
  else {
1485
1511
  // ripgrep unavailable — fall back to the pure-JS async walker.
1486
- await searchFileContentsAsync(dirPath, query.toLowerCase(), results, maxResults);
1512
+ await searchFileContentsAsync(dirPath, query.toLowerCase(), results, maxResults, 0, skipDirs);
1487
1513
  }
1488
1514
  res.json({ results });
1489
1515
  }
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Global filename search across every directory configured on areas.
3
+ * Used by Spotlight (GET /api/files/search-global).
4
+ *
5
+ * Prefer ripgrep (`rg --files`) when available; fall back to an async
6
+ * directory walk so the event loop stays unblocked.
7
+ */
8
+ import * as fs from 'fs';
9
+ import * as path from 'path';
10
+ import { spawn } from 'child_process';
11
+ import { loadAreas } from '../data/index.js';
12
+ import { DEFAULT_FILE_SEARCH_EXCLUDE_DIRS, parseExcludeDirNames, } from '../../shared/file-search.js';
13
+ function toPosixSeparators(p) {
14
+ return p.replace(/\\/g, '/');
15
+ }
16
+ export const FILE_SEARCH_MIN_QUERY = 2;
17
+ export const FILE_SEARCH_MAX_RESULTS = 50;
18
+ const MAX_AREA_DIRS = 48;
19
+ const MAX_SEARCH_DEPTH = 12;
20
+ const RG_TIMEOUT_MS = 4000;
21
+ function defaultExcludeSet() {
22
+ return new Set(DEFAULT_FILE_SEARCH_EXCLUDE_DIRS);
23
+ }
24
+ export function excludeDirsFromInput(raw, useDefaultWhenMissing) {
25
+ if (raw === undefined) {
26
+ return useDefaultWhenMissing ? defaultExcludeSet() : new Set();
27
+ }
28
+ return new Set(parseExcludeDirNames(raw));
29
+ }
30
+ function gatherRoots(areas) {
31
+ const roots = [];
32
+ const seenResolved = new Set();
33
+ for (const area of areas) {
34
+ if (area.archived)
35
+ continue;
36
+ const dirs = Array.isArray(area.directories) ? area.directories : [];
37
+ for (const raw of dirs) {
38
+ if (roots.length >= MAX_AREA_DIRS)
39
+ return roots;
40
+ if (typeof raw !== 'string' || !raw.trim())
41
+ continue;
42
+ if (!path.isAbsolute(raw))
43
+ continue;
44
+ let resolved;
45
+ try {
46
+ resolved = path.resolve(raw);
47
+ }
48
+ catch {
49
+ continue;
50
+ }
51
+ if (seenResolved.has(resolved))
52
+ continue;
53
+ let stat;
54
+ try {
55
+ stat = fs.statSync(resolved);
56
+ }
57
+ catch {
58
+ continue;
59
+ }
60
+ if (!stat.isDirectory())
61
+ continue;
62
+ seenResolved.add(resolved);
63
+ const posixRoot = toPosixSeparators(resolved);
64
+ roots.push({
65
+ areaId: area.id,
66
+ areaName: area.name,
67
+ projectName: path.basename(resolved) || posixRoot,
68
+ projectRoot: posixRoot,
69
+ resolved,
70
+ });
71
+ }
72
+ }
73
+ return roots;
74
+ }
75
+ function matchRoot(filePath, roots) {
76
+ let best = null;
77
+ for (const root of roots) {
78
+ if (filePath === root.resolved || filePath.startsWith(root.resolved + path.sep)) {
79
+ if (!best || root.resolved.length > best.resolved.length)
80
+ best = root;
81
+ }
82
+ }
83
+ return best;
84
+ }
85
+ function toHit(filePath, root) {
86
+ const rel = path.relative(root.resolved, filePath);
87
+ return {
88
+ path: toPosixSeparators(filePath),
89
+ name: path.basename(filePath),
90
+ relativePath: toPosixSeparators(rel || path.basename(filePath)),
91
+ projectName: root.projectName,
92
+ projectRoot: root.projectRoot,
93
+ areaId: root.areaId,
94
+ areaName: root.areaName,
95
+ };
96
+ }
97
+ function rankHit(hit, queryLower) {
98
+ const name = hit.name.toLowerCase();
99
+ if (name === queryLower)
100
+ return 0;
101
+ if (name.startsWith(queryLower))
102
+ return 1;
103
+ if (name.includes(queryLower))
104
+ return 2;
105
+ if (hit.relativePath.toLowerCase().includes(queryLower))
106
+ return 3;
107
+ return 4;
108
+ }
109
+ function sortHits(hits, queryLower) {
110
+ return hits.sort((a, b) => {
111
+ const ra = rankHit(a, queryLower);
112
+ const rb = rankHit(b, queryLower);
113
+ if (ra !== rb)
114
+ return ra - rb;
115
+ if (a.name.length !== b.name.length)
116
+ return a.name.length - b.name.length;
117
+ return a.relativePath.length - b.relativePath.length;
118
+ });
119
+ }
120
+ function searchOneRootRipgrep(root, query, exclude, maxResults) {
121
+ return new Promise((resolve) => {
122
+ // cwd = project root so exclude globs like `!**/tmp/**` cannot match a
123
+ // host path prefix (e.g. searching a tree that lives under /tmp).
124
+ // Do NOT use --iglob for the query: an include glob overrides exclude
125
+ // globs in rg, so `tmp/Keep.ts` would leak through. Filter the query
126
+ // against streamed paths instead.
127
+ const queryLower = query.toLowerCase();
128
+ const args = [
129
+ '--files',
130
+ '--hidden',
131
+ '--no-ignore',
132
+ '--no-messages',
133
+ '--max-depth',
134
+ String(MAX_SEARCH_DEPTH + 1),
135
+ ...[...exclude].flatMap((d) => ['--glob', `!${d}/**`, '--glob', `!**/${d}/**`]),
136
+ '--',
137
+ '.',
138
+ ];
139
+ let child;
140
+ try {
141
+ child = spawn('rg', args, { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] });
142
+ }
143
+ catch {
144
+ resolve({ ok: false, paths: [] });
145
+ return;
146
+ }
147
+ const paths = [];
148
+ let buffer = '';
149
+ let settled = false;
150
+ const finish = (ok) => {
151
+ if (settled)
152
+ return;
153
+ settled = true;
154
+ clearTimeout(timer);
155
+ resolve({ ok, paths });
156
+ };
157
+ const timer = setTimeout(() => {
158
+ try {
159
+ child.kill();
160
+ }
161
+ catch { /* already gone */ }
162
+ finish(paths.length > 0);
163
+ }, RG_TIMEOUT_MS);
164
+ child.on('error', () => finish(false));
165
+ child.stdout?.setEncoding('utf-8');
166
+ child.stdout?.on('data', (chunk) => {
167
+ buffer += chunk;
168
+ let nl;
169
+ while ((nl = buffer.indexOf('\n')) !== -1) {
170
+ const line = buffer.slice(0, nl);
171
+ buffer = buffer.slice(nl + 1);
172
+ if (!line)
173
+ continue;
174
+ const rel = line.replace(/^\.\//, '');
175
+ const name = path.basename(rel);
176
+ if (!name.toLowerCase().includes(queryLower) && !rel.toLowerCase().includes(queryLower)) {
177
+ continue;
178
+ }
179
+ const abs = path.isAbsolute(line) ? line : path.join(root, line);
180
+ paths.push(abs);
181
+ if (paths.length >= maxResults) {
182
+ try {
183
+ child.kill();
184
+ }
185
+ catch { /* already gone */ }
186
+ break;
187
+ }
188
+ }
189
+ });
190
+ child.on('close', (code) => {
191
+ // rg: 0 = matches, 1 = no matches, 2 = error.
192
+ finish(!(code === 2 && paths.length === 0));
193
+ });
194
+ });
195
+ }
196
+ async function searchFilenamesRipgrep(roots, query, exclude, maxResults) {
197
+ const paths = [];
198
+ let anyOk = false;
199
+ for (const root of roots) {
200
+ if (paths.length >= maxResults)
201
+ break;
202
+ const result = await searchOneRootRipgrep(root.resolved, query, exclude, maxResults - paths.length);
203
+ if (result.ok) {
204
+ anyOk = true;
205
+ paths.push(...result.paths);
206
+ }
207
+ }
208
+ return { ok: anyOk, paths };
209
+ }
210
+ async function walkForFiles(dirPath, queryLower, exclude, acc, maxResults, depth) {
211
+ if (acc.length >= maxResults || depth > MAX_SEARCH_DEPTH)
212
+ return;
213
+ let entries;
214
+ try {
215
+ entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
216
+ }
217
+ catch {
218
+ return;
219
+ }
220
+ const subdirs = [];
221
+ for (const entry of entries) {
222
+ if (acc.length >= maxResults)
223
+ return;
224
+ if (entry.isDirectory()) {
225
+ if (exclude.has(entry.name))
226
+ continue;
227
+ subdirs.push(path.join(dirPath, entry.name));
228
+ continue;
229
+ }
230
+ if (!entry.isFile() && !entry.isSymbolicLink())
231
+ continue;
232
+ if (!entry.name.toLowerCase().includes(queryLower))
233
+ continue;
234
+ acc.push(path.join(dirPath, entry.name));
235
+ }
236
+ for (const sub of subdirs) {
237
+ if (acc.length >= maxResults)
238
+ return;
239
+ await walkForFiles(sub, queryLower, exclude, acc, maxResults, depth + 1);
240
+ }
241
+ }
242
+ export async function searchFilesGlobal(opts) {
243
+ const query = (opts.query || '').trim();
244
+ if (query.length < FILE_SEARCH_MIN_QUERY)
245
+ return [];
246
+ const limit = Math.max(1, Math.min(opts.limit ?? FILE_SEARCH_MAX_RESULTS, FILE_SEARCH_MAX_RESULTS));
247
+ const exclude = excludeDirsFromInput(opts.exclude, true);
248
+ const areas = opts.areas ?? loadAreas();
249
+ const roots = gatherRoots(areas);
250
+ if (roots.length === 0)
251
+ return [];
252
+ const queryLower = query.toLowerCase();
253
+ const seen = new Set();
254
+ const hits = [];
255
+ const consume = (filePath) => {
256
+ const resolved = path.resolve(filePath);
257
+ if (seen.has(resolved))
258
+ return;
259
+ const root = matchRoot(resolved, roots);
260
+ if (!root)
261
+ return;
262
+ seen.add(resolved);
263
+ hits.push(toHit(resolved, root));
264
+ };
265
+ const rg = await searchFilenamesRipgrep(roots, query, exclude, limit);
266
+ if (rg.ok) {
267
+ for (const p of rg.paths)
268
+ consume(p);
269
+ }
270
+ else {
271
+ const walked = [];
272
+ for (const root of roots) {
273
+ if (walked.length >= limit * 4)
274
+ break;
275
+ await walkForFiles(root.resolved, queryLower, exclude, walked, limit * 4, 0);
276
+ }
277
+ for (const p of walked)
278
+ consume(p);
279
+ }
280
+ return sortHits(hits, queryLower).slice(0, limit);
281
+ }
@@ -241,12 +241,15 @@ function handleClientMessage(ws, message) {
241
241
  // ============================================================================
242
242
  // Initialization
243
243
  // ============================================================================
244
- export function init(server) {
245
- // Use noServer mode so we can manually route upgrade events.
246
- // This allows the terminal proxy to handle /api/terminal/*/ws upgrades
247
- // without the main WSS intercepting and rejecting them.
248
- const wss = new WebSocketServer({ noServer: true });
249
- // Handle upgrade events for /ws path only
244
+ /**
245
+ * Route /ws upgrades from `server` into `wss`.
246
+ *
247
+ * Split out of init() so that when the server listens on both HTTP and HTTPS,
248
+ * the second listener shares the SAME WebSocketServer — and therefore the same
249
+ * client set and broadcast path. Standing up a second WebSocketServer would
250
+ * silently split clients into two groups, and broadcasts would only reach one.
251
+ */
252
+ export function attachServer(server, wss) {
250
253
  server.on('upgrade', (request, socket, head) => {
251
254
  const pathname = request.url?.split('?')[0];
252
255
  if (pathname === '/ws') {
@@ -263,6 +266,13 @@ export function init(server) {
263
266
  }
264
267
  // Other paths (like /api/terminal/*/ws) are handled by the terminal proxy
265
268
  });
269
+ }
270
+ export function init(server) {
271
+ // Use noServer mode so we can manually route upgrade events.
272
+ // This allows the terminal proxy to handle /api/terminal/*/ws upgrades
273
+ // without the main WSS intercepting and rejecting them.
274
+ const wss = new WebSocketServer({ noServer: true });
275
+ attachServer(server, wss);
266
276
  wss.on('connection', (ws, request) => {
267
277
  clients.add(ws);
268
278
  setWsClientsCount(clients.size);