groove-dev 0.27.143 → 0.27.145

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 (251) hide show
  1. package/CLAUDE.md +0 -7
  2. package/node_modules/@groove-dev/cli/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/package.json +1 -1
  4. package/node_modules/@groove-dev/daemon/src/api.js +1086 -6532
  5. package/node_modules/@groove-dev/daemon/src/conversations.js +18 -48
  6. package/node_modules/@groove-dev/daemon/src/gateways/manager.js +35 -1
  7. package/node_modules/@groove-dev/daemon/src/index.js +3 -0
  8. package/node_modules/@groove-dev/daemon/src/journalist.js +23 -13
  9. package/node_modules/@groove-dev/daemon/src/mlx-server.js +365 -0
  10. package/node_modules/@groove-dev/daemon/src/model-lab.js +308 -12
  11. package/node_modules/@groove-dev/daemon/src/pm.js +1 -1
  12. package/node_modules/@groove-dev/daemon/src/process.js +2 -2
  13. package/node_modules/@groove-dev/daemon/src/providers/local.js +36 -8
  14. package/node_modules/@groove-dev/daemon/src/registry.js +21 -5
  15. package/node_modules/@groove-dev/daemon/src/routes/agents.js +812 -0
  16. package/node_modules/@groove-dev/daemon/src/routes/coordination.js +318 -0
  17. package/node_modules/@groove-dev/daemon/src/routes/files.js +751 -0
  18. package/node_modules/@groove-dev/daemon/src/routes/integrations.js +485 -0
  19. package/node_modules/@groove-dev/daemon/src/routes/network.js +1784 -0
  20. package/node_modules/@groove-dev/daemon/src/routes/providers.js +755 -0
  21. package/node_modules/@groove-dev/daemon/src/routes/schedules.js +110 -0
  22. package/node_modules/@groove-dev/daemon/src/routes/teams.js +650 -0
  23. package/node_modules/@groove-dev/daemon/src/scheduler.js +456 -24
  24. package/node_modules/@groove-dev/daemon/src/teams.js +1 -1
  25. package/node_modules/@groove-dev/daemon/src/validate.js +38 -1
  26. package/node_modules/@groove-dev/daemon/templates/mlx-setup.json +12 -0
  27. package/node_modules/@groove-dev/daemon/templates/tgi-setup.json +1 -1
  28. package/node_modules/@groove-dev/daemon/templates/vllm-setup.json +1 -1
  29. package/node_modules/@groove-dev/daemon/test/introducer.test.js +3 -3
  30. package/node_modules/@groove-dev/daemon/test/journalist.test.js +7 -10
  31. package/node_modules/@groove-dev/daemon/test/registry.test.js +38 -0
  32. package/node_modules/@groove-dev/gui/dist/assets/index-Bxc0gU06.js +1006 -0
  33. package/node_modules/@groove-dev/gui/dist/assets/index-C0pztKBn.css +1 -0
  34. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  35. package/node_modules/@groove-dev/gui/package.json +1 -1
  36. package/node_modules/@groove-dev/gui/src/{app.jsx → App.jsx} +0 -2
  37. package/node_modules/@groove-dev/gui/src/app.css +35 -0
  38. package/node_modules/@groove-dev/gui/src/components/agents/agent-config.jsx +1 -128
  39. package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +210 -112
  40. package/node_modules/@groove-dev/gui/src/components/agents/agent-node.jsx +8 -13
  41. package/node_modules/@groove-dev/gui/src/components/agents/agent-panel.jsx +2 -70
  42. package/node_modules/@groove-dev/gui/src/components/agents/code-review.jsx +159 -122
  43. package/node_modules/@groove-dev/gui/src/components/agents/diff-viewer.jsx +23 -23
  44. package/node_modules/@groove-dev/gui/src/components/agents/journalist-panel.jsx +1 -1
  45. package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +2 -135
  46. package/node_modules/@groove-dev/gui/src/components/automations/automation-card.jsx +274 -0
  47. package/node_modules/@groove-dev/gui/src/components/automations/automation-wizard.jsx +1136 -0
  48. package/node_modules/@groove-dev/gui/src/components/chat/chat-header.jsx +2 -0
  49. package/node_modules/@groove-dev/gui/src/components/chat/chat-input.jsx +68 -66
  50. package/node_modules/@groove-dev/gui/src/components/chat/chat-view.jsx +4 -8
  51. package/node_modules/@groove-dev/gui/src/components/dashboard/activity-feed.jsx +3 -3
  52. package/node_modules/@groove-dev/gui/src/components/dashboard/cache-ring.jsx +5 -5
  53. package/node_modules/@groove-dev/gui/src/components/dashboard/context-gauges.jsx +6 -8
  54. package/node_modules/@groove-dev/gui/src/components/dashboard/fleet-panel.jsx +8 -14
  55. package/node_modules/@groove-dev/gui/src/components/dashboard/intel-panel.jsx +238 -656
  56. package/node_modules/@groove-dev/gui/src/components/dashboard/kpi-card.jsx +3 -3
  57. package/node_modules/@groove-dev/gui/src/components/dashboard/routing-chart.jsx +3 -3
  58. package/node_modules/@groove-dev/gui/src/components/dashboard/team-burn-panel.jsx +1 -1
  59. package/node_modules/@groove-dev/gui/src/components/dashboard/token-chart.jsx +4 -4
  60. package/node_modules/@groove-dev/gui/src/components/lab/chat-playground.jsx +39 -31
  61. package/node_modules/@groove-dev/gui/src/components/lab/lab-assistant.jsx +316 -82
  62. package/node_modules/@groove-dev/gui/src/components/lab/metrics-panel.jsx +187 -32
  63. package/node_modules/@groove-dev/gui/src/components/lab/parameter-panel.jsx +200 -18
  64. package/node_modules/@groove-dev/gui/src/components/lab/preset-manager.jsx +17 -14
  65. package/node_modules/@groove-dev/gui/src/components/lab/runtime-config.jsx +335 -152
  66. package/node_modules/@groove-dev/gui/src/components/lab/system-prompt-editor.jsx +10 -8
  67. package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +2 -4
  68. package/node_modules/@groove-dev/gui/src/components/layout/terminal-panel.jsx +4 -2
  69. package/node_modules/@groove-dev/gui/src/components/layout/welcome-splash.jsx +137 -108
  70. package/node_modules/@groove-dev/gui/src/components/network/network-health.jsx +2 -2
  71. package/node_modules/@groove-dev/gui/src/components/network/performance-dashboard.jsx +4 -4
  72. package/node_modules/@groove-dev/gui/src/components/settings/ssh-wizard.jsx +81 -99
  73. package/node_modules/@groove-dev/gui/src/components/ui/sheet.jsx +5 -2
  74. package/node_modules/@groove-dev/gui/src/components/ui/slider.jsx +8 -8
  75. package/node_modules/@groove-dev/gui/src/lib/cron.js +64 -0
  76. package/node_modules/@groove-dev/gui/src/lib/status.js +25 -24
  77. package/node_modules/@groove-dev/gui/src/lib/theme-hex.js +1 -0
  78. package/node_modules/@groove-dev/gui/src/stores/groove.js +51 -3144
  79. package/node_modules/@groove-dev/gui/src/stores/helpers.js +10 -0
  80. package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +459 -0
  81. package/node_modules/@groove-dev/gui/src/stores/slices/automations-slice.js +96 -0
  82. package/node_modules/@groove-dev/gui/src/stores/slices/chat-slice.js +226 -0
  83. package/node_modules/@groove-dev/gui/src/stores/slices/editor-slice.js +285 -0
  84. package/node_modules/@groove-dev/gui/src/stores/slices/marketplace-slice.js +461 -0
  85. package/node_modules/@groove-dev/gui/src/stores/slices/network-slice.js +361 -0
  86. package/node_modules/@groove-dev/gui/src/stores/slices/preview-slice.js +109 -0
  87. package/node_modules/@groove-dev/gui/src/stores/slices/providers-slice.js +897 -0
  88. package/node_modules/@groove-dev/gui/src/stores/slices/teams-slice.js +413 -0
  89. package/node_modules/@groove-dev/gui/src/stores/slices/ui-slice.js +98 -0
  90. package/node_modules/@groove-dev/gui/src/views/agents.jsx +5 -5
  91. package/node_modules/@groove-dev/gui/src/views/dashboard.jsx +12 -13
  92. package/node_modules/@groove-dev/gui/src/views/marketplace.jsx +191 -3
  93. package/node_modules/@groove-dev/gui/src/views/model-lab.jsx +54 -12
  94. package/node_modules/@groove-dev/gui/src/views/models.jsx +419 -496
  95. package/node_modules/@groove-dev/gui/src/views/network.jsx +3 -3
  96. package/node_modules/@groove-dev/gui/src/views/settings.jsx +81 -94
  97. package/node_modules/@groove-dev/gui/src/views/teams.jsx +40 -483
  98. package/node_modules/axios/CHANGELOG.md +260 -0
  99. package/node_modules/axios/README.md +595 -223
  100. package/node_modules/axios/dist/axios.js +1460 -1090
  101. package/node_modules/axios/dist/axios.js.map +1 -1
  102. package/node_modules/axios/dist/axios.min.js +3 -3
  103. package/node_modules/axios/dist/axios.min.js.map +1 -1
  104. package/node_modules/axios/dist/browser/axios.cjs +1560 -1132
  105. package/node_modules/axios/dist/browser/axios.cjs.map +1 -1
  106. package/node_modules/axios/dist/esm/axios.js +1557 -1128
  107. package/node_modules/axios/dist/esm/axios.js.map +1 -1
  108. package/node_modules/axios/dist/esm/axios.min.js +2 -2
  109. package/node_modules/axios/dist/esm/axios.min.js.map +1 -1
  110. package/node_modules/axios/dist/node/axios.cjs +1594 -1057
  111. package/node_modules/axios/dist/node/axios.cjs.map +1 -1
  112. package/node_modules/axios/index.d.cts +40 -41
  113. package/node_modules/axios/index.d.ts +151 -227
  114. package/node_modules/axios/index.js +2 -0
  115. package/node_modules/axios/lib/adapters/adapters.js +4 -2
  116. package/node_modules/axios/lib/adapters/fetch.js +147 -16
  117. package/node_modules/axios/lib/adapters/http.js +306 -58
  118. package/node_modules/axios/lib/adapters/xhr.js +6 -2
  119. package/node_modules/axios/lib/core/Axios.js +7 -3
  120. package/node_modules/axios/lib/core/AxiosError.js +120 -34
  121. package/node_modules/axios/lib/core/AxiosHeaders.js +27 -25
  122. package/node_modules/axios/lib/core/buildFullPath.js +1 -1
  123. package/node_modules/axios/lib/core/dispatchRequest.js +19 -7
  124. package/node_modules/axios/lib/core/mergeConfig.js +21 -4
  125. package/node_modules/axios/lib/core/settle.js +7 -11
  126. package/node_modules/axios/lib/defaults/index.js +14 -9
  127. package/node_modules/axios/lib/env/data.js +1 -1
  128. package/node_modules/axios/lib/helpers/AxiosURLSearchParams.js +1 -2
  129. package/node_modules/axios/lib/helpers/buildURL.js +1 -1
  130. package/node_modules/axios/lib/helpers/cookies.js +14 -2
  131. package/node_modules/axios/lib/helpers/estimateDataURLDecodedBytes.js +28 -1
  132. package/node_modules/axios/lib/helpers/formDataToJSON.js +3 -1
  133. package/node_modules/axios/lib/helpers/formDataToStream.js +3 -2
  134. package/node_modules/axios/lib/helpers/parseProtocol.js +1 -1
  135. package/node_modules/axios/lib/helpers/progressEventReducer.js +5 -5
  136. package/node_modules/axios/lib/helpers/resolveConfig.js +54 -18
  137. package/node_modules/axios/lib/helpers/shouldBypassProxy.js +74 -2
  138. package/node_modules/axios/lib/helpers/toFormData.js +10 -2
  139. package/node_modules/axios/lib/helpers/validator.js +3 -1
  140. package/node_modules/axios/lib/utils.js +33 -21
  141. package/node_modules/axios/package.json +17 -24
  142. package/node_modules/follow-redirects/README.md +7 -5
  143. package/node_modules/follow-redirects/index.js +24 -1
  144. package/node_modules/follow-redirects/package.json +1 -1
  145. package/package.json +1 -1
  146. package/packages/cli/package.json +1 -1
  147. package/packages/daemon/package.json +1 -1
  148. package/packages/daemon/src/api.js +1086 -6532
  149. package/packages/daemon/src/conversations.js +18 -48
  150. package/packages/daemon/src/gateways/manager.js +35 -1
  151. package/packages/daemon/src/index.js +3 -0
  152. package/packages/daemon/src/journalist.js +23 -13
  153. package/packages/daemon/src/mlx-server.js +365 -0
  154. package/packages/daemon/src/model-lab.js +308 -12
  155. package/packages/daemon/src/pm.js +1 -1
  156. package/packages/daemon/src/process.js +2 -2
  157. package/packages/daemon/src/providers/local.js +36 -8
  158. package/packages/daemon/src/registry.js +21 -5
  159. package/packages/daemon/src/routes/agents.js +812 -0
  160. package/packages/daemon/src/routes/coordination.js +318 -0
  161. package/packages/daemon/src/routes/files.js +751 -0
  162. package/packages/daemon/src/routes/integrations.js +485 -0
  163. package/packages/daemon/src/routes/network.js +1784 -0
  164. package/packages/daemon/src/routes/providers.js +755 -0
  165. package/packages/daemon/src/routes/schedules.js +110 -0
  166. package/packages/daemon/src/routes/teams.js +650 -0
  167. package/packages/daemon/src/scheduler.js +456 -24
  168. package/packages/daemon/src/teams.js +1 -1
  169. package/packages/daemon/src/validate.js +38 -1
  170. package/packages/daemon/templates/mlx-setup.json +12 -0
  171. package/packages/daemon/templates/tgi-setup.json +1 -1
  172. package/packages/daemon/templates/vllm-setup.json +1 -1
  173. package/packages/gui/dist/assets/index-Bxc0gU06.js +1006 -0
  174. package/packages/gui/dist/assets/index-C0pztKBn.css +1 -0
  175. package/packages/gui/dist/index.html +2 -2
  176. package/packages/gui/package.json +1 -1
  177. package/packages/gui/src/{app.jsx → App.jsx} +0 -2
  178. package/packages/gui/src/app.css +35 -0
  179. package/packages/gui/src/components/agents/agent-config.jsx +1 -128
  180. package/packages/gui/src/components/agents/agent-feed.jsx +210 -112
  181. package/packages/gui/src/components/agents/agent-node.jsx +8 -13
  182. package/packages/gui/src/components/agents/agent-panel.jsx +2 -70
  183. package/packages/gui/src/components/agents/code-review.jsx +159 -122
  184. package/packages/gui/src/components/agents/diff-viewer.jsx +23 -23
  185. package/packages/gui/src/components/agents/journalist-panel.jsx +1 -1
  186. package/packages/gui/src/components/agents/spawn-wizard.jsx +2 -135
  187. package/packages/gui/src/components/automations/automation-card.jsx +274 -0
  188. package/packages/gui/src/components/automations/automation-wizard.jsx +1136 -0
  189. package/packages/gui/src/components/chat/chat-header.jsx +2 -0
  190. package/packages/gui/src/components/chat/chat-input.jsx +68 -66
  191. package/packages/gui/src/components/chat/chat-view.jsx +4 -8
  192. package/packages/gui/src/components/dashboard/activity-feed.jsx +3 -3
  193. package/packages/gui/src/components/dashboard/cache-ring.jsx +5 -5
  194. package/packages/gui/src/components/dashboard/context-gauges.jsx +6 -8
  195. package/packages/gui/src/components/dashboard/fleet-panel.jsx +8 -14
  196. package/packages/gui/src/components/dashboard/intel-panel.jsx +238 -656
  197. package/packages/gui/src/components/dashboard/kpi-card.jsx +3 -3
  198. package/packages/gui/src/components/dashboard/routing-chart.jsx +3 -3
  199. package/packages/gui/src/components/dashboard/team-burn-panel.jsx +1 -1
  200. package/packages/gui/src/components/dashboard/token-chart.jsx +4 -4
  201. package/packages/gui/src/components/lab/chat-playground.jsx +39 -31
  202. package/packages/gui/src/components/lab/lab-assistant.jsx +316 -82
  203. package/packages/gui/src/components/lab/metrics-panel.jsx +187 -32
  204. package/packages/gui/src/components/lab/parameter-panel.jsx +200 -18
  205. package/packages/gui/src/components/lab/preset-manager.jsx +17 -14
  206. package/packages/gui/src/components/lab/runtime-config.jsx +335 -152
  207. package/packages/gui/src/components/lab/system-prompt-editor.jsx +10 -8
  208. package/packages/gui/src/components/layout/activity-bar.jsx +2 -4
  209. package/packages/gui/src/components/layout/terminal-panel.jsx +4 -2
  210. package/packages/gui/src/components/layout/welcome-splash.jsx +137 -108
  211. package/packages/gui/src/components/network/network-health.jsx +2 -2
  212. package/packages/gui/src/components/network/performance-dashboard.jsx +4 -4
  213. package/packages/gui/src/components/settings/ssh-wizard.jsx +81 -99
  214. package/packages/gui/src/components/ui/sheet.jsx +5 -2
  215. package/packages/gui/src/components/ui/slider.jsx +8 -8
  216. package/packages/gui/src/lib/cron.js +64 -0
  217. package/packages/gui/src/lib/status.js +25 -24
  218. package/packages/gui/src/lib/theme-hex.js +1 -0
  219. package/packages/gui/src/stores/groove.js +51 -3144
  220. package/packages/gui/src/stores/helpers.js +10 -0
  221. package/packages/gui/src/stores/slices/agents-slice.js +459 -0
  222. package/packages/gui/src/stores/slices/automations-slice.js +96 -0
  223. package/packages/gui/src/stores/slices/chat-slice.js +226 -0
  224. package/packages/gui/src/stores/slices/editor-slice.js +285 -0
  225. package/packages/gui/src/stores/slices/marketplace-slice.js +461 -0
  226. package/packages/gui/src/stores/slices/network-slice.js +361 -0
  227. package/packages/gui/src/stores/slices/preview-slice.js +109 -0
  228. package/packages/gui/src/stores/slices/providers-slice.js +897 -0
  229. package/packages/gui/src/stores/slices/teams-slice.js +413 -0
  230. package/packages/gui/src/stores/slices/ui-slice.js +98 -0
  231. package/packages/gui/src/views/agents.jsx +5 -5
  232. package/packages/gui/src/views/dashboard.jsx +12 -13
  233. package/packages/gui/src/views/marketplace.jsx +191 -3
  234. package/packages/gui/src/views/model-lab.jsx +54 -12
  235. package/packages/gui/src/views/models.jsx +419 -496
  236. package/packages/gui/src/views/network.jsx +3 -3
  237. package/packages/gui/src/views/settings.jsx +81 -94
  238. package/packages/gui/src/views/teams.jsx +40 -483
  239. package/SECURITY_SWEEP.md +0 -228
  240. package/TRAINING_DATA_v4.md +0 -6
  241. package/node_modules/@groove-dev/gui/dist/assets/index-CCVvAoQn.css +0 -1
  242. package/node_modules/@groove-dev/gui/dist/assets/index-DGIv_TRm.js +0 -984
  243. package/node_modules/@groove-dev/gui/src/components/agents/agent-chat.jsx +0 -379
  244. package/node_modules/@groove-dev/gui/src/views/preview.jsx +0 -6
  245. package/node_modules/@groove-dev/gui/src/views/subscription-panel.jsx +0 -327
  246. package/packages/gui/dist/assets/index-CCVvAoQn.css +0 -1
  247. package/packages/gui/dist/assets/index-DGIv_TRm.js +0 -984
  248. package/packages/gui/src/components/agents/agent-chat.jsx +0 -379
  249. package/packages/gui/src/views/preview.jsx +0 -6
  250. package/packages/gui/src/views/subscription-panel.jsx +0 -327
  251. package/test.py +0 -571
