groove-dev 0.27.74 → 0.27.77

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 (70) 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 +256 -4
  4. package/node_modules/@groove-dev/daemon/src/conversations.js +16 -0
  5. package/node_modules/@groove-dev/daemon/src/index.js +41 -1
  6. package/node_modules/@groove-dev/daemon/src/preview.js +32 -2
  7. package/node_modules/@groove-dev/daemon/src/process.js +9 -1
  8. package/node_modules/@groove-dev/daemon/src/providers/base.js +4 -0
  9. package/node_modules/@groove-dev/daemon/src/providers/codex.js +38 -0
  10. package/node_modules/@groove-dev/daemon/src/providers/grok.js +156 -0
  11. package/node_modules/@groove-dev/daemon/src/providers/index.js +5 -1
  12. package/node_modules/@groove-dev/daemon/src/providers/nano-banana.js +103 -0
  13. package/node_modules/@groove-dev/gui/dist/assets/index-BbmPDhuW.js +8616 -0
  14. package/node_modules/@groove-dev/gui/dist/assets/index-kbR5tOHu.css +1 -0
  15. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  16. package/node_modules/@groove-dev/gui/package.json +1 -1
  17. package/node_modules/@groove-dev/gui/src/app.css +41 -0
  18. package/node_modules/@groove-dev/gui/src/app.jsx +2 -0
  19. package/node_modules/@groove-dev/gui/src/components/chat/chat-header.jsx +16 -5
  20. package/node_modules/@groove-dev/gui/src/components/chat/chat-input.jsx +49 -11
  21. package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +144 -24
  22. package/node_modules/@groove-dev/gui/src/components/chat/chat-view.jsx +26 -2
  23. package/node_modules/@groove-dev/gui/src/components/chat/model-picker.jsx +105 -52
  24. package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +5 -2
  25. package/node_modules/@groove-dev/gui/src/components/layout/welcome-splash.jsx +215 -88
  26. package/node_modules/@groove-dev/gui/src/components/preview/preview-toolbar.jsx +109 -0
  27. package/node_modules/@groove-dev/gui/src/components/preview/preview-workspace.jsx +278 -0
  28. package/node_modules/@groove-dev/gui/src/components/preview/screenshot-overlay.jsx +237 -0
  29. package/node_modules/@groove-dev/gui/src/components/ui/toast.jsx +6 -2
  30. package/node_modules/@groove-dev/gui/src/stores/groove.js +149 -9
  31. package/node_modules/@groove-dev/gui/src/views/preview.jsx +6 -0
  32. package/node_modules/@groove-dev/gui/src/views/settings.jsx +199 -114
  33. package/package.json +1 -1
  34. package/packages/cli/package.json +1 -1
  35. package/packages/daemon/package.json +1 -1
  36. package/packages/daemon/src/api.js +256 -4
  37. package/packages/daemon/src/conversations.js +16 -0
  38. package/packages/daemon/src/index.js +41 -1
  39. package/packages/daemon/src/preview.js +32 -2
  40. package/packages/daemon/src/process.js +9 -1
  41. package/packages/daemon/src/providers/base.js +4 -0
  42. package/packages/daemon/src/providers/codex.js +38 -0
  43. package/packages/daemon/src/providers/grok.js +156 -0
  44. package/packages/daemon/src/providers/index.js +5 -1
  45. package/packages/daemon/src/providers/nano-banana.js +103 -0
  46. package/packages/gui/dist/assets/index-BbmPDhuW.js +8616 -0
  47. package/packages/gui/dist/assets/index-kbR5tOHu.css +1 -0
  48. package/packages/gui/dist/index.html +2 -2
  49. package/packages/gui/package.json +1 -1
  50. package/packages/gui/src/app.css +41 -0
  51. package/packages/gui/src/app.jsx +2 -0
  52. package/packages/gui/src/components/chat/chat-header.jsx +16 -5
  53. package/packages/gui/src/components/chat/chat-input.jsx +49 -11
  54. package/packages/gui/src/components/chat/chat-messages.jsx +144 -24
  55. package/packages/gui/src/components/chat/chat-view.jsx +26 -2
  56. package/packages/gui/src/components/chat/model-picker.jsx +105 -52
  57. package/packages/gui/src/components/layout/activity-bar.jsx +5 -2
  58. package/packages/gui/src/components/layout/welcome-splash.jsx +215 -88
  59. package/packages/gui/src/components/preview/preview-toolbar.jsx +109 -0
  60. package/packages/gui/src/components/preview/preview-workspace.jsx +278 -0
  61. package/packages/gui/src/components/preview/screenshot-overlay.jsx +237 -0
  62. package/packages/gui/src/components/ui/toast.jsx +6 -2
  63. package/packages/gui/src/stores/groove.js +149 -9
  64. package/packages/gui/src/views/preview.jsx +6 -0
  65. package/packages/gui/src/views/settings.jsx +199 -114
  66. package/welcome.png +0 -0
  67. package/node_modules/@groove-dev/gui/dist/assets/index-DFP3r2yE.js +0 -8615
  68. package/node_modules/@groove-dev/gui/dist/assets/index-QR7lyguO.css +0 -1
  69. package/packages/gui/dist/assets/index-DFP3r2yE.js +0 -8615
  70. package/packages/gui/dist/assets/index-QR7lyguO.css +0 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.74",
