groove-dev 0.27.142 → 0.27.144

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 (187) hide show
  1. package/node_modules/@groove-dev/cli/package.json +1 -1
  2. package/node_modules/@groove-dev/daemon/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/src/api.js +1086 -6532
  4. package/node_modules/@groove-dev/daemon/src/gateways/manager.js +35 -1
  5. package/node_modules/@groove-dev/daemon/src/index.js +3 -0
  6. package/node_modules/@groove-dev/daemon/src/journalist.js +23 -13
  7. package/node_modules/@groove-dev/daemon/src/mlx-server.js +365 -0
  8. package/node_modules/@groove-dev/daemon/src/model-lab.js +308 -12
  9. package/node_modules/@groove-dev/daemon/src/pm.js +1 -1
  10. package/node_modules/@groove-dev/daemon/src/process.js +2 -2
  11. package/node_modules/@groove-dev/daemon/src/providers/local.js +36 -8
  12. package/node_modules/@groove-dev/daemon/src/registry.js +21 -5
  13. package/node_modules/@groove-dev/daemon/src/routes/agents.js +889 -0
  14. package/node_modules/@groove-dev/daemon/src/routes/coordination.js +318 -0
  15. package/node_modules/@groove-dev/daemon/src/routes/files.js +751 -0
  16. package/node_modules/@groove-dev/daemon/src/routes/integrations.js +485 -0
  17. package/node_modules/@groove-dev/daemon/src/routes/network.js +1784 -0
  18. package/node_modules/@groove-dev/daemon/src/routes/providers.js +755 -0
  19. package/node_modules/@groove-dev/daemon/src/routes/schedules.js +110 -0
  20. package/node_modules/@groove-dev/daemon/src/routes/teams.js +650 -0
  21. package/node_modules/@groove-dev/daemon/src/scheduler.js +456 -24
  22. package/node_modules/@groove-dev/daemon/src/teams.js +1 -1
  23. package/node_modules/@groove-dev/daemon/src/validate.js +38 -1
  24. package/node_modules/@groove-dev/daemon/templates/mlx-setup.json +12 -0
  25. package/node_modules/@groove-dev/daemon/templates/tgi-setup.json +1 -1
  26. package/node_modules/@groove-dev/daemon/templates/vllm-setup.json +1 -1
  27. package/node_modules/@groove-dev/daemon/test/introducer.test.js +3 -3
  28. package/node_modules/@groove-dev/daemon/test/journalist.test.js +7 -10
  29. package/node_modules/@groove-dev/daemon/test/registry.test.js +38 -0
  30. package/node_modules/@groove-dev/gui/dist/assets/index-BcoF6_eF.js +1012 -0
  31. package/node_modules/@groove-dev/gui/dist/assets/index-Dd7qhiEd.css +1 -0
  32. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  33. package/node_modules/@groove-dev/gui/package.json +1 -1
  34. package/{packages/gui/src/app.jsx → node_modules/@groove-dev/gui/src/App.jsx} +0 -2
  35. package/node_modules/@groove-dev/gui/src/app.css +35 -0
  36. package/node_modules/@groove-dev/gui/src/components/agents/agent-config.jsx +1 -128
  37. package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +144 -31
  38. package/node_modules/@groove-dev/gui/src/components/agents/agent-node.jsx +8 -13
  39. package/node_modules/@groove-dev/gui/src/components/agents/code-review.jsx +159 -122
  40. package/node_modules/@groove-dev/gui/src/components/agents/diff-viewer.jsx +23 -23
  41. package/node_modules/@groove-dev/gui/src/components/agents/journalist-panel.jsx +1 -1
  42. package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +2 -135
  43. package/node_modules/@groove-dev/gui/src/components/automations/automation-card.jsx +274 -0
  44. package/node_modules/@groove-dev/gui/src/components/automations/automation-wizard.jsx +1136 -0
  45. package/node_modules/@groove-dev/gui/src/components/dashboard/activity-feed.jsx +3 -3
  46. package/node_modules/@groove-dev/gui/src/components/dashboard/cache-ring.jsx +5 -5
  47. package/node_modules/@groove-dev/gui/src/components/dashboard/context-gauges.jsx +6 -8
  48. package/node_modules/@groove-dev/gui/src/components/dashboard/fleet-panel.jsx +8 -14
  49. package/node_modules/@groove-dev/gui/src/components/dashboard/intel-panel.jsx +238 -656
  50. package/node_modules/@groove-dev/gui/src/components/dashboard/kpi-card.jsx +3 -3
  51. package/node_modules/@groove-dev/gui/src/components/dashboard/routing-chart.jsx +3 -3
  52. package/node_modules/@groove-dev/gui/src/components/dashboard/team-burn-panel.jsx +1 -1
  53. package/node_modules/@groove-dev/gui/src/components/dashboard/token-chart.jsx +4 -4
  54. package/node_modules/@groove-dev/gui/src/components/editor/selection-menu.jsx +2 -0
  55. package/node_modules/@groove-dev/gui/src/components/lab/lab-assistant.jsx +316 -82
  56. package/node_modules/@groove-dev/gui/src/components/lab/metrics-panel.jsx +187 -32
  57. package/node_modules/@groove-dev/gui/src/components/lab/parameter-panel.jsx +195 -14
  58. package/node_modules/@groove-dev/gui/src/components/lab/runtime-config.jsx +286 -102
  59. package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +2 -4
  60. package/node_modules/@groove-dev/gui/src/components/layout/terminal-panel.jsx +4 -2
  61. package/node_modules/@groove-dev/gui/src/components/layout/welcome-splash.jsx +137 -108
  62. package/node_modules/@groove-dev/gui/src/components/network/network-health.jsx +2 -2
  63. package/node_modules/@groove-dev/gui/src/components/network/performance-dashboard.jsx +4 -4
  64. package/node_modules/@groove-dev/gui/src/components/settings/ssh-wizard.jsx +81 -99
  65. package/node_modules/@groove-dev/gui/src/components/ui/sheet.jsx +5 -2
  66. package/node_modules/@groove-dev/gui/src/lib/cron.js +64 -0
  67. package/node_modules/@groove-dev/gui/src/lib/status.js +24 -24
  68. package/node_modules/@groove-dev/gui/src/lib/theme-hex.js +1 -0
  69. package/node_modules/@groove-dev/gui/src/stores/groove.js +34 -3144
  70. package/node_modules/@groove-dev/gui/src/stores/helpers.js +10 -0
  71. package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +452 -0
  72. package/node_modules/@groove-dev/gui/src/stores/slices/automations-slice.js +96 -0
  73. package/node_modules/@groove-dev/gui/src/stores/slices/chat-slice.js +227 -0
  74. package/node_modules/@groove-dev/gui/src/stores/slices/editor-slice.js +285 -0
  75. package/node_modules/@groove-dev/gui/src/stores/slices/marketplace-slice.js +461 -0
  76. package/node_modules/@groove-dev/gui/src/stores/slices/network-slice.js +361 -0
  77. package/node_modules/@groove-dev/gui/src/stores/slices/preview-slice.js +109 -0
  78. package/node_modules/@groove-dev/gui/src/stores/slices/providers-slice.js +897 -0
  79. package/node_modules/@groove-dev/gui/src/stores/slices/teams-slice.js +413 -0
  80. package/node_modules/@groove-dev/gui/src/stores/slices/ui-slice.js +98 -0
  81. package/node_modules/@groove-dev/gui/src/views/agents.jsx +5 -5
  82. package/node_modules/@groove-dev/gui/src/views/dashboard.jsx +12 -13
  83. package/node_modules/@groove-dev/gui/src/views/marketplace.jsx +191 -3
  84. package/node_modules/@groove-dev/gui/src/views/model-lab.jsx +17 -6
  85. package/node_modules/@groove-dev/gui/src/views/models.jsx +410 -509
  86. package/node_modules/@groove-dev/gui/src/views/network.jsx +3 -3
  87. package/node_modules/@groove-dev/gui/src/views/settings.jsx +81 -94
  88. package/node_modules/@groove-dev/gui/src/views/teams.jsx +40 -483
  89. package/package.json +1 -1
  90. package/packages/cli/package.json +1 -1
  91. package/packages/daemon/package.json +1 -1
  92. package/packages/daemon/src/api.js +1086 -6532
  93. package/packages/daemon/src/gateways/manager.js +35 -1
  94. package/packages/daemon/src/index.js +3 -0
  95. package/packages/daemon/src/journalist.js +23 -13
  96. package/packages/daemon/src/mlx-server.js +365 -0
  97. package/packages/daemon/src/model-lab.js +308 -12
  98. package/packages/daemon/src/pm.js +1 -1
  99. package/packages/daemon/src/process.js +2 -2
  100. package/packages/daemon/src/providers/local.js +36 -8
  101. package/packages/daemon/src/registry.js +21 -5
  102. package/packages/daemon/src/routes/agents.js +889 -0
  103. package/packages/daemon/src/routes/coordination.js +318 -0
  104. package/packages/daemon/src/routes/files.js +751 -0
  105. package/packages/daemon/src/routes/integrations.js +485 -0
  106. package/packages/daemon/src/routes/network.js +1784 -0
  107. package/packages/daemon/src/routes/providers.js +755 -0
  108. package/packages/daemon/src/routes/schedules.js +110 -0
  109. package/packages/daemon/src/routes/teams.js +650 -0
  110. package/packages/daemon/src/scheduler.js +456 -24
  111. package/packages/daemon/src/teams.js +1 -1
  112. package/packages/daemon/src/validate.js +38 -1
  113. package/packages/daemon/templates/mlx-setup.json +12 -0
  114. package/packages/daemon/templates/tgi-setup.json +1 -1
  115. package/packages/daemon/templates/vllm-setup.json +1 -1
  116. package/packages/gui/dist/assets/index-BcoF6_eF.js +1012 -0
  117. package/packages/gui/dist/assets/index-Dd7qhiEd.css +1 -0
  118. package/packages/gui/dist/index.html +2 -2
  119. package/packages/gui/package.json +1 -1
  120. package/{node_modules/@groove-dev/gui/src/app.jsx → packages/gui/src/App.jsx} +0 -2
  121. package/packages/gui/src/app.css +35 -0
  122. package/packages/gui/src/components/agents/agent-config.jsx +1 -128
  123. package/packages/gui/src/components/agents/agent-feed.jsx +144 -31
  124. package/packages/gui/src/components/agents/agent-node.jsx +8 -13
  125. package/packages/gui/src/components/agents/code-review.jsx +159 -122
  126. package/packages/gui/src/components/agents/diff-viewer.jsx +23 -23
  127. package/packages/gui/src/components/agents/journalist-panel.jsx +1 -1
  128. package/packages/gui/src/components/agents/spawn-wizard.jsx +2 -135
  129. package/packages/gui/src/components/automations/automation-card.jsx +274 -0
  130. package/packages/gui/src/components/automations/automation-wizard.jsx +1136 -0
  131. package/packages/gui/src/components/dashboard/activity-feed.jsx +3 -3
  132. package/packages/gui/src/components/dashboard/cache-ring.jsx +5 -5
  133. package/packages/gui/src/components/dashboard/context-gauges.jsx +6 -8
  134. package/packages/gui/src/components/dashboard/fleet-panel.jsx +8 -14
  135. package/packages/gui/src/components/dashboard/intel-panel.jsx +238 -656
  136. package/packages/gui/src/components/dashboard/kpi-card.jsx +3 -3
  137. package/packages/gui/src/components/dashboard/routing-chart.jsx +3 -3
  138. package/packages/gui/src/components/dashboard/team-burn-panel.jsx +1 -1
  139. package/packages/gui/src/components/dashboard/token-chart.jsx +4 -4
  140. package/packages/gui/src/components/editor/selection-menu.jsx +2 -0
  141. package/packages/gui/src/components/lab/lab-assistant.jsx +316 -82
  142. package/packages/gui/src/components/lab/metrics-panel.jsx +187 -32
  143. package/packages/gui/src/components/lab/parameter-panel.jsx +195 -14
  144. package/packages/gui/src/components/lab/runtime-config.jsx +286 -102
  145. package/packages/gui/src/components/layout/activity-bar.jsx +2 -4
  146. package/packages/gui/src/components/layout/terminal-panel.jsx +4 -2
  147. package/packages/gui/src/components/layout/welcome-splash.jsx +137 -108
  148. package/packages/gui/src/components/network/network-health.jsx +2 -2
  149. package/packages/gui/src/components/network/performance-dashboard.jsx +4 -4
  150. package/packages/gui/src/components/settings/ssh-wizard.jsx +81 -99
  151. package/packages/gui/src/components/ui/sheet.jsx +5 -2
  152. package/packages/gui/src/lib/cron.js +64 -0
  153. package/packages/gui/src/lib/status.js +24 -24
  154. package/packages/gui/src/lib/theme-hex.js +1 -0
  155. package/packages/gui/src/stores/groove.js +34 -3144
  156. package/packages/gui/src/stores/helpers.js +10 -0
  157. package/packages/gui/src/stores/slices/agents-slice.js +452 -0
  158. package/packages/gui/src/stores/slices/automations-slice.js +96 -0
  159. package/packages/gui/src/stores/slices/chat-slice.js +227 -0
  160. package/packages/gui/src/stores/slices/editor-slice.js +285 -0
  161. package/packages/gui/src/stores/slices/marketplace-slice.js +461 -0
  162. package/packages/gui/src/stores/slices/network-slice.js +361 -0
  163. package/packages/gui/src/stores/slices/preview-slice.js +109 -0
  164. package/packages/gui/src/stores/slices/providers-slice.js +897 -0
  165. package/packages/gui/src/stores/slices/teams-slice.js +413 -0
  166. package/packages/gui/src/stores/slices/ui-slice.js +98 -0
  167. package/packages/gui/src/views/agents.jsx +5 -5
  168. package/packages/gui/src/views/dashboard.jsx +12 -13
  169. package/packages/gui/src/views/marketplace.jsx +191 -3
  170. package/packages/gui/src/views/model-lab.jsx +17 -6
  171. package/packages/gui/src/views/models.jsx +410 -509
  172. package/packages/gui/src/views/network.jsx +3 -3
  173. package/packages/gui/src/views/settings.jsx +81 -94
  174. package/packages/gui/src/views/teams.jsx +40 -483
  175. package/SECURITY_SWEEP.md +0 -228
  176. package/TRAINING_DATA_v4.md +0 -6
  177. package/node_modules/@groove-dev/gui/dist/assets/index-Bjd91ufV.js +0 -984
  178. package/node_modules/@groove-dev/gui/dist/assets/index-BqdwIFn4.css +0 -1
  179. package/node_modules/@groove-dev/gui/src/components/agents/agent-chat.jsx +0 -322
  180. package/node_modules/@groove-dev/gui/src/views/preview.jsx +0 -6
  181. package/node_modules/@groove-dev/gui/src/views/subscription-panel.jsx +0 -327
  182. package/packages/gui/dist/assets/index-Bjd91ufV.js +0 -984
  183. package/packages/gui/dist/assets/index-BqdwIFn4.css +0 -1
  184. package/packages/gui/src/components/agents/agent-chat.jsx +0 -322
  185. package/packages/gui/src/views/preview.jsx +0 -6
  186. package/packages/gui/src/views/subscription-panel.jsx +0 -327
  187. 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
+ }