specrails-desktop 2.36.0 → 2.42.2

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 (510) hide show
  1. package/README.md +94 -247
  2. package/client/dist/assets/ActivityFeedPage-DoEMumQS.js +1 -0
  3. package/client/dist/assets/AgentBrowserCapture-DIh4K0gh.js +1 -0
  4. package/client/dist/assets/AgentModeAnalyticsPane-mbq4WEoO.js +2 -0
  5. package/client/dist/assets/AgentModeCodePane-CKh7zuA4.js +2 -0
  6. package/client/dist/assets/AgentModeJobsPane-BDokPiLJ.js +1 -0
  7. package/client/dist/assets/AgentsPage-BFVAZbBj.js +87 -0
  8. package/client/dist/assets/AnalyticsPage-3Y9LuWwz.js +1 -0
  9. package/client/dist/assets/CodePage-C9KyuV_C.js +3 -0
  10. package/client/dist/assets/DesktopAnalyticsPage-Bhi54bXQ.js +1 -0
  11. package/client/dist/assets/{DocsDialog-C76VdBJC.js → DocsDialog-Dj6mt4ga.js} +2 -2
  12. package/client/dist/assets/{DocsPage-ziMlj6B6.js → DocsPage-24yWglNI.js} +1 -1
  13. package/client/dist/assets/ExportDropdown-ophapHrT.js +1 -0
  14. package/client/dist/assets/InteractiveJobComposer-C1ucmxQh.js +19 -0
  15. package/client/dist/assets/JobDetailModal-DNWmgVHD.js +1 -0
  16. package/client/dist/assets/JobDetailPage-BcajZyuu.js +1 -0
  17. package/client/dist/assets/JobsPage-B9nTZw6e.js +1 -0
  18. package/client/dist/assets/LoopBuilderPage-Oc48iOFW.js +7 -0
  19. package/client/dist/assets/LoopPreviewModal-ae0Fr6SU.js +1 -0
  20. package/client/dist/assets/LoopsPage-CUEqzUBI.js +1 -0
  21. package/client/dist/assets/MinimizedChatsContext-Deg39peL.js +1 -0
  22. package/client/dist/assets/PluginsPage-C1xtARIx.js +2 -0
  23. package/client/dist/assets/ProjectSettingsDialog-JZzrXFBN.js +1 -0
  24. package/client/dist/assets/RepositoryDeliveries-CSDsiVUz.js +5 -0
  25. package/client/dist/assets/RepositoryScopeSelector-D-RCaknl.js +1 -0
  26. package/client/dist/assets/ReviewPacketPage-x5EfGTiL.js +1 -0
  27. package/client/dist/assets/TemplatePreviewModal-BSyuZxGq.js +1 -0
  28. package/client/dist/assets/TicketDetailModalContext-DqyF0so-.js +14 -0
  29. package/client/dist/assets/Trans-SnMX4MhW.js +1 -0
  30. package/client/dist/assets/agent-BdT0SB1D.js +1 -0
  31. package/client/dist/assets/agent-Bh9K7iEt.js +1 -0
  32. package/client/dist/assets/agent-C4ibdFk9.js +1 -0
  33. package/client/dist/assets/agent-CSFY22Zo.js +1 -0
  34. package/client/dist/assets/agent-DAnqxUn8.js +1 -0
  35. package/client/dist/assets/agent-DTQiu0Mi.js +1 -0
  36. package/client/dist/assets/agent-T54WuR_Y.js +1 -0
  37. package/client/dist/assets/agent-i75WDZOA.js +1 -0
  38. package/client/dist/assets/browser-05vRDg_y.js +1 -0
  39. package/client/dist/assets/browser-Bulb-cB1.js +1 -0
  40. package/client/dist/assets/browser-C7f-Jsg1.js +1 -0
  41. package/client/dist/assets/browser-CG0PE1j_.js +1 -0
  42. package/client/dist/assets/browser-CMzaqAjo.js +1 -0
  43. package/client/dist/assets/browser-DMajYdNX.js +1 -0
  44. package/client/dist/assets/browser-DxcOHrdP.js +1 -0
  45. package/client/dist/assets/browser-ObE7xVo3.js +1 -0
  46. package/client/dist/assets/builder-765NeYTp.js +1 -0
  47. package/client/dist/assets/builder-8MG3V6ea.js +1 -0
  48. package/client/dist/assets/builder-B-1UBs5K.js +1 -0
  49. package/client/dist/assets/builder-BTiGnPT0.js +1 -0
  50. package/client/dist/assets/builder-Cx9Rz-7u.js +1 -0
  51. package/client/dist/assets/builder-DKFMELBl.js +1 -0
  52. package/client/dist/assets/builder-OcmKrC0h.js +1 -0
  53. package/client/dist/assets/builder-P9qVBXFZ.js +1 -0
  54. package/client/dist/assets/{button-CJqFnUOL.js → button-DCBqEaZl.js} +1 -1
  55. package/client/dist/assets/{code-CkUuW4m9.js → code-B37GRg9C.js} +1 -1
  56. package/client/dist/assets/{code-B0O8-p8k.js → code-BBOEZbf9.js} +1 -1
  57. package/client/dist/assets/{code-B1VLZDhS.js → code-BRm-jcyx.js} +1 -1
  58. package/client/dist/assets/{code-Bpj4Ddml.js → code-CF7w62CR.js} +1 -1
  59. package/client/dist/assets/{code-BykBZ5-e.js → code-CLuvJt2r.js} +1 -1
  60. package/client/dist/assets/{code-B86IMAfz.js → code-D0RCm59f.js} +1 -1
  61. package/client/dist/assets/code-DaZ-ZwCT.js +1 -0
  62. package/client/dist/assets/{code-cAqaqMTx.js → code-iIHkGaCL.js} +1 -1
  63. package/client/dist/assets/commands-0XjAftla.js +1 -0
  64. package/client/dist/assets/commands-BAWSYHjQ.js +1 -0
  65. package/client/dist/assets/commands-BBwowgiE.js +1 -0
  66. package/client/dist/assets/commands-BacjIZLN.js +1 -0
  67. package/client/dist/assets/commands-Bs62IAZ3.js +1 -0
  68. package/client/dist/assets/commands-CLPdh-Hh.js +1 -0
  69. package/client/dist/assets/commands-DlbFzgrp.js +1 -0
  70. package/client/dist/assets/commands-LjPSsi5F.js +1 -0
  71. package/client/dist/assets/common-B5AZNYMH.js +1 -0
  72. package/client/dist/assets/common-BFZ0c_vN.js +1 -0
  73. package/client/dist/assets/common-BrlwEnL7.js +1 -0
  74. package/client/dist/assets/common-BttQx3z4.js +1 -0
  75. package/client/dist/assets/common-CM65k_-e.js +1 -0
  76. package/client/dist/assets/common-D4mvFFlu.js +1 -0
  77. package/client/dist/assets/common-TZ9Ny96S.js +1 -0
  78. package/client/dist/assets/common-nFpSF4GC.js +1 -0
  79. package/client/dist/assets/dashboard-B14uYXkG.js +1 -0
  80. package/client/dist/assets/dashboard-BK_mez-z.js +1 -0
  81. package/client/dist/assets/dashboard-BfBir_6o.js +1 -0
  82. package/client/dist/assets/dashboard-Bt0cCzeG.js +1 -0
  83. package/client/dist/assets/dashboard-BwkjxI6H.js +1 -0
  84. package/client/dist/assets/{dashboard-C8WOEiPu.js → dashboard-C3bDKf7s.js} +1 -1
  85. package/client/dist/assets/dashboard-CdvjGfog.js +1 -0
  86. package/client/dist/assets/dashboard-fRrP0Kv0.js +1 -0
  87. package/client/dist/assets/dialog-SRw01_QU.js +45 -0
  88. package/client/dist/assets/dist-js-CJ_2XX1Y.js +1 -0
  89. package/client/dist/assets/formatDistanceToNow-DSj5DprF.js +1 -0
  90. package/client/dist/assets/getTimezoneOffsetInMilliseconds-YhD8rIEs.js +1 -0
  91. package/client/dist/assets/index-DwAmL7m5.css +2 -0
  92. package/client/dist/assets/index-EzrkUb8B.js +76 -0
  93. package/client/dist/assets/{jira-api-B1-MUehg.js → jira-api-CSCqGr-Q.js} +1 -1
  94. package/client/dist/assets/{jobs-CN_zv4Gr.js → jobs-B8rrzfyZ.js} +1 -1
  95. package/client/dist/assets/{jobs-DUlWSYq2.js → jobs-BS6RTurf.js} +1 -1
  96. package/client/dist/assets/{jobs-Bpks6doU.js → jobs-BTT0Bsks.js} +1 -1
  97. package/client/dist/assets/{jobs-Bgq-PZkC.js → jobs-CezU69AE.js} +1 -1
  98. package/client/dist/assets/{jobs-SwYS2M-B.js → jobs-ClFJae0s.js} +1 -1
  99. package/client/dist/assets/{jobs-BAls3bWq.js → jobs-DJSFIFVy.js} +1 -1
  100. package/client/dist/assets/{jobs-DhxKgWFq.js → jobs-DhgGr8vm.js} +1 -1
  101. package/client/dist/assets/{jobs-B_TeSQxb.js → jobs-DzMjUcaN.js} +1 -1
  102. package/client/dist/assets/mcp-6rMoZfEO.js +1 -0
  103. package/client/dist/assets/mcp-BD8sL5mP.js +1 -0
  104. package/client/dist/assets/mcp-BkcA3I5s.js +1 -0
  105. package/client/dist/assets/mcp-CCpSelOh.js +1 -0
  106. package/client/dist/assets/mcp-CUAN6ORK.js +1 -0
  107. package/client/dist/assets/mcp-CqbRxRTE.js +1 -0
  108. package/client/dist/assets/mcp-DP2YfVhO.js +1 -0
  109. package/client/dist/assets/mcp-DUVQamDz.js +1 -0
  110. package/client/dist/assets/narration-B-ynWTpn.js +1 -0
  111. package/client/dist/assets/narration-Bj_laZwg.js +1 -0
  112. package/client/dist/assets/narration-BxHpmMv_.js +1 -0
  113. package/client/dist/assets/narration-C1Px0-0j.js +1 -0
  114. package/client/dist/assets/narration-CAXhGbzM.js +1 -0
  115. package/client/dist/assets/narration-DQ1l_Kq6.js +1 -0
  116. package/client/dist/assets/narration-XISXwqZd.js +1 -0
  117. package/client/dist/assets/narration-kwzfglQo.js +1 -0
  118. package/client/dist/assets/nav-1-Hh2kOz.js +1 -0
  119. package/client/dist/assets/nav-B34QkESm.js +1 -0
  120. package/client/dist/assets/nav-Bap_UXAq.js +1 -0
  121. package/client/dist/assets/{nav-pYuXq7sf.js → nav-BwxXIB24.js} +1 -1
  122. package/client/dist/assets/nav-Ch-zHBG5.js +1 -0
  123. package/client/dist/assets/nav-CygC_WN-.js +1 -0
  124. package/client/dist/assets/{nav-iwjCg5FR.js → nav-KnnLQtOQ.js} +1 -1
  125. package/client/dist/assets/nav-m00uxYUG.js +1 -0
  126. package/client/dist/assets/packet-BQ2tXfJI.js +1 -0
  127. package/client/dist/assets/packet-BdpOJ1Cy.js +1 -0
  128. package/client/dist/assets/packet-C3Ievb9R.js +1 -0
  129. package/client/dist/assets/packet-C4zJIG0Y.js +1 -0
  130. package/client/dist/assets/packet-Cx-zRuWn.js +1 -0
  131. package/client/dist/assets/packet-DUTGX52M.js +1 -0
  132. package/client/dist/assets/packet-DhsKtUPB.js +1 -0
  133. package/client/dist/assets/packet-z3LJ8Zyy.js +1 -0
  134. package/client/dist/assets/project-repositories-DYhTvwQ1.js +1 -0
  135. package/client/dist/assets/provider-capabilities-B-wOHfLP.js +1 -0
  136. package/client/dist/assets/settings-BBt2xtDQ.js +1 -0
  137. package/client/dist/assets/settings-BJB9RKJj.js +1 -0
  138. package/client/dist/assets/settings-CHwQR2rB.js +1 -0
  139. package/client/dist/assets/settings-CrMi3kSi.js +1 -0
  140. package/client/dist/assets/settings-DKyhDGD1.js +1 -0
  141. package/client/dist/assets/settings-Deupji9c.js +1 -0
  142. package/client/dist/assets/settings-DqRMmwAM.js +1 -0
  143. package/client/dist/assets/settings-J4rj6vo-.js +1 -0
  144. package/client/dist/assets/setup-2xGgu4Sk.js +1 -0
  145. package/client/dist/assets/setup-Ba11l2JA.js +1 -0
  146. package/client/dist/assets/setup-BzW4kMok.js +1 -0
  147. package/client/dist/assets/setup-COzwYhkJ.js +1 -0
  148. package/client/dist/assets/setup-CRVULhtF.js +1 -0
  149. package/client/dist/assets/setup-DgvlxwpE.js +1 -0
  150. package/client/dist/assets/setup-Dw_wYeEY.js +1 -0
  151. package/client/dist/assets/setup-ZPtEeJPW.js +1 -0
  152. package/client/dist/assets/spending-BqbdPm_4.js +1 -0
  153. package/client/dist/assets/square-Ci-PFcFe.js +1 -0
  154. package/client/dist/assets/{terminal-BFRZnMPP.js → terminal-BAzpHtpY.js} +1 -1
  155. package/client/dist/assets/{terminal-IahE4LW9.js → terminal-C7dHeR3x.js} +1 -1
  156. package/client/dist/assets/{terminal-tITa81iz.js → terminal-CMla5Qsz.js} +1 -1
  157. package/client/dist/assets/{terminal-BZiIu3rf.js → terminal-CdNXH-n9.js} +1 -1
  158. package/client/dist/assets/{terminal-ScjyXNxQ.js → terminal-Cw5bZsmV.js} +1 -1
  159. package/client/dist/assets/{terminal-C2NAd7BW.js → terminal-DFUP4eNv.js} +1 -1
  160. package/client/dist/assets/{terminal-d7GadXbN.js → terminal-DGqljeK_.js} +1 -1
  161. package/client/dist/assets/{terminal-QpXZU2Bn.js → terminal-cU70NodO.js} +1 -1
  162. package/client/dist/assets/useDesktop-RSMTyy7V.js +1 -0
  163. package/client/dist/assets/useSharedWebSocket-BOdS5_Iz.js +2 -0
  164. package/client/dist/index.html +34 -35
  165. package/docs/README.md +3 -0
  166. package/docs/agent-live-steering.md +57 -0
  167. package/docs/ci-cd.md +57 -0
  168. package/docs/code-explorer.md +48 -0
  169. package/docs/codex.md +12 -1
  170. package/docs/features/detachable-mission-windows.md +59 -0
  171. package/docs/guide/de/integrations/5-mcp-server.md +13 -9
  172. package/docs/guide/de/integrations/6-agent-chat.md +1 -0
  173. package/docs/guide/en/integrations/5-mcp-server.md +13 -9
  174. package/docs/guide/en/integrations/6-agent-chat.md +1 -0
  175. package/docs/guide/es/integrations/5-mcp-server.md +13 -9
  176. package/docs/guide/es/integrations/6-agent-chat.md +1 -0
  177. package/docs/guide/fr/integrations/5-mcp-server.md +13 -9
  178. package/docs/guide/fr/integrations/6-agent-chat.md +1 -0
  179. package/docs/guide/it/integrations/5-mcp-server.md +13 -9
  180. package/docs/guide/it/integrations/6-agent-chat.md +1 -0
  181. package/docs/guide/ja/integrations/5-mcp-server.md +13 -9
  182. package/docs/guide/ja/integrations/6-agent-chat.md +1 -0
  183. package/docs/guide/pt/integrations/5-mcp-server.md +13 -9
  184. package/docs/guide/pt/integrations/6-agent-chat.md +1 -0
  185. package/docs/guide/zh/integrations/5-mcp-server.md +13 -9
  186. package/docs/guide/zh/integrations/6-agent-chat.md +1 -0
  187. package/docs/internals/README.md +1 -0
  188. package/docs/internals/browser-capture-performance.md +55 -3
  189. package/docs/internals/browser-login-popups.md +45 -0
  190. package/docs/internals/browser-native-retina-audit.md +39 -0
  191. package/docs/internals/ci-cd-audit.md +39 -0
  192. package/docs/internals/core-runtime-updates.md +68 -0
  193. package/docs/internals/embedded-browser-native-webview-evaluation.md +2 -0
  194. package/docs/internals/implementation-reliability-audit.md +93 -0
  195. package/docs/internals/interactive-jobs.md +110 -6
  196. package/docs/internals/mcp-mission-audit.md +76 -0
  197. package/docs/internals/project-builder.md +404 -42
  198. package/docs/internals/safe-pr-review-flow.md +52 -10
  199. package/docs/internals/startup-recovery-audit.md +58 -0
  200. package/docs/mcp.md +74 -41
  201. package/docs/mission-processes.md +59 -0
  202. package/docs/multi-repo-projects.md +51 -0
  203. package/docs/platforms/windows-parity.md +70 -0
  204. package/docs/platforms/windows.md +13 -11
  205. package/docs/running-pipelines.md +14 -0
  206. package/mcp-bridge/dist/specrails-mcp.js +7186 -0
  207. package/package.json +9 -4
  208. package/server/dist/agent-chat-manager.js +537 -68
  209. package/server/dist/agent-chat-router.js +87 -6
  210. package/server/dist/agent-context-resolver.js +129 -25
  211. package/server/dist/agent-defaults.js +19 -11
  212. package/server/dist/agent-input-store.js +206 -0
  213. package/server/dist/agent-mcp-config.js +54 -15
  214. package/server/dist/agent-operator-prompt.js +255 -65
  215. package/server/dist/agent-spec-framing.js +202 -0
  216. package/server/dist/agent-steering.js +218 -0
  217. package/server/dist/agent-store.js +153 -0
  218. package/server/dist/api-not-found.js +8 -0
  219. package/server/dist/background-process-control.js +164 -0
  220. package/server/dist/background-process-service.js +61 -0
  221. package/server/dist/background-process-store.js +242 -0
  222. package/server/dist/background-windows-bootstrap.js +238 -0
  223. package/server/dist/blueprint-chat-manager.js +476 -62
  224. package/server/dist/blueprint-commit.js +21 -1
  225. package/server/dist/blueprint-draft-parser.js +122 -17
  226. package/server/dist/blueprint-generation.js +238 -0
  227. package/server/dist/blueprint-operator-prompt.js +91 -63
  228. package/server/dist/blueprint-router.js +55 -3
  229. package/server/dist/blueprint-spec-fixtures.js +85 -0
  230. package/server/dist/blueprint-spec-quality.js +57 -16
  231. package/server/dist/blueprint-store.js +92 -1
  232. package/server/dist/browser-capture-manager.js +86 -30
  233. package/server/dist/browser-playwright.js +168 -45
  234. package/server/dist/browser-viewport.js +26 -0
  235. package/server/dist/chat-manager.js +23 -15
  236. package/server/dist/chromium-archive.cjs +83 -0
  237. package/server/dist/chromium-resolver.js +72 -64
  238. package/server/dist/code-activity.js +145 -0
  239. package/server/dist/code-explorer-router.js +714 -114
  240. package/server/dist/core-compat.js +28 -1
  241. package/server/dist/core-execution.js +111 -0
  242. package/server/dist/core-package.js +4 -16
  243. package/server/dist/core-runtime.js +202 -0
  244. package/server/dist/core-update-manager.js +161 -60
  245. package/server/dist/core-update-state.js +29 -0
  246. package/server/dist/db.js +66 -35
  247. package/server/dist/delivery-evidence.js +142 -39
  248. package/server/dist/desktop-db.js +239 -43
  249. package/server/dist/desktop-router.js +109 -9
  250. package/server/dist/dev-ports.js +8 -3
  251. package/server/dist/file-provenance.js +34 -22
  252. package/server/dist/file-story-manager.js +62 -64
  253. package/server/dist/file-story.js +118 -35
  254. package/server/dist/file-summary-generator.js +46 -5
  255. package/server/dist/file-summary-manager.js +355 -106
  256. package/server/dist/framework-manager.js +98 -110
  257. package/server/dist/framework-reseed.js +76 -10
  258. package/server/dist/git-diagnostics.js +2 -2
  259. package/server/dist/headroom-manager.js +38 -3
  260. package/server/dist/host-control.js +52 -0
  261. package/server/dist/index.js +43 -2
  262. package/server/dist/interactive-job-session.js +149 -16
  263. package/server/dist/internal-api.js +48 -0
  264. package/server/dist/jira/jira-materializer.js +1 -0
  265. package/server/dist/json-tolerant.js +174 -0
  266. package/server/dist/legacy-migration.js +4 -2
  267. package/server/dist/loop-command-catalog.js +83 -21
  268. package/server/dist/loop-constants.js +6 -1
  269. package/server/dist/loop-decider.js +11 -1
  270. package/server/dist/loop-executors.js +194 -45
  271. package/server/dist/loop-factory.js +11 -4
  272. package/server/dist/loop-graph.js +90 -4
  273. package/server/dist/loop-run-manager.js +301 -69
  274. package/server/dist/loop-shell-invocation.js +21 -0
  275. package/server/dist/loop-step-idle.js +55 -0
  276. package/server/dist/loop-templates.js +31 -7
  277. package/server/dist/mcp/agent-capability.js +37 -4
  278. package/server/dist/mcp/guide.js +149 -36
  279. package/server/dist/mcp/mcp-admin-router.js +20 -4
  280. package/server/dist/mcp/mcp-server.js +89 -43
  281. package/server/dist/mcp/mcp-tiers.js +6 -5
  282. package/server/dist/mcp/mcp-token.js +3 -1
  283. package/server/dist/mcp/resources.js +5 -4
  284. package/server/dist/mcp/tools/catalog.js +4 -0
  285. package/server/dist/mcp/tools/code.js +125 -14
  286. package/server/dist/mcp/tools/context.js +153 -0
  287. package/server/dist/mcp/tools/git.js +15 -5
  288. package/server/dist/mcp/tools/jobs.js +84 -71
  289. package/server/dist/mcp/tools/loops.js +17 -6
  290. package/server/dist/mcp/tools/meta.js +74 -27
  291. package/server/dist/mcp/tools/mission.js +29 -0
  292. package/server/dist/mcp/tools/projects.js +22 -6
  293. package/server/dist/mcp/tools/rails.js +48 -11
  294. package/server/dist/mcp/tools/specs.js +86 -14
  295. package/server/dist/mcp/tools/support.js +1 -1
  296. package/server/dist/mcp/tools/types.js +117 -43
  297. package/server/dist/mcp/tools/watch.js +144 -51
  298. package/server/dist/milestone-chain-store.js +164 -0
  299. package/server/dist/milestone-chain.js +487 -0
  300. package/server/dist/milestone-progress.js +348 -0
  301. package/server/dist/mobile/mobile-devices.js +10 -7
  302. package/server/dist/mobile/mobile-gateway.js +2 -0
  303. package/server/dist/mobile/mobile-missions.js +214 -0
  304. package/server/dist/mobile/mobile-redact.js +17 -3
  305. package/server/dist/mobile/mobile-router.js +46 -4
  306. package/server/dist/mobile/mobile-ws.js +49 -4
  307. package/server/dist/multi-repo-bases.js +56 -0
  308. package/server/dist/multi-repo-checkout.js +101 -0
  309. package/server/dist/multi-repo-delivery.js +100 -0
  310. package/server/dist/multi-repo-execution-store.js +65 -0
  311. package/server/dist/multi-repo-execution.js +453 -0
  312. package/server/dist/offline-assemble.js +10 -8
  313. package/server/dist/openspec-runtime-plugin-commands.json +18 -0
  314. package/server/dist/openspec-runtime-plugin.js +120 -0
  315. package/server/dist/plugins/codex-spawn.js +49 -0
  316. package/server/dist/plugins/serena/manifest.js +2 -2
  317. package/server/dist/plugins/serena/templates/instructions.md +16 -0
  318. package/server/dist/pr-publisher.js +1 -1
  319. package/server/dist/profiles-router.js +3 -18
  320. package/server/dist/project-code-discovery.js +142 -0
  321. package/server/dist/project-git.js +19 -21
  322. package/server/dist/project-registry.js +272 -20
  323. package/server/dist/project-repositories.js +157 -0
  324. package/server/dist/project-repository-provenance.js +31 -0
  325. package/server/dist/project-router-background-processes.js +105 -0
  326. package/server/dist/project-router-git.js +75 -16
  327. package/server/dist/project-router-jobs.js +2 -126
  328. package/server/dist/project-router-loop-runs.js +72 -2
  329. package/server/dist/project-router-tickets.js +53 -8
  330. package/server/dist/project-router.js +239 -38
  331. package/server/dist/provider-detection.js +18 -6
  332. package/server/dist/provider-limit.js +64 -0
  333. package/server/dist/providers/claude-adapter.js +26 -1
  334. package/server/dist/providers/claude-background-tasks.js +18 -0
  335. package/server/dist/providers/claude-live-session.js +383 -0
  336. package/server/dist/providers/codex-adapter.js +19 -0
  337. package/server/dist/providers/codex-live-session.js +545 -0
  338. package/server/dist/providers/gemini-adapter.js +1 -0
  339. package/server/dist/providers/live-session-types.js +12 -0
  340. package/server/dist/providers/live-session.js +13 -0
  341. package/server/dist/providers/terminal-result.js +24 -0
  342. package/server/dist/queue-manager.js +2 -0
  343. package/server/dist/rail-isolated-launch.js +141 -16
  344. package/server/dist/rail-pr-decision.js +364 -99
  345. package/server/dist/rail-pr-store.js +24 -10
  346. package/server/dist/rail-worktree-release.js +34 -2
  347. package/server/dist/rail-worktrees-store.js +4 -4
  348. package/server/dist/rails-router.js +249 -55
  349. package/server/dist/repo-lock.js +92 -17
  350. package/server/dist/review-packet.js +23 -13
  351. package/server/dist/schemas/file-summary.v1.json +59 -59
  352. package/server/dist/schemas/profile.v1.json +137 -137
  353. package/server/dist/setup-manager.js +52 -8
  354. package/server/dist/shell-integration/bash-shim.bash +45 -0
  355. package/server/dist/shell-integration/fish-shim.fish +36 -0
  356. package/server/dist/shell-integration/powershell-shim.ps1 +48 -0
  357. package/server/dist/shell-integration/zsh-shim.zsh +47 -0
  358. package/server/dist/smash-runner.js +1 -0
  359. package/server/dist/spawn-lifecycle.js +44 -27
  360. package/server/dist/spec-contract-prompt.js +143 -0
  361. package/server/dist/spec-draft-parser.js +6 -1
  362. package/server/dist/spec-models.js +1 -0
  363. package/server/dist/stuck-run-detector.js +1 -0
  364. package/server/dist/terminal-manager.js +21 -1
  365. package/server/dist/terminal-shell-integration.js +4 -1
  366. package/server/dist/transient-children.js +619 -230
  367. package/server/dist/util/cli-prompt.js +78 -5
  368. package/server/dist/util/sqlite-migrations.js +20 -0
  369. package/server/dist/util/win-spawn.js +18 -3
  370. package/server/dist/util/windows-provider-shell.js +29 -0
  371. package/server/dist/verification-sentinel.js +14 -0
  372. package/server/dist/vitest-setup.js +2 -0
  373. package/server/dist/windows-job-supervisor.js +133 -0
  374. package/server/dist/workspace-manager.js +9 -30
  375. package/server/dist/worktree-manager.js +17 -2
  376. package/server/dist/worktree-overlay.js +3 -1
  377. package/client/dist/assets/ActivityFeedPage-tAQcWs1R.js +0 -1
  378. package/client/dist/assets/AgentBrowserCapture-BX8BNae5.js +0 -1
  379. package/client/dist/assets/AgentModeAnalyticsPane-BVAqCL0K.js +0 -2
  380. package/client/dist/assets/AgentModeCodePane-9XfqQc9c.js +0 -2
  381. package/client/dist/assets/AgentModeJobsPane-BCjLsPhc.js +0 -1
  382. package/client/dist/assets/AgentsPage-C8J_42sm.js +0 -87
  383. package/client/dist/assets/AnalyticsPage-BtQuyfJm.js +0 -1
  384. package/client/dist/assets/CodePage-BZ2MwdCu.js +0 -2
  385. package/client/dist/assets/DesktopAnalyticsPage-Bh_29V4g.js +0 -1
  386. package/client/dist/assets/ExportDropdown-BrtfWzEG.js +0 -1
  387. package/client/dist/assets/InteractiveJobComposer-BCZiSfzc.js +0 -19
  388. package/client/dist/assets/JobDetailModal-NAJmuYIT.js +0 -1
  389. package/client/dist/assets/JobDetailPage-fE0ZycUX.js +0 -1
  390. package/client/dist/assets/JobsPage-z5M_HVJG.js +0 -1
  391. package/client/dist/assets/LoopBuilderPage-j1FQS47c.js +0 -7
  392. package/client/dist/assets/LoopPreviewModal-DhDJjdCe.js +0 -1
  393. package/client/dist/assets/LoopsPage-Ll7zORZb.js +0 -1
  394. package/client/dist/assets/MinimizedChatsContext-Bd3OqR_S.js +0 -1
  395. package/client/dist/assets/PluginsPage-DN4kGvJt.js +0 -2
  396. package/client/dist/assets/ProjectSettingsDialog-OgsgxjzF.js +0 -1
  397. package/client/dist/assets/ReviewPacketPage-DFog4sXn.js +0 -1
  398. package/client/dist/assets/TemplatePreviewModal-L2XxqiOD.js +0 -1
  399. package/client/dist/assets/TicketDetailModal-CsF-AWZX.js +0 -14
  400. package/client/dist/assets/Trans-Bsz9_gyq.js +0 -1
  401. package/client/dist/assets/agent-BQgKv_2F.js +0 -1
  402. package/client/dist/assets/agent-C1NRDJqL.js +0 -1
  403. package/client/dist/assets/agent-CLNleiy6.js +0 -1
  404. package/client/dist/assets/agent-CmceArkQ.js +0 -1
  405. package/client/dist/assets/agent-D3UxQ3SR.js +0 -1
  406. package/client/dist/assets/agent-DB1JeE64.js +0 -1
  407. package/client/dist/assets/agent-DXkHcQ-D.js +0 -1
  408. package/client/dist/assets/agent-DjJJJ2M8.js +0 -1
  409. package/client/dist/assets/auth-D_5bQv5W.js +0 -1
  410. package/client/dist/assets/ban-E4EaZe3a.js +0 -1
  411. package/client/dist/assets/bot-DzuuuC5R.js +0 -1
  412. package/client/dist/assets/browser-BgqHDMXP.js +0 -1
  413. package/client/dist/assets/browser-CsKJtrpv.js +0 -1
  414. package/client/dist/assets/browser-Cu58EpZU.js +0 -1
  415. package/client/dist/assets/browser-CvXOJm-j.js +0 -1
  416. package/client/dist/assets/browser-DOrFh1c7.js +0 -1
  417. package/client/dist/assets/browser-DQv_Jo7x.js +0 -1
  418. package/client/dist/assets/browser-LPgxvJu2.js +0 -1
  419. package/client/dist/assets/browser-eL-3MIEI.js +0 -1
  420. package/client/dist/assets/builder-0IWtosUm.js +0 -1
  421. package/client/dist/assets/builder-Bf2N6XWn.js +0 -1
  422. package/client/dist/assets/builder-BnqaaBkT.js +0 -1
  423. package/client/dist/assets/builder-BxxfGgAO.js +0 -1
  424. package/client/dist/assets/builder-C5IIgWsq.js +0 -1
  425. package/client/dist/assets/builder-CHuahItu.js +0 -1
  426. package/client/dist/assets/builder-CjWosXL_.js +0 -1
  427. package/client/dist/assets/builder-CpCIOoKj.js +0 -1
  428. package/client/dist/assets/code-CHPkOkDS.js +0 -1
  429. package/client/dist/assets/commands-6tYQih5m.js +0 -1
  430. package/client/dist/assets/commands-BK2QinXN.js +0 -1
  431. package/client/dist/assets/commands-C2Nca76h.js +0 -1
  432. package/client/dist/assets/commands-CKdLihNX.js +0 -1
  433. package/client/dist/assets/commands-CYIBTAlc.js +0 -1
  434. package/client/dist/assets/commands-CjUtYJIM.js +0 -1
  435. package/client/dist/assets/commands-Cq_u_8y9.js +0 -1
  436. package/client/dist/assets/commands-D_ZHYQug.js +0 -1
  437. package/client/dist/assets/common-6MLbD-jM.js +0 -1
  438. package/client/dist/assets/common-BKGEu4rm.js +0 -1
  439. package/client/dist/assets/common-BNbnv8cE.js +0 -1
  440. package/client/dist/assets/common-BqGWtDbt.js +0 -1
  441. package/client/dist/assets/common-DBtTzeYm.js +0 -1
  442. package/client/dist/assets/common-DEW3Nr8n.js +0 -1
  443. package/client/dist/assets/common-DHsv9N5d.js +0 -1
  444. package/client/dist/assets/common-mkrEcW-Y.js +0 -1
  445. package/client/dist/assets/dashboard-B9GhC5ku.js +0 -1
  446. package/client/dist/assets/dashboard-CBnv6zOI.js +0 -1
  447. package/client/dist/assets/dashboard-CaP0fU87.js +0 -1
  448. package/client/dist/assets/dashboard-D4ZZ4x84.js +0 -1
  449. package/client/dist/assets/dashboard-DmuTQBX8.js +0 -1
  450. package/client/dist/assets/dashboard-DynLYj3d.js +0 -1
  451. package/client/dist/assets/dashboard-zj4gGFJ1.js +0 -1
  452. package/client/dist/assets/dialog-CdWCb02j.js +0 -45
  453. package/client/dist/assets/dist-bVqfYmby.js +0 -1
  454. package/client/dist/assets/dist-js-CWeUdf5Z.js +0 -1
  455. package/client/dist/assets/formatDistanceToNow-DQCvMgUc.js +0 -1
  456. package/client/dist/assets/getTimezoneOffsetInMilliseconds-C16cxSe1.js +0 -1
  457. package/client/dist/assets/git-refresh-wTW0rO0Z.js +0 -1
  458. package/client/dist/assets/i18n-hnX2DPmo.js +0 -2
  459. package/client/dist/assets/index-DIhMEAVw.js +0 -66
  460. package/client/dist/assets/index-DwygcU7s.css +0 -2
  461. package/client/dist/assets/mcp-B8X8wBLu.js +0 -1
  462. package/client/dist/assets/mcp-C2AojZZN.js +0 -1
  463. package/client/dist/assets/mcp-D8BT78_L.js +0 -1
  464. package/client/dist/assets/mcp-Df5RWBNk.js +0 -1
  465. package/client/dist/assets/mcp-Dj7BgMQW.js +0 -1
  466. package/client/dist/assets/mcp-Q7iT7rcz.js +0 -1
  467. package/client/dist/assets/mcp-YlvNvJbW.js +0 -1
  468. package/client/dist/assets/mcp-mKFpTSRb.js +0 -1
  469. package/client/dist/assets/narration-4Z2VUhZw.js +0 -1
  470. package/client/dist/assets/narration-BGuiEDj3.js +0 -1
  471. package/client/dist/assets/narration-BuSoqRmv.js +0 -1
  472. package/client/dist/assets/narration-CB9t29fy.js +0 -1
  473. package/client/dist/assets/narration-CU3x8FHD.js +0 -1
  474. package/client/dist/assets/narration-CWeXhfP5.js +0 -1
  475. package/client/dist/assets/narration-DRaDnhT5.js +0 -1
  476. package/client/dist/assets/narration-DtEbQgCw.js +0 -1
  477. package/client/dist/assets/nav-ApJVg3xu.js +0 -1
  478. package/client/dist/assets/nav-BKNky1Bg.js +0 -1
  479. package/client/dist/assets/nav-C6vWWKYx.js +0 -1
  480. package/client/dist/assets/nav-CXJ6Aw89.js +0 -1
  481. package/client/dist/assets/nav-CsmFBIDf.js +0 -1
  482. package/client/dist/assets/nav-DjkmQJjd.js +0 -1
  483. package/client/dist/assets/packet-B1IxaL5f.js +0 -1
  484. package/client/dist/assets/packet-Bh5kkbiB.js +0 -1
  485. package/client/dist/assets/packet-By8fqYxW.js +0 -1
  486. package/client/dist/assets/packet-CZdvA44E.js +0 -1
  487. package/client/dist/assets/packet-CrjHRQCP.js +0 -1
  488. package/client/dist/assets/packet-Dwi2pTtx.js +0 -1
  489. package/client/dist/assets/packet-_-ZIa0IR.js +0 -1
  490. package/client/dist/assets/packet-n0W6E1Hn.js +0 -1
  491. package/client/dist/assets/play-DNlrawbS.js +0 -1
  492. package/client/dist/assets/provider-capabilities-Doej9lfa.js +0 -1
  493. package/client/dist/assets/settings-Bd0x4TBa.js +0 -1
  494. package/client/dist/assets/settings-C1-7cXzC.js +0 -1
  495. package/client/dist/assets/settings-CAFaT38b.js +0 -1
  496. package/client/dist/assets/settings-Ct3v1nW2.js +0 -1
  497. package/client/dist/assets/settings-DHh7_IF5.js +0 -1
  498. package/client/dist/assets/settings-DQXAmdcv.js +0 -1
  499. package/client/dist/assets/settings-guzlhpup.js +0 -1
  500. package/client/dist/assets/settings-sZfTPMEH.js +0 -1
  501. package/client/dist/assets/setup-3jWaoVUO.js +0 -1
  502. package/client/dist/assets/setup-BjgRneb2.js +0 -1
  503. package/client/dist/assets/setup-BzFIjv-f.js +0 -1
  504. package/client/dist/assets/setup-C4VfQsxE.js +0 -1
  505. package/client/dist/assets/setup-DfUkpDbN.js +0 -1
  506. package/client/dist/assets/setup-ECe90Pmu.js +0 -1
  507. package/client/dist/assets/setup-dlqC4Jmm.js +0 -1
  508. package/client/dist/assets/setup-jwfLMiM2.js +0 -1
  509. package/client/dist/assets/spending-EC9xqio-.js +0 -1
  510. package/client/dist/assets/useDesktop-BQVt8lFd.js +0 -1
