groove-dev 0.19.9 → 0.21.0

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 (45) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/CLAUDE.md +1 -1
  3. package/node_modules/@groove-dev/cli/bin/groove.js +1 -1
  4. package/node_modules/@groove-dev/daemon/package.json +4 -0
  5. package/node_modules/@groove-dev/daemon/src/api.js +86 -0
  6. package/node_modules/@groove-dev/daemon/src/gateways/base.js +87 -0
  7. package/node_modules/@groove-dev/daemon/src/gateways/discord.js +220 -0
  8. package/node_modules/@groove-dev/daemon/src/gateways/formatter.js +201 -0
  9. package/node_modules/@groove-dev/daemon/src/gateways/manager.js +695 -0
  10. package/node_modules/@groove-dev/daemon/src/gateways/slack.js +165 -0
  11. package/node_modules/@groove-dev/daemon/src/gateways/telegram.js +265 -0
  12. package/node_modules/@groove-dev/daemon/src/index.js +4 -0
  13. package/node_modules/@groove-dev/daemon/src/validate.js +55 -0
  14. package/node_modules/@groove-dev/gui/.groove/audit.log +2 -0
  15. package/node_modules/@groove-dev/gui/.groove/codebase-index.json +1 -1
  16. package/node_modules/@groove-dev/gui/.groove/config.json +2 -2
  17. package/node_modules/@groove-dev/gui/.groove/daemon.pid +1 -1
  18. package/node_modules/@groove-dev/gui/.groove/timeline.json +3000 -0
  19. package/node_modules/@groove-dev/gui/AGENTS_REGISTRY.md +1 -1
  20. package/node_modules/@groove-dev/gui/dist/assets/index-CNqM3_F2.js +552 -0
  21. package/node_modules/@groove-dev/gui/dist/assets/index-ChDhUvQR.css +1 -0
  22. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  23. package/node_modules/@groove-dev/gui/src/stores/groove.js +7 -0
  24. package/node_modules/@groove-dev/gui/src/views/settings.jsx +382 -25
  25. package/package.json +1 -1
  26. package/packages/cli/bin/groove.js +1 -1
  27. package/packages/daemon/package.json +4 -0
  28. package/packages/daemon/src/api.js +86 -0
  29. package/packages/daemon/src/gateways/base.js +87 -0
  30. package/packages/daemon/src/gateways/discord.js +220 -0
  31. package/packages/daemon/src/gateways/formatter.js +201 -0
  32. package/packages/daemon/src/gateways/manager.js +695 -0
  33. package/packages/daemon/src/gateways/slack.js +165 -0
  34. package/packages/daemon/src/gateways/telegram.js +265 -0
  35. package/packages/daemon/src/index.js +4 -0
  36. package/packages/daemon/src/validate.js +55 -0
  37. package/packages/gui/dist/assets/index-CNqM3_F2.js +552 -0
  38. package/packages/gui/dist/assets/index-ChDhUvQR.css +1 -0
  39. package/packages/gui/dist/index.html +2 -2
  40. package/packages/gui/src/stores/groove.js +7 -0
  41. package/packages/gui/src/views/settings.jsx +382 -25
  42. package/node_modules/@groove-dev/gui/dist/assets/index-CdbNHOqF.css +0 -1
  43. package/node_modules/@groove-dev/gui/dist/assets/index-Db0ZssmH.js +0 -537
  44. package/packages/gui/dist/assets/index-CdbNHOqF.css +0 -1
  45. package/packages/gui/dist/assets/index-Db0ZssmH.js +0 -537
@@ -15,7 +15,8 @@ import {
15
15
  Key, Eye, EyeOff, Check, Cpu, ChevronDown,
16
16
  FolderOpen, FolderSearch, RotateCw, Users, Gauge, Zap,
17
17
  LogIn, LogOut, User, ShieldCheck, Settings,
18
- Newspaper, Layers,
18
+ Newspaper, Layers, Radio, Send, MessageSquare, MessageCircle,
19
+ Plus, Trash2, Plug, PlugZap, TestTube, X,
19
20
  } from 'lucide-react';
20
21
 
21
22
  /* ── Toggle ────────────────────────────────────────────────── */