3
+ "version": "0.27.77",
4
4
  "description": "GROOVE CLI — manage AI coding agents from your terminal",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/daemon",
3
- "version": "0.27.74",
3
+ "version": "0.27.77",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -9,6 +9,7 @@ import { spawn, execFile, execFileSync } from 'child_process';
9
9
  import { createHash, randomUUID } from 'crypto';
10
10
  import { hostname, networkInterfaces, homedir } from 'os';
11
11
  import { StringDecoder } from 'string_decoder';
12
+ import { request as httpRequest } from 'http';
12
13
  import { lookup as mimeLookup } from './mimetypes.js';
13
14
  import { listProviders, getProvider, clearInstallCache, getProviderMetadata, getProviderPath, setProviderPaths } from './providers/index.js';
14
15
  import { OllamaProvider } from './providers/ollama.js';
@@ -97,12 +98,18 @@ export function createApi(app, daemon) {
97
98
  next();
98
99
  });
99
100
 
100
- // Security headers
101
+ // Security headers — preview proxy routes get relaxed framing policy so the
102
+ // GUI can iframe the proxied dev server content.
101
103
  app.use((req, res, next) => {
102
104
  res.setHeader('X-Content-Type-Options', 'nosniff');
103
- res.setHeader('X-Frame-Options', 'DENY');
104
105
  res.setHeader('X-XSS-Protection', '0');
105
- 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'");
106
+ const isPreviewProxy = req.path.match(/^\/api\/preview\/[^/]+\/proxy/);
107
+ if (isPreviewProxy) {
108
+ res.setHeader('Content-Security-Policy', "default-src * 'unsafe-inline' 'unsafe-eval' data: blob:; frame-ancestors 'self'");
109
+ } else {
110
+ res.setHeader('X-Frame-Options', 'DENY');
111
+ 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-src 'self'; frame-ancestors 'none'");
112
+ }
106
113
  next();
107
114
  });
108
115
 
@@ -1258,6 +1265,66 @@ export function createApi(app, daemon) {
1258
1265
  }
1259
1266
  });
1260
1267
 