@@ -3,9 +3,17 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.filterCodeExplorerActivityPaths = void 0;
7
+ exports.readBoundedSource = readBoundedSource;
8
+ exports.isDeniedRelPath = isDeniedRelPath;
9
+ exports.gitIgnoredSet = gitIgnoredSet;
10
+ exports.rankFindMatches = rankFindMatches;
11
+ exports.listAllEntries = listAllEntries;
6
12
  exports.createCodeExplorerRouter = createCodeExplorerRouter;
13
+ exports.resolveSafePath = resolveSafePath;
7
14
  const fs_1 = __importDefault(require("fs"));
8
15
  const path_1 = __importDefault(require("path"));
16
+ const node_crypto_1 = require("node:crypto");
9
17
  const child_process_1 = require("child_process");
10
18
  const express_1 = require("express");
11
19
  const feature_flags_1 = require("./feature-flags");
@@ -13,10 +21,56 @@ const build_dirs_1 = require("./build-dirs");
13
21
  const file_provenance_1 = require("./file-provenance");
14
22
  const file_summary_manager_1 = require("./file-summary-manager");
15
23
  const file_story_1 = require("./file-story");
24
+ const project_repository_provenance_1 = require("./project-repository-provenance");
16
25
  const providers_1 = require("./providers");
26
+ const project_repositories_1 = require("./project-repositories");
27
+ const code_activity_1 = require("./code-activity");
17
28
  const MAX_TREE_PAGE = 2000;
