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,889 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+
3
+ import { resolve } from 'path';
4
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, mkdirSync } from 'fs';
5
+ import { validateAgentConfig, validateReasoningEffort, validateVerbosity } from '../validate.js';
6
+ import { ROLE_INTEGRATIONS, wrapWithRoleReminder } from '../process.js';
7
+ import { getProvider } from '../providers/index.js';
8
+
9
+ export function registerAgentRoutes(app, daemon) {
10
+ // List all agents
11
+ app.get('/api/agents', (req, res) => {
12
+ res.json(daemon.registry.getAll());
13
+ });
14
+
15
+ // Get single agent
16
+ app.get('/api/agents/:id', (req, res) => {
17
+ const agent = daemon.registry.get(req.params.id);
18
+ if (!agent) return res.status(404).json({ error: 'Agent not found' });
19
+ res.json(agent);
20
+ });
21
+
22
+ // Spawn a new agent
23
+ app.post('/api/agents', async (req, res) => {
24
+ try {
25
+ const config = validateAgentConfig(req.body);
26
+ config.teamId = req.body.teamId || daemon.teams.getDefault()?.id || null;
27
+ // Inherit team working directory if agent doesn't specify one
28
+ if (!config.workingDir) {
29
+ const team = daemon.teams.get(config.teamId);
30
+ if (team?.workingDir) config.workingDir = team.workingDir;
31
+ }
32
+ // Inherit configured defaults if the request didn't pick them
33
+ if (!config.provider && daemon.config?.defaultProvider) {
34
+ config.provider = daemon.config.defaultProvider;
35
+ }
36
+ if (!config.model && daemon.config?.defaultModel) {
37
+ config.model = daemon.config.defaultModel;
38
+ }
39
+ const agent = await daemon.processes.spawn(config);
40
+ daemon.audit.log('agent.spawn', { id: agent.id, role: agent.role, provider: agent.provider });
41
+ res.status(201).json(agent);
42
+ } catch (err) {
43
+ res.status(400).json({ error: err.message });
44
+ }
45
+ });
46
+
47
+ // Update agent
48
+ app.patch('/api/agents/:id', (req, res) => {
49
+ const agent = daemon.registry.update(req.params.id, req.body);
50
+ if (!agent) return res.status(404).json({ error: 'Agent not found' });
51
+ res.json(agent);
52
+ });
53
+
54
+ // Kill an agent (add ?purge=true to also remove from registry)
55
+ app.delete('/api/agents/:id', async (req, res) => {
56
+ try {
57
+ const agent = daemon.registry.get(req.params.id);
58
+ if (!agent) return res.status(404).json({ error: 'Agent not found' });
59
+
60
+ // Always attempt kill — handles race where GUI sees 'running' but daemon
61
+ // already marked the agent completed (common with fast non-interactive
62
+ // providers like Gemini). processes.kill() is a no-op when no handle exists.
63
+ await daemon.processes.kill(req.params.id);
64
+
65
+ // Only purge from registry when explicitly requested.
66
+ // Killed/completed agents stay visible so the user can review output.
67
+ const purge = req.query.purge === 'true';
68
+ if (purge) {
69
+ daemon.registry.remove(req.params.id);
70
+ }
71
+
72
+ daemon.audit.log('agent.kill', { id: agent.id, role: agent.role, purged: purge });
73
+ res.json({ ok: true, purged: purge });
74
+ } catch (err) {
75
+ res.status(400).json({ error: err.message });
76
+ }
77
+ });
78
+
79
+ // Kill all agents and purge registry (used by groove nuke)
80
+ app.delete('/api/agents', async (req, res) => {
81
+ const count = daemon.processes.getRunningCount();
82
+ await daemon.processes.killAll();
83
+ // Purge all agents from registry — kill() no longer does this automatically
84
+ for (const agent of daemon.registry.getAll()) {
85
+ daemon.registry.remove(agent.id);
86
+ }
87
+ daemon.audit.log('agent.kill_all', { count });
88
+ res.json({ ok: true });
89
+ });
90
+
91
+ // --- Role-to-Integration Mapping ---
92
+
93
+ app.get('/api/roles/integrations', (req, res) => {
94
+ const roleFilter = req.query.role;
95
+ const entries = roleFilter ? { [roleFilter]: ROLE_INTEGRATIONS[roleFilter] || [] } : ROLE_INTEGRATIONS;
96
+ const result = {};
97
+ for (const [role, ids] of Object.entries(entries)) {
98
+ result[role] = (ids || []).map((id) => {
99
+ const status = daemon.integrations.getStatus(id);
100
+ const entry = daemon.integrations.registry.find((r) => r.id === id);
101
+ return {
102
+ id,
103
+ name: entry?.name || id,
104
+ installed: status?.installed || false,
105
+ configured: status?.configured || false,
106
+ authenticated: status?.authenticated || false,
107
+ };
108
+ });
109
+ }
110
+ if (roleFilter) return res.json(result[roleFilter] || []);
111
+ res.json(result);
112
+ });
113
+
114
+ app.post('/api/agents/preflight', (req, res) => {
115
+ const { role, integrations } = req.body || {};
116
+ if (!role || !Array.isArray(integrations)) {
117
+ return res.status(400).json({ error: 'role and integrations[] required' });
118
+ }
119
+ const issues = [];
120
+ for (const id of integrations) {
121
+ const status = daemon.integrations.getStatus(id);
122
+ const entry = daemon.integrations.registry.find((r) => r.id === id);
123
+ const name = entry?.name || id;
124
+ if (!status || !status.installed) {
125
+ issues.push({ integrationId: id, name, problem: 'not_installed' });
126
+ } else if (!status.configured) {
127
+ issues.push({ integrationId: id, name, problem: 'not_configured' });
128
+ } else if (!status.authenticated) {
129
+ issues.push({ integrationId: id, name, problem: 'not_authenticated' });
130
+ }
131
+ }
132
+ res.json({ ready: issues.length === 0, issues });
133
+ });
134
+
135
+ // --- Agent Integration Attach/Detach ---
136
+
137
+ app.post('/api/agents/:id/integrations/:integrationId', (req, res) => {
138
+ const agent = daemon.registry.get(req.params.id);
139
+ if (!agent) return res.status(404).json({ error: 'Agent not found' });
140
+
141
+ const integrationId = req.params.integrationId;
142
+ const status = daemon.integrations.getStatus(integrationId);
143
+ if (!status || !status.installed) {
144
+ return res.status(400).json({ error: `Integration not installed: ${integrationId}` });
145
+ }
146
+
147
+ const integrations = new Set(agent.integrations || []);
148
+ integrations.add(integrationId);
149
+ const updated = Array.from(integrations);
150
+
151
+ daemon.registry.update(req.params.id, { integrations: updated });
152
+ daemon.integrations.writeMcpJson(daemon.integrations.getActiveIntegrations());
153
+ daemon.integrations.refreshMcpJson();
154
+ daemon.audit.log('agent.integration.attach', { agentId: req.params.id, integrationId });
155
+ daemon.broadcast({ type: 'agent:integration:attach', agentId: req.params.id, integrationId });
156
+ res.json({ ok: true, integrations: updated });
157
+ });
158
+
159
+ app.delete('/api/agents/:id/integrations/:integrationId', (req, res) => {
160
+ const agent = daemon.registry.get(req.params.id);
161
+ if (!agent) return res.status(404).json({ error: 'Agent not found' });
162
+
163
+ const integrationId = req.params.integrationId;
164
+ const integrations = (agent.integrations || []).filter((id) => id !== integrationId);
165
+
166
+ daemon.registry.update(req.params.id, { integrations });
167
+ daemon.integrations.refreshMcpJson();
168
+ daemon.audit.log('agent.integration.detach', { agentId: req.params.id, integrationId });
169
+ daemon.broadcast({ type: 'agent:integration:detach', agentId: req.params.id, integrationId });
170
+ res.json({ ok: true, integrations });
171
+ });
172
+
173
+ // --- Agent Routing ---
174
+
175
+ app.post('/api/agents/:id/routing', (req, res) => {
176
+ daemon.router.setMode(req.params.id, req.body.mode, {
177
+ fixedModel: req.body.fixedModel,
178
+ floorModel: req.body.floorModel,
179
+ });
180
+ res.json(daemon.router.getMode(req.params.id));
181
+ });
182
+
183
+ app.get('/api/agents/:id/routing/recommend', (req, res) => {
184
+ const rec = daemon.router.recommend(req.params.id);
185
+ if (!rec) return res.status(404).json({ error: 'Agent not found' });
186
+ res.json(rec);
187
+ });
188
+
189
+ // Downshift suggestion — NEVER auto-applied. User must accept via UI.
190
+ // Returns null (204) when classifier has no strong suggestion.
191
+ app.get('/api/agents/:id/routing/suggestion', (req, res) => {
192
+ const suggestion = daemon.router.getSuggestion(req.params.id);
193
+ if (!suggestion) return res.status(204).send();
194
+ res.json(suggestion);
195
+ });
196
+
197
+ // --- Conversations ---
198
+
199
+ app.get('/api/conversations', (req, res) => {
200
+ res.json({ conversations: daemon.conversations.list() });
201
+ });
202
+
203
+ app.post('/api/conversations', async (req, res) => {
204
+ try {
205
+ const { provider, model, title, mode, reasoning_effort, verbosity } = req.body;
206
+ if (provider && typeof provider !== 'string') {
207
+ return res.status(400).json({ error: 'provider must be a string' });
208
+ }
209
+ if (mode && mode !== 'api' && mode !== 'agent') {
210
+ return res.status(400).json({ error: 'mode must be "api" or "agent"' });
211
+ }
212
+ const validatedEffort = validateReasoningEffort(reasoning_effort);
213
+ const validatedVerbosity = validateVerbosity(verbosity);
214
+ const conversation = await daemon.conversations.create(provider, model, title, mode || 'api', {
215
+ reasoningEffort: validatedEffort,
216
+ verbosity: validatedVerbosity,
217
+ });
218
+ daemon.audit.log('conversation.create', { id: conversation.id, provider, model, mode: conversation.mode });
219
+ res.status(201).json(conversation);
220
+ } catch (err) {
221
+ res.status(400).json({ error: err.message });
222
+ }
223
+ });
224
+
225
+ app.get('/api/conversations/:id', (req, res) => {
226
+ const conversation = daemon.conversations.get(req.params.id);
227
+ if (!conversation) return res.status(404).json({ error: 'Conversation not found' });
228
+ res.json(conversation);
229
+ });
230
+
231
+ app.patch('/api/conversations/:id', async (req, res) => {
232
+ try {
233
+ const conv = daemon.conversations.get(req.params.id);
234
+ if (!conv) return res.status(404).json({ error: 'Conversation not found' });
235
+ if (req.body.title !== undefined) daemon.conversations.rename(req.params.id, req.body.title);
236
+ if (req.body.pinned !== undefined) daemon.conversations.pin(req.params.id, req.body.pinned);
237
+ if (req.body.archived !== undefined) daemon.conversations.archive(req.params.id, req.body.archived);
238
+ if (req.body.model !== undefined || req.body.provider !== undefined) {
239
+ const newProvider = req.body.provider || conv.provider;
240
+ const newModel = req.body.model || conv.model;
241
+ daemon.conversations.updateModel(req.params.id, newProvider, newModel);
242
+ }
243
+ if (req.body.mode !== undefined) {
244
+ if (req.body.mode !== 'api' && req.body.mode !== 'agent') {
245
+ return res.status(400).json({ error: 'mode must be "api" or "agent"' });
246
+ }
247
+ await daemon.conversations.setMode(req.params.id, req.body.mode);
248
+ }
249
+ if (req.body.reasoning_effort !== undefined || req.body.verbosity !== undefined) {
250
+ const validatedEffort = req.body.reasoning_effort !== undefined ? validateReasoningEffort(req.body.reasoning_effort) : undefined;
251
+ const validatedVerbosity = req.body.verbosity !== undefined ? validateVerbosity(req.body.verbosity) : undefined;
252
+ daemon.conversations.updateReasoningSettings(req.params.id, validatedEffort, validatedVerbosity);
253
+ }
254
+ daemon.audit.log('conversation.update', { id: req.params.id, provider: req.body.provider, model: req.body.model, mode: req.body.mode });
255
+ res.json(daemon.conversations.get(req.params.id));
256
+ } catch (err) {
257
+ res.status(400).json({ error: err.message });
258
+ }
259
+ });
260
+
261
+ app.delete('/api/conversations/:id', async (req, res) => {
262
+ try {
263
+ const conv = daemon.conversations.get(req.params.id);
264
+ if (!conv) return res.status(404).json({ error: 'Conversation not found' });
265
+ await daemon.conversations.delete(req.params.id);
266
+ daemon.audit.log('conversation.delete', { id: req.params.id });
267
+ res.json({ ok: true });
268
+ } catch (err) {
269
+ res.status(400).json({ error: err.message });
270
+ }
271
+ });
272
+
273
+ app.post('/api/conversations/:id/message', async (req, res) => {
274
+ try {
275
+ const { message, history, reasoning_effort, verbosity } = req.body;
276
+ if (!message || typeof message !== 'string' || !message.trim()) {
277
+ return res.status(400).json({ error: 'message is required' });
278
+ }
279
+ const validatedEffort = validateReasoningEffort(reasoning_effort);
280
+ const validatedVerbosity = validateVerbosity(verbosity);
281
+
282
+ const conv = daemon.conversations.get(req.params.id);
283
+ if (!conv) return res.status(404).json({ error: 'Conversation not found' });
284
+
285
+ daemon.conversations.autoTitle(req.params.id, message.trim());
286
+ daemon.conversations.touchUpdatedAt(req.params.id);
287
+
288
+ // API mode — lightweight headless streaming, no agent spawned
289
+ if (conv.mode === 'api' || !conv.agentId) {
290
+ await daemon.conversations.sendMessage(req.params.id, message.trim(), history || [], {
291
+ reasoningEffort: validatedEffort,
292
+ verbosity: validatedVerbosity,
293
+ });
294
+ daemon.audit.log('conversation.message', { id: req.params.id, mode: 'api' });
295
+ return res.json({ status: 'streaming', mode: 'api' });
296
+ }
297
+
298
+ // Agent mode — existing behavior
299
+ const agent = daemon.registry.get(conv.agentId);
300
+ if (!agent) return res.status(400).json({ error: 'Agent no longer exists' });
301
+
302
+ // Record user feedback for journalist context
303
+ if (daemon.journalist) daemon.journalist.recordUserFeedback(agent, message.trim());
304
+
305
+ // Agent loop path — send message directly to the running loop
306
+ if (daemon.processes.hasAgentLoop(conv.agentId)) {
307
+ const sent = await daemon.processes.sendMessage(conv.agentId, message.trim());
308
+ if (sent) {
309
+ daemon.audit.log('conversation.message', { id: req.params.id, agentId: conv.agentId });
310
+ return res.json({ id: conv.agentId, status: 'message_sent' });
311
+ }
312
+ }
313
+
314
+ // One-shot providers: kill and respawn with the message as prompt
315
+ const provider = getProvider(agent.provider);
316
+ if (provider?.constructor?.isOneShot) {
317
+ const oldConfig = { ...agent };
318
+ if (daemon.processes.isRunning(conv.agentId)) {
319
+ await daemon.processes.kill(conv.agentId);
320
+ }
321
+ daemon.registry.remove(conv.agentId);
322
+ daemon.locks.release(conv.agentId);
323
+
324
+ const newAgent = await daemon.processes.spawn({
325
+ role: 'chat',
326
+ scope: oldConfig.scope,
327
+ provider: oldConfig.provider,
328
+ model: oldConfig.model,
329
+ prompt: message.trim(),
330
+ permission: oldConfig.permission || 'full',
331
+ workingDir: oldConfig.workingDir,
332
+ name: oldConfig.name,
333
+ teamId: oldConfig.teamId,
334
+ });
335
+
336
+ // Update conversation to point to new agent
337
+ const convObj = daemon.conversations.conversations.get(req.params.id);
338
+ if (convObj) {
339
+ convObj.agentId = newAgent.id;
340
+ daemon.conversations._save();
341
+ }
342
+ daemon.audit.log('conversation.message', { id: req.params.id, agentId: newAgent.id, oneShot: true });
343
+ return res.json({ id: newAgent.id, status: 'respawned' });
344
+ }
345
+
346
+ // Running CLI agent — queue the message
347
+ if (daemon.processes.isRunning(conv.agentId)) {
348
+ daemon.processes.queueMessage(conv.agentId, message.trim());
349
+ daemon.audit.log('conversation.message', { id: req.params.id, agentId: conv.agentId, queued: true });
350
+ return res.json({ id: conv.agentId, status: 'message_queued' });
351
+ }
352
+
353
+ // CLI agent — session resume or rotation
354
+ const SESSION_RESUME_CEILING = 5_000_000;
355
+ const resumed = !!agent.sessionId && (agent.tokensUsed || 0) < SESSION_RESUME_CEILING;
356
+ const newAgent = resumed
357
+ ? await daemon.processes.resume(conv.agentId, message.trim())
358
+ : await daemon.rotator.rotate(conv.agentId, { additionalPrompt: message.trim() });
359
+
360
+ // Update conversation to point to new agent if rotated
361
+ if (newAgent.id !== conv.agentId) {
362
+ const convObj = daemon.conversations.conversations.get(req.params.id);
363
+ if (convObj) {
364
+ convObj.agentId = newAgent.id;
365
+ daemon.conversations._save();
366
+ }
367
+ }
368
+
369
+ daemon.audit.log('conversation.message', { id: req.params.id, agentId: newAgent.id, resumed });
370
+ res.json({ id: newAgent.id, status: resumed ? 'resumed' : 'rotated' });
371
+ } catch (err) {
372
+ res.status(400).json({ error: err.message });
373
+ }
374
+ });
375
+
376
+ app.post('/api/conversations/:id/stop', (req, res) => {
377
+ try {
378
+ const conv = daemon.conversations.get(req.params.id);
379
+ if (!conv) return res.status(404).json({ error: 'Conversation not found' });
380
+ daemon.conversations.stopStreaming(req.params.id);
381
+ res.json({ ok: true });
382
+ } catch (err) {
383
+ res.status(400).json({ error: err.message });
384
+ }
385
+ });
386
+
387
+ // --- Image Generation ---
388
+
389
+ app.post('/api/conversations/:id/generate-image', async (req, res) => {
390
+ try {
391
+ const { prompt, model, size, quality } = req.body;
392
+ if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
393
+ return res.status(400).json({ error: 'prompt is required' });
394
+ }
395
+ const conv = daemon.conversations.get(req.params.id);
396
+ if (!conv) return res.status(404).json({ error: 'Conversation not found' });
397
+
398
+ let providerName = conv.provider;
399
+ let provider = getProvider(providerName);
400
+
401
+ // If a specific image model was requested, find the right provider
402
+ if (model) {
403
+ const imageProviders = ['codex', 'grok', 'nano-banana'];
404
+ for (const pid of imageProviders) {
405
+ const p = getProvider(pid);
406
+ if (p?.constructor.models.some((m) => m.id === model)) {
407
+ provider = p;
408
+ providerName = pid;
409
+ break;
410
+ }
411
+ }
412
+ }
413
+
414
+ if (!provider?.generateImage) {
415
+ return res.status(400).json({ error: 'Provider does not support image generation' });
416
+ }
417
+
418
+ const apiKey = daemon.conversations._getApiKey(providerName);
419
+ if (!apiKey) {
420
+ return res.status(400).json({ error: `No API key configured for ${providerName}` });
421
+ }
422
+
423
+ daemon.broadcast({
424
+ type: 'conversation:image-progress',
425
+ data: { conversationId: req.params.id, status: 'generating', prompt: prompt.trim() },
426
+ });
427
+
428
+ const result = await provider.generateImage(prompt.trim(), { model, size, quality, apiKey });
429
+
430
+ daemon.broadcast({
431
+ type: 'conversation:image',
432
+ data: { conversationId: req.params.id, ...result, prompt: prompt.trim() },
433
+ });
434
+
435
+ daemon.conversations.touchUpdatedAt(req.params.id);
436
+ daemon.audit.log('conversation.image', { id: req.params.id, model: result.model, provider: result.provider });
437
+ res.json(result);
438
+ } catch (err) {
439
+ daemon.broadcast({
440
+ type: 'conversation:image-progress',
441
+ data: { conversationId: req.params.id, status: 'error', error: err.message },
442
+ });
443
+ res.status(500).json({ error: err.message });
444
+ }
445
+ });
446
+
447
+ // Stop an agent's current work without killing the agent
448
+ app.post('/api/agents/:id/stop', async (req, res) => {
449
+ try {
450
+ const agent = daemon.registry.get(req.params.id);
451
+ if (!agent) return res.status(404).json({ error: 'Agent not found' });
452
+ await daemon.processes.stop(req.params.id);
453
+ daemon.audit.log('agent.stop', { id: req.params.id, name: agent.name });
454
+ res.json({ id: req.params.id, status: 'stopped' });
455
+ } catch (err) {
456
+ res.status(500).json({ error: err.message });
457
+ }
458
+ });
459
+
460
+ // Rotate an agent
461
+ app.post('/api/agents/:id/rotate', async (req, res) => {
462
+ try {
463
+ const oldAgent = daemon.registry.get(req.params.id);
464
+ const newAgent = await daemon.rotator.rotate(req.params.id);
465
+ daemon.audit.log('agent.rotate', { oldId: req.params.id, newId: newAgent.id, role: oldAgent?.role });
466
+ res.json(newAgent);
467
+ } catch (err) {
468
+ res.status(400).json({ error: err.message });
469
+ }
470
+ });
471
+
472
+ // Instruct an agent — send message to agent loop, resume session, or rotate
473
+ // Agent loop = direct message to running loop (local models)
474
+ // Resume = zero cold-start (uses --resume SESSION_ID)
475
+ // Rotation = full handoff brief (only for degradation or no session)
476
+ app.post('/api/agents/:id/instruct', async (req, res) => {
477
+ try {
478
+ const { message, codeContext } = req.body;
479
+ if (!message || typeof message !== 'string' || !message.trim()) {
480
+ return res.status(400).json({ error: 'message is required' });
481
+ }
482
+ const agent = daemon.registry.get(req.params.id);
483
+ if (!agent) return res.status(404).json({ error: 'Agent not found' });
484
+
485
+ // Build the final instruction, optionally enriched with code context
486
+ let finalMessage = message.trim();
487
+ if (codeContext && typeof codeContext === 'object') {
488
+ const { filePath, lineStart, lineEnd, selectedCode } = codeContext;
489
+ if (filePath && typeof filePath === 'string' && selectedCode && typeof selectedCode === 'string') {
490
+ const start = Number.isFinite(lineStart) ? lineStart : '?';
491
+ const end = Number.isFinite(lineEnd) ? lineEnd : '?';
492
+ finalMessage = `${finalMessage}\n\nCode context from ${filePath} (lines ${start}-${end}):\n\`\`\`\n${selectedCode}\n\`\`\``;
493
+ }
494
+ }
495
+
496
+ // Record user feedback so the journalist can include it in future agent context
497
+ if (daemon.journalist) daemon.journalist.recordUserFeedback(agent, finalMessage);
498
+
499
+ // Agent loop path — send message directly to the running loop
500
+ const wrappedMessage = wrapWithRoleReminder(agent.role, finalMessage);
501
+ if (daemon.processes.hasAgentLoop(req.params.id)) {
502
+ const sent = await daemon.processes.sendMessage(req.params.id, wrappedMessage);
503
+ if (sent) {
504
+ daemon.audit.log('agent.chat', { id: req.params.id });
505
+ return res.json({ id: agent.id, status: 'message_sent' });
506
+ }
507
+ // Loop exists but not running — fall through to resume/rotate
508
+ }
509
+
510
+ // One-shot providers (groove-network): kill any running instance and
511
+ // respawn with the user's message as --prompt. No handoff brief, no
512
+ // session resume, no message queue — each chat message is a fresh spawn.
513
+ const provider = getProvider(agent.provider);
514
+ if (provider?.constructor?.isOneShot) {
515
+ const oldConfig = { ...agent };
516
+ if (daemon.processes.isRunning(req.params.id)) {
517
+ await daemon.processes.kill(req.params.id);
518
+ }
519
+ daemon.registry.remove(req.params.id);
520
+ daemon.locks.release(req.params.id);
521
+
522
+ const newAgent = await daemon.processes.spawn({
523
+ role: oldConfig.role,
524
+ scope: oldConfig.scope,
525
+ provider: oldConfig.provider,
526
+ model: oldConfig.model,
527
+ prompt: finalMessage,
528
+ permission: oldConfig.permission || 'full',
529
+ workingDir: oldConfig.workingDir,
530
+ name: oldConfig.name,
531
+ teamId: oldConfig.teamId,
532
+ });
533
+ daemon.audit.log('agent.instruct', { id: req.params.id, newId: newAgent.id, resumed: false });
534
+ return res.json(newAgent);
535
+ }
536
+
537
+ // Non-interactive CLI providers (e.g. Gemini): respawn with the new
538
+ // message as the prompt, preserving original introContext. These providers
539
+ // run one prompt per spawn and cannot resume sessions.
540
+ if (provider?.constructor?.nonInteractive && !daemon.processes.isRunning(req.params.id)) {
541
+ const oldConfig = { ...agent };
542
+ daemon.registry.remove(req.params.id);
543
+ daemon.locks.release(req.params.id);
544
+
545
+ const newAgent = await daemon.processes.spawn({
546
+ role: oldConfig.role,
547
+ scope: oldConfig.scope,
548
+ provider: oldConfig.provider,
549
+ model: oldConfig.model,
550
+ prompt: finalMessage,
551
+ introContext: oldConfig.introContext,
552
+ permission: oldConfig.permission || 'full',
553
+ workingDir: oldConfig.workingDir,
554
+ name: oldConfig.name,
555
+ teamId: oldConfig.teamId,
556
+ });
557
+ daemon.audit.log('agent.instruct', { id: req.params.id, newId: newAgent.id, resumed: false });
558
+ return res.json(newAgent);
559
+ }
560
+
561
+ // Running CLI agent (no loop) — queue the message for delivery after
562
+ // the current task completes instead of killing and respawning.
563
+ if (daemon.processes.isRunning(req.params.id)) {
564
+ daemon.processes.queueMessage(req.params.id, wrappedMessage);
565
+ daemon.audit.log('agent.chat.queued', { id: req.params.id });
566
+ return res.json({ id: agent.id, status: 'message_queued' });
567
+ }
568
+
569
+ // CLI agent path — session resume or rotation.
570
+ // Force rotation (fresh session + handoff brief) past the resume ceiling:
571
+ // reviving a >5M-token claude session has crashed the CLI mid-HTTP-parse
572
+ // (V8 fatal in JsonStringifier) — the rotator's handoff brief sidesteps that.
573
+ const SESSION_RESUME_CEILING = 5_000_000;
574
+ const resumed = !!agent.sessionId && (agent.tokensUsed || 0) < SESSION_RESUME_CEILING;
575
+ const newAgent = resumed
576
+ ? await daemon.processes.resume(req.params.id, wrappedMessage)
577
+ : await daemon.rotator.rotate(req.params.id, { additionalPrompt: wrappedMessage });
578
+
579
+ daemon.audit.log('agent.instruct', { id: req.params.id, newId: newAgent.id, resumed });
580
+ res.json(newAgent);
581
+ } catch (err) {
582
+ res.status(400).json({ error: err.message });
583
+ }
584
+ });
585
+
586
+ // Query an agent (headless one-shot, agent keeps running)
587
+ // For agent loop agents: sends message directly to the loop
588
+ app.post('/api/agents/:id/query', async (req, res) => {
589
+ try {
590
+ const { message } = req.body;
591
+ if (!message || typeof message !== 'string' || !message.trim()) {
592
+ return res.status(400).json({ error: 'message is required' });
593
+ }
594
+ const agent = daemon.registry.get(req.params.id);
595
+ if (!agent) return res.status(404).json({ error: 'Agent not found' });
596
+
597
+ // Agent loop agents: send message directly (they're interactive)
598
+ if (daemon.processes.hasAgentLoop(req.params.id)) {
599
+ const sent = await daemon.processes.sendMessage(req.params.id, message.trim());
600
+ return res.json({ response: sent ? 'Message sent to agent' : 'Agent not running', agentId: agent.id, agentName: agent.name });
601
+ }
602
+
603
+ // Build context about the agent's work
604
+ const activity = daemon.classifier?.agentWindows?.[agent.id] || [];
605
+ const recentActivity = activity.slice(-20).map((e) => e.data || e.text || '').join('\n');
606
+
607
+ // Truncate the agent's original prompt to avoid massive payloads
608
+ const taskSummary = agent.prompt ? agent.prompt.slice(0, 500) : '';
609
+ const prompt = [
610
+ `You are answering a question about agent "${agent.name}" (role: ${agent.role}).`,
611
+ `Provider: ${agent.provider}, Tokens used: ${agent.tokensUsed || 0}`,
612
+ taskSummary ? `Task summary: ${taskSummary}` : '',
613
+ recentActivity ? `\nRecent activity:\n${recentActivity}` : '',
614
+ `\nUser question: ${message.trim()}`,
615
+ '\nAnswer concisely based on the agent context above.',
616
+ ].filter(Boolean).join('\n');
617
+
618
+ const response = await daemon.journalist.callHeadless(prompt, { trackAs: '__agent_qa__' });
619
+ res.json({ response, agentId: agent.id, agentName: agent.name });
620
+ } catch (err) {
621
+ res.status(400).json({ error: err.message });
622
+ }
623
+ });
624
+
625
+ // Upload file to agent's working directory
626
+ app.post('/api/agents/:id/upload', (req, res) => {
627
+ const agent = daemon.registry.get(req.params.id);
628
+ if (!agent) return res.status(404).json({ error: 'Agent not found' });
629
+
630
+ const { filename, content } = req.body;
631
+ if (!filename || !content) return res.status(400).json({ error: 'filename and content required' });
632
+
633
+ // Sanitize filename — strict allowlist, no path traversal
634
+ const safeName = String(filename).replace(/[^a-zA-Z0-9._-]/g, '_').replace(/^\.+/, '');
635
+ if (!safeName) return res.status(400).json({ error: 'Invalid filename' });
636
+
637
+ const dir = agent.workingDir || daemon.projectDir;
638
+ const filePath = resolve(dir, safeName);
639
+
640
+ // Ensure file stays within working directory
641
+ if (!filePath.startsWith(dir)) {
642
+ return res.status(400).json({ error: 'Path traversal detected' });
643
+ }
644
+
645
+ try {
646
+ mkdirSync(dir, { recursive: true });
647
+ const buffer = Buffer.from(content, 'base64');
648
+ writeFileSync(filePath, buffer);
649
+ daemon.audit.log('file.upload', { agentId: agent.id, filename: safeName, size: buffer.length });
650
+ res.json({ ok: true, path: safeName, size: buffer.length });
651
+ } catch (err) {
652
+ res.status(500).json({ error: `Upload failed: ${err.message}` });
653
+ }
654
+ });
655
+
656
+ // List MD files for an agent (from its working directory + .groove)
657
+ app.get('/api/agents/:id/mdfiles', (req, res) => {
658
+ const agent = daemon.registry.get(req.params.id);
659
+ if (!agent) return res.status(404).json({ error: 'Agent not found' });
660
+
661
+ const dir = agent.workingDir || daemon.projectDir;
662
+ const files = [];
663
+
664
+ // Scan working directory for .md files (top level + .groove/)
665
+ try {
666
+ for (const entry of readdirSync(dir)) {
667
+ if (entry.endsWith('.md') && !entry.startsWith('.')) {
668
+ const fullPath = resolve(dir, entry);
669
+ if (statSync(fullPath).isFile()) {
670
+ files.push({ name: entry, path: entry, size: statSync(fullPath).size, source: 'project' });
671
+ }
672
+ }
673
+ }
674
+ const grooveDir = resolve(dir, '.groove');
675
+ if (existsSync(grooveDir)) {
676
+ for (const entry of readdirSync(grooveDir)) {
677
+ if (entry.endsWith('.md')) {
678
+ const fullPath = resolve(grooveDir, entry);
679
+ if (statSync(fullPath).isFile()) {
680
+ files.push({ name: entry, path: `.groove/${entry}`, size: statSync(fullPath).size, source: 'project' });
681
+ }
682
+ }
683
+ }
684
+ }
685
+ } catch { /* dir might not exist */ }
686
+
687
+ // Include personality file from .groove/personalities/
688
+ try {
689
+ const personalityFile = resolve(daemon.grooveDir, 'personalities', `${agent.name}.md`);
690
+ if (existsSync(personalityFile)) {
691
+ const size = statSync(personalityFile).size;
692
+ files.unshift({ name: 'personality.md', path: '__personality__', size, source: 'personality' });
693
+ }
694
+ } catch { /* ignore */ }
695
+
696
+ // Include user-created agent files from .groove/agent-files/<name>/
697
+ try {
698
+ const agentFilesDir = resolve(daemon.grooveDir, 'agent-files', agent.name);
699
+ if (existsSync(agentFilesDir)) {
700
+ for (const entry of readdirSync(agentFilesDir)) {
701
+ if (entry.endsWith('.md')) {
702
+ const fullPath = resolve(agentFilesDir, entry);
703
+ if (statSync(fullPath).isFile()) {
704
+ files.push({ name: entry, path: `__user__/${entry}`, size: statSync(fullPath).size, source: 'user' });
705
+ }
706
+ }
707
+ }
708
+ }
709
+ } catch { /* ignore */ }
710
+
711
+ res.json({ files, workingDir: dir });
712
+ });
713
+
714
+ // Read a specific MD file for an agent
715
+ app.get('/api/agents/:id/mdfiles/read', (req, res) => {
716
+ const agent = daemon.registry.get(req.params.id);
717
+ if (!agent) return res.status(404).json({ error: 'Agent not found' });
718
+
719
+ const dir = agent.workingDir || daemon.projectDir;
720
+ const relPath = req.query.path;
721
+ if (!relPath || relPath.includes('..')) return res.status(400).json({ error: 'Invalid path' });
722
+
723
+ if (relPath === '__personality__') {
724
+ const personalityFile = resolve(daemon.grooveDir, 'personalities', `${agent.name}.md`);
725
+ if (existsSync(personalityFile)) {
726
+ return res.json({ content: readFileSync(personalityFile, 'utf8') });
727
+ }
728
+ return res.json({ content: '' });
729
+ }
730
+
731
+ if (relPath.startsWith('__user__/')) {
732
+ const fileName = relPath.slice('__user__/'.length);
733
+ if (!fileName || fileName.includes('/') || fileName.includes('..')) return res.status(400).json({ error: 'Invalid path' });
734
+ const filePath = resolve(daemon.grooveDir, 'agent-files', agent.name, fileName);
735
+ if (existsSync(filePath)) return res.json({ content: readFileSync(filePath, 'utf8') });
736
+ return res.json({ content: '' });
737
+ }
738
+
739
+ const fullPath = resolve(dir, relPath);
740
+ if (!fullPath.startsWith(dir)) return res.status(400).json({ error: 'Path traversal' });
741
+
742
+ try {
743
+ const content = readFileSync(fullPath, 'utf8');
744
+ res.json({ path: relPath, content });
745
+ } catch {
746
+ res.status(404).json({ error: 'File not found' });
747
+ }
748
+ });
749
+
750
+ // Save a MD file for an agent
751
+ app.put('/api/agents/:id/mdfiles/write', (req, res) => {
752
+ const agent = daemon.registry.get(req.params.id);
753
+ if (!agent) return res.status(404).json({ error: 'Agent not found' });
754
+
755
+ const dir = agent.workingDir || daemon.projectDir;
756
+ const { path: relPath, content } = req.body;
757
+ if (!relPath || relPath.includes('..')) return res.status(400).json({ error: 'Invalid path' });
758
+ if (typeof content !== 'string') return res.status(400).json({ error: 'Content required' });
759
+
760
+ if (relPath === '__personality__') {
761
+ const personalityDir = resolve(daemon.grooveDir, 'personalities');
762
+ mkdirSync(personalityDir, { recursive: true });
763
+ writeFileSync(resolve(personalityDir, `${agent.name}.md`), content || '', { mode: 0o600 });
764
+ daemon.audit.log('personality.update', { name: agent.name, agentId: agent.id });
765
+ return res.json({ saved: true });
766
+ }
767
+
768
+ if (relPath.startsWith('__user__/')) {
769
+ const fileName = relPath.slice('__user__/'.length);
770
+ if (!fileName || fileName.includes('/') || fileName.includes('..')) return res.status(400).json({ error: 'Invalid path' });
771
+ const agentFilesDir = resolve(daemon.grooveDir, 'agent-files', agent.name);
772
+ mkdirSync(agentFilesDir, { recursive: true });
773
+ writeFileSync(resolve(agentFilesDir, fileName), content || '', { mode: 0o600 });
774
+ daemon.audit.log('mdfile.write.user', { agentId: agent.id, name: fileName });
775
+ return res.json({ saved: true });
776
+ }
777
+
778
+ const fullPath = resolve(dir, relPath);
779
+ if (!fullPath.startsWith(dir)) return res.status(400).json({ error: 'Path traversal' });
780
+
781
+ try {
782
+ writeFileSync(fullPath, content, 'utf8');
783
+ daemon.audit.log('mdfile.write', { agentId: agent.id, path: relPath });
784
+ res.json({ ok: true });
785
+ } catch (err) {
786
+ res.status(500).json({ error: err.message });
787
+ }
788
+ });
789
+
790
+ // Create a new MD file for an agent
791
+ app.post('/api/agents/:id/mdfiles/create', (req, res) => {
792
+ const agent = daemon.registry.get(req.params.id);
793
+ if (!agent) return res.status(404).json({ error: 'Agent not found' });
794
+ let name = req.body?.name;
795
+ if (!name || typeof name !== 'string') return res.status(400).json({ error: 'name required' });
796
+ name = name.replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 64);
797
+ if (!name) return res.status(400).json({ error: 'Invalid name' });
798
+ if (!name.endsWith('.md')) name += '.md';
799
+ const agentFilesDir = resolve(daemon.grooveDir, 'agent-files', agent.name);
800
+ mkdirSync(agentFilesDir, { recursive: true });
801
+ const filePath = resolve(agentFilesDir, name);
802
+ if (existsSync(filePath)) return res.status(409).json({ error: 'File already exists' });
803
+ writeFileSync(filePath, '', { mode: 0o600 });
804
+ daemon.audit.log('mdfile.create', { agentId: agent.id, name });
805
+ res.json({ name, path: `__user__/${name}` });
806
+ });
807
+
808
+ // --- Agent Skills (attach/detach) ---
809
+
810
+ app.post('/api/agents/:agentId/skills/:skillId', (req, res) => {
811
+ const agent = daemon.registry.get(req.params.agentId);
812
+ if (!agent) return res.status(404).json({ error: 'Agent not found' });
813
+ const skillId = req.params.skillId;
814
+ if (!daemon.skills.getContent(skillId)) {
815
+ return res.status(400).json({ error: 'Skill not installed. Install it first.' });
816
+ }
817
+ const skills = agent.skills || [];
818
+ if (skills.includes(skillId)) {
819
+ return res.json({ id: agent.id, skills });
820
+ }
821
+ daemon.registry.update(agent.id, { skills: [...skills, skillId] });
822
+ daemon.audit.log('skill.attach', { agentId: agent.id, skillId });
823
+ res.json({ id: agent.id, skills: [...skills, skillId] });
824
+ });
825
+
826
+ app.delete('/api/agents/:agentId/skills/:skillId', (req, res) => {
827
+ const agent = daemon.registry.get(req.params.agentId);
828
+ if (!agent) return res.status(404).json({ error: 'Agent not found' });
829
+ const skills = (agent.skills || []).filter((s) => s !== req.params.skillId);
830
+ daemon.registry.update(agent.id, { skills });
831
+ daemon.audit.log('skill.detach', { agentId: agent.id, skillId: req.params.skillId });
832
+ res.json({ id: agent.id, skills });
833
+ });
834
+
835
+ // --- Agent Repos (attach/detach) ---
836
+
837
+ app.post('/api/agents/:agentId/repos/:importId', (req, res) => {
838
+ const agent = daemon.registry.get(req.params.agentId);
839
+ if (!agent) return res.status(404).json({ error: 'Agent not found' });
840
+ const importId = req.params.importId;
841
+ const manifest = daemon.repoImporter.getImport(importId);
842
+ if (!manifest || manifest.status !== 'active') {
843
+ return res.status(400).json({ error: 'Repo not found or not active' });
844
+ }
845
+ const repos = agent.repos || [];
846
+ if (repos.includes(importId)) {
847
+ return res.json({ id: agent.id, repos });
848
+ }
849
+ daemon.registry.update(agent.id, { repos: [...repos, importId] });
850
+ daemon.audit.log('repo.attach', { agentId: agent.id, importId });
851
+ res.json({ id: agent.id, repos: [...repos, importId] });
852
+ });
853
+
854
+ app.delete('/api/agents/:agentId/repos/:importId', (req, res) => {
855
+ const agent = daemon.registry.get(req.params.agentId);
856
+ if (!agent) return res.status(404).json({ error: 'Agent not found' });
857
+ const repos = (agent.repos || []).filter((r) => r !== req.params.importId);
858
+ daemon.registry.update(agent.id, { repos });
859
+ daemon.audit.log('repo.detach', { agentId: agent.id, importId: req.params.importId });
860
+ res.json({ id: agent.id, repos });
861
+ });
862
+
863
+ // --- Agent Integrations (attach/detach) ---
864
+
865
+ app.post('/api/agents/:agentId/integrations/:integrationId', (req, res) => {
866
+ const agent = daemon.registry.get(req.params.agentId);
867
+ if (!agent) return res.status(404).json({ error: 'Agent not found' });
868
+ const integrationId = req.params.integrationId;
869
+ if (!daemon.integrations._isInstalled(integrationId)) {
870
+ return res.status(400).json({ error: 'Integration not installed. Install it first.' });
871
+ }
872
+ const integrations = agent.integrations || [];
873
+ if (integrations.includes(integrationId)) {
874
+ return res.json({ id: agent.id, integrations });
875
+ }
876
+ daemon.registry.update(agent.id, { integrations: [...integrations, integrationId] });
877
+ daemon.audit.log('integration.attach', { agentId: agent.id, integrationId });
878
+ res.json({ id: agent.id, integrations: [...integrations, integrationId] });
879
+ });
880
+
881
+ app.delete('/api/agents/:agentId/integrations/:integrationId', (req, res) => {
882
+ const agent = daemon.registry.get(req.params.agentId);
883
+ if (!agent) return res.status(404).json({ error: 'Agent not found' });
884
+ const integrations = (agent.integrations || []).filter((s) => s !== req.params.integrationId);
885
+ daemon.registry.update(agent.id, { integrations });
886
+ daemon.audit.log('integration.detach', { agentId: agent.id, integrationId: req.params.integrationId });
887
+ res.json({ id: agent.id, integrations });
888
+ });
889
+ }