1268
+ // --- Image Generation ---
1269
+
1270
+ app.post('/api/conversations/:id/generate-image', async (req, res) => {
1271
+ try {
1272
+ const { prompt, model, size, quality } = req.body;
1273
+ if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
1274
+ return res.status(400).json({ error: 'prompt is required' });
1275
+ }
1276
+ const conv = daemon.conversations.get(req.params.id);
1277
+ if (!conv) return res.status(404).json({ error: 'Conversation not found' });
1278
+
1279
+ let providerName = conv.provider;
1280
+ let provider = getProvider(providerName);
1281
+
1282
+ // If a specific image model was requested, find the right provider
1283
+ if (model) {
1284
+ const imageProviders = ['codex', 'grok', 'nano-banana'];
1285
+ for (const pid of imageProviders) {
1286
+ const p = getProvider(pid);
1287
+ if (p?.constructor.models.some((m) => m.id === model)) {
1288
+ provider = p;
1289
+ providerName = pid;
1290
+ break;
1291
+ }
1292
+ }
1293
+ }
1294
+
1295
+ if (!provider?.generateImage) {
1296
+ return res.status(400).json({ error: 'Provider does not support image generation' });
1297
+ }
1298
+
1299
+ const apiKey = daemon.conversations._getApiKey(providerName);
1300
+ if (!apiKey) {
1301
+ return res.status(400).json({ error: `No API key configured for ${providerName}` });
1302
+ }
1303
+
1304
+ daemon.broadcast({
1305
+ type: 'conversation:image-progress',
1306
+ data: { conversationId: req.params.id, status: 'generating', prompt: prompt.trim() },
1307
+ });
1308
+
1309
+ const result = await provider.generateImage(prompt.trim(), { model, size, quality, apiKey });
1310
+
1311
+ daemon.broadcast({
1312
+ type: 'conversation:image',
1313
+ data: { conversationId: req.params.id, ...result, prompt: prompt.trim() },
1314
+ });
1315
+
1316
+ daemon.conversations.touchUpdatedAt(req.params.id);
1317
+ daemon.audit.log('conversation.image', { id: req.params.id, model: result.model, provider: result.provider });
1318
+ res.json(result);
1319
+ } catch (err) {
1320
+ daemon.broadcast({
1321
+ type: 'conversation:image-progress',
1322
+ data: { conversationId: req.params.id, status: 'error', error: err.message },
1323
+ });
1324
+ res.status(500).json({ error: err.message });
1325
+ }
1326
+ });
1327
+
1261
1328
  // --- Approvals ---
1262
1329
 
1263
1330
  app.get('/api/approvals', (req, res) => {
@@ -3385,6 +3452,191 @@ Keep responses concise. Help them think, don't lecture them about the system the
3385
3452
  res.json(result);
3386
3453
  });
3387
3454
 
