groove-dev 0.27.74 → 0.27.75

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/CLAUDE.md +0 -7
  2. package/node_modules/@groove-dev/cli/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/package.json +1 -1
  4. package/node_modules/@groove-dev/daemon/src/api.js +256 -4
  5. package/node_modules/@groove-dev/daemon/src/conversations.js +16 -0
  6. package/node_modules/@groove-dev/daemon/src/index.js +41 -1
  7. package/node_modules/@groove-dev/daemon/src/preview.js +18 -2
  8. package/node_modules/@groove-dev/daemon/src/process.js +6 -1
  9. package/node_modules/@groove-dev/daemon/src/providers/base.js +4 -0
  10. package/node_modules/@groove-dev/daemon/src/providers/codex.js +38 -0
  11. package/node_modules/@groove-dev/daemon/src/providers/grok.js +156 -0
  12. package/node_modules/@groove-dev/daemon/src/providers/index.js +5 -1
  13. package/node_modules/@groove-dev/daemon/src/providers/nano-banana.js +103 -0
  14. package/node_modules/@groove-dev/gui/dist/assets/index-CAT9SCJi.js +8620 -0
  15. package/node_modules/@groove-dev/gui/dist/assets/index-CVzz6zyb.css +1 -0
  16. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  17. package/node_modules/@groove-dev/gui/package.json +1 -1
  18. package/node_modules/@groove-dev/gui/src/app.css +29 -0
  19. package/node_modules/@groove-dev/gui/src/app.jsx +2 -0
  20. package/node_modules/@groove-dev/gui/src/components/chat/chat-header.jsx +16 -5
  21. package/node_modules/@groove-dev/gui/src/components/chat/chat-input.jsx +40 -7
  22. package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +149 -31
  23. package/node_modules/@groove-dev/gui/src/components/chat/chat-view.jsx +26 -2
  24. package/node_modules/@groove-dev/gui/src/components/chat/model-picker.jsx +105 -52
  25. package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +5 -2
  26. package/node_modules/@groove-dev/gui/src/components/layout/welcome-splash.jsx +215 -88
  27. package/node_modules/@groove-dev/gui/src/components/preview/preview-toolbar.jsx +81 -0
  28. package/node_modules/@groove-dev/gui/src/components/preview/preview-workspace.jsx +263 -0
  29. package/node_modules/@groove-dev/gui/src/components/preview/screenshot-overlay.jsx +203 -0
  30. package/node_modules/@groove-dev/gui/src/components/ui/toast.jsx +6 -2
  31. package/node_modules/@groove-dev/gui/src/stores/groove.js +149 -9
  32. package/node_modules/@groove-dev/gui/src/views/preview.jsx +6 -0
  33. package/node_modules/@groove-dev/gui/src/views/settings.jsx +199 -114
  34. package/package.json +1 -1
  35. package/packages/cli/package.json +1 -1
  36. package/packages/daemon/package.json +1 -1
  37. package/packages/daemon/src/api.js +256 -4
  38. package/packages/daemon/src/conversations.js +16 -0
  39. package/packages/daemon/src/index.js +41 -1
  40. package/packages/daemon/src/preview.js +18 -2
  41. package/packages/daemon/src/process.js +6 -1
  42. package/packages/daemon/src/providers/base.js +4 -0
  43. package/packages/daemon/src/providers/codex.js +38 -0
  44. package/packages/daemon/src/providers/grok.js +156 -0
  45. package/packages/daemon/src/providers/index.js +5 -1
  46. package/packages/daemon/src/providers/nano-banana.js +103 -0
  47. package/packages/gui/dist/assets/index-CAT9SCJi.js +8620 -0
  48. package/packages/gui/dist/assets/index-CVzz6zyb.css +1 -0
  49. package/packages/gui/dist/index.html +2 -2
  50. package/packages/gui/package.json +1 -1
  51. package/packages/gui/src/app.css +29 -0
  52. package/packages/gui/src/app.jsx +2 -0
  53. package/packages/gui/src/components/chat/chat-header.jsx +16 -5
  54. package/packages/gui/src/components/chat/chat-input.jsx +40 -7
  55. package/packages/gui/src/components/chat/chat-messages.jsx +149 -31
  56. package/packages/gui/src/components/chat/chat-view.jsx +26 -2
  57. package/packages/gui/src/components/chat/model-picker.jsx +105 -52
  58. package/packages/gui/src/components/layout/activity-bar.jsx +5 -2
  59. package/packages/gui/src/components/layout/welcome-splash.jsx +215 -88
  60. package/packages/gui/src/components/preview/preview-toolbar.jsx +81 -0
  61. package/packages/gui/src/components/preview/preview-workspace.jsx +263 -0
  62. package/packages/gui/src/components/preview/screenshot-overlay.jsx +203 -0
  63. package/packages/gui/src/components/ui/toast.jsx +6 -2
  64. package/packages/gui/src/stores/groove.js +149 -9
  65. package/packages/gui/src/views/preview.jsx +6 -0
  66. package/packages/gui/src/views/settings.jsx +199 -114
  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
package/CLAUDE.md CHANGED
@@ -263,10 +263,3 @@ Audit-driven release. Multi-agent orchestration system with 7 coordination layer
263
263
  - Dashboard: routing donut, cache panel, context health gauges
264
264
  - Monitor/QC agent mode (stay active, loop)
265
265
  - Distribution: demo video, HN launch, Twitter content
266
-
267
- <!-- GROOVE:START -->
268
- ## GROOVE Orchestration (auto-injected)
269
- Active agents: 0
270
- See AGENTS_REGISTRY.md for full agent state.
271
- **Memory policy:** GROOVE manages project memory automatically. Do not read or write MEMORY.md or .groove/memory/ files directly.
272
- <!-- GROOVE:END -->
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.74",
3
+ "version": "0.27.75",
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.75",
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,8 +147,24 @@ 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
  }
@@ -1161,6 +1161,11 @@ For normal file edits within your scope, proceed without review.
1161
1161
  const workingDir = plan.workingDir;
1162
1162
  preview.launch(teamId, workingDir, plan.preview).then((result) => {
1163
1163
  if (!result.launched) {
1164
+ const intentionalSkips = new Set(['no_preview', 'cli', 'none']);
1165
+ if (intentionalSkips.has(result.reason)) {
1166
+ console.log(`[Groove] Preview for team ${teamId} intentionally skipped: ${result.reason}`);
1167
+ return;
1168
+ }
1164
1169
  console.warn(`[Groove] Preview for team ${teamId} did not launch: ${result.reason}`);
1165
1170
  this.daemon.broadcast({
1166
1171
  type: 'preview:failed',
@@ -1171,7 +1176,7 @@ For normal file edits within your scope, proceed without review.
1171
1176
  }
1172
1177
  }).catch((err) => {
1173
1178
  console.error(`[Groove] Preview launch error for team ${teamId}:`, err.message);
1174
- this.daemon.broadcast({ type: 'preview:failed', teamId, reason: err.message });
1179
+ this.daemon.broadcast({ type: 'preview:failed', teamId, kind: plan.preview?.kind, reason: err.message });
1175
1180
  });
1176
1181
  }
1177
1182
 
@@ -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;