@@ -0,0 +1,751 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+ import { resolve, sep, isAbsolute } from 'path';
3
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, mkdirSync, unlinkSync, renameSync, rmSync, createReadStream, realpathSync } from 'fs';
4
+ import { execFile, execFileSync } from 'child_process';
5
+ import { homedir } from 'os';
6
+ import { lookup as mimeLookup } from '../mimetypes.js';
7
+
8
+ // Editor root directory — always tracks daemon.projectDir unless explicitly
9
+ // overridden via POST /api/files/root. Reset on project-dir change.
10
+ let editorRootOverride = null;
11
+
12
+ export function resetEditorRoot() { editorRootOverride = null; }
13
+
14
+ const LANG_MAP = {
15
+ js: 'javascript', jsx: 'javascript', mjs: 'javascript', cjs: 'javascript',
16
+ ts: 'typescript', tsx: 'typescript',
17
+ css: 'css', scss: 'css', html: 'html', json: 'json',
18
+ md: 'markdown', py: 'python', rs: 'rust', go: 'go',
19
+ sh: 'shell', bash: 'shell', zsh: 'shell',
20
+ yaml: 'yaml', yml: 'yaml', toml: 'toml',
21
+ sql: 'sql', xml: 'xml', java: 'java', c: 'cpp', cpp: 'cpp', h: 'cpp',
22
+ rb: 'ruby', php: 'php', swift: 'swift', kt: 'kotlin',
23
+ dockerfile: 'dockerfile', makefile: 'makefile',
24
+ };
25
+ function detectLanguage(filename) {
26
+ const ext = filename.split('.').pop()?.toLowerCase();
27
+ return LANG_MAP[ext] || 'text';
28
+ }
29
+
30
+ const IGNORED_NAMES = new Set(['.DS_Store', '__pycache__']);
31
+
32
+ function getEditorRoot(daemon) { return editorRootOverride || daemon.projectDir; }
33
+
34
+ function validateFilePath(relPath, projectDir) {
35
+ if (!relPath || typeof relPath !== 'string') return { error: 'path is required' };
36
+ if (relPath.includes('\0')) return { error: 'Invalid path' };
37
+
38
+ let fullPath;
39
+ if (relPath.startsWith('/')) {
40
+ if (relPath.includes('..')) return { error: 'Invalid path' };
41
+ if (!relPath.startsWith(projectDir + '/') && relPath !== projectDir) {
42
+ return { error: 'Path outside project' };
43
+ }
44
+ fullPath = relPath;
45
+ } else {
46
+ if (relPath.includes('..')) return { error: 'Invalid path' };
47
+ fullPath = resolve(projectDir, relPath);
48
+ if (!fullPath.startsWith(projectDir)) return { error: 'Path outside project' };
49
+ }
50
+
51
+ // Symlink resolution — ensure real path is also within project
52
+ try {
53
+ const realPath = realpathSync(fullPath);
54
+ const realBase = realpathSync(projectDir);
55
+ if (!realPath.startsWith(realBase)) {
56
+ return { error: 'Path outside project (symlink)' };
57
+ }
58
+ } catch {
59
+ // File may not exist yet (for writes) — path prefix check is sufficient
60
+ }
61
+ return { fullPath };
62
+ }
63
+
64
+ function parseDiffOutput(raw) {
65
+ if (!raw) return [];
66
+ const fileDiffs = raw.split(/^diff --git /m).filter(Boolean);
67
+ return fileDiffs.map(chunk => {
68
+ const lines = chunk.split('\n');
69
+ const headerMatch = lines[0]?.match(/a\/(.+?) b\/(.+)/);
70
+ const filePath = headerMatch ? headerMatch[2] : 'unknown';
71
+ let status = 'modified';
72
+ if (lines.some(l => l.startsWith('new file'))) status = 'added';
73
+ else if (lines.some(l => l.startsWith('deleted file'))) status = 'deleted';
74
+ let additions = 0, deletions = 0;
75
+ const hunks = [];
76
+ let currentHunk = null;
77
+ for (const line of lines) {
78
+ if (line.startsWith('@@')) {
79
+ if (currentHunk) hunks.push(currentHunk);
80
+ currentHunk = { header: line, lines: [] };
81
+ } else if (currentHunk) {
82
+ currentHunk.lines.push(line);
83
+ if (line.startsWith('+') && !line.startsWith('+++')) additions++;
84
+ else if (line.startsWith('-') && !line.startsWith('---')) deletions++;
85
+ }
86
+ }
87
+ if (currentHunk) hunks.push(currentHunk);
88
+ return { path: filePath, status, hunks, additions, deletions, content: 'diff --git ' + chunk };
89
+ });
90
+ }
91
+
92
+ export function registerFileRoutes(app, daemon) {
93
+
94
+ app.get('/api/browse', (req, res) => {
95
+ const relPath = req.query.path || '';
96
+
97
+ // Security: no absolute paths, no traversal
98
+ if (relPath.startsWith('/') || relPath.includes('..') || relPath.includes('\0')) {
99
+ return res.status(400).json({ error: 'Invalid path' });
100
+ }
101
+
102
+ const fullPath = relPath ? resolve(daemon.projectDir, relPath) : daemon.projectDir;
103
+
104
+ // Must stay within project directory
105
+ if (!fullPath.startsWith(daemon.projectDir)) {
106
+ return res.status(400).json({ error: 'Path outside project' });
107
+ }
108
+
109
+ if (!existsSync(fullPath)) {
110
+ return res.status(404).json({ error: 'Directory not found' });
111
+ }
112
+
113
+ try {
114
+ const entries = readdirSync(fullPath, { withFileTypes: true })
115
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules')
116
+ .sort((a, b) => a.name.localeCompare(b.name))
117
+ .map((e) => {
118
+ const childPath = relPath ? `${relPath}/${e.name}` : e.name;
119
+ const childFull = resolve(fullPath, e.name);
120
+ let hasChildren = false;
121
+ let childCount = 0;
122
+ let fileCount = 0;
123
+ try {
124
+ const children = readdirSync(childFull, { withFileTypes: true });
125
+ for (const c of children) {
126
+ if (c.name.startsWith('.') || c.name === 'node_modules') continue;
127
+ if (c.isDirectory()) { childCount++; hasChildren = true; }
128
+ else fileCount++;
129
+ }
130
+ } catch { /* unreadable */ }
131
+ return { name: e.name, path: childPath, hasChildren, childCount, fileCount };
132
+ });
133
+
134
+ // Count files in current dir
135
+ let currentFiles = 0;
136
+ try {
137
+ currentFiles = readdirSync(fullPath, { withFileTypes: true })
138
+ .filter((e) => e.isFile() && !e.name.startsWith('.')).length;
139
+ } catch { /* ignore */ }
140
+
141
+ res.json({
142
+ current: relPath || '.',
143
+ parent: relPath ? relPath.split('/').slice(0, -1).join('/') : null,
144
+ dirs: entries,
145
+ fileCount: currentFiles,
146
+ });
147
+ } catch (err) {
148
+ res.status(500).json({ error: err.message });
149
+ }
150
+ });
151
+
152
+ // Browse absolute paths (for directory picker in agent config)
153
+ // Dirs only, localhost-only, no file content exposed
154
+ app.get('/api/browse-system', (req, res) => {
155
+ const absPath = req.query.path || homedir();
156
+ if (absPath.includes('\0')) return res.status(400).json({ error: 'Invalid path' });
157
+ if (!existsSync(absPath)) return res.status(404).json({ error: 'Not found' });
158
+
159
+ try {
160
+ const entries = readdirSync(absPath, { withFileTypes: true })
161
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules')
162
+ .sort((a, b) => a.name.localeCompare(b.name))
163
+ .map((e) => {
164
+ const full = resolve(absPath, e.name);
165
+ let hasChildren = false;
166
+ try {
167
+ hasChildren = readdirSync(full, { withFileTypes: true })
168
+ .some((c) => c.isDirectory() && !c.name.startsWith('.') && c.name !== 'node_modules');
169
+ } catch { /* unreadable */ }
170
+ return { name: e.name, path: full, hasChildren };
171
+ });
172
+
173
+ const parent = absPath === '/' ? null : resolve(absPath, '..');
174
+ res.json({ current: absPath, parent, dirs: entries });
175
+ } catch (err) {
176
+ res.status(500).json({ error: err.message });
177
+ }
178
+ });
179
+
180
+ // --- File Editor API ---
181
+
182
+ // Get/set the editor working directory
183
+ app.get('/api/files/root', (req, res) => {
184
+ res.json({ root: getEditorRoot(daemon) });
185
+ });
186
+
187
+ app.post('/api/files/root', (req, res) => {
188
+ const { root } = req.body || {};
189
+ if (!root || typeof root !== 'string') return res.status(400).json({ error: 'root path is required' });
190
+ if (!root.startsWith('/')) return res.status(400).json({ error: 'root must be an absolute path' });
191
+ if (root.includes('\0') || root.includes('..')) return res.status(400).json({ error: 'Invalid path' });
192
+ if (!existsSync(root)) return res.status(404).json({ error: 'Directory not found' });
193
+ try {
194
+ const stat = statSync(root);
195
+ if (!stat.isDirectory()) return res.status(400).json({ error: 'Path is not a directory' });
196
+ } catch { return res.status(400).json({ error: 'Cannot access directory' }); }
197
+ editorRootOverride = root;
198
+ daemon.audit.log('editor.root.set', { root });
199
+ res.json({ ok: true, root: getEditorRoot(daemon) });
200
+ });
201
+
202
+ // File tree — returns dirs + files for a given path
203
+ app.get('/api/files/tree', (req, res) => {
204
+ const relPath = req.query.path || '';
205
+
206
+ // Security: reuse browse validation
207
+ if (relPath && (relPath.startsWith('/') || relPath.includes('..') || relPath.includes('\0'))) {
208
+ return res.status(400).json({ error: 'Invalid path' });
209
+ }
210
+
211
+ const rootDir = getEditorRoot(daemon);
212
+ const fullPath = relPath ? resolve(rootDir, relPath) : rootDir;
213
+ if (!fullPath.startsWith(rootDir)) {
214
+ return res.status(400).json({ error: 'Path outside project' });
215
+ }
216
+ if (!existsSync(fullPath)) {
217
+ return res.status(404).json({ error: 'Directory not found' });
218
+ }
219
+
220
+ try {
221
+ const raw = readdirSync(fullPath, { withFileTypes: true });
222
+ const entries = [];
223
+
224
+ const HIDDEN_DIRS = new Set(['.git', 'node_modules', '.groove', '.next', '.nuxt', '__pycache__', '.venv', 'dist', '.cache']);
225
+ const HIDDEN_FILES = new Set(['.DS_Store']);
226
+
227
+ const dirs = raw.filter((e) => {
228
+ if (HIDDEN_FILES.has(e.name) || HIDDEN_DIRS.has(e.name)) return false;
229
+ if (e.isDirectory()) return true;
230
+ if (e.isSymbolicLink()) {
231
+ try { return statSync(resolve(fullPath, e.name)).isDirectory(); }
232
+ catch { return true; }
233
+ }
234
+ return false;
235
+ }).sort((a, b) => a.name.localeCompare(b.name));
236
+ const files = raw.filter((e) => {
237
+ if (HIDDEN_FILES.has(e.name)) return false;
238
+ if (e.isFile()) return true;
239
+ if (e.isSymbolicLink()) {
240
+ try { return statSync(resolve(fullPath, e.name)).isFile(); }
241
+ catch { return false; }
242
+ }
243
+ return false;
244
+ }).sort((a, b) => a.name.localeCompare(b.name));
245
+
246
+ for (const d of dirs) {
247
+ const childPath = relPath ? `${relPath}/${d.name}` : d.name;
248
+ const childFull = resolve(fullPath, d.name);
249
+ let hasChildren = false;
250
+ try {
251
+ const children = readdirSync(childFull, { withFileTypes: true });
252
+ hasChildren = children.some((c) => c.name !== '.DS_Store');
253
+ } catch { /* unreadable */ }
254
+ entries.push({ name: d.name, type: 'dir', path: childPath, hasChildren });
255
+ }
256
+
257
+ for (const f of files) {
258
+ const childPath = relPath ? `${relPath}/${f.name}` : f.name;
259
+ let size = 0;
260
+ try { size = statSync(resolve(fullPath, f.name)).size; } catch { /* ignore */ }
261
+ entries.push({
262
+ name: f.name, type: 'file', path: childPath, size,
263
+ language: detectLanguage(f.name),
264
+ });
265
+ }
266
+
267
+ res.json({
268
+ current: relPath || '.',
269
+ parent: relPath ? relPath.split('/').slice(0, -1).join('/') : null,
270
+ entries,
271
+ });
272
+ } catch (err) {
273
+ res.status(500).json({ error: err.message });
274
+ }
275
+ });
276
+
277
+ // Read file contents
278
+ app.get('/api/files/read', (req, res) => {
279
+ const result = validateFilePath(req.query.path, getEditorRoot(daemon));
280
+ if (result.error) return res.status(400).json({ error: result.error });
281
+
282
+ if (!existsSync(result.fullPath)) {
283
+ return res.status(404).json({ error: 'File not found' });
284
+ }
285
+
286
+ try {
287
+ const stat = statSync(result.fullPath);
288
+ if (stat.size > 50 * 1024 * 1024) {
289
+ return res.status(400).json({ error: 'File too large (>50MB)' });
290
+ }
291
+
292
+ // Binary detection: check first 8KB for null bytes
293
+ const buf = readFileSync(result.fullPath);
294
+ const sample = buf.subarray(0, 8192);
295
+ if (sample.includes(0)) {
296
+ return res.json({ path: req.query.path, binary: true, size: stat.size });
297
+ }
298
+
299
+ const content = buf.toString('utf8');
300
+ const filename = req.query.path.split('/').pop();
301
+ res.json({
302
+ path: req.query.path,
303
+ content,
304
+ size: stat.size,
305
+ language: detectLanguage(filename),
306
+ });
307
+ } catch (err) {
308
+ res.status(500).json({ error: err.message });
309
+ }
310
+ });
311
+
312
+ // Write file contents
313
+ app.post('/api/files/write', (req, res) => {
314
+ const { path: relPath, content } = req.body;
315
+ const result = validateFilePath(relPath, getEditorRoot(daemon));
316
+ if (result.error) return res.status(400).json({ error: result.error });
317
+
318
+ if (typeof content !== 'string') {
319
+ return res.status(400).json({ error: 'content must be a string' });
320
+ }
321
+ if (content.length > 50 * 1024 * 1024) {
322
+ return res.status(400).json({ error: 'Content too large (>50MB)' });
323
+ }
324
+
325
+ try {
326
+ writeFileSync(result.fullPath, content, 'utf8');
327
+ daemon.audit.log('file.write', { path: relPath });
328
+ res.json({ ok: true, size: Buffer.byteLength(content, 'utf8') });
329
+ } catch (err) {
330
+ res.status(500).json({ error: err.message });
331
+ }
332
+ });
333
+
334
+ // Create a new file
335
+ app.post('/api/files/create', (req, res) => {
336
+ const { path: relPath, content = '' } = req.body;
337
+ const result = validateFilePath(relPath, getEditorRoot(daemon));
338
+ if (result.error) return res.status(400).json({ error: result.error });
339
+
340
+ if (existsSync(result.fullPath)) {
341
+ return res.status(409).json({ error: 'File already exists' });
342
+ }
343
+
344
+ try {
345
+ // Ensure parent directory exists
346
+ const parentDir = resolve(result.fullPath, '..');
347
+ if (!parentDir.startsWith(daemon.projectDir)) {
348
+ return res.status(400).json({ error: 'Path outside project' });
349
+ }
350
+ mkdirSync(parentDir, { recursive: true });
351
+ writeFileSync(result.fullPath, content, 'utf8');
352
+ daemon.audit.log('file.create', { path: relPath });
353
+ res.status(201).json({ ok: true, path: relPath });
354
+ } catch (err) {
355
+ res.status(500).json({ error: err.message });
356
+ }
357
+ });
358
+
359
+ // Create a new directory
360
+ app.post('/api/files/mkdir', (req, res) => {
361
+ const { path: relPath } = req.body;
362
+ const result = validateFilePath(relPath, getEditorRoot(daemon));
363
+ if (result.error) return res.status(400).json({ error: result.error });
364
+
365
+ if (existsSync(result.fullPath)) {
366
+ return res.status(409).json({ error: 'Directory already exists' });
367
+ }
368
+
369
+ try {
370
+ mkdirSync(result.fullPath, { recursive: true });
371
+ daemon.audit.log('file.mkdir', { path: relPath });
372
+ res.status(201).json({ ok: true, path: relPath });
373
+ } catch (err) {
374
+ res.status(500).json({ error: err.message });
375
+ }
376
+ });
377
+
378
+ // Delete a file or directory
379
+ app.delete('/api/files/delete', (req, res) => {
380
+ const relPath = req.query.path || req.body?.path;
381
+ const result = validateFilePath(relPath, getEditorRoot(daemon));
382
+ if (result.error) return res.status(400).json({ error: result.error });
383
+
384
+ if (!existsSync(result.fullPath)) {
385
+ return res.status(404).json({ error: 'Not found' });
386
+ }
387
+
388
+ try {
389
+ const stat = statSync(result.fullPath);
390
+ if (stat.isDirectory()) {
391
+ rmSync(result.fullPath, { recursive: true });
392
+ } else {
393
+ unlinkSync(result.fullPath);
394
+ }
395
+ daemon.audit.log('file.delete', { path: relPath });
396
+ res.json({ ok: true });
397
+ } catch (err) {
398
+ res.status(500).json({ error: err.message });
399
+ }
400
+ });
401
+
402
+ // Rename / move a file or directory
403
+ app.post('/api/files/rename', (req, res) => {
404
+ const { oldPath, newPath } = req.body;
405
+ const oldResult = validateFilePath(oldPath, getEditorRoot(daemon));
406
+ if (oldResult.error) return res.status(400).json({ error: oldResult.error });
407
+ const newResult = validateFilePath(newPath, getEditorRoot(daemon));
408
+ if (newResult.error) return res.status(400).json({ error: newResult.error });
409
+
410
+ if (!existsSync(oldResult.fullPath)) {
411
+ return res.status(404).json({ error: 'Source not found' });
412
+ }
413
+ if (existsSync(newResult.fullPath)) {
414
+ return res.status(409).json({ error: 'Destination already exists' });
415
+ }
416
+
417
+ try {
418
+ // Ensure parent of new path exists
419
+ const parentDir = resolve(newResult.fullPath, '..');
420
+ mkdirSync(parentDir, { recursive: true });
421
+ renameSync(oldResult.fullPath, newResult.fullPath);
422
+ daemon.audit.log('file.rename', { oldPath, newPath });
423
+ res.json({ ok: true, oldPath, newPath });
424
+ } catch (err) {
425
+ res.status(500).json({ error: err.message });
426
+ }
427
+ });
428
+
429
+ // Serve raw file (images, video, etc.)
430
+ app.get('/api/files/raw', (req, res) => {
431
+ const result = validateFilePath(req.query.path, getEditorRoot(daemon));
432
+ if (result.error) return res.status(400).json({ error: result.error });
433
+
434
+ if (!existsSync(result.fullPath)) {
435
+ return res.status(404).json({ error: 'File not found' });
436
+ }
437
+
438
+ try {
439
+ const stat = statSync(result.fullPath);
440
+ if (stat.size > 50 * 1024 * 1024) {
441
+ return res.status(400).json({ error: 'File too large (>50MB)' });
442
+ }
443
+ const filename = req.query.path.split('/').pop();
444
+ const contentType = mimeLookup(filename);
445
+ res.setHeader('Content-Type', contentType);
446
+ res.setHeader('Content-Length', stat.size);
447
+ res.setHeader('Cache-Control', 'no-cache');
448
+ createReadStream(result.fullPath).pipe(res);
449
+ } catch (err) {
450
+ res.status(500).json({ error: err.message });
451
+ }
452
+ });
453
+
454
+ // Git status — returns modified/added/deleted/untracked files
455
+ app.get('/api/files/git-status', (req, res) => {
456
+ const rootDir = getEditorRoot(daemon);
457
+ if (!rootDir) return res.status(400).json({ error: 'Editor root not set' });
458
+
459
+ execFile('git', ['status', '--porcelain'], { cwd: rootDir, timeout: 10000 }, (err, stdout) => {
460
+ if (err) {
461
+ // Not a git repo or git not installed — return empty
462
+ return res.json({ entries: [] });
463
+ }
464
+ const STATUS_MAP = { 'M': 'M', 'A': 'A', '?': '?', 'D': 'D', 'R': 'R', 'U': 'U' };
465
+ const entries = [];
466
+ for (const line of stdout.split('\n')) {
467
+ if (!line.trim()) continue;
468
+ const code = line[0] === ' ' ? line[1] : line[0];
469
+ const filePath = line.slice(3).trim();
470
+ if (!filePath) continue;
471
+ entries.push({ path: filePath, status: STATUS_MAP[code] || code });
472
+ }
473
+ res.json({ entries });
474
+ });
475
+ });
476
+
477
+ // Git branch — returns the current branch name
478
+ app.get('/api/files/git-branch', (req, res) => {
479
+ const rootDir = getEditorRoot(daemon);
480
+ if (!rootDir) return res.status(400).json({ error: 'Editor root not set' });
481
+
482
+ execFile('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: rootDir, timeout: 5000 }, (err, stdout) => {
483
+ if (err) {
484
+ return res.json({ branch: null });
485
+ }
486
+ res.json({ branch: stdout.trim() });
487
+ });
488
+ });
489
+
490
+ // Git line status — per-line modification status for editor gutter decorations
491
+ app.get('/api/files/git-line-status', (req, res) => {
492
+ const relPath = req.query.path;
493
+ if (!relPath || typeof relPath !== 'string') {
494
+ return res.status(400).json({ error: 'path parameter is required' });
495
+ }
496
+ if (relPath.includes('\0') || relPath.startsWith('/')) {
497
+ return res.status(400).json({ error: 'Invalid path' });
498
+ }
499
+ const segments = relPath.split(/[/\\]/);
500
+ if (segments.some(s => s === '..')) {
501
+ return res.status(400).json({ error: 'Path traversal not allowed' });
502
+ }
503
+
504
+ const rootDir = getEditorRoot(daemon);
505
+ if (!rootDir) return res.status(400).json({ error: 'Editor root not set' });
506
+
507
+ const fullPath = resolve(rootDir, relPath);
508
+ if (!fullPath.startsWith(rootDir + sep) && fullPath !== rootDir) {
509
+ return res.status(400).json({ error: 'Path outside project' });
510
+ }
511
+
512
+ const result = { lines: { added: [], modified: [], deleted: [] } };
513
+
514
+ // Check if file is tracked by git
515
+ try {
516
+ execFileSync('git', ['ls-files', '--error-unmatch', '--', relPath], { cwd: rootDir, timeout: 5000, stdio: 'pipe' });
517
+ } catch {
518
+ // File not tracked — check if it exists (untracked = all lines added)
519
+ if (existsSync(fullPath)) {
520
+ try {
521
+ const content = readFileSync(fullPath, 'utf8');
522
+ const lineCount = content.split('\n').length;
523
+ for (let i = 1; i <= lineCount; i++) result.lines.added.push(i);
524
+ } catch { /* binary or unreadable */ }
525
+ }
526
+ return res.json(result);
527
+ }
528
+
529
+ try {
530
+ const diffOut = execFileSync('git', ['diff', '--unified=0', '--', relPath], {
531
+ cwd: rootDir, timeout: 10000, maxBuffer: 5 * 1024 * 1024,
532
+ }).toString();
533
+
534
+ if (!diffOut.trim()) return res.json(result);
535
+
536
+ // Check for binary
537
+ if (diffOut.includes('Binary files')) return res.json(result);
538
+
539
+ // Parse unified diff hunks: @@ -oldStart,oldCount +newStart,newCount @@
540
+ const hunkRe = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/gm;
541
+ let match;
542
+ while ((match = hunkRe.exec(diffOut)) !== null) {
543
+ const oldCount = parseInt(match[2] ?? '1', 10);
544
+ const newStart = parseInt(match[3], 10);
545
+ const newCount = parseInt(match[4] ?? '1', 10);
546
+
547
+ if (oldCount === 0 && newCount > 0) {
548
+ // Pure addition
549
+ for (let i = newStart; i < newStart + newCount; i++) result.lines.added.push(i);
550
+ } else if (newCount === 0 && oldCount > 0) {
551
+ // Pure deletion — mark the line where content was removed
552
+ result.lines.deleted.push(newStart);
553
+ } else {
554
+ // Modification
555
+ for (let i = newStart; i < newStart + newCount; i++) result.lines.modified.push(i);
556
+ }
557
+ }
558
+
559
+ res.json(result);
560
+ } catch (err) {
561
+ if (err.status !== undefined) return res.json(result);
562
+ res.status(500).json({ error: 'Failed to compute line status' });
563
+ }
564
+ });
565
+
566
+ // Git branches — list all local branches with current branch marked
567
+ app.get('/api/files/git-branches', (req, res) => {
568
+ const rootDir = getEditorRoot(daemon);
569
+ if (!rootDir) return res.status(400).json({ error: 'Editor root not set' });
570
+
571
+ const fallback = { current: null, branches: [] };
572
+
573
+ try {
574
+ let current = null;
575
+ try {
576
+ current = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
577
+ cwd: rootDir, timeout: 5000, stdio: 'pipe',
578
+ }).toString().trim();
579
+ } catch { return res.json(fallback); }
580
+
581
+ const branchOut = execFileSync('git', ['branch', '--list', '--format=%(refname:short)'], {
582
+ cwd: rootDir, timeout: 5000, stdio: 'pipe',
583
+ }).toString();
584
+
585
+ const branches = branchOut.split('\n').map(b => b.trim()).filter(Boolean);
586
+ res.json({ current, branches });
587
+ } catch {
588
+ res.json(fallback);
589
+ }
590
+ });
591
+
592
+ // Files touched by an agent during its session
593
+ app.get('/api/agents/:id/files-touched', (req, res) => {
594
+ const agent = daemon.registry.get(req.params.id);
595
+ if (!agent) return res.status(404).json({ error: 'Agent not found' });
596
+ const rawFiles = daemon.registry.getFilesTouched(req.params.id);
597
+ const rootDir = agent.workingDir || daemon.projectDir;
598
+ const files = rawFiles.map(f => {
599
+ const fullPath = isAbsolute(f.path) ? f.path : resolve(rootDir, f.path);
600
+ return { ...f, exists: existsSync(fullPath) };
601
+ });
602
+ res.json({ files, total: files.length });
603
+ });
604
+
605
+ // Git diff — structured diff for a file, an agent's touched files, or all uncommitted changes
606
+ app.get('/api/files/git-diff', (req, res) => {
607
+ const rootDir = getEditorRoot(daemon);
608
+ if (!rootDir) return res.status(400).json({ error: 'Editor root not set' });
609
+
610
+ let paths = [];
611
+
612
+ if (req.query.path) {
613
+ const result = validateFilePath(req.query.path, rootDir);
614
+ if (result.error) return res.status(400).json({ error: result.error });
615
+ paths = [req.query.path];
616
+ } else if (req.query.agentId) {
617
+ const agent = daemon.registry.get(req.query.agentId);
618
+ if (!agent) return res.status(404).json({ error: 'Agent not found' });
619
+ paths = daemon.registry.getFilesTouched(req.query.agentId).map(f => f.path);
620
+ if (paths.length === 0) return res.json({ diffs: [] });
621
+ // Validate each path
622
+ for (const p of paths) {
623
+ if (p.startsWith('/') || p.includes('..') || p.includes('\0')) {
624
+ return res.status(400).json({ error: 'Invalid path in agent files' });
625
+ }
626
+ }
627
+ }
628
+
629
+ const args = ['diff'];
630
+ const cachedArgs = ['diff', '--cached'];
631
+ if (paths.length > 0) {
632
+ args.push('--', ...paths);
633
+ cachedArgs.push('--', ...paths);
634
+ }
635
+
636
+ try {
637
+ const unstaged = execFileSync('git', args, { cwd: rootDir, timeout: 15000, maxBuffer: 10 * 1024 * 1024 }).toString();
638
+ const staged = execFileSync('git', cachedArgs, { cwd: rootDir, timeout: 15000, maxBuffer: 10 * 1024 * 1024 }).toString();
639
+ const combined = (staged + '\n' + unstaged).trim();
640
+ const diffs = parseDiffOutput(combined);
641
+ res.json({ diffs });
642
+ } catch (err) {
643
+ if (err.status !== undefined) {
644
+ return res.json({ diffs: [] });
645
+ }
646
+ res.status(500).json({ error: 'Failed to compute diff' });
647
+ }
648
+ });
649
+
650
+ // Git checkout — revert a file to its HEAD version
651
+ app.post('/api/files/revert', (req, res) => {
652
+ const rootDir = getEditorRoot(daemon);
653
+ if (!rootDir) return res.status(400).json({ error: 'Editor root not set' });
654
+ const filePath = req.body?.path;
655
+ if (!filePath || typeof filePath !== 'string') return res.status(400).json({ error: 'path is required' });
656
+ const result = validateFilePath(filePath, rootDir);
657
+ if (result.error) return res.status(400).json({ error: result.error });
658
+
659
+ try {
660
+ execFileSync('git', ['checkout', 'HEAD', '--', filePath], { cwd: rootDir, timeout: 10000 });
661
+ res.json({ ok: true, path: filePath });
662
+ } catch (err) {
663
+ res.status(500).json({ error: 'Failed to revert file', detail: err.message });
664
+ }
665
+ });
666
+
667
+ // Git show — retrieve original file content from HEAD
668
+ app.get('/api/files/git-show', (req, res) => {
669
+ const rootDir = getEditorRoot(daemon);
670
+ if (!rootDir) return res.status(400).json({ error: 'Editor root not set' });
671
+ const result = validateFilePath(req.query.path, rootDir);
672
+ if (result.error) return res.status(400).json({ error: result.error });
673
+
674
+ try {
675
+ const content = execFileSync('git', ['show', `HEAD:${req.query.path}`], {
676
+ cwd: rootDir, timeout: 10000, maxBuffer: 10 * 1024 * 1024,
677
+ }).toString();
678
+ res.json({ path: req.query.path, content });
679
+ } catch {
680
+ res.json({ path: req.query.path, content: null });
681
+ }
682
+ });
683
+
684
+ // File search — fuzzy filename matching for quick-open (Ctrl+P)
685
+ app.get('/api/files/search', (req, res) => {
686
+ const query = req.query.q;
687
+ if (!query || typeof query !== 'string') return res.status(400).json({ error: 'q parameter is required' });
688
+ if (query.length > 200) return res.status(400).json({ error: 'Query too long' });
689
+
690
+ const maxResults = Math.min(parseInt(req.query.maxResults, 10) || 50, 200);
691
+ const rootDir = getEditorRoot(daemon);
692
+ if (!rootDir) return res.status(400).json({ error: 'Editor root not set' });
693
+
694
+ const lowerQuery = query.toLowerCase();
695
+ const results = [];
696
+
697
+ function fuzzyMatch(name) {
698
+ const lower = name.toLowerCase();
699
+ let qi = 0;
700
+ for (let i = 0; i < lower.length && qi < lowerQuery.length; i++) {
701
+ if (lower[i] === lowerQuery[qi]) qi++;
702
+ }
703
+ return qi === lowerQuery.length;
704
+ }
705
+
706
+ function walk(dir, rel) {
707
+ if (results.length >= maxResults) return;
708
+ let entries;
709
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
710
+ for (const entry of entries) {
711
+ if (results.length >= maxResults) return;
712
+ if (IGNORED_NAMES.has(entry.name) || entry.name.startsWith('.')) continue;
713
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
714
+ if (entry.isDirectory()) {
715
+ walk(resolve(dir, entry.name), childRel);
716
+ } else if (entry.isFile() && fuzzyMatch(entry.name)) {
717
+ results.push({ path: childRel, name: entry.name });
718
+ }
719
+ }
720
+ }
721
+
722
+ try {
723
+ walk(rootDir, '');
724
+ res.json({ files: results });
725
+ } catch (err) {
726
+ res.status(500).json({ error: err.message });
727
+ }
728
+ });
729
+
730
+ // --- Codebase Indexer ---
731
+
732
+ app.get('/api/indexer', (req, res) => {
733
+ res.json(daemon.indexer.getStatus());
734
+ });
735
+
736
+ app.get('/api/indexer/workspaces', (req, res) => {
737
+ res.json({
738
+ workspaces: daemon.indexer.getWorkspaces(),
739
+ });
740
+ });
741
+
742
+ app.post('/api/indexer/rescan', (req, res) => {
743
+ try {
744
+ daemon.indexer.scan();
745
+ res.json({ ok: true, ...daemon.indexer.getStatus() });
746
+ } catch (err) {
747
+ res.status(500).json({ error: err.message });
748
+ }
749
+ });
750
+
751
+ }