3455
+ // --- Preview Proxy (same-origin iframe support) ---
3456
+ // Forwards HTTP requests to the dev server so the GUI can iframe the preview
3457
+ // without cross-origin issues. WebSocket upgrade for HMR is handled in index.js.
3458
+ app.all('/api/preview/:teamId/proxy/*', (req, res) => {
3459
+ const entry = daemon.preview?.get(req.params.teamId);
3460
+ if (!entry || !entry.url) return res.status(404).json({ error: 'No active preview for this team' });
3461
+
3462
+ let targetUrl;
3463
+ try { targetUrl = new URL(entry.url); } catch { return res.status(500).json({ error: 'Invalid preview URL' }); }
3464
+
3465
+ const proxyPath = req.params[0] || '';
3466
+ const search = req.url.includes('?') ? '?' + req.url.split('?').slice(1).join('?') : '';
3467
+ const fullPath = '/' + proxyPath + search;
3468
+
3469
+ const headers = { ...req.headers };
3470
+ delete headers.host;
3471
+ headers.host = targetUrl.host;
3472
+
3473
+ const proxyReq = httpRequest({
3474
+ hostname: targetUrl.hostname,
3475
+ port: targetUrl.port,
3476
+ path: fullPath,
3477
+ method: req.method,
3478
+ headers,
3479
+ }, (proxyRes) => {
3480
+ const fwdHeaders = { ...proxyRes.headers };
3481
+ delete fwdHeaders['content-security-policy'];
3482
+ delete fwdHeaders['x-frame-options'];
3483
+ res.writeHead(proxyRes.statusCode, fwdHeaders);
3484
+ proxyRes.pipe(res);
3485
+ });
3486
+
3487
+ proxyReq.on('error', (err) => {
3488
+ if (!res.headersSent) res.status(502).json({ error: `Proxy error: ${err.message}` });
3489
+ });
3490
+ req.pipe(proxyReq);
3491
+ });
3492
+
3493
+ // Also handle the bare path (no trailing wildcard)
3494
+ app.all('/api/preview/:teamId/proxy', (req, res) => {
3495
+ const entry = daemon.preview?.get(req.params.teamId);
3496
+ if (!entry || !entry.url) return res.status(404).json({ error: 'No active preview for this team' });
3497
+
3498
+ let targetUrl;
3499
+ try { targetUrl = new URL(entry.url); } catch { return res.status(500).json({ error: 'Invalid preview URL' }); }
3500
+
3501
+ const search = req.url.includes('?') ? '?' + req.url.split('?').slice(1).join('?') : '';
3502
+
3503
+ const headers = { ...req.headers };
3504
+ delete headers.host;
3505
+ headers.host = targetUrl.host;
3506
+
3507
+ const proxyReq = httpRequest({
3508
+ hostname: targetUrl.hostname,
3509
+ port: targetUrl.port,
3510
+ path: '/' + search,
3511
+ method: req.method,
3512
+ headers,
3513
+ }, (proxyRes) => {
3514
+ const fwdHeaders = { ...proxyRes.headers };
3515
+ delete fwdHeaders['content-security-policy'];
3516
+ delete fwdHeaders['x-frame-options'];
3517
+ res.writeHead(proxyRes.statusCode, fwdHeaders);
3518
+ proxyRes.pipe(res);
3519
+ });
3520
+
3521
+ proxyReq.on('error', (err) => {
3522
+ if (!res.headersSent) res.status(502).json({ error: `Proxy error: ${err.message}` });
3523
+ });
3524
+ req.pipe(proxyReq);
3525
+ });
3526
+
3527
+ // --- Iteration endpoint (planner routing for live preview feedback) ---
3528
+ app.post('/api/preview/:teamId/iterate', async (req, res) => {
3529
+ try {
3530
+ const { message, screenshot } = req.body;
3531
+ if (!message || typeof message !== 'string' || !message.trim()) {
3532
+ return res.status(400).json({ error: 'message is required and must be a non-empty string' });
3533
+ }
3534
+
3535
+ const teamId = req.params.teamId;
3536
+ const agents = daemon.registry.getAll().filter((a) => a.teamId === teamId);
3537
+ const planner = agents.find((a) => a.role === 'planner');
3538
+
3539
+ if (!planner) {
3540
+ return res.status(400).json({ error: 'No planner found for this team. Iteration routing requires a planner-based team.' });
3541
+ }
3542
+
3543
+ const terminal = new Set(['completed', 'crashed', 'stopped', 'killed']);
3544
+ const feedbackPrompt = [
3545
+ 'ITERATION REQUEST: The user is viewing the live preview and wants changes.',
3546
+ '',
3547
+ `User feedback: ${message.trim()}`,
3548
+ '',
3549
+ screenshot ? 'The user attached a screenshot highlighting what they want changed.' : '',
3550
+ '',
3551
+ 'Analyze this feedback and route it to the appropriate team agent (frontend, backend, or fullstack) by writing .groove/recommended-team.json. Be specific about what files to change and what the change should be.',
3552
+ ].filter(Boolean).join('\n');
3553
+
3554
+ if (terminal.has(planner.status)) {
3555
+ const newAgent = await daemon.processes.spawn({
3556
+ role: planner.role,
3557
+ scope: planner.scope,
3558
+ provider: planner.provider,
3559
+ model: planner.model,
3560
+ prompt: feedbackPrompt,
3561
+ permission: planner.permission || 'full',
3562
+ workingDir: planner.workingDir,
3563
+ name: planner.name,
3564
+ teamId: planner.teamId,
3565
+ });
3566
+ daemon.audit.log('preview.iterate', { teamId, plannerId: newAgent.id, respawned: true });
3567
+ return res.json({ status: 'routed', plannerAgent: newAgent.id, message: 'Feedback sent to respawned planner for routing' });
3568
+ }
3569
+
3570
+ if (daemon.processes.hasAgentLoop(planner.id)) {
3571
+ await daemon.processes.sendMessage(planner.id, feedbackPrompt);
3572
+ } else if (daemon.processes.isRunning(planner.id)) {
3573
+ daemon.processes.queueMessage(planner.id, feedbackPrompt);
3574
+ } else {
3575
+ return res.status(400).json({ error: 'Planner exists but is not reachable. Try again.' });
3576
+ }
3577
+
3578
+ daemon.audit.log('preview.iterate', { teamId, plannerId: planner.id, respawned: false });
3579
+ res.json({ status: 'routed', plannerAgent: planner.id, message: 'Feedback sent to planner for routing' });
3580
+ } catch (err) {
3581
+ res.status(500).json({ error: err.message });
3582
+ }
3583
+ });
3584
+
3585
+ // --- Screenshot storage for preview iteration ---
3586
+ app.post('/api/preview/:teamId/screenshot', (req, res) => {
3587
+ try {
3588
+ const { image, filename } = req.body;
3589
+ if (!image || typeof image !== 'string') {
3590
+ return res.status(400).json({ error: 'image (base64 string) is required' });
3591
+ }
3592
+
3593
+ const teamId = req.params.teamId;
3594
+ const agents = daemon.registry.getAll().filter((a) => a.teamId === teamId);
3595
+ const teamAgent = agents[0];
3596
+ if (!teamAgent) return res.status(404).json({ error: 'No agents found for this team' });
3597
+
3598
+ const workDir = teamAgent.workingDir || daemon.projectDir;
3599
+ const screenshotDir = resolve(workDir, '.groove', 'screenshots');
3600
+ mkdirSync(screenshotDir, { recursive: true });
3601
+
3602
+ const ts = Date.now();
3603
+ const safeName = (filename || 'screenshot').replace(/[^a-zA-Z0-9._-]/g, '_');
3604
+ const fname = `${ts}-${safeName}.png`;
3605
+ const filePath = resolve(screenshotDir, fname);
3606
+
3607
+ const base64Data = image.replace(/^data:image\/\w+;base64,/, '');
3608
+ writeFileSync(filePath, Buffer.from(base64Data, 'base64'));
3609
+
3610
+ const relativePath = `.groove/screenshots/${fname}`;
3611
+ daemon.audit.log('preview.screenshot', { teamId, path: relativePath });
3612
+ res.json({
3613
+ path: relativePath,
3614
+ url: `/api/preview/${teamId}/screenshots/${fname}`,
3615
+ });
3616
+ } catch (err) {
3617
+ res.status(500).json({ error: err.message });
3618
+ }
3619
+ });
3620
+
3621
+ app.get('/api/preview/:teamId/screenshots/:filename', (req, res) => {
3622
+ const teamId = req.params.teamId;
3623
+ const fname = req.params.filename;
3624
+ if (!fname || fname.includes('..') || fname.includes('/') || fname.includes('\\')) {
3625
+ return res.status(400).json({ error: 'Invalid filename' });
3626
+ }
3627
+
3628
+ const agents = daemon.registry.getAll().filter((a) => a.teamId === teamId);
3629
+ const teamAgent = agents[0];
3630
+ if (!teamAgent) return res.status(404).json({ error: 'No agents found for this team' });
3631
+
3632
+ const workDir = teamAgent.workingDir || daemon.projectDir;
3633
+ const filePath = resolve(workDir, '.groove', 'screenshots', fname);
3634
+ if (!existsSync(filePath)) return res.status(404).json({ error: 'Screenshot not found' });
3635
+
3636
+ res.setHeader('Content-Type', 'image/png');
3637
+ createReadStream(filePath).pipe(res);
3638
+ });
3639
+
3388
3640
  // Clean up stale artifacts. Scope to a single team when teamId is provided —