29
+ const DEFAULT_MAX_TREE_ENTRIES = 20_000;
30
+ const DEFAULT_TREE_SCAN_MS = 5_000;
31
+ const TREE_YIELD_EVERY = 128;
32
+ const GIT_IGNORE_CHUNK = 1_000;
18
33
  const MAX_FILE_BYTES = 2 * 1024 * 1024;
19
34
  const BINARY_PROBE_BYTES = 8 * 1024;
35
+ const MAX_CODE_PAGE_LINES = 500;
36
+ const MAX_CODE_PAGE_CHARS = 20_000;
37
+ const MAX_SEARCH_FILES = 1_000;
38
+ const MAX_SEARCH_BYTES = 8 * 1024 * 1024;
39
+ const MAX_SEARCH_MS = 2_000;
40
+ /** Read a regular file through one descriptor with a hard byte ceiling, even
41
+ * if a writer grows it after stat. Never interpret a truncated read as text. */
42
+ async function readBoundedSource(abs, maxBytes) {
43
+ const before = await fs_1.default.promises.stat(abs);
44
+ if (!before.isFile())
45
+ return 'not-file';
46
+ if (before.size > maxBytes)
47
+ return 'too-large';
48
+ // A FIFO swapped in between stat/open must not hang an agent request. fstat
49
+ // below still validates the descriptor before any bytes are read.
50
+ const flags = fs_1.default.constants.O_RDONLY | (process.platform === 'win32' ? 0 : fs_1.default.constants.O_NONBLOCK);
51
+ const handle = await fs_1.default.promises.open(abs, flags);
52
+ try {
53
+ const stat = await handle.stat();
54
+ if (!stat.isFile())
55
+ return 'not-file';
56
+ if (stat.size > maxBytes)
57
+ return 'too-large';
58
+ const buffer = Buffer.alloc(Math.min(stat.size + 1, maxBytes + 1));
59
+ let offset = 0;
60
+ while (offset < buffer.length) {
61
+ const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset);
62
+ if (!bytesRead)
63
+ return buffer.subarray(0, offset);
64
+ offset += bytesRead;
65
+ }
66
+ // Filled the extra byte: the file changed while reading. Retry a fresh
67
+ // snapshot instead of serving content whose completeness is unknown.
68
+ return 'too-large';
69
+ }
70
+ finally {
71
+ await handle.close();
72
+ }
73
+ }
20
74
  // Hard-coded app deny-list (mirrors design D8). Dotfiles are excluded by name
