groove-dev 0.27.14 → 0.27.17
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.
- package/README.md +37 -1
- package/developerID_application.cer +0 -0
- package/node_modules/@groove-dev/daemon/src/api.js +587 -68
- package/node_modules/@groove-dev/daemon/src/classifier.js +24 -0
- package/node_modules/@groove-dev/daemon/src/credentials.js +12 -2
- package/node_modules/@groove-dev/daemon/src/federation/ambassador.js +204 -0
- package/node_modules/@groove-dev/daemon/src/federation/connection.js +359 -0
- package/node_modules/@groove-dev/daemon/src/federation/contracts.js +112 -0
- package/node_modules/@groove-dev/daemon/src/federation/whitelist.js +190 -0
- package/node_modules/@groove-dev/daemon/src/federation.js +166 -7
- package/node_modules/@groove-dev/daemon/src/index.js +172 -19
- package/node_modules/@groove-dev/daemon/src/introducer.js +52 -7
- package/node_modules/@groove-dev/daemon/src/journalist.js +46 -1
- package/node_modules/@groove-dev/daemon/src/memory.js +36 -16
- package/node_modules/@groove-dev/daemon/src/process.js +140 -23
- package/node_modules/@groove-dev/daemon/src/providers/base.js +1 -0
- package/node_modules/@groove-dev/daemon/src/providers/claude-code.js +1 -0
- package/node_modules/@groove-dev/daemon/src/providers/codex.js +124 -28
- package/node_modules/@groove-dev/daemon/src/providers/gemini.js +104 -17
- package/node_modules/@groove-dev/daemon/src/providers/index.js +17 -0
- package/node_modules/@groove-dev/daemon/src/registry.js +10 -1
- package/node_modules/@groove-dev/daemon/src/rotator.js +93 -30
- package/node_modules/@groove-dev/daemon/src/skills.js +33 -3
- package/node_modules/@groove-dev/daemon/src/terminal-pty.js +9 -1
- package/node_modules/@groove-dev/daemon/src/tool-executor.js +11 -5
- package/node_modules/@groove-dev/daemon/src/toys.js +69 -0
- package/node_modules/@groove-dev/daemon/src/tunnel-manager.js +24 -5
- package/node_modules/@groove-dev/daemon/templates/toys-catalog.json +242 -0
- package/node_modules/@groove-dev/daemon/test/classifier.test.js +98 -0
- package/node_modules/@groove-dev/daemon/test/introducer.test.js +72 -1
- package/node_modules/@groove-dev/daemon/test/journalist.test.js +117 -0
- package/node_modules/@groove-dev/daemon/test/memory.test.js +37 -1
- package/node_modules/@groove-dev/daemon/test/rotator.test.js +183 -4
- package/node_modules/@groove-dev/gui/dist/assets/index-BglPgjlu.js +8607 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-CGcwmmJv.css +1 -0
- package/node_modules/@groove-dev/gui/dist/index.html +3 -2
- package/node_modules/@groove-dev/gui/index.html +1 -0
- package/node_modules/@groove-dev/gui/src/app.css +7 -0
- package/node_modules/@groove-dev/gui/src/app.jsx +37 -10
- package/node_modules/@groove-dev/gui/src/components/agents/agent-chat.jsx +21 -31
- package/node_modules/@groove-dev/gui/src/components/agents/agent-config.jsx +11 -6
- package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +2 -2
- package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +42 -1
- package/node_modules/@groove-dev/gui/src/components/editor/breadcrumbs.jsx +30 -0
- package/node_modules/@groove-dev/gui/src/components/editor/code-editor.jsx +33 -2
- package/node_modules/@groove-dev/gui/src/components/editor/editor-status-bar.jsx +26 -0
- package/node_modules/@groove-dev/gui/src/components/editor/editor-tabs.jsx +113 -34
- package/node_modules/@groove-dev/gui/src/components/editor/goto-line.jsx +35 -0
- package/node_modules/@groove-dev/gui/src/components/editor/terminal.jsx +12 -6
- package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +13 -3
- package/node_modules/@groove-dev/gui/src/components/layout/app-shell.jsx +0 -1
- package/node_modules/@groove-dev/gui/src/components/layout/breadcrumb-bar.jsx +165 -47
- package/node_modules/@groove-dev/gui/src/components/layout/command-palette.jsx +6 -2
- package/node_modules/@groove-dev/gui/src/components/layout/terminal-panel.jsx +10 -9
- package/node_modules/@groove-dev/gui/src/components/marketplace/repo-import.jsx +9 -1
- package/node_modules/@groove-dev/gui/src/components/onboarding/provider-card.jsx +134 -0
- package/node_modules/@groove-dev/gui/src/components/onboarding/setup-wizard.jsx +819 -0
- package/node_modules/@groove-dev/gui/src/components/pro/pro-gate.jsx +12 -5
- package/node_modules/@groove-dev/gui/src/components/pro/upgrade-card.jsx +15 -8
- package/node_modules/@groove-dev/gui/src/components/pro/upgrade-modal.jsx +151 -0
- package/node_modules/@groove-dev/gui/src/components/settings/federation-activity.jsx +98 -0
- package/node_modules/@groove-dev/gui/src/components/settings/federation-panel.jsx +290 -0
- package/node_modules/@groove-dev/gui/src/components/settings/federation-peers.jsx +126 -0
- package/node_modules/@groove-dev/gui/src/components/settings/federation-wizard.jsx +293 -0
- package/node_modules/@groove-dev/gui/src/components/settings/quick-connect.jsx +110 -67
- package/node_modules/@groove-dev/gui/src/components/settings/remote-server-card.jsx +3 -3
- package/node_modules/@groove-dev/gui/src/components/settings/server-detail.jsx +310 -0
- package/node_modules/@groove-dev/gui/src/components/settings/server-dialog.jsx +4 -1
- package/node_modules/@groove-dev/gui/src/components/settings/server-list.jsx +59 -0
- package/node_modules/@groove-dev/gui/src/components/settings/ssh-wizard.jsx +549 -0
- package/node_modules/@groove-dev/gui/src/components/toys/toy-card.jsx +78 -0
- package/node_modules/@groove-dev/gui/src/components/toys/toy-creator.jsx +144 -0
- package/node_modules/@groove-dev/gui/src/components/toys/toy-launcher.jsx +187 -0
- package/node_modules/@groove-dev/gui/src/components/ui/toast.jsx +2 -2
- package/node_modules/@groove-dev/gui/src/lib/electron.js +15 -0
- package/node_modules/@groove-dev/gui/src/lib/format.js +1 -0
- package/node_modules/@groove-dev/gui/src/stores/groove.js +373 -58
- package/node_modules/@groove-dev/gui/src/views/agents.jsx +148 -42
- package/node_modules/@groove-dev/gui/src/views/editor.jsx +92 -2
- package/node_modules/@groove-dev/gui/src/views/federation.jsx +37 -0
- package/node_modules/@groove-dev/gui/src/views/marketplace.jsx +2 -42
- package/node_modules/@groove-dev/gui/src/views/settings.jsx +32 -132
- package/node_modules/@groove-dev/gui/src/views/subscription-panel.jsx +327 -0
- package/node_modules/@groove-dev/gui/src/views/teams.jsx +3 -3
- package/node_modules/@groove-dev/gui/src/views/toys.jsx +162 -0
- package/package.json +1 -1
- package/packages/daemon/src/api.js +587 -68
- package/packages/daemon/src/classifier.js +24 -0
- package/packages/daemon/src/credentials.js +12 -2
- package/packages/daemon/src/federation/ambassador.js +204 -0
- package/packages/daemon/src/federation/connection.js +359 -0
- package/packages/daemon/src/federation/contracts.js +112 -0
- package/packages/daemon/src/federation/whitelist.js +190 -0
- package/packages/daemon/src/federation.js +166 -7
- package/packages/daemon/src/index.js +172 -19
- package/packages/daemon/src/introducer.js +52 -7
- package/packages/daemon/src/journalist.js +46 -1
- package/packages/daemon/src/memory.js +36 -16
- package/packages/daemon/src/process.js +140 -23
- package/packages/daemon/src/providers/base.js +1 -0
- package/packages/daemon/src/providers/claude-code.js +1 -0
- package/packages/daemon/src/providers/codex.js +124 -28
- package/packages/daemon/src/providers/gemini.js +104 -17
- package/packages/daemon/src/providers/index.js +17 -0
- package/packages/daemon/src/registry.js +10 -1
- package/packages/daemon/src/rotator.js +93 -30
- package/packages/daemon/src/skills.js +33 -3
- package/packages/daemon/src/terminal-pty.js +9 -1
- package/packages/daemon/src/tool-executor.js +11 -5
- package/packages/daemon/src/toys.js +69 -0
- package/packages/daemon/src/tunnel-manager.js +24 -5
- package/packages/daemon/templates/toys-catalog.json +242 -0
- package/packages/gui/dist/assets/index-BglPgjlu.js +8607 -0
- package/packages/gui/dist/assets/index-CGcwmmJv.css +1 -0
- package/packages/gui/dist/index.html +3 -2
- package/packages/gui/index.html +1 -0
- package/packages/gui/src/app.css +7 -0
- package/packages/gui/src/app.jsx +37 -10
- package/packages/gui/src/components/agents/agent-chat.jsx +21 -31
- package/packages/gui/src/components/agents/agent-config.jsx +11 -6
- package/packages/gui/src/components/agents/agent-feed.jsx +2 -2
- package/packages/gui/src/components/agents/spawn-wizard.jsx +42 -1
- package/packages/gui/src/components/editor/breadcrumbs.jsx +30 -0
- package/packages/gui/src/components/editor/code-editor.jsx +33 -2
- package/packages/gui/src/components/editor/editor-status-bar.jsx +26 -0
- package/packages/gui/src/components/editor/editor-tabs.jsx +113 -34
- package/packages/gui/src/components/editor/goto-line.jsx +35 -0
- package/packages/gui/src/components/editor/terminal.jsx +12 -6
- package/packages/gui/src/components/layout/activity-bar.jsx +13 -3
- package/packages/gui/src/components/layout/app-shell.jsx +0 -1
- package/packages/gui/src/components/layout/breadcrumb-bar.jsx +165 -47
- package/packages/gui/src/components/layout/command-palette.jsx +6 -2
- package/packages/gui/src/components/layout/terminal-panel.jsx +10 -9
- package/packages/gui/src/components/marketplace/repo-import.jsx +9 -1
- package/packages/gui/src/components/onboarding/provider-card.jsx +134 -0
- package/packages/gui/src/components/onboarding/setup-wizard.jsx +819 -0
- package/packages/gui/src/components/pro/pro-gate.jsx +12 -5
- package/packages/gui/src/components/pro/upgrade-card.jsx +15 -8
- package/packages/gui/src/components/pro/upgrade-modal.jsx +151 -0
- package/packages/gui/src/components/settings/federation-activity.jsx +98 -0
- package/packages/gui/src/components/settings/federation-panel.jsx +290 -0
- package/packages/gui/src/components/settings/federation-peers.jsx +126 -0
- package/packages/gui/src/components/settings/federation-wizard.jsx +293 -0
- package/packages/gui/src/components/settings/quick-connect.jsx +110 -67
- package/packages/gui/src/components/settings/remote-server-card.jsx +3 -3
- package/packages/gui/src/components/settings/server-detail.jsx +310 -0
- package/packages/gui/src/components/settings/server-dialog.jsx +4 -1
- package/packages/gui/src/components/settings/server-list.jsx +59 -0
- package/packages/gui/src/components/settings/ssh-wizard.jsx +549 -0
- package/packages/gui/src/components/toys/toy-card.jsx +78 -0
- package/packages/gui/src/components/toys/toy-creator.jsx +144 -0
- package/packages/gui/src/components/toys/toy-launcher.jsx +187 -0
- package/packages/gui/src/components/ui/toast.jsx +2 -2
- package/packages/gui/src/lib/electron.js +15 -0
- package/packages/gui/src/lib/format.js +1 -0
- package/packages/gui/src/stores/groove.js +373 -58
- package/packages/gui/src/views/agents.jsx +148 -42
- package/packages/gui/src/views/editor.jsx +92 -2
- package/packages/gui/src/views/federation.jsx +37 -0
- package/packages/gui/src/views/marketplace.jsx +2 -42
- package/packages/gui/src/views/settings.jsx +32 -132
- package/packages/gui/src/views/subscription-panel.jsx +327 -0
- package/packages/gui/src/views/teams.jsx +3 -3
- package/packages/gui/src/views/toys.jsx +162 -0
- package/plans/chat-persistence-refactor.md +154 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-BE6lYcd7.css +0 -1
- package/node_modules/@groove-dev/gui/dist/assets/index-zdzOLAZM.js +0 -677
- package/packages/gui/dist/assets/index-BE6lYcd7.css +0 -1
- package/packages/gui/dist/assets/index-zdzOLAZM.js +0 -677
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
import express from 'express';
|
|
5
5
|
import { resolve, dirname } from 'path';
|
|
6
6
|
import { fileURLToPath } from 'url';
|
|
7
|
-
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, mkdirSync, unlinkSync, renameSync, rmSync, createReadStream, copyFileSync } from 'fs';
|
|
7
|
+
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, mkdirSync, unlinkSync, renameSync, rmSync, createReadStream, copyFileSync, realpathSync } from 'fs';
|
|
8
|
+
import { spawn, execFile } from 'child_process';
|
|
8
9
|
import { lookup as mimeLookup } from './mimetypes.js';
|
|
9
10
|
import { listProviders, getProvider } from './providers/index.js';
|
|
10
11
|
import { OllamaProvider } from './providers/ollama.js';
|
|
@@ -13,25 +14,22 @@ import { validateAgentConfig } from './validate.js';
|
|
|
13
14
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
15
|
const isPro = process.env.GROOVE_EDITION === 'pro';
|
|
15
16
|
|
|
16
|
-
let
|
|
17
|
+
let _daemon = null;
|
|
17
18
|
|
|
18
19
|
function proOnly(req, res, next) {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
});
|
|
33
|
-
}
|
|
34
|
-
next();
|
|
20
|
+
const sub = _daemon?.subscriptionCache || {};
|
|
21
|
+
if (isPro || sub.active) return next();
|
|
22
|
+
return res.status(403).json({
|
|
23
|
+
error: 'Pro subscription required',
|
|
24
|
+
edition: 'community',
|
|
25
|
+
plan: sub.plan || 'community',
|
|
26
|
+
subscriptionActive: false,
|
|
27
|
+
upgrade: 'https://groovedev.ai/pro',
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function hasFeature(name) {
|
|
32
|
+
return (_daemon?.subscriptionCache?.features || []).includes(name);
|
|
35
33
|
}
|
|
36
34
|
|
|
37
35
|
async function _executeApprovalRetry(daemon, approval) {
|
|
@@ -64,6 +62,8 @@ async function _executeApprovalRetry(daemon, approval) {
|
|
|
64
62
|
}
|
|
65
63
|
|
|
66
64
|
export function createApi(app, daemon) {
|
|
65
|
+
_daemon = daemon;
|
|
66
|
+
|
|
67
67
|
// CORS — restrict to localhost + bound interface origins
|
|
68
68
|
app.use((req, res, next) => {
|
|
69
69
|
const origin = req.headers.origin;
|
|
@@ -88,6 +88,15 @@ export function createApi(app, daemon) {
|
|
|
88
88
|
next();
|
|
89
89
|
});
|
|
90
90
|
|
|
91
|
+
// Security headers
|
|
92
|
+
app.use((req, res, next) => {
|
|
93
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
94
|
+
res.setHeader('X-Frame-Options', 'DENY');
|
|
95
|
+
res.setHeader('X-XSS-Protection', '0');
|
|
96
|
+
res.setHeader('Content-Security-Policy', "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; connect-src 'self' ws://localhost:* ws://127.0.0.1:* http://localhost:* http://127.0.0.1:*; font-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'");
|
|
97
|
+
next();
|
|
98
|
+
});
|
|
99
|
+
|
|
91
100
|
app.use(express.json({ limit: '6mb' }));
|
|
92
101
|
|
|
93
102
|
// Health check
|
|
@@ -143,22 +152,28 @@ export function createApi(app, daemon) {
|
|
|
143
152
|
await daemon.processes.kill(req.params.id);
|
|
144
153
|
}
|
|
145
154
|
|
|
146
|
-
//
|
|
147
|
-
|
|
155
|
+
// Only purge from registry when explicitly requested.
|
|
156
|
+
// Killed/completed agents stay visible so the user can review output.
|
|
157
|
+
const purge = req.query.purge === 'true';
|
|
158
|
+
if (purge) {
|
|
148
159
|
daemon.registry.remove(req.params.id);
|
|
149
160
|
}
|
|
150
161
|
|
|
151
|
-
daemon.audit.log('agent.kill', { id: agent.id, role: agent.role, purged:
|
|
152
|
-
res.json({ ok: true, purged:
|
|
162
|
+
daemon.audit.log('agent.kill', { id: agent.id, role: agent.role, purged: purge });
|
|
163
|
+
res.json({ ok: true, purged: purge });
|
|
153
164
|
} catch (err) {
|
|
154
165
|
res.status(400).json({ error: err.message });
|
|
155
166
|
}
|
|
156
167
|
});
|
|
157
168
|
|
|
158
|
-
// Kill all agents
|
|
169
|
+
// Kill all agents and purge registry (used by groove nuke)
|
|
159
170
|
app.delete('/api/agents', async (req, res) => {
|
|
160
171
|
const count = daemon.processes.getRunningCount();
|
|
161
172
|
await daemon.processes.killAll();
|
|
173
|
+
// Purge all agents from registry — kill() no longer does this automatically
|
|
174
|
+
for (const agent of daemon.registry.getAll()) {
|
|
175
|
+
daemon.registry.remove(agent.id);
|
|
176
|
+
}
|
|
162
177
|
daemon.audit.log('agent.kill_all', { count });
|
|
163
178
|
res.json({ ok: true });
|
|
164
179
|
});
|
|
@@ -227,11 +242,12 @@ export function createApi(app, daemon) {
|
|
|
227
242
|
res.json({ removed });
|
|
228
243
|
});
|
|
229
244
|
|
|
230
|
-
// Handoff chains (per role)
|
|
245
|
+
// Handoff chains (per role, optionally scoped by workspace)
|
|
231
246
|
app.get('/api/memory/handoff-chain/:role', (req, res) => {
|
|
232
247
|
res.json({
|
|
233
248
|
role: req.params.role,
|
|
234
|
-
|
|
249
|
+
workspace: req.query.workspace || null,
|
|
250
|
+
entries: daemon.memory.getHandoffChain(req.params.role, req.query.workspace),
|
|
235
251
|
});
|
|
236
252
|
});
|
|
237
253
|
|
|
@@ -239,12 +255,13 @@ export function createApi(app, daemon) {
|
|
|
239
255
|
const count = Math.min(parseInt(req.query.count) || 3, 10);
|
|
240
256
|
res.json({
|
|
241
257
|
role: req.params.role,
|
|
242
|
-
|
|
258
|
+
workspace: req.query.workspace || null,
|
|
259
|
+
markdown: daemon.memory.getRecentHandoffMarkdown(req.params.role, count, 10_000, req.query.workspace),
|
|
243
260
|
});
|
|
244
261
|
});
|
|
245
262
|
|
|
246
263
|
app.get('/api/memory/handoff-chain', (req, res) => {
|
|
247
|
-
res.json({ roles: daemon.memory.listHandoffRoles() });
|
|
264
|
+
res.json({ roles: daemon.memory.listHandoffRoles(req.query.workspace) });
|
|
248
265
|
});
|
|
249
266
|
|
|
250
267
|
// Discoveries (error → fix pairs)
|
|
@@ -546,11 +563,22 @@ export function createApi(app, daemon) {
|
|
|
546
563
|
|
|
547
564
|
// Edition
|
|
548
565
|
app.get('/api/edition', (req, res) => {
|
|
549
|
-
|
|
566
|
+
const sub = daemon.subscriptionCache || {};
|
|
567
|
+
res.json({
|
|
568
|
+
edition: (isPro || sub.active) ? 'pro' : 'community',
|
|
569
|
+
plan: sub.plan || 'community',
|
|
570
|
+
subscriptionActive: sub.active || false,
|
|
571
|
+
features: sub.features || [],
|
|
572
|
+
seats: sub.seats || 1,
|
|
573
|
+
periodEnd: sub.periodEnd || null,
|
|
574
|
+
cancelAtPeriodEnd: sub.cancelAtPeriodEnd || false,
|
|
575
|
+
status: sub.status || 'none',
|
|
576
|
+
});
|
|
550
577
|
});
|
|
551
578
|
|
|
552
579
|
// Daemon status
|
|
553
580
|
app.get('/api/status', (req, res) => {
|
|
581
|
+
const sub = daemon.subscriptionCache || {};
|
|
554
582
|
res.json({
|
|
555
583
|
pid: process.pid,
|
|
556
584
|
uptime: process.uptime(),
|
|
@@ -559,7 +587,7 @@ export function createApi(app, daemon) {
|
|
|
559
587
|
host: daemon.host,
|
|
560
588
|
port: daemon.port,
|
|
561
589
|
projectDir: daemon.projectDir,
|
|
562
|
-
edition: isPro ? 'pro' : 'community',
|
|
590
|
+
edition: (isPro || sub.active) ? 'pro' : 'community',
|
|
563
591
|
});
|
|
564
592
|
});
|
|
565
593
|
|
|
@@ -739,8 +767,12 @@ export function createApi(app, daemon) {
|
|
|
739
767
|
// Loop exists but not running — fall through to resume/rotate
|
|
740
768
|
}
|
|
741
769
|
|
|
742
|
-
// CLI agent path — session resume or rotation
|
|
743
|
-
|
|
770
|
+
// CLI agent path — session resume or rotation.
|
|
771
|
+
// Force rotation (fresh session + handoff brief) past the resume ceiling:
|
|
772
|
+
// reviving a >5M-token claude session has crashed the CLI mid-HTTP-parse
|
|
773
|
+
// (V8 fatal in JsonStringifier) — the rotator's handoff brief sidesteps that.
|
|
774
|
+
const SESSION_RESUME_CEILING = 5_000_000;
|
|
775
|
+
const resumed = !!agent.sessionId && (agent.tokensUsed || 0) < SESSION_RESUME_CEILING;
|
|
744
776
|
const newAgent = resumed
|
|
745
777
|
? await daemon.processes.resume(req.params.id, message.trim())
|
|
746
778
|
: await daemon.rotator.rotate(req.params.id, { additionalPrompt: message.trim() });
|
|
@@ -799,8 +831,8 @@ export function createApi(app, daemon) {
|
|
|
799
831
|
const { filename, content } = req.body;
|
|
800
832
|
if (!filename || !content) return res.status(400).json({ error: 'filename and content required' });
|
|
801
833
|
|
|
802
|
-
// Sanitize filename — no path traversal
|
|
803
|
-
const safeName = String(filename).replace(/[
|
|
834
|
+
// Sanitize filename — strict allowlist, no path traversal
|
|
835
|
+
const safeName = String(filename).replace(/[^a-zA-Z0-9._-]/g, '_').replace(/^\.+/, '');
|
|
804
836
|
if (!safeName) return res.status(400).json({ error: 'Invalid filename' });
|
|
805
837
|
|
|
806
838
|
const dir = agent.workingDir || daemon.projectDir;
|
|
@@ -1077,6 +1109,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
1077
1109
|
}
|
|
1078
1110
|
|
|
1079
1111
|
const user = await daemon.skills.setAuth(token);
|
|
1112
|
+
if (user) await daemon.setAuthToken(token);
|
|
1080
1113
|
if (!user) {
|
|
1081
1114
|
return res.send(`<!DOCTYPE html>
|
|
1082
1115
|
<html><head><meta charset="UTF-8"><title>Groove — Login Failed</title>
|
|
@@ -1098,7 +1131,8 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
1098
1131
|
</body></html>`);
|
|
1099
1132
|
}
|
|
1100
1133
|
|
|
1101
|
-
const
|
|
1134
|
+
const rawName = user?.displayName || user?.id || '';
|
|
1135
|
+
const displayName = rawName.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
1102
1136
|
res.send(`<!DOCTYPE html>
|
|
1103
1137
|
<html><head><meta charset="UTF-8"><title>Groove — Signed In</title>
|
|
1104
1138
|
<link rel="icon" href="/favicon.png">
|
|
@@ -1111,28 +1145,28 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
1111
1145
|
h2{font-size:16px;font-weight:600;color:#e6e6e6;margin-bottom:6px}
|
|
1112
1146
|
.user{font-size:13px;color:#33afbc;margin-bottom:16px}
|
|
1113
1147
|
p{font-size:13px;color:#505862;line-height:1.5}
|
|
1114
|
-
.
|
|
1115
|
-
.
|
|
1116
|
-
@keyframes close{from{width:100%}to{width:0%}}
|
|
1148
|
+
.btn{display:inline-block;margin-top:20px;padding:8px 20px;font-size:13px;font-weight:500;color:#e6e6e6;background:#2c313a;border:1px solid #3a3f48;border-radius:999px;cursor:pointer;transition:background 0.15s}
|
|
1149
|
+
.btn:hover{background:#33afbc;border-color:#33afbc;color:#1a1e25}
|
|
1117
1150
|
</style>
|
|
1118
1151
|
</head><body>
|
|
1119
1152
|
<div class="card">
|
|
1120
1153
|
<div class="logo"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg></div>
|
|
1121
1154
|
<h2>Connected to Groove</h2>
|
|
1122
1155
|
${displayName ? `<div class="user">${displayName}</div>` : ''}
|
|
1123
|
-
<p>
|
|
1124
|
-
<
|
|
1156
|
+
<p id="msg">You can close this tab and return to Groove.</p>
|
|
1157
|
+
<button class="btn" onclick="window.close()">Close tab</button>
|
|
1125
1158
|
</div>
|
|
1126
|
-
<script>setTimeout(()=>window.close(),3000)</script>
|
|
1159
|
+
<script>setTimeout(()=>{try{window.close()}catch(e){}setTimeout(()=>{document.getElementById('msg').textContent='Return to the Groove app to continue.'},500)},3000)</script>
|
|
1127
1160
|
</body></html>`);
|
|
1128
1161
|
});
|
|
1129
1162
|
|
|
1130
|
-
// Auth status — returns current user or { authenticated: false }
|
|
1163
|
+
// Auth status — returns current user + subscription or { authenticated: false }
|
|
1131
1164
|
app.get('/api/auth/status', async (req, res) => {
|
|
1132
1165
|
const user = daemon.skills.getUser();
|
|
1133
1166
|
const token = daemon.skills.getToken();
|
|
1134
1167
|
if (!user || !token) return res.json({ authenticated: false });
|
|
1135
|
-
|
|
1168
|
+
const sub = daemon.subscriptionCache || {};
|
|
1169
|
+
res.json({ authenticated: true, user, subscription: sub });
|
|
1136
1170
|
});
|
|
1137
1171
|
|
|
1138
1172
|
// Validate stored token (hits remote API)
|
|
@@ -1177,6 +1211,80 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
1177
1211
|
}
|
|
1178
1212
|
});
|
|
1179
1213
|
|
|
1214
|
+
// --- Subscription ---
|
|
1215
|
+
|
|
1216
|
+
const SUB_API = 'https://docs.groovedev.ai/api/v1';
|
|
1217
|
+
|
|
1218
|
+
app.get('/api/subscription/plans', async (req, res) => {
|
|
1219
|
+
try {
|
|
1220
|
+
const resp = await fetch(`${SUB_API}/subscription/plans`);
|
|
1221
|
+
const data = await resp.json();
|
|
1222
|
+
res.json(data);
|
|
1223
|
+
} catch (err) {
|
|
1224
|
+
res.status(502).json({ error: 'Failed to fetch plans', message: err.message });
|
|
1225
|
+
}
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
app.get('/api/subscription/status', (req, res) => {
|
|
1229
|
+
const sub = daemon.subscriptionCache || {};
|
|
1230
|
+
res.json(sub);
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
app.post('/api/subscription/checkout', async (req, res) => {
|
|
1234
|
+
if (!daemon.authToken) return res.status(401).json({ error: 'Not authenticated' });
|
|
1235
|
+
const { priceId } = req.body;
|
|
1236
|
+
if (!priceId || typeof priceId !== 'string') return res.status(400).json({ error: 'priceId required' });
|
|
1237
|
+
try {
|
|
1238
|
+
const resp = await fetch(`${SUB_API}/subscription/checkout`, {
|
|
1239
|
+
method: 'POST',
|
|
1240
|
+
headers: { 'Authorization': `Bearer ${daemon.authToken}`, 'Content-Type': 'application/json' },
|
|
1241
|
+
body: JSON.stringify({ priceId }),
|
|
1242
|
+
});
|
|
1243
|
+
const data = await resp.json();
|
|
1244
|
+
if (!resp.ok) return res.status(resp.status).json(data);
|
|
1245
|
+
res.json(data);
|
|
1246
|
+
} catch (err) {
|
|
1247
|
+
res.status(502).json({ error: 'Checkout failed', message: err.message });
|
|
1248
|
+
}
|
|
1249
|
+
});
|
|
1250
|
+
|
|
1251
|
+
app.post('/api/subscription/portal', async (req, res) => {
|
|
1252
|
+
if (!daemon.authToken) return res.status(401).json({ error: 'Not authenticated' });
|
|
1253
|
+
try {
|
|
1254
|
+
const resp = await fetch(`${SUB_API}/subscription/portal`, {
|
|
1255
|
+
method: 'POST',
|
|
1256
|
+
headers: { 'Authorization': `Bearer ${daemon.authToken}`, 'Content-Type': 'application/json' },
|
|
1257
|
+
body: '{}',
|
|
1258
|
+
});
|
|
1259
|
+
const data = await resp.json();
|
|
1260
|
+
if (!resp.ok) return res.status(resp.status).json(data);
|
|
1261
|
+
res.json(data);
|
|
1262
|
+
} catch (err) {
|
|
1263
|
+
res.status(502).json({ error: 'Portal failed', message: err.message });
|
|
1264
|
+
}
|
|
1265
|
+
});
|
|
1266
|
+
|
|
1267
|
+
app.patch('/api/subscription', async (req, res) => {
|
|
1268
|
+
if (!daemon.authToken) return res.status(401).json({ error: 'Not authenticated' });
|
|
1269
|
+
const { seats } = req.body;
|
|
1270
|
+
if (!seats || typeof seats !== 'number' || seats < 1 || seats > 999 || !Number.isInteger(seats)) {
|
|
1271
|
+
return res.status(400).json({ error: 'seats must be integer 1-999' });
|
|
1272
|
+
}
|
|
1273
|
+
try {
|
|
1274
|
+
const resp = await fetch(`${SUB_API}/subscription`, {
|
|
1275
|
+
method: 'PATCH',
|
|
1276
|
+
headers: { 'Authorization': `Bearer ${daemon.authToken}`, 'Content-Type': 'application/json' },
|
|
1277
|
+
body: JSON.stringify({ seats }),
|
|
1278
|
+
});
|
|
1279
|
+
const data = await resp.json();
|
|
1280
|
+
if (!resp.ok) return res.status(resp.status).json(data);
|
|
1281
|
+
daemon._pollSubscription();
|
|
1282
|
+
res.json(data);
|
|
1283
|
+
} catch (err) {
|
|
1284
|
+
res.status(502).json({ error: 'Seat update failed', message: err.message });
|
|
1285
|
+
}
|
|
1286
|
+
});
|
|
1287
|
+
|
|
1180
1288
|
// --- Skills Marketplace ---
|
|
1181
1289
|
|
|
1182
1290
|
app.get('/api/skills/registry', async (req, res) => {
|
|
@@ -1900,6 +2008,16 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
1900
2008
|
}
|
|
1901
2009
|
const fullPath = resolve(projectDir, relPath);
|
|
1902
2010
|
if (!fullPath.startsWith(projectDir)) return { error: 'Path outside project' };
|
|
2011
|
+
// Symlink resolution — ensure real path is also within project
|
|
2012
|
+
try {
|
|
2013
|
+
const realPath = realpathSync(fullPath);
|
|
2014
|
+
const realBase = realpathSync(projectDir);
|
|
2015
|
+
if (!realPath.startsWith(realBase)) {
|
|
2016
|
+
return { error: 'Path outside project (symlink)' };
|
|
2017
|
+
}
|
|
2018
|
+
} catch {
|
|
2019
|
+
// File may not exist yet (for writes) — path prefix check is sufficient
|
|
2020
|
+
}
|
|
1903
2021
|
return { fullPath };
|
|
1904
2022
|
}
|
|
1905
2023
|
|
|
@@ -2160,6 +2278,88 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
2160
2278
|
}
|
|
2161
2279
|
});
|
|
2162
2280
|
|
|
2281
|
+
// Git status — returns modified/added/deleted/untracked files
|
|
2282
|
+
app.get('/api/files/git-status', (req, res) => {
|
|
2283
|
+
const rootDir = getEditorRoot();
|
|
2284
|
+
if (!rootDir) return res.status(400).json({ error: 'Editor root not set' });
|
|
2285
|
+
|
|
2286
|
+
execFile('git', ['status', '--porcelain'], { cwd: rootDir, timeout: 10000 }, (err, stdout) => {
|
|
2287
|
+
if (err) {
|
|
2288
|
+
// Not a git repo or git not installed — return empty
|
|
2289
|
+
return res.json({ entries: [] });
|
|
2290
|
+
}
|
|
2291
|
+
const STATUS_MAP = { 'M': 'M', 'A': 'A', '?': '?', 'D': 'D', 'R': 'R', 'U': 'U' };
|
|
2292
|
+
const entries = [];
|
|
2293
|
+
for (const line of stdout.split('\n')) {
|
|
2294
|
+
if (!line.trim()) continue;
|
|
2295
|
+
const code = line[0] === ' ' ? line[1] : line[0];
|
|
2296
|
+
const filePath = line.slice(3).trim();
|
|
2297
|
+
if (!filePath) continue;
|
|
2298
|
+
entries.push({ path: filePath, status: STATUS_MAP[code] || code });
|
|
2299
|
+
}
|
|
2300
|
+
res.json({ entries });
|
|
2301
|
+
});
|
|
2302
|
+
});
|
|
2303
|
+
|
|
2304
|
+
// Git branch — returns the current branch name
|
|
2305
|
+
app.get('/api/files/git-branch', (req, res) => {
|
|
2306
|
+
const rootDir = getEditorRoot();
|
|
2307
|
+
if (!rootDir) return res.status(400).json({ error: 'Editor root not set' });
|
|
2308
|
+
|
|
2309
|
+
execFile('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: rootDir, timeout: 5000 }, (err, stdout) => {
|
|
2310
|
+
if (err) {
|
|
2311
|
+
return res.json({ branch: null });
|
|
2312
|
+
}
|
|
2313
|
+
res.json({ branch: stdout.trim() });
|
|
2314
|
+
});
|
|
2315
|
+
});
|
|
2316
|
+
|
|
2317
|
+
// File search — fuzzy filename matching for quick-open (Ctrl+P)
|
|
2318
|
+
app.get('/api/files/search', (req, res) => {
|
|
2319
|
+
const query = req.query.q;
|
|
2320
|
+
if (!query || typeof query !== 'string') return res.status(400).json({ error: 'q parameter is required' });
|
|
2321
|
+
if (query.length > 200) return res.status(400).json({ error: 'Query too long' });
|
|
2322
|
+
|
|
2323
|
+
const maxResults = Math.min(parseInt(req.query.maxResults, 10) || 50, 200);
|
|
2324
|
+
const rootDir = getEditorRoot();
|
|
2325
|
+
if (!rootDir) return res.status(400).json({ error: 'Editor root not set' });
|
|
2326
|
+
|
|
2327
|
+
const lowerQuery = query.toLowerCase();
|
|
2328
|
+
const results = [];
|
|
2329
|
+
|
|
2330
|
+
function fuzzyMatch(name) {
|
|
2331
|
+
const lower = name.toLowerCase();
|
|
2332
|
+
let qi = 0;
|
|
2333
|
+
for (let i = 0; i < lower.length && qi < lowerQuery.length; i++) {
|
|
2334
|
+
if (lower[i] === lowerQuery[qi]) qi++;
|
|
2335
|
+
}
|
|
2336
|
+
return qi === lowerQuery.length;
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
function walk(dir, rel) {
|
|
2340
|
+
if (results.length >= maxResults) return;
|
|
2341
|
+
let entries;
|
|
2342
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
2343
|
+
for (const entry of entries) {
|
|
2344
|
+
if (results.length >= maxResults) return;
|
|
2345
|
+
if (IGNORED_NAMES.has(entry.name) || entry.name.startsWith('.')) continue;
|
|
2346
|
+
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
|
|
2347
|
+
if (entry.isDirectory()) {
|
|
2348
|
+
walk(resolve(dir, entry.name), childRel);
|
|
2349
|
+
} else if (entry.isFile() && fuzzyMatch(entry.name)) {
|
|
2350
|
+
results.push({ path: childRel, name: entry.name });
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
try {
|
|
2356
|
+
walk(rootDir, '');
|
|
2357
|
+
res.json({ files: results });
|
|
2358
|
+
} catch (err) {
|
|
2359
|
+
res.status(500).json({ error: err.message });
|
|
2360
|
+
}
|
|
2361
|
+
});
|
|
2362
|
+
|
|
2163
2363
|
// --- Codebase Indexer ---
|
|
2164
2364
|
|
|
2165
2365
|
app.get('/api/indexer', (req, res) => {
|
|
@@ -2208,19 +2408,27 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
2208
2408
|
|
|
2209
2409
|
// --- Recommended Team (from planner) ---
|
|
2210
2410
|
|
|
2211
|
-
// Find recommended-team.json — check
|
|
2411
|
+
// Find recommended-team.json — check planner agents first (they write the file),
|
|
2412
|
+
// sorted by most recent activity so the latest planner's team wins.
|
|
2212
2413
|
function findRecommendedTeam() {
|
|
2213
|
-
// Check agent working dirs first (planner may have written there)
|
|
2214
2414
|
const agents = daemon.registry.getAll();
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2415
|
+
const planners = agents
|
|
2416
|
+
.filter((a) => a.role === 'planner' && a.workingDir)
|
|
2417
|
+
.sort((a, b) => (b.lastActivity || b.spawnedAt || '').localeCompare(a.lastActivity || a.spawnedAt || ''));
|
|
2418
|
+
|
|
2419
|
+
// Check planner workingDirs first — most recently active planner wins
|
|
2420
|
+
for (const planner of planners) {
|
|
2421
|
+
const p = resolve(planner.workingDir, '.groove', 'recommended-team.json');
|
|
2422
|
+
if (existsSync(p)) return { path: p, teamId: planner.teamId || null, agentId: planner.id || null };
|
|
2220
2423
|
}
|
|
2221
|
-
|
|
2424
|
+
|
|
2425
|
+
// Fallback to daemon's .groove dir — try to attribute to most recent planner
|
|
2222
2426
|
const p = resolve(daemon.grooveDir, 'recommended-team.json');
|
|
2223
|
-
if (existsSync(p))
|
|
2427
|
+
if (existsSync(p)) {
|
|
2428
|
+
const fallbackTeamId = planners[0]?.teamId || null;
|
|
2429
|
+
const fallbackAgentId = planners[0]?.id || null;
|
|
2430
|
+
return { path: p, teamId: fallbackTeamId, agentId: fallbackAgentId };
|
|
2431
|
+
}
|
|
2224
2432
|
return null;
|
|
2225
2433
|
}
|
|
2226
2434
|
|
|
@@ -2252,6 +2460,9 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
2252
2460
|
try {
|
|
2253
2461
|
const raw = JSON.parse(readFileSync(found.path, 'utf8'));
|
|
2254
2462
|
|
|
2463
|
+
// Delete immediately after reading to prevent duplicate launches from poll races
|
|
2464
|
+
try { unlinkSync(found.path); } catch { /* already gone */ }
|
|
2465
|
+
|
|
2255
2466
|
// Support both old format (bare array) and new format ({ projectDir, agents })
|
|
2256
2467
|
let agentConfigs;
|
|
2257
2468
|
let projectDir = null;
|
|
@@ -2268,7 +2479,9 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
2268
2479
|
return res.status(400).json({ error: 'Recommended team is empty' });
|
|
2269
2480
|
}
|
|
2270
2481
|
|
|
2271
|
-
|
|
2482
|
+
// Resolve base directory from the planner that wrote the file, not the daemon root
|
|
2483
|
+
const plannerAgent = found.agentId ? daemon.registry.get(found.agentId) : null;
|
|
2484
|
+
const baseDir = plannerAgent?.workingDir || daemon.config?.defaultWorkingDir || daemon.projectDir;
|
|
2272
2485
|
|
|
2273
2486
|
// Use the planner's teamId so launched agents join the correct team.
|
|
2274
2487
|
// Priority: explicit from frontend > agent that wrote the file > most recent planner > default
|
|
@@ -2304,6 +2517,13 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
2304
2517
|
}];
|
|
2305
2518
|
}
|
|
2306
2519
|
|
|
2520
|
+
// Reset handoff cycle counters for this team so a fresh launch starts clean
|
|
2521
|
+
if (daemon._handoffCounts) {
|
|
2522
|
+
for (const key of [...daemon._handoffCounts.keys()]) {
|
|
2523
|
+
if (key.startsWith(`${defaultTeamId}:`)) daemon._handoffCounts.delete(key);
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2307
2527
|
// Spawn phase 1 agents — reuse idle team members with matching roles when possible
|
|
2308
2528
|
const spawned = [];
|
|
2309
2529
|
const reused = [];
|
|
@@ -2592,7 +2812,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
2592
2812
|
|
|
2593
2813
|
// --- Federation ---
|
|
2594
2814
|
|
|
2595
|
-
// Federation status
|
|
2815
|
+
// Federation status (v1 — includes whitelist, connections, ambassadors)
|
|
2596
2816
|
app.get('/api/federation', proOnly, (req, res) => {
|
|
2597
2817
|
res.json(daemon.federation.getStatus());
|
|
2598
2818
|
});
|
|
@@ -2602,12 +2822,22 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
2602
2822
|
res.json(daemon.federation.getPeers());
|
|
2603
2823
|
});
|
|
2604
2824
|
|
|
2605
|
-
//
|
|
2825
|
+
// Unpair a peer
|
|
2826
|
+
app.delete('/api/federation/peers/:id', proOnly, (req, res) => {
|
|
2827
|
+
try {
|
|
2828
|
+
daemon.federation.unpair(req.params.id);
|
|
2829
|
+
res.json({ ok: true });
|
|
2830
|
+
} catch (err) {
|
|
2831
|
+
res.status(400).json({ error: err.message });
|
|
2832
|
+
}
|
|
2833
|
+
});
|
|
2834
|
+
|
|
2835
|
+
// Initiate pairing with a remote daemon
|
|
2606
2836
|
app.post('/api/federation/initiate', proOnly, async (req, res) => {
|
|
2607
2837
|
try {
|
|
2608
2838
|
const { remoteUrl } = req.body;
|
|
2609
2839
|
if (!remoteUrl || typeof remoteUrl !== 'string') {
|
|
2610
|
-
return res.status(400).json({ error: 'remoteUrl is required' });
|
|
2840
|
+
return res.status(400).json({ error: 'remoteUrl is required (string)' });
|
|
2611
2841
|
}
|
|
2612
2842
|
const result = await daemon.federation.initiatePairing(remoteUrl);
|
|
2613
2843
|
res.json(result);
|
|
@@ -2616,29 +2846,128 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
2616
2846
|
}
|
|
2617
2847
|
});
|
|
2618
2848
|
|
|
2619
|
-
//
|
|
2620
|
-
|
|
2849
|
+
// --- Federation v1: Whitelist ---
|
|
2850
|
+
|
|
2851
|
+
app.get('/api/federation/whitelist', proOnly, (req, res) => {
|
|
2852
|
+
res.json(daemon.federation.whitelist?.list() || []);
|
|
2853
|
+
});
|
|
2854
|
+
|
|
2855
|
+
app.post('/api/federation/whitelist', proOnly, (req, res) => {
|
|
2621
2856
|
try {
|
|
2622
|
-
const
|
|
2623
|
-
|
|
2857
|
+
const { ip, port, name } = req.body;
|
|
2858
|
+
if (!ip || typeof ip !== 'string') {
|
|
2859
|
+
return res.status(400).json({ error: 'ip is required (string)' });
|
|
2860
|
+
}
|
|
2861
|
+
const entry = daemon.federation.whitelist.add(ip, port, name);
|
|
2862
|
+
daemon.broadcast({ type: 'federation:whitelist', data: daemon.federation.whitelist.list() });
|
|
2863
|
+
res.json(entry);
|
|
2624
2864
|
} catch (err) {
|
|
2625
2865
|
res.status(400).json({ error: err.message });
|
|
2626
2866
|
}
|
|
2627
2867
|
});
|
|
2628
2868
|
|
|
2629
|
-
|
|
2630
|
-
app.delete('/api/federation/peers/:id', proOnly, (req, res) => {
|
|
2869
|
+
app.delete('/api/federation/whitelist/:ip', proOnly, (req, res) => {
|
|
2631
2870
|
try {
|
|
2632
|
-
daemon.federation.
|
|
2871
|
+
daemon.federation.whitelist.remove(req.params.ip);
|
|
2872
|
+
daemon.broadcast({ type: 'federation:whitelist', data: daemon.federation.whitelist.list() });
|
|
2633
2873
|
res.json({ ok: true });
|
|
2634
2874
|
} catch (err) {
|
|
2635
2875
|
res.status(400).json({ error: err.message });
|
|
2636
2876
|
}
|
|
2637
2877
|
});
|
|
2638
2878
|
|
|
2639
|
-
//
|
|
2640
|
-
app.
|
|
2879
|
+
// Probe endpoint — remote daemons hit this to check if they are whitelisted
|
|
2880
|
+
app.get('/api/federation/whitelist-check', (req, res) => {
|
|
2881
|
+
const ip = req.ip?.replace('::ffff:', '') || req.socket?.remoteAddress?.replace('::ffff:', '') || '';
|
|
2882
|
+
const whitelisted = daemon.federation.isWhitelisted(ip);
|
|
2883
|
+
res.json({
|
|
2884
|
+
whitelisted,
|
|
2885
|
+
...(whitelisted ? { daemonId: daemon.federation._daemonId() } : {}),
|
|
2886
|
+
});
|
|
2887
|
+
});
|
|
2888
|
+
|
|
2889
|
+
// --- Federation v1: Knock ---
|
|
2890
|
+
|
|
2891
|
+
app.post('/api/federation/knock', (req, res) => {
|
|
2641
2892
|
try {
|
|
2893
|
+
const callerIp = req.ip?.replace('::ffff:', '') || req.socket?.remoteAddress?.replace('::ffff:', '') || '';
|
|
2894
|
+
const { senderId, publicKey, payload, signature } = req.body;
|
|
2895
|
+
if (!senderId || !publicKey || !payload || !signature) {
|
|
2896
|
+
return res.status(400).json({ error: 'senderId, publicKey, payload, and signature are required' });
|
|
2897
|
+
}
|
|
2898
|
+
const result = daemon.federation.handleKnock(senderId, publicKey, payload, signature, callerIp);
|
|
2899
|
+
res.json(result);
|
|
2900
|
+
} catch (err) {
|
|
2901
|
+
res.status(403).json({ error: err.message });
|
|
2902
|
+
}
|
|
2903
|
+
});
|
|
2904
|
+
|
|
2905
|
+
// --- Federation v1: Connections ---
|
|
2906
|
+
|
|
2907
|
+
app.get('/api/federation/connections', proOnly, (req, res) => {
|
|
2908
|
+
res.json(daemon.federation.connections?.getStatus() || []);
|
|
2909
|
+
});
|
|
2910
|
+
|
|
2911
|
+
// --- Federation v1: Diplomatic Pouch ---
|
|
2912
|
+
|
|
2913
|
+
app.post('/api/federation/pouch', (req, res) => {
|
|
2914
|
+
try {
|
|
2915
|
+
const callerIp = req.ip?.replace('::ffff:', '') || req.socket?.remoteAddress?.replace('::ffff:', '') || '';
|
|
2916
|
+
if (!callerIp || !daemon.federation.isWhitelisted(callerIp)) {
|
|
2917
|
+
return res.status(403).json({ error: 'Caller IP not whitelisted' });
|
|
2918
|
+
}
|
|
2919
|
+
const { senderId, payload, signature } = req.body;
|
|
2920
|
+
if (!senderId || !payload || !signature) {
|
|
2921
|
+
return res.status(400).json({ error: 'senderId, payload, and signature are required' });
|
|
2922
|
+
}
|
|
2923
|
+
const result = daemon.federation.ambassadors.receivePouch(senderId, payload, signature);
|
|
2924
|
+
res.json(result);
|
|
2925
|
+
} catch (err) {
|
|
2926
|
+
res.status(403).json({ error: err.message });
|
|
2927
|
+
}
|
|
2928
|
+
});
|
|
2929
|
+
|
|
2930
|
+
app.get('/api/federation/pouch/log', proOnly, (req, res) => {
|
|
2931
|
+
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
|
|
2932
|
+
res.json(daemon.federation.ambassadors?.getPouchLog(limit) || []);
|
|
2933
|
+
});
|
|
2934
|
+
|
|
2935
|
+
// Send a pouch message to a peer (local agents/GUI call this)
|
|
2936
|
+
app.post('/api/federation/pouch/send', proOnly, async (req, res) => {
|
|
2937
|
+
try {
|
|
2938
|
+
const { peerId, contract } = req.body;
|
|
2939
|
+
if (!peerId || !contract) {
|
|
2940
|
+
return res.status(400).json({ error: 'peerId and contract are required' });
|
|
2941
|
+
}
|
|
2942
|
+
const result = await daemon.federation.ambassadors.sendPouch(peerId, contract);
|
|
2943
|
+
res.json(result);
|
|
2944
|
+
} catch (err) {
|
|
2945
|
+
res.status(400).json({ error: err.message });
|
|
2946
|
+
}
|
|
2947
|
+
});
|
|
2948
|
+
|
|
2949
|
+
// Accept incoming pairing request from a remote daemon
|
|
2950
|
+
app.post('/api/federation/pair', (req, res) => {
|
|
2951
|
+
try {
|
|
2952
|
+
const callerIp = req.ip?.replace('::ffff:', '') || req.socket?.remoteAddress?.replace('::ffff:', '') || '';
|
|
2953
|
+
const { id, name, port, publicKey } = req.body;
|
|
2954
|
+
if (!id || !publicKey) {
|
|
2955
|
+
return res.status(400).json({ error: 'id and publicKey are required' });
|
|
2956
|
+
}
|
|
2957
|
+
const result = daemon.federation.acceptPairing({ id, name, port, publicKey }, callerIp);
|
|
2958
|
+
res.json(result);
|
|
2959
|
+
} catch (err) {
|
|
2960
|
+
res.status(403).json({ error: err.message });
|
|
2961
|
+
}
|
|
2962
|
+
});
|
|
2963
|
+
|
|
2964
|
+
// Legacy contract endpoints (kept for backward compat)
|
|
2965
|
+
app.post('/api/federation/contract', (req, res) => {
|
|
2966
|
+
try {
|
|
2967
|
+
const callerIp = req.ip?.replace('::ffff:', '') || req.socket?.remoteAddress?.replace('::ffff:', '') || '';
|
|
2968
|
+
if (!callerIp || !daemon.federation.isWhitelisted(callerIp)) {
|
|
2969
|
+
return res.status(403).json({ error: 'Caller IP not whitelisted' });
|
|
2970
|
+
}
|
|
2642
2971
|
const { senderId, payload, signature } = req.body;
|
|
2643
2972
|
if (!senderId || !payload || !signature) {
|
|
2644
2973
|
return res.status(400).json({ error: 'senderId, payload, and signature are required' });
|
|
@@ -2650,7 +2979,6 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
2650
2979
|
}
|
|
2651
2980
|
});
|
|
2652
2981
|
|
|
2653
|
-
// Send a contract to a peer (local agents call this)
|
|
2654
2982
|
app.post('/api/federation/contract/send', proOnly, async (req, res) => {
|
|
2655
2983
|
try {
|
|
2656
2984
|
const { peerId, contract } = req.body;
|
|
@@ -2863,9 +3191,9 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
2863
3191
|
}
|
|
2864
3192
|
});
|
|
2865
3193
|
|
|
2866
|
-
app.delete('/api/tunnels/:id', proOnly, (req, res) => {
|
|
3194
|
+
app.delete('/api/tunnels/:id', proOnly, async (req, res) => {
|
|
2867
3195
|
try {
|
|
2868
|
-
daemon.tunnelManager.delete(req.params.id);
|
|
3196
|
+
await daemon.tunnelManager.delete(req.params.id);
|
|
2869
3197
|
res.json({ ok: true });
|
|
2870
3198
|
} catch (err) {
|
|
2871
3199
|
res.status(400).json({ error: err.message });
|
|
@@ -2925,6 +3253,169 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
2925
3253
|
res.json(s);
|
|
2926
3254
|
});
|
|
2927
3255
|
|
|
3256
|
+
// --- Onboarding (Electron wizard) ---
|
|
3257
|
+
|
|
3258
|
+
const INSTALLABLE_PROVIDERS = {
|
|
3259
|
+
'claude-code': '@anthropic-ai/claude-code',
|
|
3260
|
+
'codex': '@openai/codex',
|
|
3261
|
+
'gemini': '@google/gemini-cli',
|
|
3262
|
+
};
|
|
3263
|
+
|
|
3264
|
+
app.get('/api/onboarding/status', (req, res) => {
|
|
3265
|
+
const providers = listProviders();
|
|
3266
|
+
const enriched = providers.map((p) => {
|
|
3267
|
+
const hasKey = daemon.credentials.hasKey(p.id);
|
|
3268
|
+
let authStatus = 'not-configured';
|
|
3269
|
+
if (p.authType === 'subscription') {
|
|
3270
|
+
authStatus = p.installed ? 'authenticated' : 'not-configured';
|
|
3271
|
+
} else if (p.authType === 'api-key') {
|
|
3272
|
+
authStatus = hasKey ? 'key-set' : 'not-configured';
|
|
3273
|
+
if (p.authStatus?.authenticated) authStatus = 'authenticated';
|
|
3274
|
+
} else if (p.authType === 'local') {
|
|
3275
|
+
authStatus = p.installed ? 'authenticated' : 'not-configured';
|
|
3276
|
+
}
|
|
3277
|
+
return {
|
|
3278
|
+
id: p.id,
|
|
3279
|
+
displayName: p.name,
|
|
3280
|
+
installed: p.installed,
|
|
3281
|
+
authType: p.authType,
|
|
3282
|
+
authStatus,
|
|
3283
|
+
hasKey,
|
|
3284
|
+
models: p.models,
|
|
3285
|
+
installCommand: p.installCommand,
|
|
3286
|
+
installable: !!INSTALLABLE_PROVIDERS[p.id],
|
|
3287
|
+
};
|
|
3288
|
+
});
|
|
3289
|
+
|
|
3290
|
+
const dismissed = !!(daemon.config.onboardingDismissed);
|
|
3291
|
+
const hasReadyProvider = enriched.some((p) =>
|
|
3292
|
+
p.installed && (p.authStatus === 'authenticated' || p.authStatus === 'key-set'),
|
|
3293
|
+
);
|
|
3294
|
+
|
|
3295
|
+
res.json({
|
|
3296
|
+
complete: dismissed || hasReadyProvider,
|
|
3297
|
+
dismissed,
|
|
3298
|
+
providers: enriched,
|
|
3299
|
+
defaultProvider: daemon.config.defaultProvider || 'claude-code',
|
|
3300
|
+
defaultModel: daemon.config.defaultModel || null,
|
|
3301
|
+
});
|
|
3302
|
+
});
|
|
3303
|
+
|
|
3304
|
+
app.post('/api/onboarding/dismiss', async (req, res) => {
|
|
3305
|
+
daemon.config.onboardingDismissed = true;
|
|
3306
|
+
const { saveConfig } = await import('./firstrun.js');
|
|
3307
|
+
saveConfig(daemon.grooveDir, daemon.config);
|
|
3308
|
+
daemon.audit.log('onboarding.dismiss', {});
|
|
3309
|
+
daemon.broadcast({ type: 'onboarding:dismissed' });
|
|
3310
|
+
res.json({ ok: true });
|
|
3311
|
+
});
|
|
3312
|
+
|
|
3313
|
+
app.post('/api/onboarding/install-provider', (req, res) => {
|
|
3314
|
+
const { provider } = req.body;
|
|
3315
|
+
if (!provider || typeof provider !== 'string') {
|
|
3316
|
+
return res.status(400).json({ error: 'provider is required' });
|
|
3317
|
+
}
|
|
3318
|
+
const pkg = INSTALLABLE_PROVIDERS[provider];
|
|
3319
|
+
if (!pkg) {
|
|
3320
|
+
return res.status(400).json({ error: `Provider '${provider}' is not installable via npm. Valid: ${Object.keys(INSTALLABLE_PROVIDERS).join(', ')}` });
|
|
3321
|
+
}
|
|
3322
|
+
|
|
3323
|
+
res.setHeader('Content-Type', 'application/x-ndjson');
|
|
3324
|
+
res.setHeader('Transfer-Encoding', 'chunked');
|
|
3325
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
3326
|
+
|
|
3327
|
+
const write = (obj) => {
|
|
3328
|
+
try { res.write(JSON.stringify(obj) + '\n'); } catch { /* client disconnected */ }
|
|
3329
|
+
};
|
|
3330
|
+
|
|
3331
|
+
write({ status: 'installing', output: `Installing ${pkg}...`, progress: 0 });
|
|
3332
|
+
|
|
3333
|
+
const proc = spawn('npm', ['install', '-g', pkg], {
|
|
3334
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
3335
|
+
env: { ...process.env, NODE_ENV: undefined },
|
|
3336
|
+
});
|
|
3337
|
+
|
|
3338
|
+
let output = '';
|
|
3339
|
+
let errOutput = '';
|
|
3340
|
+
|
|
3341
|
+
proc.stdout.on('data', (data) => {
|
|
3342
|
+
output += data.toString();
|
|
3343
|
+
write({ status: 'installing', output: data.toString().trim(), progress: 50 });
|
|
3344
|
+
});
|
|
3345
|
+
|
|
3346
|
+
proc.stderr.on('data', (data) => {
|
|
3347
|
+
errOutput += data.toString();
|
|
3348
|
+
const line = data.toString().trim();
|
|
3349
|
+
if (line) write({ status: 'installing', output: line, progress: 50 });
|
|
3350
|
+
});
|
|
3351
|
+
|
|
3352
|
+
proc.on('close', (code) => {
|
|
3353
|
+
const providerObj = getProvider(provider);
|
|
3354
|
+
const installed = providerObj ? providerObj.constructor.isInstalled() : false;
|
|
3355
|
+
|
|
3356
|
+
if (code === 0 && installed) {
|
|
3357
|
+
write({ status: 'complete', output: `${pkg} installed successfully`, progress: 100, installed: true });
|
|
3358
|
+
daemon.audit.log('onboarding.installProvider', { provider, pkg, success: true });
|
|
3359
|
+
daemon.broadcast({ type: 'onboarding:provider-installed', provider });
|
|
3360
|
+
} else {
|
|
3361
|
+
const reason = code !== 0
|
|
3362
|
+
? (errOutput || output).slice(-500)
|
|
3363
|
+
: 'Install succeeded but provider binary not found in PATH';
|
|
3364
|
+
write({ status: 'error', output: reason, progress: 100, installed: false });
|
|
3365
|
+
daemon.audit.log('onboarding.installProvider', { provider, pkg, success: false, code });
|
|
3366
|
+
}
|
|
3367
|
+
res.end();
|
|
3368
|
+
});
|
|
3369
|
+
|
|
3370
|
+
proc.on('error', (err) => {
|
|
3371
|
+
write({ status: 'error', output: `Failed to start npm: ${err.message}`, progress: 100, installed: false });
|
|
3372
|
+
res.end();
|
|
3373
|
+
});
|
|
3374
|
+
|
|
3375
|
+
req.on('close', () => {
|
|
3376
|
+
try { proc.kill(); } catch { /* already exited */ }
|
|
3377
|
+
});
|
|
3378
|
+
});
|
|
3379
|
+
|
|
3380
|
+
app.post('/api/onboarding/set-default', async (req, res) => {
|
|
3381
|
+
const { provider, model } = req.body;
|
|
3382
|
+
const validProviders = ['claude-code', 'codex', 'gemini', 'ollama'];
|
|
3383
|
+
if (!provider || !validProviders.includes(provider)) {
|
|
3384
|
+
return res.status(400).json({ error: `Invalid provider. Valid: ${validProviders.join(', ')}` });
|
|
3385
|
+
}
|
|
3386
|
+
|
|
3387
|
+
daemon.config.defaultProvider = provider;
|
|
3388
|
+
if (model && typeof model === 'string' && model.length <= 100) {
|
|
3389
|
+
daemon.config.defaultModel = model.trim();
|
|
3390
|
+
}
|
|
3391
|
+
const { saveConfig } = await import('./firstrun.js');
|
|
3392
|
+
saveConfig(daemon.grooveDir, daemon.config);
|
|
3393
|
+
daemon.audit.log('onboarding.setDefault', { provider, model: model || null });
|
|
3394
|
+
daemon.broadcast({ type: 'onboarding:default-changed', provider, model });
|
|
3395
|
+
res.json({ ok: true });
|
|
3396
|
+
});
|
|
3397
|
+
|
|
3398
|
+
// --- Project Directory ---
|
|
3399
|
+
|
|
3400
|
+
app.post('/api/project-dir', async (req, res) => {
|
|
3401
|
+
const { dir } = req.body;
|
|
3402
|
+
if (!dir || typeof dir !== 'string') return res.status(400).json({ error: 'dir required' });
|
|
3403
|
+
if (/[\0\n\r]/.test(dir)) return res.status(400).json({ error: 'Invalid characters in path' });
|
|
3404
|
+
const { existsSync, statSync } = await import('fs');
|
|
3405
|
+
const { resolve, isAbsolute } = await import('path');
|
|
3406
|
+
const resolved = resolve(dir);
|
|
3407
|
+
if (!isAbsolute(resolved)) return res.status(400).json({ error: 'Path must be absolute' });
|
|
3408
|
+
if (!existsSync(resolved) || !statSync(resolved).isDirectory()) {
|
|
3409
|
+
return res.status(400).json({ error: 'Directory does not exist' });
|
|
3410
|
+
}
|
|
3411
|
+
daemon.config.defaultWorkingDir = resolved;
|
|
3412
|
+
const { saveConfig } = await import('./firstrun.js');
|
|
3413
|
+
saveConfig(daemon.grooveDir, daemon.config);
|
|
3414
|
+
daemon.broadcast({ type: 'config:updated', data: { defaultWorkingDir: resolved } });
|
|
3415
|
+
daemon.audit.log('project.dir.change', { dir: resolved });
|
|
3416
|
+
res.json({ ok: true, dir: resolved });
|
|
3417
|
+
});
|
|
3418
|
+
|
|
2928
3419
|
// --- Config ---
|
|
2929
3420
|
|
|
2930
3421
|
app.get('/api/config', (req, res) => {
|
|
@@ -2935,6 +3426,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
2935
3426
|
const ALLOWED_KEYS = [
|
|
2936
3427
|
'port', 'journalistInterval', 'rotationThreshold', 'autoRotation',
|
|
2937
3428
|
'qcThreshold', 'maxAgents', 'defaultProvider', 'defaultWorkingDir',
|
|
3429
|
+
'onboardingDismissed', 'defaultModel',
|
|
2938
3430
|
];
|
|
2939
3431
|
for (const key of Object.keys(req.body)) {
|
|
2940
3432
|
if (!ALLOWED_KEYS.includes(key)) {
|
|
@@ -2948,8 +3440,35 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
2948
3440
|
res.json(daemon.config);
|
|
2949
3441
|
});
|
|
2950
3442
|
|
|
3443
|
+
// --- Toys ---
|
|
3444
|
+
|
|
3445
|
+
app.get('/api/toys', (req, res) => {
|
|
3446
|
+
const category = req.query.category;
|
|
3447
|
+
if (category && (typeof category !== 'string' || category.length > 30)) {
|
|
3448
|
+
return res.status(400).json({ error: 'Invalid category' });
|
|
3449
|
+
}
|
|
3450
|
+
res.json(daemon.toys.list(category || undefined));
|
|
3451
|
+
});
|
|
3452
|
+
|
|
3453
|
+
app.get('/api/toys/:id', (req, res) => {
|
|
3454
|
+
const toy = daemon.toys.get(req.params.id);
|
|
3455
|
+
if (!toy) return res.status(404).json({ error: 'Toy not found' });
|
|
3456
|
+
res.json(toy);
|
|
3457
|
+
});
|
|
3458
|
+
|
|
3459
|
+
app.post('/api/toys/:id/launch', async (req, res) => {
|
|
3460
|
+
try {
|
|
3461
|
+
const { apiKey, starterPrompt } = req.body || {};
|
|
3462
|
+
const result = await daemon.toys.launch(req.params.id, { apiKey, starterPrompt });
|
|
3463
|
+
daemon.audit.log('toy.launch', { toyId: req.params.id, teamId: result.team.id });
|
|
3464
|
+
res.status(201).json(result);
|
|
3465
|
+
} catch (err) {
|
|
3466
|
+
res.status(400).json({ error: err.message });
|
|
3467
|
+
}
|
|
3468
|
+
});
|
|
3469
|
+
|
|
2951
3470
|
// Serve GUI static files (built GUI) — no-cache headers to prevent stale bundles
|
|
2952
|
-
const guiPath = resolve(__dirname, '../../gui/dist');
|
|
3471
|
+
const guiPath = process.env.GROOVE_GUI_PATH || resolve(__dirname, '../../gui/dist');
|
|
2953
3472
|
app.use(express.static(guiPath, { etag: false, maxAge: 0, lastModified: false }));
|
|
2954
3473
|
app.use((req, res, next) => {
|
|
2955
3474
|
if (!req.path.startsWith('/api/')) {
|