3389
3641
  // wiping every agent's working dir on a global cleanup would delete other
3390
3642
  // in-flight teams' unlaunched plans. When called with no teamId, only the
@@ -4166,7 +4418,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
4166
4418
  const ALLOWED_KEYS = [
4167
4419
  'port', 'journalistInterval', 'rotationThreshold', 'autoRotation',
4168
4420
  'qcThreshold', 'maxAgents', 'defaultProvider', 'defaultWorkingDir',
4169
- 'onboardingDismissed', 'defaultModel',
4421
+ 'onboardingDismissed', 'defaultModel', 'defaultChatProvider', 'defaultChatModel',
4170
4422
  ];
4171
4423
  for (const key of Object.keys(req.body)) {
4172
4424
  if (!ALLOWED_KEYS.includes(key)) {
@@ -56,6 +56,13 @@ export class ConversationManager {
56
56
  }
57
57
 
58
58
  async create(provider, model, title, mode = 'api') {
59
+ if (!provider && this.daemon.config?.defaultChatProvider) {
60
+ provider = this.daemon.config.defaultChatProvider;
61
+ }
62
+ if (!model && this.daemon.config?.defaultChatModel) {
63
+ model = this.daemon.config.defaultChatModel;
64
+ }
65
+
59
66
  const id = randomUUID().slice(0, 12);
60
67
  const now = new Date().toISOString();
61
68
 
@@ -285,6 +292,8 @@ export class ConversationManager {
285
292
  'claude-code': 'ANTHROPIC_API_KEY',
286
293
  'codex': 'OPENAI_API_KEY',
287
294
  'gemini': 'GEMINI_API_KEY',
295
+ 'grok': 'XAI_API_KEY',
296
+ 'nano-banana': 'GEMINI_API_KEY',
288
297
  };
289
298
  const envVar = envMap[providerName];
290
299
  if (envVar && process.env[envVar]) return process.env[envVar];
@@ -355,6 +364,13 @@ export class ConversationManager {
355
364
  // Fallback: headless CLI spawn (for providers without streamChat or missing API key)
356
365
  const prompt = this._buildHistoryPrompt(history, message);
357
366
  const headlessCmd = provider.buildHeadlessCommand(prompt, modelId);
367
+ if (!headlessCmd) {
368
+ this.daemon.broadcast({
369
+ type: 'conversation:error',
370
+ data: { conversationId: id, error: `${providerName} requires an API key for chat` },
371
+ });
372
+ return;
373
+ }
358
374
  const { command, args, env, stdin: stdinData, cwd } = headlessCmd;
359
375
 
360
376
  const spawnOpts = {
@@ -1,7 +1,7 @@
1
1
  // GROOVE Daemon — Entry Point
2
2
  // FSL-1.1-Apache-2.0 — see LICENSE
3
3
 
4
- import { createServer as createHttpServer } from 'http';
4
+ import { createServer as createHttpServer, request as httpProxyRequest } from 'http';
5
5
  import { createServer as createNetServer } from 'net';
6
6
  import { execFileSync } from 'child_process';
7
7
  import { resolve } from 'path';
@@ -208,6 +208,46 @@ export class Daemon {
208
208
  this.federationWss.handleUpgrade(req, socket, head, (ws) => {
209
209
  this.federation.handleWsUpgrade(ws, daemonId, callerIp, signatureHeader);
210
210
  });
211
+ } else if (req.url?.startsWith('/api/preview/') && req.url.includes('/proxy')) {
212
+ // HMR WebSocket proxy — forward to the dev server's WebSocket
213
+ const match = req.url.match(/^\/api\/preview\/([^/]+)\/proxy\/?(.*)$/);
214
+ if (!match || !this.preview) { socket.destroy(); return; }
215
+ const entry = this.preview.get(match[1]);
216
+ if (!entry?.url) { socket.destroy(); return; }
217
+ let targetUrl;
218
+ try { targetUrl = new URL(entry.url); } catch { socket.destroy(); return; }
219
+ const wsPath = '/' + (match[2] || '');
220
+ const opts = {
221
+ hostname: targetUrl.hostname,
222
+ port: targetUrl.port,
223
+ path: wsPath,
224
+ method: 'GET',
225
+ headers: { ...req.headers, host: targetUrl.host },
226
+ };
227
+ const upstream = httpProxyRequest(opts);
228
+ upstream.on('upgrade', (_res, upSocket, upHead) => {
229
+ const skipHeaders = new Set(['upgrade', 'connection', 'sec-websocket-accept']);
230
+ const extra = Object.entries(_res.headers)
231
+ .filter(([k]) => !skipHeaders.has(k))
232
+ .map(([k, v]) => `${k}: ${v}\r\n`).join('');
233
+ socket.write(
234
+ 'HTTP/1.1 101 Switching Protocols\r\n' +
235
+ `Upgrade: ${_res.headers.upgrade || 'websocket'}\r\n` +
236
+ `Connection: Upgrade\r\n` +
237
+ `Sec-WebSocket-Accept: ${_res.headers['sec-websocket-accept'] || ''}\r\n` +
238
+ extra +
239
+ '\r\n'
240
+ );
241
+ if (upHead.length) socket.write(upHead);
242
+ upSocket.pipe(socket);
243
+ socket.pipe(upSocket);
244
+ upSocket.on('error', () => socket.destroy());
245
+ socket.on('error', () => upSocket.destroy());
246
+ upSocket.on('close', () => socket.destroy());
247
+ socket.on('close', () => upSocket.destroy());
248
+ });
249
+ upstream.on('error', () => socket.destroy());
250
+ upstream.end();
211
251
  } else {
212
252
  this.wss.handleUpgrade(req, socket, head, (ws) => {
213
253
  this.wss.emit('connection', ws, req);
@@ -17,7 +17,7 @@ import { existsSync, readFileSync, statSync } from 'fs';
17
17
  import { createServer } from 'http';
18
18
  import { lookup as mimeLookup } from './mimetypes.js';
19
19
 
20
- const READY_TIMEOUT_MS = 60_000; // give dev servers a minute to boot
20
+ const READY_TIMEOUT_MS = 120_000; // give dev servers 2 minutes (large projects need npm install)
21
21
  const MAX_STDOUT_BYTES = 256 * 1024;
22
22
  // Strip CSI/OSC/other ANSI escape sequences — Vite prints URLs with inline
23
23
  // bold/color codes (e.g. "http://localhost:\x1b[1m5175\x1b[22m/") which would
@@ -147,11 +147,41 @@ export class PreviewService {
147
147
  });
148
148
  }
149
149
 
150
+ _autoDetectDevCommand(baseDir) {
151
+ const pkgPath = resolve(baseDir, 'package.json');
152
+ if (!existsSync(pkgPath)) return null;
153
+ try {
154
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
155
+ const scripts = pkg.scripts || {};
156
+ for (const name of ['dev', 'start', 'serve']) {
157
+ if (scripts[name]) return `npm run ${name}`;
158
+ }
159
+ } catch { /* malformed package.json */ }
160
+ return null;
161
+ }
162
+
150
163
  _launchDevServer(teamId, baseDir, preview) {
151
- const command = String(preview.command || '').trim();
164
+ let command = String(preview.command || '').trim();
165
+ if (!command) {
166
+ command = this._autoDetectDevCommand(baseDir) || '';
167
+ }
152
168
  if (!command) {
153
169
  return Promise.resolve({ launched: false, reason: 'no_command' });
154
170
  }
171
+ // If command references an npm script, verify it exists in package.json
172
+ const npmRunMatch = command.match(/npm\s+run\s+(\S+)/);
173
+ if (npmRunMatch) {
174
+ const scriptName = npmRunMatch[1];
175
+ const pkgPath = resolve(baseDir, 'package.json');
176
+ try {
177
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
178
+ if (!pkg.scripts || !pkg.scripts[scriptName]) {
179
+ return Promise.resolve({ launched: false, reason: 'no_dev_script' });
180
+ }
181
+ } catch {
182
+ return Promise.resolve({ launched: false, reason: 'no_dev_script' });
183
+ }
184
+ }
155
185
  const urlPattern = preview.urlPattern
156
186
  ? new RegExp(preview.urlPattern)
157
187
  : /https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0):\d+/;
@@ -114,6 +114,9 @@ CRITICAL — NEVER DO THESE:
114
114
  - NEVER kill the daemon process. No "kill <pid>", "pkill groove", "killall node", etc.
115
115
  - NEVER run "./promote.sh", "./promote-local.sh", or any publish/deploy script.
116
116
  - NEVER start long-running dev servers (vite dev, npm start, next dev, etc.).
117
+ - NEVER use 'git add -f' or 'git add --force' to bypass .gitignore. If a file is gitignored, it should stay gitignored. Only stage files that git tracks normally. If .gitignore prevents staging, report it in your output — do NOT force-add.
118
+ - NEVER use 'git push --force' or 'git push -f'. Force-pushing can destroy shared history.
119
+ - NEVER modify .gitignore to include files that were previously excluded.
117
120
 
118
121
  Restarting the daemon destroys ALL other agents currently running in other teams. Verification is done via "npm run build" and "npm test", which exit cleanly. If code changes require a daemon restart to take effect, report that in your output so the user can restart manually — do NOT do it yourself.
119
122
 
@@ -1161,6 +1164,11 @@ For normal file edits within your scope, proceed without review.
1161
1164
  const workingDir = plan.workingDir;
1162
1165
  preview.launch(teamId, workingDir, plan.preview).then((result) => {
1163
1166
  if (!result.launched) {
1167
+ const intentionalSkips = new Set(['no_preview', 'cli', 'none', 'no_command', 'no_dev_script']);
1168
+ if (intentionalSkips.has(result.reason)) {
1169
+ console.log(`[Groove] Preview for team ${teamId} intentionally skipped: ${result.reason}`);
1170
+ return;
1171
+ }
1164
1172
  console.warn(`[Groove] Preview for team ${teamId} did not launch: ${result.reason}`);
1165
1173
  this.daemon.broadcast({
1166
1174
  type: 'preview:failed',
@@ -1171,7 +1179,7 @@ For normal file edits within your scope, proceed without review.
1171
1179
  }
1172
1180
  }).catch((err) => {
1173
1181
  console.error(`[Groove] Preview launch error for team ${teamId}:`, err.message);
1174
- this.daemon.broadcast({ type: 'preview:failed', teamId, reason: err.message });
1182
+ this.daemon.broadcast({ type: 'preview:failed', teamId, kind: plan.preview?.kind, reason: err.message });
1175
1183
  });
1176
1184
  }
1177
1185
 
@@ -37,6 +37,10 @@ export class Provider {
37
37
  return null;
38
38
  }
39
39
 
40
+ async generateImage(prompt, options = {}) {
41
+ return null;
42
+ }
43
+
40
44
  static setupGuide() {
41
45
  return { installSteps: [], authMethods: [], authInstructions: {} };
42
46
  }
@@ -43,6 +43,8 @@ export class CodexProvider extends Provider {
43
43
  { id: 'gpt-5.4-nano', name: 'GPT-5.4 Nano', tier: 'light', maxContext: 200000, pricing: { input: 0.0004, output: 0.0016 } },
44
44
  { id: 'gpt-5-mini', name: 'GPT-5 Mini', tier: 'medium', maxContext: 200000, pricing: { input: 0.0005, output: 0.002 } },
45
45
  { id: 'gpt-5-nano', name: 'GPT-5 Nano', tier: 'light', maxContext: 200000, pricing: { input: 0.0001, output: 0.0004 } },
46
+ { id: 'gpt-image-2', name: 'GPT Image 2', tier: 'medium', type: 'image', pricing: { perImage: 0.07 } },
47
+ { id: 'gpt-image-1', name: 'GPT Image 1', tier: 'medium', type: 'image', pricing: { perImage: 0.02 } },
46
48
  ];
47
49
 
48
50
  static isInstalled() {
@@ -186,6 +188,42 @@ export class CodexProvider extends Provider {
186
188
  return controller;
187
189
  }
188
190
 
191
+ async generateImage(prompt, options = {}) {
192
+ const apiKey = options.apiKey;
193
+ if (!apiKey) throw new Error('OPENAI_API_KEY required for image generation');
194
+
195
+ const body = {
196
+ model: options.model || 'gpt-image-1',
197
+ prompt,
198
+ n: 1,
199
+ };
200
+ if (options.size) body.size = options.size;
201
+ if (options.quality) body.quality = options.quality;
202
+
203
+ const res = await fetch('https://api.openai.com/v1/images/generations', {
204
+ method: 'POST',
205
+ headers: {
206
+ 'Authorization': `Bearer ${apiKey}`,
207
+ 'Content-Type': 'application/json',
208
+ },
209
+ body: JSON.stringify(body),
210
+ });
211
+
212
+ if (!res.ok) {
213
+ const text = await res.text();
214
+ throw new Error(`OpenAI Image API ${res.status}: ${text.slice(0, 200)}`);
215
+ }
216
+
217
+ const data = await res.json();
218
+ const image = data.data?.[0];
219
+ return {
220
+ url: image?.url || null,
221
+ b64_json: image?.b64_json || null,
222
+ model: body.model,
223
+ provider: 'codex',
224
+ };
225
+ }
226
+
189
227
  parseOutput(line) {
190
228
  const trimmed = line.trim();
191
229
  if (!trimmed) return null;