21
75
  // prefix; build/dep dirs come from the shared BUILD_DIRS set (node_modules, dist,
22
76
  // build, out, coverage, target, vendor) so the on-demand tree walk skips the same
@@ -49,7 +103,7 @@ function isDenied(entryName) {
49
103
  // single source of truth across every surface (tree walk, touched-by-ai list,
50
104
  // and the content endpoints) — not just the top-level `all` walk.
51
105
  function isDeniedRelPath(rel) {
52
- return rel.split(/[\\/]/).filter(Boolean).some(isDenied);
106
+ return rel.split(/[\\/]/).filter((segment) => segment !== '' && segment !== '.').some(isDenied);
53
107
  }
54
108
  // Normalize a client-supplied relative path to POSIX separators so summary
55
109
  // (sha256 of relPath), provenance (git always emits '/'), and content lookups
@@ -63,27 +117,77 @@ function normalizeRel(rel) {
63
117
  // paths reported, so the deny-list remains the only filter. `check-ignore` exits
64
118
  // 1 ("none ignored") which execFileSync throws on — the matched list still lands
65
119
  // on stdout, so both branches read stdout.
66
- function gitIgnoredSet(projectPath, relPaths) {
120
+ async function gitIgnoredSet(projectPath, relPaths, maxDurationMs = Number.POSITIVE_INFINITY) {
67
121
  if (relPaths.length === 0)
68
122
  return new Set();
69
- let out = '';
70
- try {
71
- out = (0, child_process_1.execFileSync)('git', ['check-ignore', '--stdin', '-z'], {
72
- cwd: projectPath,
73
- input: relPaths.join('\0') + '\0',
74
- encoding: 'utf8',
75
- stdio: ['pipe', 'pipe', 'ignore'],
76
- maxBuffer: 16 * 1024 * 1024,
77
- timeout: 15_000,
123
+ if ((0, project_repositories_1.legacyRepositoryKind)(projectPath) === 'folder')
124
+ return new Set();
125
+ const ignored = new Set();
126
+ const excludeUnverified = (offset) => {
127
+ ignored.incomplete = true;
128
+ for (const rel of relPaths.slice(offset))
129
+ ignored.add(rel);
130
+ };
131
+ const deadline = Date.now() + maxDurationMs;
132
+ for (let offset = 0; offset < relPaths.length; offset += GIT_IGNORE_CHUNK) {
133
+ if (Date.now() >= deadline) {
134
+ excludeUnverified(offset);
135
+ break;
136
+ }
137
+ const chunk = relPaths.slice(offset, offset + GIT_IGNORE_CHUNK);
138
+ const result = await new Promise((resolve) => {
139
+ let stdout = '';
140
+ let settled = false;
141
+ let overflow = false;
142
+ const child = (0, child_process_1.spawn)('git', ['check-ignore', '--stdin', '-z'], { cwd: projectPath, stdio: ['pipe', 'pipe', 'ignore'], env: (0, file_provenance_1.gitExecEnv)() });
143
+ const finish = (complete) => { if (!settled) {
144
+ settled = true;
145
+ resolve({ stdout, complete: complete && !overflow });
146
+ } };
147
+ const timer = setTimeout(() => { try {
148
+ child.kill('SIGTERM');
149
+ }
150
+ catch { /* gone */ } ; finish(false); }, Math.min(5_000, Math.max(1, deadline - Date.now())));
151
+ timer.unref?.();
152
+ child.stdout?.on('data', (data) => {
153
+ if (stdout.length < 4 * 1024 * 1024)
154
+ stdout += data.toString('utf8');
155
+ else
156
+ overflow = true;
157
+ });
158
+ child.once('error', () => { clearTimeout(timer); finish(false); });
159
+ child.once('close', code => { clearTimeout(timer); finish(code === 0 || code === 1); });
160
+ child.stdin?.on('error', () => { });
161
+ child.stdin?.end(chunk.join('\0') + '\0');
78
162
  });
163
+ if (!result.complete) {
164
+ excludeUnverified(offset);
165
+ break;
166
+ }
167
+ for (const rel of result.stdout.split('\0'))
168
+ if (rel)
169
+ ignored.add(rel);
79
170
  }
80
- catch (err) {
81
- out = (err.stdout ?? '').toString();
82
- }
83
- return new Set(out.split('\0').filter((p) => p.length > 0));
171
+ return ignored;
84
172
  }
85
- function isGitIgnored(projectPath, relPath) {
86
- return gitIgnoredSet(projectPath, [relPath]).has(relPath);
173
+ /** One policy for current source and historical metadata. Missing files remain
174
+ * eligible; unavailable roots or unverified ignore rules fail closed explicitly. */
175
+ const filterCodeExplorerActivityPaths = async (repository, paths, remainingMs) => {
176
+ try {
177
+ if (!(await fs_1.default.promises.stat(repository.path)).isDirectory())
178
+ throw new Error('not a directory');
179
+ }
180
+ catch {
181
+ return { allowed: new Set(), unavailable: true };
182
+ }
183
+ const candidates = paths.filter(rel => !isDeniedRelPath(rel) && resolveSafePath(repository.path, rel) !== null);
184
+ const normalized = candidates.map(normalizeRel);
185
+ const ignored = await gitIgnoredSet(repository.path, normalized, remainingMs);
186
+ return { allowed: new Set(candidates.filter((_, index) => !ignored.has(normalized[index]))), incomplete: ignored.incomplete };
187
+ };
188
+ exports.filterCodeExplorerActivityPaths = filterCodeExplorerActivityPaths;
189
+ async function isGitIgnored(projectPath, relPath) {
190
+ return (await gitIgnoredSet(projectPath, [relPath])).has(relPath);
87
191
  }
88
192
  function languageForExt(ext) {
89
193
  const e = ext.toLowerCase();
@@ -112,21 +216,25 @@ function languageForExt(ext) {
112
216
  }
113
217
  }
114
218
  function decodeCursor(raw) {
115
- if (!raw)
116
- return { skip: 0 };
219
+ if (raw === undefined)
220
+ return null;
221
+ if (typeof raw !== 'string' || raw.length > 1024)
222
+ throw new Error('invalid_tree_cursor');
117
223
  try {
118
- const json = Buffer.from(raw, 'base64').toString('utf8');
119
- const parsed = JSON.parse(json);
120
- if (typeof parsed.skip === 'number' && parsed.skip >= 0)
121
- return { skip: parsed.skip };
122
- }
123
- catch {
124
- // fall through to default
224
+ const parsed = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'));
225
+ // Offset-only cursors from an older server cannot identify their original
226
+ // list. Ask the caller to refresh instead of silently mixing snapshots.
227
+ if (parsed.v === undefined && Number.isSafeInteger(parsed.skip))
228
+ return 'expired';
229
+ if (parsed.v === 2 && typeof parsed.snapshot === 'string' && /^[a-f0-9-]{36}$/.test(parsed.snapshot) &&
230
+ Number.isSafeInteger(parsed.skip) && parsed.skip > 0)
231
+ return parsed;
125
232
  }
126
- return { skip: 0 };
233
+ catch { /* Invalid input is never treated as page zero. */ }
234
+ throw new Error('invalid_tree_cursor');
127
235
  }
128
- function encodeCursor(skip) {
129
- return Buffer.from(JSON.stringify({ skip }), 'utf8').toString('base64');
236
+ function encodeCursor(snapshot, skip) {
237
+ return Buffer.from(JSON.stringify({ v: 2, snapshot, skip }), 'utf8').toString('base64url');
130
238
  }
131
239
  function rollupProvenance(rows) {
132
240
  let createdByTicketId = null;
@@ -208,9 +316,10 @@ function parsePositiveInt(raw) {
208
316
  function parseNonEmptyString(raw) {
209
317
  return typeof raw === 'string' && raw.trim() !== '' ? raw.trim() : null;
210
318
  }
211
- function listTouchedRows(db, filters) {
212
- const where = [];
213
- const args = [];
319
+ function listTouchedRows(db, filters, repository) {
320
+ const scope = (0, project_repository_provenance_1.provenanceRepositoryFilter)(db, repository);
321
+ const where = [scope.sql];
322
+ const args = [...scope.params];
214
323
  if (filters.ticketId != null) {
215
324
  where.push('ticket_id = ?');
216
325
  args.push(filters.ticketId);
@@ -228,19 +337,86 @@ function listTouchedRows(db, filters) {
228
337
  FROM file_provenance ${whereSql}
229
338
  ORDER BY file_path ASC, at DESC`).all(...args);
230
339
  }
231
- function listAllEntries(projectPath) {
340
+ const DEFAULT_FIND_LIMIT = 20;
341
+ const MAX_FIND_LIMIT = 50;
342
+ const MAX_FIND_QUERY_LENGTH = 256;
343
+ /**
344
+ * Rank the files of a scan against a name / path-suffix / fragment query.
345
+ * Case-insensitive, POSIX-normalised. Order: `exact` (whole relpath), `suffix`
346
+ * (the query is the tail of the relpath — the "path copied from a stack trace
347
+ * or import that is relative to some subdirectory" case), `basename` (same
348
+ * file name under another directory), `substring`; ties break on the shorter
349
+ * path. Directories never match — the caller wants something to read.
350
+ */
351
+ function rankFindMatches(entries, query) {
352
+ const needle = query.trim().replace(/\\/g, '/').replace(/^(\.\/|\/)+/, '').toLowerCase();
353
+ if (!needle)
354
+ return [];
355
+ const needleBase = needle.slice(needle.lastIndexOf('/') + 1);
356
+ const order = { exact: 0, suffix: 1, basename: 2, substring: 3 };
357
+ const out = [];
358
+ for (const e of entries) {
359
+ if (e.isDir)
360
+ continue;
361
+ const rel = e.rel.toLowerCase();
362
+ const base = rel.slice(rel.lastIndexOf('/') + 1);
363
+ let match = null;
364
+ if (rel === needle)
365
+ match = 'exact';
366
+ else if (rel.endsWith(`/${needle}`))
367
+ match = 'suffix';
368
+ else if (needleBase && base === needleBase)
369
+ match = 'basename';
370
+ else if (rel.includes(needle))
371
+ match = 'substring';
372
+ if (match)
373
+ out.push({ rel: e.rel, size: e.size, match });
374
+ }
375
+ out.sort((a, b) => order[a.match] - order[b.match] || a.rel.length - b.rel.length || (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
376
+ return out;
377
+ }
378
+ function positiveEnvInt(name, fallback) {
379
+ const parsed = Number.parseInt(process.env[name] ?? '', 10);
380
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
381
+ }
382
+ async function listAllEntries(projectPath, budget) {
383
+ const started = Date.now();
384
+ const maxEntries = Math.min(positiveEnvInt('SPECRAILS_CODE_TREE_MAX_ENTRIES', DEFAULT_MAX_TREE_ENTRIES), budget?.maxEntries ?? Infinity);
385
+ const maxMs = Math.min(positiveEnvInt('SPECRAILS_CODE_TREE_MAX_MS', DEFAULT_TREE_SCAN_MS), budget?.maxDurationMs ?? Infinity);
232
386
  const out = [];
233
387
  const stack = [projectPath];
388
+ let visited = 0;
389
+ let reason = null;
234
390
  while (stack.length > 0) {
391
+ if (visited >= maxEntries) {
392
+ reason = 'entry-limit';
393
+ break;
394
+ }
395
+ if (Date.now() - started >= maxMs) {
396
+ reason = 'time-limit';
397
+ break;
398
+ }
235
399
  const dir = stack.pop();
236
400
  let entries;
237
401
  try {
238
- entries = fs_1.default.readdirSync(dir, { withFileTypes: true });
402
+ entries = await fs_1.default.promises.readdir(dir, { withFileTypes: true });
239
403
  }
240
404
  catch {
405
+ reason ??= 'read-errors';
241
406
  continue;
242
407
  }
243
408
  for (const entry of entries) {
409
+ visited += 1;
410
+ if (visited % TREE_YIELD_EVERY === 0)
411
+ await new Promise((resolve) => setImmediate(resolve));
412
+ if (visited >= maxEntries) {
413
+ reason = 'entry-limit';
414
+ break;
415
+ }
416
+ if (Date.now() - started >= maxMs) {
417
+ reason = 'time-limit';
418
+ break;
419
+ }
244
420
  if (isDenied(entry.name))
245
421
  continue;
246
422
  const abs = path_1.default.join(dir, entry.name);
@@ -255,7 +431,7 @@ function listAllEntries(projectPath) {
255
431
  let size = null;
256
432
  let mtime = null;
257
433
  try {
258
- const st = fs_1.default.statSync(abs);
434
+ const st = await fs_1.default.promises.stat(abs);
259
435
  size = st.size;
260
436
  mtime = st.mtimeMs;
261
437
  }
@@ -270,12 +446,22 @@ function listAllEntries(projectPath) {
270
446
  // Directories are kept — git can't report an ignored dir without its files, and
271
447
  // an empty dir node is harmless; its ignored children are already filtered.
272
448
  const files = out.filter((e) => !e.isDir).map((e) => e.rel);
273
- const ignored = gitIgnoredSet(projectPath, files);
449
+ const ignored = await gitIgnoredSet(projectPath, files, budget ? Math.max(1, maxMs - (Date.now() - started)) : Infinity);
450
+ if (ignored.incomplete)
451
+ reason ??= 'read-errors';
274
452
  const filtered = ignored.size > 0 ? out.filter((e) => e.isDir || !ignored.has(e.rel)) : out;
275
453
  filtered.sort((a, b) => a.rel.localeCompare(b.rel));
276
- return filtered;
454
+ return {
455
+ entries: filtered,
456
+ truncated: reason !== null,
457
+ reason,
458
+ visited,
459
+ durationMs: Date.now() - started,
460
+ maxEntries,
461
+ maxDurationMs: maxMs,
462
+ };
277
463
  }
278
- function listTouchedEntries(projectPath, rowsByPath) {
464
+ async function listTouchedEntries(projectPath, rowsByPath) {
279
465
  const seen = new Set();
280
466
  const out = [];
281
467
  // Drop gitignored files too — mirroring listAllEntries — so a gitignored
@@ -283,7 +469,9 @@ function listTouchedEntries(projectPath, rowsByPath) {
283
469
  // not deny-listed never surfaces its path / ticket-attribution / mtime. One
284
470
  // batched git check-ignore over the not-already-denied touched files.
285
471
  const candidateFiles = [...rowsByPath.keys()].filter((p) => !isDeniedRelPath(p));
286
- const ignored = gitIgnoredSet(projectPath, candidateFiles);
472
+ const ignored = await gitIgnoredSet(projectPath, candidateFiles, DEFAULT_TREE_SCAN_MS);
473
+ if (ignored.incomplete)
474
+ throw new Error('tree_policy_unavailable');
287
475
  for (const filePath of rowsByPath.keys()) {
288
476
  // Keep touched-by-ai consistent with the `all` tree (and never surface
289
477
  // secrets like .env that an AI job happened to touch).
@@ -343,6 +531,27 @@ function readSummaryHashSet(projectPath) {
343
531
  }
344
532
  function createCodeExplorerRouter(deps) {
345
533
  const router = (0, express_1.Router)({ mergeParams: true });
534
+ const repository = deps.repositoryId ? { repositoryId: deps.repositoryId, includeLegacy: deps.includeLegacyProvenance === true } : undefined;
535
+ if (deps.repositoryId) {
536
+ router.use((_req, res, next) => {
537
+ const json = res.json.bind(res);
538
+ const identify = (item) => item && typeof item === 'object'
539
+ ? { ...item, repositoryId: deps.repositoryId } : item;
540
+ res.json = (body) => {
541
+ if (Array.isArray(body))
542
+ return json(body.map(identify));
543
+ if (!body || typeof body !== 'object')
544
+ return json(body);
545
+ const result = { ...body, repositoryId: deps.repositoryId };
546
+ for (const key of ['entries', 'matches', 'provenance', 'story']) {
547
+ if (Array.isArray(result[key]))
548
+ result[key] = result[key].map(identify);
549
+ }
550
+ return json(result);
551
+ };
552
+ next();
553
+ });
554
+ }
346
555
  const listByPath = deps.listProvenanceByPath ?? file_provenance_1.listProvenanceByPath;
347
556
  const listByTicket = deps.listProvenanceByTicket ?? file_provenance_1.listProvenanceByTicket;
348
557
  // Summary OUTPUT root (workspace when relocated). Source reads use projectPath.
@@ -359,21 +568,50 @@ function createCodeExplorerRouter(deps) {
359
568
  });
360
569
  return false;
361
570
  }
362
- // Short-TTL per-project cache so paginating a large `all` tree reuses ONE
363
- // synchronous filesystem walk instead of re-walking (and re-statting) on every
364
- // page — the cursor only slices an already-materialized array. Also caches the
365
- // one-readdir summary-hash set. 5s is long enough for a pagination burst and
366
- // short enough that tree edits surface promptly.
571
+ // Short-lived discovery cache deduplicates initial tree/find/search scans.
572
+ // Separate bounded path snapshots keep tree pagination stable after this
573
+ // cache expires. Provenance and summaries are enriched per page, not retained
574
+ // as unbounded copies inside every snapshot.
367
575
  const WALK_CACHE_TTL_MS = 5000;
368
- let allEntriesCache = null;
576
+ const allEntriesCache = new Map();
577
+ const allEntriesInFlight = new Map();
578
+ const TREE_SNAPSHOT_TTL_MS = 120_000;
579
+ const MAX_TREE_SNAPSHOTS = 4;
580
+ const treeSnapshots = new Map();
581
+ let watcherScheduled = false;
369
582
  let summaryHashCache = null;
370
583
  const nowMs = () => Date.now();
371
- function getAllEntriesCached() {
372
- if (allEntriesCache && nowMs() - allEntriesCache.at < WALK_CACHE_TTL_MS)
373
- return allEntriesCache.entries;
374
- const entries = listAllEntries(deps.projectPath);
375
- allEntriesCache = { at: nowMs(), entries };
376
- return entries;
584
+ async function getAllEntriesCached(relativeRoot = '') {
585
+ const cached = allEntriesCache.get(relativeRoot);
586
+ if (cached && nowMs() - cached.at < WALK_CACHE_TTL_MS) {
587
+ return { scan: cached.scan, cache: 'hit' };
588
+ }
589
+ const existing = allEntriesInFlight.get(relativeRoot);
590
+ if (existing)
591
+ return { scan: await existing, cache: 'shared' };
592
+ const pending = listAllEntries(relativeRoot ? path_1.default.join(deps.projectPath, relativeRoot) : deps.projectPath).then((scan) => relativeRoot
593
+ ? { ...scan, entries: scan.entries.map((entry) => ({ ...entry, rel: `${relativeRoot}/${entry.rel}` })) }
594
+ : scan);
595
+ allEntriesInFlight.set(relativeRoot, pending);
596
+ try {
597
+ const scan = await pending;
598
+ // Bound retained trees when an agent searches many different subfolders.
599
+ if (allEntriesCache.size >= 4 && !allEntriesCache.has(relativeRoot))
600
+ allEntriesCache.delete(allEntriesCache.keys().next().value);
601
+ allEntriesCache.set(relativeRoot, { at: nowMs(), scan });
602
+ console.info('[code-explorer] tree scan', JSON.stringify({
603
+ projectId: deps.projectId,
604
+ durationMs: scan.durationMs,
605
+ visited: scan.visited,
606
+ returned: scan.entries.length,
607
+ truncated: scan.truncated,
608
+ reason: scan.reason,
609
+ }));
610
+ return { scan, cache: 'miss' };
611
+ }
612
+ finally {
613
+ allEntriesInFlight.delete(relativeRoot);
614
+ }
377
615
  }
378
616
  function getSummaryHashesCached() {
379
617
  if (summaryHashCache && nowMs() - summaryHashCache.at < WALK_CACHE_TTL_MS)
@@ -392,24 +630,70 @@ function createCodeExplorerRouter(deps) {
392
630
  // provider without a native pure-output boundary must not attach it:
393
631
  // implicit file-change work and explicit POSTs both fail closed before a
394
632
  // manager or provider process can run.
395
- if (aiTransformsAvailable) {
396
- try {
397
- deps.fileSummaryManager.attachWatcher(deps.projectId, deps.projectPath, summaryRoot());
398
- }
399
- catch { /* non-fatal */ }
633
+ if (aiTransformsAvailable && !watcherScheduled) {
634
+ watcherScheduled = true;
635
+ setImmediate(() => {
636
+ try {
637
+ deps.fileSummaryManager.attachWatcher(deps.projectId, deps.projectPath, summaryRoot(), deps.repositoryId);
638
+ }
639
+ catch (err) {
640
+ console.warn('[code-explorer] watcher startup failed', JSON.stringify({
641
+ projectId: deps.projectId,
642
+ error: err instanceof Error ? err.message : String(err),
643
+ }));
644
+ }
645
+ });
400
646
  }
401
647
  next();
402
648
  });
403
- router.get('/tree', (req, res) => {
649
+ router.get('/tree', async (req, res) => {
404
650
  const filter = req.query.filter ?? 'touched-by-ai';
651
+ if (filter !== 'all' && filter !== 'touched-by-ai') {
652
+ res.status(400).json({ error: 'invalid_tree_filter' });
653
+ return;
654
+ }
405
655
  const withProvenance = req.query.withProvenance === '1' || req.query.withProvenance === 'true';
406
- const { skip } = decodeCursor(req.query.cursor);
656
+ const requestedLimit = parsePositiveInt(req.query.limit);
657
+ const pageLimit = Math.min(requestedLimit ?? MAX_TREE_PAGE, MAX_TREE_PAGE);
407
658
  const ticketId = parsePositiveInt(req.query.ticketId);
408
659
  const jobId = parseNonEmptyString(req.query.jobId);
409
- let entries;
660
+ if ((req.query.ticketId !== undefined && ticketId === null) || (req.query.jobId !== undefined && jobId === null)) {
661
+ res.status(400).json({ error: 'invalid_tree_scope' });
662
+ return;
663
+ }
664
+ const scope = JSON.stringify([deps.projectId, deps.repositoryId, deps.projectPath, filter, withProvenance,
665
+ filter === 'touched-by-ai' ? ticketId : null, filter === 'touched-by-ai' ? jobId : null]);
666
+ let cursor;
667
+ try {
668
+ cursor = decodeCursor(req.query.cursor);
669
+ }
670
+ catch {
671
+ res.status(400).json({ error: 'invalid_tree_cursor' });
672
+ return;
673
+ }
674
+ for (const [id, snapshot] of treeSnapshots) {
675
+ if (nowMs() - snapshot.createdAt >= TREE_SNAPSHOT_TTL_MS)
676
+ treeSnapshots.delete(id);
677
+ }
678
+ let snapshot = cursor && cursor !== 'expired' ? treeSnapshots.get(cursor.snapshot) : undefined;
679
+ if (cursor && (cursor === 'expired' || !snapshot)) {
680
+ res.status(409).json({ error: 'tree_snapshot_expired', retryable: true });
681
+ return;
682
+ }
683
+ if (snapshot && snapshot.scope !== scope) {
684
+ res.status(400).json({ error: 'tree_cursor_scope_mismatch' });
685
+ return;
686
+ }
687
+ const skip = cursor ? cursor.skip : 0;
688
+ if (snapshot && skip >= snapshot.entries.length) {
689
+ res.status(400).json({ error: 'invalid_tree_cursor' });
690
+ return;
691
+ }
410
692
  const touchedRowsByPath = new Map();
411
- if (filter === 'touched-by-ai') {
412
- const rows = listTouchedRows(deps.db, { ticketId, jobId });
693
+ // Initial touched discovery needs the complete filtered file set. Later
694
+ // pages re-enrich the frozen paths without recomputing their positions.
695
+ if ((filter === 'touched-by-ai' && !snapshot) || withProvenance) {
696
+ const rows = listTouchedRows(deps.db, filter === 'touched-by-ai' ? { ticketId, jobId } : {}, repository);
413
697
  for (const row of rows) {
414
698
  const existing = touchedRowsByPath.get(row.file_path);
415
699
  if (existing)
@@ -417,47 +701,250 @@ function createCodeExplorerRouter(deps) {
417
701
  else
418
702
  touchedRowsByPath.set(row.file_path, [row]);
419
703
  }
420
- entries = listTouchedEntries(deps.projectPath, touchedRowsByPath);
421
- }
422
- else {
423
- entries = getAllEntriesCached();
424
- // Batch-load ALL provenance once instead of a per-entry SQL query (N+1).
425
- if (withProvenance) {
426
- for (const row of listTouchedRows(deps.db, {})) {
427
- const existing = touchedRowsByPath.get(row.file_path);
428
- if (existing)
429
- existing.push(row);
430
- else
431
- touchedRowsByPath.set(row.file_path, [row]);
704
+ }
705
+ let cache = snapshot ? 'snapshot' : null;
706
+ if (!snapshot) {
707
+ let entries;
708
+ let scanMeta = null;
709
+ if (filter === 'touched-by-ai') {
710
+ try {
711
+ entries = await listTouchedEntries(deps.projectPath, touchedRowsByPath);
432
712
  }
713
+ catch {
714
+ res.status(503).json({ error: 'tree_policy_unavailable', retryable: true });
715
+ return;
716
+ }
717
+ if (entries.length > DEFAULT_MAX_TREE_ENTRIES) {
718
+ scanMeta = { truncated: true, reason: 'entry-limit', visited: entries.length, durationMs: 0,
719
+ maxEntries: DEFAULT_MAX_TREE_ENTRIES, maxDurationMs: DEFAULT_TREE_SCAN_MS };
720
+ }
721
+ }
722
+ else {
723
+ const result = await getAllEntriesCached();
724
+ entries = result.scan.entries;
725
+ cache = result.cache;
726
+ const { entries: _entries, ...metadata } = result.scan;
727
+ scanMeta = metadata;
728
+ if (entries.length > DEFAULT_MAX_TREE_ENTRIES) {
729
+ scanMeta = { ...metadata, truncated: true, reason: 'entry-limit', maxEntries: DEFAULT_MAX_TREE_ENTRIES };
730
+ }
731
+ }
732
+ snapshot = { id: (0, node_crypto_1.randomUUID)(), scope, createdAt: nowMs(), entries: entries.slice(0, DEFAULT_MAX_TREE_ENTRIES), scanMeta };
733
+ if (snapshot.entries.length > pageLimit) {
734
+ while (treeSnapshots.size >= MAX_TREE_SNAPSHOTS)
735
+ treeSnapshots.delete(treeSnapshots.keys().next().value);
736
+ treeSnapshots.set(snapshot.id, snapshot);
433
737
  }
434
738
  }
435
- const page = entries.slice(skip, skip + MAX_TREE_PAGE);
436
- const nextCursor = skip + page.length < entries.length ? encodeCursor(skip + page.length) : null;
437
- // Read the summaries dir ONCE into a Set of path-hashes instead of opening +
438
- // parsing a JSON file per entry just to test existence (cached per project).
739
+ const candidates = snapshot.entries.slice(skip, skip + pageLimit);
740
+ const nextCursor = skip + candidates.length < snapshot.entries.length ? encodeCursor(snapshot.id, skip + candidates.length) : null;
741
+ // The cursor preserves position, never access: recheck current exclusions
742
+ // and symlink boundaries. Directory badges must not leak excluded child
743
+ // provenance through their aggregate rows either.
744
+ const policyPaths = new Set(candidates.map(entry => entry.rel));
745
+ if (withProvenance && filter === 'touched-by-ai') {
746
+ const directories = new Set(candidates.filter(entry => entry.isDir).map(entry => entry.rel));
747
+ for (const entry of snapshot.entries) {
748
+ if (entry.isDir)
749
+ continue;
750
+ const parts = entry.rel.split('/');
751
+ while (parts.length > 1) {
752
+ parts.pop();
753
+ if (directories.has(parts.join('/'))) {
754
+ policyPaths.add(entry.rel);
755
+ break;
756
+ }
757
+ }
758
+ }
759
+ }
760
+ const visibility = await (0, exports.filterCodeExplorerActivityPaths)({
761
+ id: deps.repositoryId ?? `primary-${deps.projectId}`, projectId: deps.projectId,
762
+ name: deps.repositoryName ?? path_1.default.basename(deps.projectPath), path: deps.projectPath,
763
+ isPrimary: deps.includeLegacyProvenance !== false, kind: (0, project_repositories_1.legacyRepositoryKind)(deps.projectPath), integrationBranch: null, addedAt: '',
764
+ }, [...policyPaths], DEFAULT_TREE_SCAN_MS);
765
+ if (visibility.unavailable || visibility.incomplete) {
766
+ res.status(503).json({ error: 'tree_policy_unavailable', retryable: true });
767
+ return;
768
+ }
769
+ for (const filePath of touchedRowsByPath.keys()) {
770
+ if (!visibility.allowed.has(filePath))
771
+ touchedRowsByPath.delete(filePath);
772
+ }
439
773
  const summaryHashes = getSummaryHashesCached();
440
- const out = page.map((e) => {
774
+ const out = candidates.filter(entry => visibility.allowed.has(entry.rel)).map((e) => {
441
775
  const isTouchedDir = filter === 'touched-by-ai' && e.isDir;
442
776
  const rawRows = withProvenance && !isTouchedDir ? (touchedRowsByPath.get(e.rel) ?? []) : [];
443
777
  const provenance = withProvenance && isTouchedDir
444
778
  ? rollupDirectoryProvenance(touchedRowsByPath, e.rel)
445
779
  : rollupProvenance(rawRows);
446
- return {
447
- path: e.rel,
448
- kind: e.isDir ? 'dir' : 'file',
449
- sizeBytes: e.size,
450
- hasSummary: !e.isDir && summaryHashes.has((0, file_summary_manager_1.pathHash)(e.rel)),
451
- provenance,
452
- lastModifiedAt: e.mtime,
453
- };
780
+ return { path: e.rel, kind: e.isDir ? 'dir' : 'file', sizeBytes: e.size,
781
+ hasSummary: !e.isDir && summaryHashes.has((0, file_summary_manager_1.pathHash)(e.rel)), provenance, lastModifiedAt: e.mtime };
782
+ });
783
+ const scanMeta = snapshot.scanMeta;
784
+ res.json({
785
+ entries: out.map(entry => ({ ...entry, provenance: treeProvenanceToJson(entry.provenance) })), nextCursor,
786
+ ...(scanMeta ? {
787
+ truncated: scanMeta.truncated, truncationReason: scanMeta.reason,
788
+ scan: { visited: scanMeta.visited, durationMs: scanMeta.durationMs, maxEntries: scanMeta.maxEntries,
789
+ maxDurationMs: scanMeta.maxDurationMs, retryable: scanMeta.truncated, cache },
790
+ } : {}),
454
791
  });
792
+ });
793
+ /**
794
+ * Locate files by name / path-suffix / fragment across the whole scanned tree
795
+ * (same deny-list + .gitignore rules as `/tree?filter=all`, same cached scan).
796
+ * The escape hatch for a caller holding a path that is relative to some
797
+ * subdirectory (a stack trace, an import): `/file` 404s, `/find` says where
798
+ * the file actually lives.
799
+ */
800
+ router.get('/find', async (req, res) => {
801
+ const q = parseNonEmptyString(req.query.q);
802
+ if (!q) {
803
+ res.status(400).json({ error: 'q is required' });
804
+ return;
805
+ }
806
+ if (q.length > MAX_FIND_QUERY_LENGTH) {
807
+ res.status(400).json({ error: `q must be at most ${MAX_FIND_QUERY_LENGTH} characters` });
808
+ return;
809
+ }
810
+ const limit = Math.min(parsePositiveInt(req.query.limit) ?? DEFAULT_FIND_LIMIT, MAX_FIND_LIMIT);
811
+ const { scan } = await getAllEntriesCached();
812
+ const ranked = rankFindMatches(scan.entries, q);
813
+ res.json({
814
+ query: q,
815
+ matches: ranked.slice(0, limit).map((m) => ({ path: m.rel, sizeBytes: m.size, match: m.match })),
816
+ total: ranked.length,
817
+ truncated: scan.truncated || ranked.length > limit,
818
+ truncationReason: scan.reason ?? (ranked.length > limit ? 'match-limit' : null),
819
+ });
820
+ });
821
+ router.get('/search', async (req, res) => {
822
+ const query = typeof req.query.q === 'string' ? req.query.q : '';
823
+ if (!query.trim() || query.length > 256 || /[\r\n]/.test(query)) {
824
+ res.status(400).json({ error: 'q must be a non-empty literal single-line query of at most 256 characters' });
825
+ return;
826
+ }
827
+ const selectedPath = parseNonEmptyString(req.query.path);
828
+ const rawPath = selectedPath === '.' || selectedPath === './' ? null : selectedPath;
829
+ if (rawPath && !resolveSafePath(deps.projectPath, rawPath)) {
830
+ res.status(400).json({ error: 'path traversal not allowed' });
831
+ return;
832
+ }
833
+ if (rawPath && isDeniedRelPath(rawPath)) {
834
+ res.status(403).json({ error: 'path is excluded by the code-explorer deny-list' });
835
+ return;
836
+ }
837
+ const prefix = rawPath ? normalizeRel(rawPath).replace(/^\.\//, '') : '';
838
+ const caseSensitive = req.query.caseSensitive === 'true' || req.query.caseSensitive === '1';
839
+ const needle = caseSensitive ? query : query.toLowerCase();
840
+ const limit = Math.min(parsePositiveInt(req.query.limit) ?? 30, 100);
841
+ let scan;
842
+ if (prefix) {
843
+ // Narrowing must narrow the walk itself. Filtering an already truncated
844
+ // full-tree cache would make later subfolders impossible to discover.
845
+ try {
846
+ const stat = await fs_1.default.promises.stat(path_1.default.join(deps.projectPath, prefix));
847
+ if (stat.isDirectory())
848
+ scan = (await getAllEntriesCached(prefix)).scan;
849
+ else if (stat.isFile())
850
+ scan = { entries: [{ rel: prefix, isDir: false, size: stat.size, mtime: stat.mtimeMs }], truncated: false, reason: null, visited: 1, durationMs: 0, maxEntries: 1, maxDurationMs: DEFAULT_TREE_SCAN_MS };
851
+ else {
852
+ res.status(400).json({ error: 'search path is not a regular file or directory' });
853
+ return;
854
+ }
855
+ }
856
+ catch (err) {
857
+ res.status(err.code === 'ENOENT' ? 404 : 500).json({ error: 'search path is unavailable' });
858
+ return;
859
+ }
860
+ }
861
+ else
862
+ scan = (await getAllEntriesCached()).scan;
863
+ const candidates = scan.entries.filter((entry) => !entry.isDir && (!prefix || entry.rel === prefix || entry.rel.startsWith(`${prefix}/`)));
864
+ const maxFiles = Math.min(positiveEnvInt('SPECRAILS_CODE_SEARCH_MAX_FILES', MAX_SEARCH_FILES), MAX_SEARCH_FILES);
865
+ const maxBytes = Math.min(positiveEnvInt('SPECRAILS_CODE_SEARCH_MAX_BYTES', MAX_SEARCH_BYTES), MAX_SEARCH_BYTES);
866
+ const maxMs = Math.min(positiveEnvInt('SPECRAILS_CODE_SEARCH_MAX_MS', MAX_SEARCH_MS), MAX_SEARCH_MS);
867
+ const started = Date.now();
868
+ const matches = [];
869
+ const reasons = new Set(scan.reason ? [`tree-${scan.reason}`] : []);
870
+ const skipped = { binary: 0, tooLarge: 0, unreadable: 0, excluded: 0 };
871
+ let scannedFiles = 0;
872
+ let bytesRead = 0;
873
+ // The tree is cached. Revalidate ignore rules for this request so adding a
874
+ // .gitignore cannot leak newly excluded content during the cache TTL.
875
+ const ignored = await gitIgnoredSet(deps.projectPath, candidates.slice(0, maxFiles).map((entry) => entry.rel), maxMs);
876
+ if (ignored.incomplete)
877
+ reasons.add('ignore-rules-unavailable');
878
+ search: for (const entry of candidates) {
879
+ if (Date.now() - started >= maxMs) {
880
+ reasons.add('time-limit');
881
+ break;
882
+ }
883
+ if (scannedFiles >= maxFiles) {
884
+ reasons.add('file-limit');
885
+ break;
886
+ }
887
+ if (bytesRead >= maxBytes) {
888
+ reasons.add('byte-limit');
889
+ break;
890
+ }
891
+ scannedFiles++;
892
+ const abs = resolveSafePath(deps.projectPath, entry.rel);
893
+ if (!abs || isDeniedRelPath(entry.rel) || ignored.has(entry.rel)) {
894
+ skipped.excluded++;
895
+ continue;
896
+ }
897
+ try {
898
+ const data = await readBoundedSource(abs, Math.min(MAX_FILE_BYTES, maxBytes - bytesRead));
899
+ if (data === 'too-large') {
900
+ skipped.tooLarge++;
901
+ reasons.add('oversized-or-changing-files');
902
+ continue;
903
+ }
904
+ if (data === 'not-file') {
905
+ skipped.unreadable++;
906
+ reasons.add('unreadable-files');
907
+ continue;
908
+ }
909
+ bytesRead += data.length;
910
+ if (data.includes(0)) {
911
+ skipped.binary++;
912
+ continue;
913
+ }
914
+ const lines = data.toString('utf8').split('\n');
915
+ let hash = null;
916
+ for (let index = 0; index < lines.length; index++) {
917
+ if (index % 128 === 0 && Date.now() - started >= maxMs) {
918
+ reasons.add('time-limit');
919
+ break search;
920
+ }
921
+ const line = lines[index].replace(/\r$/, '');
922
+ const position = (caseSensitive ? line : line.toLowerCase()).indexOf(needle);
923
+ if (position < 0)
924
+ continue;
925
+ if (matches.length >= limit) {
926
+ reasons.add('match-limit');
927
+ break search;
928
+ }
929
+ const from = Math.max(0, position - 80);
930
+ const to = Math.min(line.length, from + 320);
931
+ hash ??= (0, node_crypto_1.createHash)('sha256').update(data).digest('hex');
932
+ matches.push({ path: entry.rel, lineNumber: index + 1, column: position + 1, snippet: line.slice(from, to), snippetTruncated: from > 0 || to < line.length, fileHash: hash });
933
+ }
934
+ }
935
+ catch {
936
+ skipped.unreadable++;
937
+ reasons.add('unreadable-files');
938
+ }
939
+ }
940
+ const truncated = reasons.size > 0;
455
941
  res.json({
456
- entries: out.map((entry) => ({
457
- ...entry,
458
- provenance: treeProvenanceToJson(entry.provenance),
459
- })),
460
- nextCursor,
942
+ query, path: prefix || null, caseSensitive, matches, truncated,
943
+ truncationReasons: [...reasons],
944
+ scan: { candidateFiles: candidates.length, scannedFiles, bytesRead, durationMs: Date.now() - started, maxFiles, maxBytes, maxDurationMs: maxMs, skipped, treeTruncated: scan.truncated },
945
+ hint: truncated
946
+ ? 'Partial search: absence of a match is not proof of absence. Narrow path/query and retry; use read_file at a returned line with its fileHash as expectedHash.'
947
+ : 'Search complete for eligible text files; binary, denied and gitignored paths are excluded. Use read_file at a matching line.',
461
948
  });
462
949
  });
463
950
  router.get('/file', async (req, res) => {
@@ -477,11 +964,88 @@ function createCodeExplorerRouter(deps) {
477
964
  }
478
965
  // Canonical POSIX form for all summary/provenance/hash lookups.
479
966
  const rel = normalizeRel(relRaw);
480
- if (isGitIgnored(deps.projectPath, rel)) {
967
+ if (await isGitIgnored(deps.projectPath, rel)) {
481
968
  res.status(403).json({ error: 'path is gitignored' });
482
969
  return;
483
970
  }
484
971
  const abs = guard;
972
+ // The editor keeps its existing full-preview contract. MCP callers always
973
+ // supply a range, which avoids spending a model context on a 2 MB response.
974
+ const ranged = req.query.startLine !== undefined || req.query.endLine !== undefined || req.query.startColumn !== undefined;
975
+ if (ranged) {
976
+ const startLine = req.query.startLine === undefined ? 1 : parsePositiveInt(req.query.startLine);
977
+ const endLine = req.query.endLine === undefined && startLine !== null ? startLine + 199 : parsePositiveInt(req.query.endLine);
978
+ const startColumn = req.query.startColumn === undefined ? 1 : parsePositiveInt(req.query.startColumn);
979
+ if (startLine === null || endLine === null || startColumn === null || endLine < startLine) {
980
+ res.status(400).json({ error: 'invalid line range; startLine/endLine/startColumn must be positive integers' });
981
+ return;
982
+ }
983
+ const expectedHash = parseNonEmptyString(req.query.expectedHash);
984
+ if (expectedHash && !/^[a-f0-9]{64}$/i.test(expectedHash)) {
985
+ res.status(400).json({ error: 'expectedHash must be a SHA-256 file hash' });
986
+ return;
987
+ }
988
+ let bytes;
989
+ try {
990
+ bytes = await readBoundedSource(abs, MAX_FILE_BYTES);
991
+ }
992
+ catch (err) {
993
+ res.status(err.code === 'ENOENT' ? 404 : 500).json({ error: err.code === 'ENOENT' ? 'file not found' : 'failed to read file' });
994
+ return;
995
+ }
996
+ if (bytes === 'too-large') {
997
+ res.status(413).json({ error: 'file exceeds the 2 MB read limit or changed while reading; use a narrower code search' });
998
+ return;
999
+ }
1000
+ if (bytes === 'not-file') {
1001
+ res.status(400).json({ error: 'path is not a regular file' });
1002
+ return;
1003
+ }
1004
+ if (bytes.includes(0)) {
1005
+ res.status(415).json({ error: 'binary file is not readable as source text' });
1006
+ return;
1007
+ }
1008
+ const fileHash = (0, node_crypto_1.createHash)('sha256').update(bytes).digest('hex');
1009
+ if (expectedHash && expectedHash.toLowerCase() !== fileHash) {
1010
+ res.status(409).json({ error: 'file_changed', fileHash, detail: 'The file changed since the previous page/search. Restart the read using the current hash.' });
1011
+ return;
1012
+ }
1013
+ const lines = bytes.toString('utf8').split('\n');
1014
+ if (startLine > lines.length || startColumn > lines[startLine - 1].length + 1) {
1015
+ res.status(416).json({ error: 'range_out_of_bounds', totalLines: lines.length, fileHash });
1016
+ return;
1017
+ }
1018
+ const lastLine = Math.min(endLine, startLine + MAX_CODE_PAGE_LINES - 1, lines.length);
1019
+ let content = '';
1020
+ let actualEndLine = startLine;
1021
+ let nextLine = lastLine < lines.length ? lastLine + 1 : null;
1022
+ let nextColumn = nextLine === null ? null : 1;
1023
+ let reason = nextLine === null ? null : 'line-limit';
1024
+ for (let lineNumber = startLine; lineNumber <= lastLine; lineNumber++) {
1025
+ const column = lineNumber === startLine ? startColumn : 1;
1026
+ const line = lines[lineNumber - 1].slice(column - 1);
1027
+ const remaining = MAX_CODE_PAGE_CHARS - content.length;
1028
+ actualEndLine = lineNumber;
1029
+ if (line.length > remaining) {
1030
+ content += line.slice(0, remaining);
1031
+ nextLine = lineNumber;
1032
+ nextColumn = column + remaining;
1033
+ reason = 'character-limit';
1034
+ break;
1035
+ }
1036
+ content += line;
1037
+ if (content.length >= MAX_CODE_PAGE_CHARS && lineNumber < lastLine) {
1038
+ nextLine = lineNumber + 1;
1039
+ nextColumn = 1;
1040
+ reason = 'character-limit';
1041
+ break;
1042
+ }
1043
+ if (lineNumber < lastLine)
1044
+ content += '\n';
1045
+ }
1046
+ res.json({ path: rel, content, encoding: 'utf-8', language: languageForExt(path_1.default.extname(rel)), fileHash, sizeBytes: bytes.length, startLine, startColumn, endLine: actualEndLine, totalLines: lines.length, nextLine, nextColumn, truncated: nextLine !== null, truncationReason: reason });
1047
+ return;
1048
+ }
485
1049
  let stat;
486
1050
  try {
487
1051
  stat = fs_1.default.statSync(abs);
@@ -490,7 +1054,7 @@ function createCodeExplorerRouter(deps) {
490
1054
  // Honour the staleness scenario: even if content is unavailable, return
491
1055
  // the existing summary so the client can render a "not found" banner.
492
1056
  const summary = (0, file_summary_manager_1.readSummary)(summaryRoot(), rel);
493
- const provenance = listByPath(deps.db, deps.projectId, rel);
1057
+ const provenance = listByPath(deps.db, deps.projectId, rel, repository);
494
1058
  if (summary || provenance.length > 0) {
495
1059
  res.json({
496
1060
  content: null,
@@ -512,7 +1076,7 @@ function createCodeExplorerRouter(deps) {
512
1076
  res.json({
513
1077
  tooLarge: true,
514
1078
  sizeBytes: stat.size,
515
- provenance: provenanceRowsToJson(listByPath(deps.db, deps.projectId, rel)),
1079
+ provenance: provenanceRowsToJson(listByPath(deps.db, deps.projectId, rel, repository)),
516
1080
  summary: (0, file_summary_manager_1.readSummary)(summaryRoot(), rel),
517
1081
  absolutePath: abs,
518
1082
  });
@@ -539,7 +1103,7 @@ function createCodeExplorerRouter(deps) {
539
1103
  binary: true,
540
1104
  sizeBytes: stat.size,
541
1105
  mime: 'application/octet-stream',
542
- provenance: provenanceRowsToJson(listByPath(deps.db, deps.projectId, rel)),
1106
+ provenance: provenanceRowsToJson(listByPath(deps.db, deps.projectId, rel, repository)),
543
1107
  summary: (0, file_summary_manager_1.readSummary)(summaryRoot(), rel),
544
1108
  absolutePath: abs,
545
1109
  });
@@ -554,12 +1118,12 @@ function createCodeExplorerRouter(deps) {
554
1118
  return;
555
1119
  }
556
1120
  const summary = (0, file_summary_manager_1.readSummary)(summaryRoot(), rel);
557
- const summaryStale = await computeStaleness(abs, summary);
1121
+ const summaryStale = await computeStaleness(abs, summary, deps.fileSummaryManager.getLanguage?.() ?? 'en');
558
1122
  res.json({
559
1123
  content,
560
1124
  encoding: 'utf-8',
561
1125
  language: languageForExt(path_1.default.extname(rel)),
562
- provenance: provenanceRowsToJson(listByPath(deps.db, deps.projectId, rel)),
1126
+ provenance: provenanceRowsToJson(listByPath(deps.db, deps.projectId, rel, repository)),
563
1127
  summary,
564
1128
  summaryStale,
565
1129
  absolutePath: abs,
@@ -572,7 +1136,7 @@ function createCodeExplorerRouter(deps) {
572
1136
  // scope here. After a write the existing hash-gated `summaryStale` flag makes
573
1137
  // the next GET /file surface the summary as stale (regenerate via the existing
574
1138
  // POST /file/regenerate-summary).
575
- router.put('/file', (req, res) => {
1139
+ router.put('/file', async (req, res) => {
576
1140
  const body = (req.body ?? {});
577
1141
  const relRaw = typeof body.path === 'string' ? body.path : undefined;
578
1142
  const content = typeof body.content === 'string' ? body.content : undefined;
@@ -590,7 +1154,7 @@ function createCodeExplorerRouter(deps) {
590
1154
  return;
591
1155
  }
592
1156
  const rel = normalizeRel(relRaw);
593
- if (isGitIgnored(deps.projectPath, rel)) {
1157
+ if (await isGitIgnored(deps.projectPath, rel)) {
594
1158
  res.status(403).json({ error: 'path is gitignored' });
595
1159
  return;
596
1160
  }
@@ -659,7 +1223,7 @@ function createCodeExplorerRouter(deps) {
659
1223
  return;
660
1224
  }
661
1225
  const rel = normalizeRel(relRaw);
662
- if (isGitIgnored(deps.projectPath, rel)) {
1226
+ if (await isGitIgnored(deps.projectPath, rel)) {
663
1227
  res.status(403).json({ error: 'path is gitignored' });
664
1228
  return;
665
1229
  }
@@ -670,7 +1234,7 @@ function createCodeExplorerRouter(deps) {
670
1234
  }
671
1235
  let summaryStale = false;
672
1236
  try {
673
- summaryStale = await computeStaleness(guard, summary);
1237
+ summaryStale = await computeStaleness(guard, summary, deps.fileSummaryManager.getLanguage?.() ?? 'en');
674
1238
  }
675
1239
  catch {
676
1240
  summaryStale = true;
@@ -682,7 +1246,7 @@ function createCodeExplorerRouter(deps) {
682
1246
  // contribution paragraph when generated, and the spec's live title/status.
683
1247
  // Same guards as every sibling content endpoint. Works for deleted files too
684
1248
  // (their story is still worth telling), so no stat/existence requirement.
685
- router.get('/file/story', (req, res) => {
1249
+ router.get('/file/story', async (req, res) => {
686
1250
  const relRaw = req.query.path;
687
1251
  if (!relRaw || typeof relRaw !== 'string') {
688
1252
  res.status(400).json({ error: 'path query parameter is required' });
@@ -698,11 +1262,11 @@ function createCodeExplorerRouter(deps) {
698
1262
  return;
699
1263
  }
700
1264
  const rel = normalizeRel(relRaw);
701
- if (isGitIgnored(deps.projectPath, rel)) {
1265
+ if (await isGitIgnored(deps.projectPath, rel)) {
702
1266
  res.status(403).json({ error: 'path is gitignored' });
703
1267
  return;
704
1268
  }
705
- res.json({ path: rel, story: (0, file_story_1.getFileStory)(deps.db, rel, deps.getTicketSpec) });
1269
+ res.json({ path: rel, story: (0, file_story_1.getFileStory)(deps.db, rel, deps.getTicketSpec, repository, deps.fileStoryManager?.getLanguage?.()) });
706
1270
  });
707
1271
  // Construction story: generate (budget-gated) the plain-language "what this
708
1272
  // spec contributed" paragraph for ONE intervention. Awaits the single model
@@ -726,7 +1290,7 @@ function createCodeExplorerRouter(deps) {
726
1290
  return;
727
1291
  }
728
1292
  const rel = normalizeRel(relRaw);
729
- if (isGitIgnored(deps.projectPath, rel)) {
1293
+ if (await isGitIgnored(deps.projectPath, rel)) {
730
1294
  res.status(403).json({ error: 'path is gitignored' });
731
1295
  return;
732
1296
  }
@@ -745,8 +1309,10 @@ function createCodeExplorerRouter(deps) {
745
1309
  try {
746
1310
  const result = await deps.fileStoryManager.explain({
747
1311
  projectId: deps.projectId,
1312
+ ...(repository ? { repository } : {}),
748
1313
  relPath: rel,
749
1314
  provenanceId,
1315
+ force: body.force === true,
750
1316
  overrideBudget: body.overrideBudget === true,
751
1317
  });
752
1318
  if (result === 'generated') {
@@ -759,6 +1325,14 @@ function createCodeExplorerRouter(deps) {
759
1325
  res.status(200).json({ skipped: 'budget' });
760
1326
  return;
761
1327
  }
1328
+ if (result === 'skipped:hash') {
1329
+ res.json({ ok: true, reused: true });
1330
+ return;
1331
+ }
1332
+ if (result === 'skipped:no-evidence' || result === 'skipped:ttl') {
1333
+ res.json({ skipped: result.slice('skipped:'.length) });
1334
+ return;
1335
+ }
762
1336
  if (result === 'skipped:not-found') {
763
1337
  res.status(404).json({ error: 'intervention not found for this file' });
764
1338
  return;
@@ -788,7 +1362,7 @@ function createCodeExplorerRouter(deps) {
788
1362
  return;
789
1363
  }
790
1364
  const rel = normalizeRel(relRaw);
791
- if (isGitIgnored(deps.projectPath, rel)) {
1365
+ if (await isGitIgnored(deps.projectPath, rel)) {
792
1366
  res.status(403).json({ error: 'path is gitignored' });
793
1367
  return;
794
1368
  }
@@ -835,6 +1409,7 @@ function createCodeExplorerRouter(deps) {
835
1409
  summaryRoot: summaryRoot(),
836
1410
  projectId: deps.projectId,
837
1411
  projectSlug: deps.projectId,
1412
+ ...(deps.repositoryId ? { repositoryId: deps.repositoryId } : {}),
838
1413
  relPath: rel,
839
1414
  triggeredBy: { kind: 'user', id: 'manual', ticketId: null },
840
1415
  overrideBudget: body.overrideBudget === true,
@@ -873,7 +1448,21 @@ function createCodeExplorerRouter(deps) {
873
1448
  res.status(500).json({ error: 'enqueue failed', message: err.message });
874
1449
  }
875
1450
  });
876
- router.get('/provenance', (req, res) => {
1451
+ router.get('/activity', async (req, res) => {
1452
+ const id = deps.repositoryId ?? `primary-${deps.projectId}`;
1453
+ try {
1454
+ res.json(await (0, code_activity_1.listCodeActivity)(deps.db, {
1455
+ id: deps.projectId, path: deps.projectPath,
1456
+ repositories: [{ id, projectId: deps.projectId, name: deps.repositoryName ?? path_1.default.basename(deps.projectPath),
1457
+ path: deps.projectPath, isPrimary: deps.repositoryId ? deps.includeLegacyProvenance === true : true,
1458
+ kind: (0, project_repositories_1.legacyRepositoryKind)(deps.projectPath), integrationBranch: null, addedAt: '' }],
1459
+ }, (0, code_activity_1.parseCodeActivityQuery)(req.query, id), exports.filterCodeExplorerActivityPaths));
1460
+ }
1461
+ catch (err) {
1462
+ res.status(err instanceof code_activity_1.CodeActivityQueryError ? err.status : 500).json({ error: err instanceof code_activity_1.CodeActivityQueryError ? err.message : 'activity_unavailable' });
1463
+ }
1464
+ });
1465
+ router.get('/provenance', async (req, res) => {
877
1466
  const ticketId = parsePositiveInt(req.query.ticketId);
878
1467
  const jobId = parseNonEmptyString(req.query.jobId);
879
1468
  const relPath = parseNonEmptyString(req.query.path);
@@ -899,11 +1488,20 @@ function createCodeExplorerRouter(deps) {
899
1488
  return;
900
1489
  }
901
1490
  const rows = ticketId != null && !jobId && !relPath
902
- ? listByTicket(deps.db, deps.projectId, ticketId)
903
- : listTouchedRows(deps.db, { ticketId, jobId, path: relPath ? normalizeRel(relPath) : relPath });
904
- res.json(provenanceRowsToJson(rows));
1491
+ ? listByTicket(deps.db, deps.projectId, ticketId, repository)
1492
+ : listTouchedRows(deps.db, { ticketId, jobId, path: relPath ? normalizeRel(relPath) : relPath }, repository);
1493
+ const visibility = await (0, exports.filterCodeExplorerActivityPaths)({
1494
+ id: deps.repositoryId ?? `primary-${deps.projectId}`, projectId: deps.projectId,
1495
+ name: deps.repositoryName ?? path_1.default.basename(deps.projectPath), path: deps.projectPath,
1496
+ isPrimary: deps.includeLegacyProvenance !== false, kind: (0, project_repositories_1.legacyRepositoryKind)(deps.projectPath), integrationBranch: null, addedAt: '',
1497
+ }, [...new Set(rows.map(row => row.file_path))], 2_000);
1498
+ if (visibility.unavailable || visibility.incomplete) {
1499
+ res.status(503).json({ error: 'provenance_policy_unavailable' });
1500
+ return;
1501
+ }
1502
+ res.json(provenanceRowsToJson(rows.filter(row => visibility.allowed.has(row.file_path))));
905
1503
  });
906
- router.get('/diff', (req, res) => {
1504
+ router.get('/diff', async (req, res) => {
907
1505
  const jobId = parseNonEmptyString(req.query.jobId);
908
1506
  const relPath = parseNonEmptyString(req.query.path);
909
1507
  if (!jobId || !relPath) {
@@ -924,11 +1522,11 @@ function createCodeExplorerRouter(deps) {
924
1522
  return;
925
1523
  }
926
1524
  const rel = normalizeRel(relPath);
927
- if (isGitIgnored(deps.projectPath, rel)) {
1525
+ if (await isGitIgnored(deps.projectPath, rel)) {
928
1526
  res.status(403).json({ error: 'path is gitignored' });
929
1527
  return;
930
1528
  }
931
- const diff = (0, file_provenance_1.getProvenanceDiff)(deps.db, deps.projectId, jobId, rel);
1529
+ const diff = (0, file_provenance_1.getProvenanceDiff)(deps.db, deps.projectId, jobId, rel, repository);
932
1530
  if (!diff) {
933
1531
  res.status(404).json({ error: 'diff not available' });
934
1532
  return;
@@ -988,9 +1586,11 @@ function resolveSafePath(projectPath, relPath) {
988
1586
  }
989
1587
  }
990
1588
  }
991
- async function computeStaleness(abs, summary) {
1589
+ async function computeStaleness(abs, summary, language) {
992
1590
  if (!summary)
993
1591
  return false;
1592
+ if ((0, file_summary_manager_1.isSummaryMetadataStale)(summary, language))
1593
+ return true;
994
1594
  try {
995
1595
  const currentHash = await (0, file_summary_manager_1.computeFileHash)(abs);
996
1596
  return currentHash !== summary.fileHash;