@@ -128,10 +129,10 @@ function ProviderCard({ provider, onKeyChange }) {
128
129
  </div>
129
130
 
130
131
  {/* Body */}
131
- <div className="flex-1 flex flex-col px-4 py-3">
132
+ <div className="flex-1 flex flex-col px-4 py-3 min-h-[120px]">
132
133
  {/* Models */}
133
134
  {provider.models?.length > 0 && (
134
- <div className="flex flex-wrap gap-1 mb-2">
135
+ <div className="flex flex-wrap gap-1 mb-3">
135
136
  {provider.models.map((m) => (
136
137
  <span key={m.id} className="px-1.5 py-0.5 rounded bg-surface-4 text-2xs font-mono text-text-3">
137
138
  {m.name || m.id}
@@ -141,16 +142,16 @@ function ProviderCard({ provider, onKeyChange }) {
141
142
  )}
142
143
 
143
144
  {/* Subscription info for Claude */}
144
- {isSubscription && isReady && !provider.hasKey && (
145
- <div className="flex items-center gap-1.5 h-7 px-2 bg-accent/8 border border-accent/20 rounded text-2xs font-sans text-accent mb-2">
145
+ {isSubscription && isReady && !provider.hasKey && !settingKey && (
146
+ <div className="flex items-center gap-1.5 h-8 px-2.5 bg-accent/8 border border-accent/20 rounded-md text-2xs font-sans text-accent mb-3">
146
147
  <Check size={10} /> Subscription active
147
148
  </div>
148
149
  )}
149
150
 
150
151
  {/* Connected state */}
151
152
  {provider.hasKey && !settingKey && (
152
- <div className="flex items-center gap-1.5 mb-2">
153
- <div className="flex-1 flex items-center gap-1.5 h-7 px-2 bg-success/8 border border-success/20 rounded text-2xs font-sans text-success">
153
+ <div className="flex items-center gap-2 mb-3">
154
+ <div className="flex-1 flex items-center gap-1.5 h-8 px-2.5 bg-success/8 border border-success/20 rounded-md text-2xs font-sans text-success">
154
155
  <Check size={10} /> API Connected
155
156
  </div>
156
157
  <button onClick={() => { setSettingKey(true); setShowKey(false); setKeyInput(''); }} className="text-2xs text-text-4 hover:text-accent cursor-pointer font-sans">Edit</button>
@@ -158,44 +159,51 @@ function ProviderCard({ provider, onKeyChange }) {
158
159
  </div>
159
160
  )}
160
161
 
161
- {/* Key input form */}
162
+ {/* Spacer */}
163
+ <div className="flex-1" />
164
+
165
+ {/* Key input form — takes over the bottom area */}
162
166
  {settingKey && (
163
- <div className="space-y-1.5 mb-2">
164
- <div className="flex gap-1.5">
165
- <div className="flex-1 relative">
167
+ <div className="space-y-2.5 pt-1">
168
+ <div>
169
+ <label className="text-2xs font-semibold text-text-2 font-sans mb-1.5 block">
170
+ {provider.hasKey ? 'Update API Key' : `${provider.name} API Key`}
171
+ </label>
172
+ <div className="relative">
166
173
  <input
167
174
  value={keyInput}
168
175
  onChange={(e) => setKeyInput(e.target.value)}
169
176
  onKeyDown={(e) => e.key === 'Enter' && handleSetKey()}
170
177
  type={showKey ? 'text' : 'password'}
171
- placeholder="Paste API key..."
172
- className="w-full h-7 px-2.5 pr-7 text-2xs bg-surface-0 border border-border rounded-md text-text-0 font-mono placeholder:text-text-4 focus:outline-none focus:ring-1 focus:ring-accent"
178
+ placeholder="sk-..."
179
+ className="w-full h-9 px-3 pr-9 text-xs bg-surface-0 border border-border rounded-md text-text-0 font-mono placeholder:text-text-4 focus:outline-none focus:ring-1 focus:ring-accent"
173
180
  autoFocus
174
181
  />
175
- <button onClick={() => setShowKey(!showKey)} className="absolute right-2 top-1/2 -translate-y-1/2 text-text-4 hover:text-text-2 cursor-pointer">
176
- {showKey ? <EyeOff size={10} /> : <Eye size={10} />}
182
+ <button onClick={() => setShowKey(!showKey)} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-text-4 hover:text-text-2 cursor-pointer">
183
+ {showKey ? <EyeOff size={12} /> : <Eye size={12} />}
177
184
  </button>
178
185
  </div>
179
186
  </div>
180
- <div className="flex gap-1.5">
181
- <Button variant="primary" size="sm" onClick={handleSetKey} disabled={!keyInput.trim()} className="flex-1 h-7 text-2xs">Save</Button>
182
- <Button variant="ghost" size="sm" onClick={() => { setSettingKey(false); setKeyInput(''); }} className="h-7 text-2xs px-2">Cancel</Button>
187
+ <div className="flex gap-2">
188
+ <Button variant="primary" size="sm" onClick={handleSetKey} disabled={!keyInput.trim()} className="flex-1 h-8 text-xs">
189
+ Save Key
190
+ </Button>
191
+ <Button variant="ghost" size="sm" onClick={() => { setSettingKey(false); setKeyInput(''); }} className="h-8 text-xs px-3">
192
+ Cancel
193
+ </Button>
183
194
  </div>
184
195
  </div>
185
196
  )}
186
197
 
187
- {/* Spacer to push button to bottom */}
188
- <div className="flex-1" />
189
-
190
198
  {/* Bottom action — always at card bottom */}
191
199
  {!settingKey && !provider.hasKey && (
192
200
  <Button
193
201
  variant={isSubscription ? 'secondary' : 'primary'}
194
202
  size="sm"
195
203
  onClick={() => { setSettingKey(true); setShowKey(false); setKeyInput(''); }}
196
- className="w-full h-7 text-2xs gap-1 mt-2"
204
+ className="w-full h-8 text-2xs gap-1.5 mt-2"
197
205
  >
198
- <Key size={10} />
206
+ <Key size={11} />
199
207
  {isSubscription ? 'Add API key for headless mode' : 'Add API Key'}
200
208
  </Button>
201
209
  )}
@@ -221,12 +229,330 @@ function ConfigCard({ icon: Icon, label, description, children }) {
221
229
  );
222
230
  }
223
231
 
232
+ /* ── Gateway Icons ─────────────────────────────────────────── */
233
+
234
+ const GATEWAY_ICONS = { telegram: Send, discord: MessageSquare, slack: MessageCircle };
235
+ const GATEWAY_LABELS = { telegram: 'Telegram', discord: 'Discord', slack: 'Slack' };
236
+ const GATEWAY_PLACEHOLDERS = { telegram: 'Bot token from @BotFather', discord: 'Bot token from Developer Portal', slack: 'Bot token (xoxb-...)' };
237
+ const NOTIFICATION_PRESETS = ['critical', 'lifecycle', 'all'];
238
+
239
+ /* ── Gateway Card ─────────────────────────────────────────── */
240
+
241
+ function GatewayCard({ gateway, onRefresh }) {
242
+ const [settingToken, setSettingToken] = useState(false);
243
+ const [tokenInput, setTokenInput] = useState('');
244
+ const [appTokenInput, setAppTokenInput] = useState('');
245
+ const [showToken, setShowToken] = useState(false);
246
+ const [testing, setTesting] = useState(false);
247
+ const [connecting, setConnecting] = useState(false);
248
+ const addToast = useGrooveStore((s) => s.addToast);
249
+
250
+ const Icon = GATEWAY_ICONS[gateway.type] || Radio;
251
+ const isSlack = gateway.type === 'slack';
252
+
253
+ async function handleSaveToken() {
254
+ if (!tokenInput.trim()) return;
255
+ try {
256
+ await api.post(`/gateways/${gateway.id}/credentials`, { key: 'bot_token', value: tokenInput.trim() });
257
+ if (isSlack && appTokenInput.trim()) {
258
+ await api.post(`/gateways/${gateway.id}/credentials`, { key: 'app_token', value: appTokenInput.trim() });
259
+ }
260
+ addToast('success', `Token saved for ${GATEWAY_LABELS[gateway.type]}`);
261
+ setTokenInput('');
262
+ setAppTokenInput('');
263
+ setSettingToken(false);
264
+ onRefresh();
265
+ } catch (err) {
266
+ addToast('error', 'Failed to save token', err.message);
267
+ }
268
+ }
269
+
270
+ async function handleTest() {
271
+ setTesting(true);
272
+ try {
273
+ await api.post(`/gateways/${gateway.id}/test`);
274
+ addToast('success', 'Test message sent!');
275
+ } catch (err) {
276
+ addToast('error', 'Test failed', err.message);
277
+ }
278
+ setTesting(false);
279
+ }
280
+
281
+ async function handleToggleConnect() {
282
+ setConnecting(true);
283
+ try {
284
+ if (gateway.connected) {
285
+ await api.post(`/gateways/${gateway.id}/disconnect`);
286
+ addToast('info', `${GATEWAY_LABELS[gateway.type]} disconnected`);
287
+ } else {
288
+ await api.post(`/gateways/${gateway.id}/connect`);
289
+ addToast('success', `${GATEWAY_LABELS[gateway.type]} connected!`);
290
+ }
291
+ onRefresh();
292
+ } catch (err) {
293
+ addToast('error', gateway.connected ? 'Disconnect failed' : 'Connect failed', err.message);
294
+ }
295
+ setConnecting(false);
296
+ }
297
+
298
+ async function handleToggleEnabled(enabled) {
299
+ try {
300
+ await api.patch(`/gateways/${gateway.id}`, { enabled });
301
+ onRefresh();
302
+ } catch (err) {
303
+ addToast('error', 'Update failed', err.message);
304
+ }
305
+ }
306
+
307
+ async function handlePresetChange(preset) {
308
+ try {
309
+ await api.patch(`/gateways/${gateway.id}`, { notifications: { preset } });
310
+ onRefresh();
311
+ } catch (err) {
312
+ addToast('error', 'Update failed', err.message);
313
+ }
314
+ }
315
+
316
+ async function handlePermissionChange(perm) {
317
+ try {
318
+ await api.patch(`/gateways/${gateway.id}`, { commandPermission: perm });
319
+ onRefresh();
320
+ } catch (err) {
321
+ addToast('error', 'Update failed', err.message);
322
+ }
323
+ }
324
+
325
+ async function handleDelete() {
326
+ try {
327
+ await api.delete(`/gateways/${gateway.id}`);
328
+ addToast('info', `${GATEWAY_LABELS[gateway.type]} gateway removed`);
329
+ onRefresh();
330
+ } catch (err) {
331
+ addToast('error', 'Delete failed', err.message);
332
+ }
333
+ }
334
+
335
+ const currentPreset = gateway.notifications?.preset || 'critical';
336
+
337
+ return (
338
+ <div className="flex flex-col rounded-lg border border-border-subtle bg-surface-1 overflow-hidden min-w-[220px]">
339
+ {/* Header */}
340
+ <div className="flex items-center gap-2.5 px-4 py-3 border-b border-border-subtle">
341
+ <StatusDot status={gateway.connected ? 'running' : 'crashed'} size="sm" />
342
+ <Icon size={13} className="text-text-2" />
343
+ <span className="text-[13px] font-semibold text-text-0 font-sans">{GATEWAY_LABELS[gateway.type]}</span>
344
+ <div className="flex-1" />
345
+ {gateway.connected ? (
346
+ <Badge variant="success" className="text-2xs gap-1"><PlugZap size={8} /> Connected</Badge>
347
+ ) : gateway.enabled ? (
348
+ <Badge variant="warning" className="text-2xs">Disconnected</Badge>
349
+ ) : (
350
+ <Badge variant="default" className="text-2xs">Disabled</Badge>
351
+ )}
352
+ </div>
353
+
354
+ {/* Body */}
355
+ <div className="flex-1 flex flex-col px-4 py-3 min-h-[140px]">
356
+
357
+ {/* Connected state */}
358
+ {gateway.connected && !settingToken && (
359
+ <>
360
+ <div className="flex items-center gap-1.5 h-8 px-2.5 bg-success/8 border border-success/20 rounded-md text-2xs font-sans text-success mb-3">
361
+ <Check size={10} /> Gateway active
362
+ {gateway.botUsername && <span className="text-text-4 ml-1">@{gateway.botUsername}</span>}
363
+ {gateway.botTag && <span className="text-text-4 ml-1">{gateway.botTag}</span>}
364
+ </div>
365
+
366
+ {/* Notification preset */}
367
+ <div className="mb-3">
368
+ <label className="text-2xs font-semibold text-text-3 font-sans mb-1.5 block">Notifications</label>
369
+ <div className="flex bg-surface-0 rounded-md p-0.5 border border-border-subtle">
370
+ {NOTIFICATION_PRESETS.map((p) => (
371
+ <button
372
+ key={p}
373
+ onClick={() => handlePresetChange(p)}
374
+ className={cn(
375
+ 'flex-1 px-2 py-1.5 text-2xs font-semibold font-sans rounded transition-all cursor-pointer capitalize',
376
+ currentPreset === p ? 'bg-accent/15 text-accent shadow-sm' : 'text-text-3 hover:text-text-1',
377
+ )}
378
+ >
379
+ {p}
380
+ </button>
381
+ ))}
382
+ </div>
383
+ </div>
384
+
385
+ {/* Command permissions */}
386
+ <div className="mb-3">
387
+ <label className="text-2xs font-semibold text-text-3 font-sans mb-1.5 block">Commands</label>
388
+ <div className="flex bg-surface-0 rounded-md p-0.5 border border-border-subtle">
389
+ {['full', 'read-only'].map((p) => (
390
+ <button
391
+ key={p}
392
+ onClick={() => handlePermissionChange(p)}
393
+ className={cn(
394
+ 'flex-1 px-2 py-1.5 text-2xs font-semibold font-sans rounded transition-all cursor-pointer capitalize',
395
+ (gateway.commandPermission || 'full') === p ? 'bg-accent/15 text-accent shadow-sm' : 'text-text-3 hover:text-text-1',
396
+ )}
397
+ >
398
+ {p === 'full' ? 'Full Access' : 'Read Only'}
399
+ </button>
400
+ ))}
401
+ </div>
402
+ </div>
403
+ </>
404
+ )}
405
+
406
+ {/* Not connected, not editing — show action */}
407
+ {!gateway.connected && !settingToken && (
408
+ <div className="text-xs text-text-3 font-sans mb-3">
409
+ {gateway.enabled ? 'Configure bot token to connect.' : 'Gateway is disabled.'}
410
+ </div>
411
+ )}
412
+
413
+ <div className="flex-1" />
414
+
415
+ {/* Token input form */}
416
+ {settingToken && (
417
+ <div className="space-y-2.5 pt-1">
418
+ <div>
419
+ <label className="text-2xs font-semibold text-text-2 font-sans mb-1.5 block">Bot Token</label>
420
+ <div className="relative">
421
+ <input
422
+ value={tokenInput}
423
+ onChange={(e) => setTokenInput(e.target.value)}
424
+ onKeyDown={(e) => e.key === 'Enter' && !isSlack && handleSaveToken()}
425
+ type={showToken ? 'text' : 'password'}
426
+ placeholder={GATEWAY_PLACEHOLDERS[gateway.type]}
427
+ className="w-full h-9 px-3 pr-9 text-xs bg-surface-0 border border-border rounded-md text-text-0 font-mono placeholder:text-text-4 focus:outline-none focus:ring-1 focus:ring-accent"
428
+ autoFocus
429
+ />
430
+ <button onClick={() => setShowToken(!showToken)} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-text-4 hover:text-text-2 cursor-pointer">
431
+ {showToken ? <EyeOff size={12} /> : <Eye size={12} />}
432
+ </button>
433
+ </div>
434
+ </div>
435
+ {isSlack && (
436
+ <div>
437
+ <label className="text-2xs font-semibold text-text-2 font-sans mb-1.5 block">App Token (Socket Mode)</label>
438
+ <input
439
+ value={appTokenInput}
440
+ onChange={(e) => setAppTokenInput(e.target.value)}
441
+ onKeyDown={(e) => e.key === 'Enter' && handleSaveToken()}
442
+ type={showToken ? 'text' : 'password'}
443
+ placeholder="xapp-..."
444
+ className="w-full h-9 px-3 text-xs bg-surface-0 border border-border rounded-md text-text-0 font-mono placeholder:text-text-4 focus:outline-none focus:ring-1 focus:ring-accent"
445
+ />
446
+ </div>
447
+ )}
448
+ <div className="flex gap-2">
449
+ <Button variant="primary" size="sm" onClick={handleSaveToken} disabled={!tokenInput.trim()} className="flex-1 h-8 text-xs">
450
+ Save Token
451
+ </Button>
452
+ <Button variant="ghost" size="sm" onClick={() => { setSettingToken(false); setTokenInput(''); setAppTokenInput(''); }} className="h-8 text-xs px-3">
453
+ Cancel
454
+ </Button>
455
+ </div>
456
+ </div>
457
+ )}
458
+
459
+ {/* Bottom actions */}
460
+ {!settingToken && (
461
+ <div className="flex gap-2 mt-2">
462
+ {!gateway.connected && (
463
+ <Button variant="primary" size="sm" onClick={() => setSettingToken(true)} className="flex-1 h-7 text-2xs gap-1.5">
464
+ <Key size={11} />
465
+ {gateway.enabled ? 'Set Token' : 'Configure'}
466
+ </Button>
467
+ )}
468
+ {gateway.connected && (
469
+ <>
470
+ <Button variant="secondary" size="sm" onClick={handleTest} disabled={testing} className="flex-1 h-7 text-2xs gap-1.5">
471
+ <TestTube size={11} />
472
+ {testing ? 'Sending...' : 'Test'}
473
+ </Button>
474
+ <Button variant="secondary" size="sm" onClick={() => setSettingToken(true)} className="h-7 text-2xs px-2.5">
475
+ <Key size={11} />
476
+ </Button>
477
+ </>
478
+ )}
479
+ <Button
480
+ variant="ghost"
481
+ size="sm"
482
+ onClick={handleToggleConnect}
483
+ disabled={connecting}
484
+ className="h-7 text-2xs px-2.5"
485
+ title={gateway.connected ? 'Disconnect' : 'Connect'}
486
+ >
487
+ {gateway.connected ? <Plug size={11} /> : <PlugZap size={11} />}
488
+ </Button>
489
+ <Toggle value={gateway.enabled} onChange={handleToggleEnabled} />
490
+ <button onClick={handleDelete} className="text-text-4 hover:text-danger cursor-pointer p-1" title="Remove gateway">
491
+ <Trash2 size={11} />
492
+ </button>
493
+ </div>
494
+ )}
495
+ </div>
496
+ </div>
497
+ );
498
+ }
499
+
500
+ /* ── Add Gateway Button ───────────────────────────────────── */
501
+
502
+ function AddGatewayCard({ existingTypes, onAdd }) {
503
+ const [open, setOpen] = useState(false);
504
+ const available = ['telegram', 'discord', 'slack'].filter((t) => !existingTypes.includes(t));
505
+
506
+ if (available.length === 0) return null;
507
+
508
+ if (!open) {
509
+ return (
510
+ <button
511
+ onClick={() => setOpen(true)}
512
+ className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border-subtle bg-surface-1/50 hover:bg-surface-1 hover:border-accent/30 min-h-[140px] min-w-[220px] cursor-pointer transition-all group"
513
+ >
514
+ <div className="w-8 h-8 rounded-full bg-accent/8 group-hover:bg-accent/15 flex items-center justify-center mb-2 transition-colors">
515
+ <Plus size={14} className="text-accent" />
516
+ </div>
517
+ <span className="text-2xs font-semibold text-text-3 group-hover:text-text-1 font-sans transition-colors">Add Gateway</span>
518
+ </button>
519
+ );
520
+ }
521
+
522
+ return (
523
+ <div className="flex flex-col rounded-lg border border-accent/30 bg-surface-1 overflow-hidden min-w-[220px]">
524
+ <div className="flex items-center gap-2.5 px-4 py-3 border-b border-border-subtle">
525
+ <Radio size={13} className="text-accent" />
526
+ <span className="text-[13px] font-semibold text-text-0 font-sans">Add Gateway</span>
527
+ <div className="flex-1" />
528
+ <button onClick={() => setOpen(false)} className="text-text-4 hover:text-text-1 cursor-pointer"><X size={12} /></button>
529
+ </div>
530
+ <div className="p-3 space-y-2">
531
+ {available.map((type) => {
532
+ const Icon = GATEWAY_ICONS[type];
533
+ return (
534
+ <button
535
+ key={type}
536
+ onClick={() => { onAdd(type); setOpen(false); }}
537
+ className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md bg-surface-0 hover:bg-accent/8 border border-border-subtle hover:border-accent/20 cursor-pointer transition-all group"
538
+ >
539
+ <Icon size={14} className="text-text-3 group-hover:text-accent" />
540
+ <span className="text-xs font-medium text-text-1 group-hover:text-accent font-sans">{GATEWAY_LABELS[type]}</span>
541
+ </button>
542
+ );
543
+ })}
544
+ </div>
545
+ </div>
546
+ );
547
+ }
548
+
224
549
  /* ── Main Settings View ────────────────────────────────────── */
225
550
 
226
551
  export default function SettingsView() {
227
552
  const [providers, setProviders] = useState([]);
228
553
  const [config, setConfig] = useState(null);
229
554
  const [daemonInfo, setDaemonInfo] = useState(null);
555
+ const [gwList, setGwList] = useState([]);
230
556
  const [loading, setLoading] = useState(true);
231
557
  const [folderBrowserOpen, setFolderBrowserOpen] = useState(false);
232
558
  const addToast = useGrooveStore((s) => s.addToast);
@@ -239,12 +565,26 @@ export default function SettingsView() {
239
565
  api.get('/providers').then((d) => setProviders(Array.isArray(d) ? d : [])).catch(() => {});
240
566
  }
241
567
 
568
+ function loadGateways() {
569
+ api.get('/gateways').then((d) => setGwList(Array.isArray(d) ? d : [])).catch(() => {});
570
+ }
571
+
242
572
  useEffect(() => {
243
- Promise.all([api.get('/providers'), api.get('/config'), api.get('/status')])
244
- .then(([p, c, s]) => { setProviders(Array.isArray(p) ? p : []); setConfig(c); setDaemonInfo(s); setLoading(false); })
573
+ Promise.all([api.get('/providers'), api.get('/config'), api.get('/status'), api.get('/gateways')])
574
+ .then(([p, c, s, g]) => { setProviders(Array.isArray(p) ? p : []); setConfig(c); setDaemonInfo(s); setGwList(Array.isArray(g) ? g : []); setLoading(false); })
245
575
  .catch(() => setLoading(false));
246
576
  }, []);
247
577
 
578
+ async function addGateway(type) {
579
+ try {
580
+ await api.post('/gateways', { type });
581
+ addToast('success', `${GATEWAY_LABELS[type]} gateway added`);
582
+ loadGateways();
583
+ } catch (err) {
584
+ addToast('error', 'Failed to add gateway', err.message);
585
+ }
586
+ }
587
+
248
588
  async function updateConfig(key, value) {
249
589
  try {
250
590
  const updated = await api.patch('/config', { [key]: value });
@@ -333,6 +673,23 @@ export default function SettingsView() {
333
673
  </div>
334
674
  </div>
335
675
 
676
+ {/* ═══════ GATEWAYS ═══════ */}
677
+ <div>
678
+ <div className="flex items-center gap-2 mb-2.5 px-0.5">
679
+ <span className="text-2xs font-semibold text-text-3 font-sans uppercase tracking-wider">Gateways</span>
680
+ <div className="flex-1 h-px bg-border-subtle" />
681
+ <span className="text-2xs text-text-4 font-sans">
682
+ {gwList.filter((g) => g.connected).length}/{gwList.length} connected
683
+ </span>
684
+ </div>
685
+ <div className="grid grid-cols-4 gap-3">
686
+ {gwList.map((gw) => (
687
+ <GatewayCard key={gw.id} gateway={gw} onRefresh={loadGateways} />
688
+ ))}
689
+ <AddGatewayCard existingTypes={gwList.map((g) => g.type)} onAdd={addGateway} />
690
+ </div>
691
+ </div>
692
+
336
693
  {/* ═══════ CONFIGURATION ═══════ */}
337
694
  {config && (
338
695
  <div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "groove-dev",
3
- "version": "0.19.9",
3
+ "version": "0.21.0",
4
4
  "description": "Open-source agent orchestration layer — the AI company OS. MCP integrations (Slack, Gmail, Stripe, 15+), agent scheduling (cron), business roles (CMO, CFO, EA). GUI dashboard, multi-agent coordination, zero cold-start, infinite sessions. Works with Claude Code, Codex, Gemini CLI, Ollama.",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "author": "Groove Dev <hello@groovedev.ai> (https://groovedev.ai)",
@@ -24,7 +24,7 @@ import { federationPair, federationUnpair, federationList, federationStatus } fr
24
24
  program
25
25
  .name('groove')
26
26
  .description('Agent orchestration layer for AI coding tools')
27
- .version('0.19.9');
27
+ .version('0.20.0');
28
28
 
29
29
  program
30
30
  .command('start')
@@ -15,5 +15,9 @@
15
15
  "express": "^4.21.0",
16
16
  "minimatch": "^10.0.0"
17
17
  },
18
+ "optionalDependencies": {
19
+ "discord.js": "^14.0.0",
20
+ "@slack/bolt": "^4.0.0"
21
+ },
18
22
  "private": true
19
23
  }
@@ -838,6 +838,92 @@ Keep responses concise. Help them think, don't lecture them about the system the
838
838
  res.json({ id: agent.id, integrations });
839
839
  });
840
840
 
841
+ // --- Gateways (Telegram, Discord, Slack) ---
842
+
843
+ app.get('/api/gateways', (req, res) => {
844
+ res.json(daemon.gateways.list());
845
+ });
846
+
847
+ app.post('/api/gateways', async (req, res) => {
848
+ try {
849
+ const result = await daemon.gateways.create(req.body || {});
850
+ res.json(result);
851
+ } catch (err) {
852
+ res.status(400).json({ error: err.message });
853
+ }
854
+ });
855
+
856
+ app.get('/api/gateways/:id', (req, res) => {
857
+ const gw = daemon.gateways.get(req.params.id);
858
+ if (!gw) return res.status(404).json({ error: 'Gateway not found' });
859
+ res.json(gw);
860
+ });
861
+
862
+ app.patch('/api/gateways/:id', async (req, res) => {
863
+ try {
864
+ const result = await daemon.gateways.update(req.params.id, req.body || {});
865
+ res.json(result);
866
+ } catch (err) {
867
+ res.status(400).json({ error: err.message });
868
+ }
869
+ });
870
+
871
+ app.delete('/api/gateways/:id', async (req, res) => {
872
+ try {
873
+ await daemon.gateways.delete(req.params.id);
874
+ res.json({ ok: true });
875
+ } catch (err) {
876
+ res.status(400).json({ error: err.message });
877
+ }
878
+ });
879
+
880
+ app.post('/api/gateways/:id/test', async (req, res) => {
881
+ try {
882
+ const result = await daemon.gateways.test(req.params.id);
883
+ res.json(result);
884
+ } catch (err) {
885
+ res.status(400).json({ error: err.message });
886
+ }
887
+ });
888
+
889
+ app.post('/api/gateways/:id/connect', async (req, res) => {
890
+ try {
891
+ const result = await daemon.gateways.connect(req.params.id);
892
+ res.json(result);
893
+ } catch (err) {
894
+ res.status(400).json({ error: err.message });
895
+ }
896
+ });
897
+
898
+ app.post('/api/gateways/:id/disconnect', async (req, res) => {
899
+ try {
900
+ const result = await daemon.gateways.disconnect(req.params.id);
901
+ res.json(result);
902
+ } catch (err) {
903
+ res.status(400).json({ error: err.message });
904
+ }
905
+ });
906
+
907
+ app.post('/api/gateways/:id/credentials', (req, res) => {
908
+ try {
909
+ const { key, value } = req.body || {};
910
+ if (!key || !value) return res.status(400).json({ error: 'key and value are required' });
911
+ daemon.gateways.setCredential(req.params.id, key, value);
912
+ res.json({ ok: true });
913
+ } catch (err) {
914
+ res.status(400).json({ error: err.message });
915
+ }
916
+ });
917
+
918
+ app.delete('/api/gateways/:id/credentials/:key', (req, res) => {
919
+ try {
920
+ daemon.gateways.deleteCredential(req.params.id, req.params.key);
921
+ res.json({ ok: true });
922
+ } catch (err) {
923
+ res.status(400).json({ error: err.message });
924
+ }
925
+ });
926
+
841
927
  // --- Schedules ---
842
928
 
843
929
  app.get('/api/schedules', (req, res) => {