groove-dev 0.20.0 → 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.
- package/node_modules/@groove-dev/daemon/package.json +4 -0
- package/node_modules/@groove-dev/daemon/src/api.js +86 -0
- package/node_modules/@groove-dev/daemon/src/gateways/base.js +87 -0
- package/node_modules/@groove-dev/daemon/src/gateways/discord.js +220 -0
- package/node_modules/@groove-dev/daemon/src/gateways/formatter.js +201 -0
- package/node_modules/@groove-dev/daemon/src/gateways/manager.js +695 -0
- package/node_modules/@groove-dev/daemon/src/gateways/slack.js +165 -0
- package/node_modules/@groove-dev/daemon/src/gateways/telegram.js +265 -0
- package/node_modules/@groove-dev/daemon/src/index.js +4 -0
- package/node_modules/@groove-dev/daemon/src/validate.js +55 -0
- package/node_modules/@groove-dev/gui/.groove/codebase-index.json +1 -1
- package/node_modules/@groove-dev/gui/.groove/daemon.host +1 -0
- package/node_modules/@groove-dev/gui/.groove/daemon.pid +1 -0
- package/node_modules/@groove-dev/gui/.groove/timeline.json +2944 -0
- package/node_modules/@groove-dev/gui/AGENTS_REGISTRY.md +9 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-CNqM3_F2.js +552 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-ChDhUvQR.css +1 -0
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/src/stores/groove.js +7 -0
- package/node_modules/@groove-dev/gui/src/views/settings.jsx +353 -3
- package/package.json +1 -1
- package/packages/daemon/package.json +4 -0
- package/packages/daemon/src/api.js +86 -0
- package/packages/daemon/src/gateways/base.js +87 -0
- package/packages/daemon/src/gateways/discord.js +220 -0
- package/packages/daemon/src/gateways/formatter.js +201 -0
- package/packages/daemon/src/gateways/manager.js +695 -0
- package/packages/daemon/src/gateways/slack.js +165 -0
- package/packages/daemon/src/gateways/telegram.js +265 -0
- package/packages/daemon/src/index.js +4 -0
- package/packages/daemon/src/validate.js +55 -0
- package/packages/gui/dist/assets/index-CNqM3_F2.js +552 -0
- package/packages/gui/dist/assets/index-ChDhUvQR.css +1 -0
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/src/stores/groove.js +7 -0
- package/packages/gui/src/views/settings.jsx +353 -3
- package/node_modules/@groove-dev/gui/dist/assets/index-B8ZmjJeV.css +0 -1
- package/node_modules/@groove-dev/gui/dist/assets/index-DKov-d0e.js +0 -537
- package/packages/gui/dist/assets/index-B8ZmjJeV.css +0 -1
- package/packages/gui/dist/assets/index-DKov-d0e.js +0 -537
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
// GROOVE — Gateway Manager (Lifecycle, Event Routing, Command Dispatch)
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, unlinkSync } from 'fs';
|
|
5
|
+
import { resolve } from 'path';
|
|
6
|
+
import { randomUUID } from 'crypto';
|
|
7
|
+
import { eventToSummary, agentListText, statusText, approvalsText, teamsText, schedulesText, truncate, formatTokens } from './formatter.js';
|
|
8
|
+
|
|
9
|
+
const GATEWAY_TYPES = ['telegram', 'discord', 'slack'];
|
|
10
|
+
|
|
11
|
+
// Notification presets — which event types each preset includes
|
|
12
|
+
const PRESETS = {
|
|
13
|
+
critical: new Set([
|
|
14
|
+
'approval:request',
|
|
15
|
+
'conflict:detected',
|
|
16
|
+
// agent:exit only when crashed — handled specially in _shouldNotify
|
|
17
|
+
]),
|
|
18
|
+
lifecycle: new Set([
|
|
19
|
+
'approval:request',
|
|
20
|
+
'conflict:detected',
|
|
21
|
+
'agent:exit',
|
|
22
|
+
'rotation:complete',
|
|
23
|
+
'rotation:failed',
|
|
24
|
+
'schedule:execute',
|
|
25
|
+
'phase2:spawned',
|
|
26
|
+
'qc:activated',
|
|
27
|
+
]),
|
|
28
|
+
all: new Set([
|
|
29
|
+
'approval:request',
|
|
30
|
+
'approval:resolved',
|
|
31
|
+
'conflict:detected',
|
|
32
|
+
'agent:exit',
|
|
33
|
+
'rotation:start',
|
|
34
|
+
'rotation:complete',
|
|
35
|
+
'rotation:failed',
|
|
36
|
+
'schedule:execute',
|
|
37
|
+
'phase2:spawned',
|
|
38
|
+
'qc:activated',
|
|
39
|
+
'journalist:cycle',
|
|
40
|
+
'team:created',
|
|
41
|
+
'team:updated',
|
|
42
|
+
'team:deleted',
|
|
43
|
+
]),
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Events that are never forwarded (too high-frequency / GUI-only)
|
|
47
|
+
const NEVER_FORWARD = new Set(['state', 'agent:output', 'file:changed', 'ollama:pull:start', 'ollama:pull:progress', 'ollama:pull:complete', 'ollama:pull:error', 'terminal:output', 'terminal:spawned', 'terminal:exit', 'indexer:complete']);
|
|
48
|
+
|
|
49
|
+
const COALESCE_WINDOW = 3000; // 3 seconds
|
|
50
|
+
const NEVER_COALESCE = new Set(['approval:request']); // Always send immediately
|
|
51
|
+
|
|
52
|
+
// Commands that require 'full' permission (mutate state)
|
|
53
|
+
const WRITE_COMMANDS = new Set(['spawn', 'kill', 'approve', 'reject', 'rotate']);
|
|
54
|
+
// Commands allowed in 'read-only' mode
|
|
55
|
+
const READ_COMMANDS = new Set(['status', 'agents', 'teams', 'schedules', 'help']);
|
|
56
|
+
|
|
57
|
+
export class GatewayManager {
|
|
58
|
+
constructor(daemon) {
|
|
59
|
+
this.daemon = daemon;
|
|
60
|
+
this.gatewaysDir = resolve(daemon.grooveDir, 'gateways');
|
|
61
|
+
mkdirSync(this.gatewaysDir, { recursive: true });
|
|
62
|
+
this.gateways = new Map(); // id -> gateway instance
|
|
63
|
+
this._coalesceTimers = new Map(); // eventType -> { timer, events[] }
|
|
64
|
+
this._originalBroadcast = null;
|
|
65
|
+
this._load();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Start all enabled gateways and begin routing events.
|
|
70
|
+
*/
|
|
71
|
+
async start() {
|
|
72
|
+
// Wrap daemon.broadcast to intercept events for gateway routing
|
|
73
|
+
this._originalBroadcast = this.daemon.broadcast.bind(this.daemon);
|
|
74
|
+
this.daemon.broadcast = (message) => {
|
|
75
|
+
this._originalBroadcast(message);
|
|
76
|
+
this._routeEvent(message);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// Replace placeholders with real gateway instances (async imports)
|
|
80
|
+
await this._materialize();
|
|
81
|
+
|
|
82
|
+
// Connect all enabled gateways
|
|
83
|
+
for (const [id, gw] of this.gateways) {
|
|
84
|
+
if (gw.config.enabled && gw.connect) {
|
|
85
|
+
try {
|
|
86
|
+
await gw.connect();
|
|
87
|
+
this.daemon.audit.log('gateway.connect', { id, type: gw.config.type });
|
|
88
|
+
} catch (err) {
|
|
89
|
+
console.log(`[Groove:Gateway] Failed to connect ${id}: ${err.message}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Disconnect all gateways and restore original broadcast.
|
|
97
|
+
*/
|
|
98
|
+
async stop() {
|
|
99
|
+
// Clear coalesce timers
|
|
100
|
+
for (const { timer } of this._coalesceTimers.values()) {
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
}
|
|
103
|
+
this._coalesceTimers.clear();
|
|
104
|
+
|
|
105
|
+
// Disconnect all gateways
|
|
106
|
+
for (const [id, gw] of this.gateways) {
|
|
107
|
+
if (gw.connected) {
|
|
108
|
+
try {
|
|
109
|
+
await gw.disconnect();
|
|
110
|
+
} catch (err) {
|
|
111
|
+
console.log(`[Groove:Gateway] Error disconnecting ${id}: ${err.message}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Restore original broadcast
|
|
117
|
+
if (this._originalBroadcast) {
|
|
118
|
+
this.daemon.broadcast = this._originalBroadcast;
|
|
119
|
+
this._originalBroadcast = null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Create a new gateway configuration.
|
|
125
|
+
*/
|
|
126
|
+
async create(config) {
|
|
127
|
+
if (!config.type || !GATEWAY_TYPES.includes(config.type)) {
|
|
128
|
+
throw new Error(`Invalid gateway type. Must be one of: ${GATEWAY_TYPES.join(', ')}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const id = config.id || `${config.type}-${randomUUID().slice(0, 6)}`;
|
|
132
|
+
|
|
133
|
+
if (this.gateways.has(id)) {
|
|
134
|
+
throw new Error(`Gateway already exists: ${id}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const gwConfig = {
|
|
138
|
+
id,
|
|
139
|
+
type: config.type,
|
|
140
|
+
enabled: config.enabled !== false,
|
|
141
|
+
chatId: config.chatId || null,
|
|
142
|
+
allowedUsers: Array.isArray(config.allowedUsers) ? config.allowedUsers.map(String) : [],
|
|
143
|
+
notifications: config.notifications || { preset: 'critical' },
|
|
144
|
+
commandPermission: config.commandPermission === 'read-only' ? 'read-only' : 'full',
|
|
145
|
+
createdAt: new Date().toISOString(),
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const gw = await this._instantiate(gwConfig);
|
|
149
|
+
this.gateways.set(id, gw);
|
|
150
|
+
this._save(id);
|
|
151
|
+
|
|
152
|
+
this.daemon.audit.log('gateway.create', { id, type: config.type });
|
|
153
|
+
|
|
154
|
+
// Broadcast gateway status to GUI
|
|
155
|
+
this._broadcastGatewayStatus();
|
|
156
|
+
|
|
157
|
+
return gw.getStatus();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Update an existing gateway configuration.
|
|
162
|
+
*/
|
|
163
|
+
async update(id, updates) {
|
|
164
|
+
const gw = this.gateways.get(id);
|
|
165
|
+
if (!gw) throw new Error(`Gateway not found: ${id}`);
|
|
166
|
+
|
|
167
|
+
const SAFE = ['enabled', 'chatId', 'allowedUsers', 'notifications', 'commandPermission'];
|
|
168
|
+
let needsReconnect = false;
|
|
169
|
+
|
|
170
|
+
for (const key of Object.keys(updates)) {
|
|
171
|
+
if (SAFE.includes(key)) {
|
|
172
|
+
if (key === 'allowedUsers' && Array.isArray(updates[key])) {
|
|
173
|
+
gw.config[key] = updates[key].map(String);
|
|
174
|
+
} else {
|
|
175
|
+
gw.config[key] = updates[key];
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// If enabled state changed, connect/disconnect
|
|
181
|
+
if ('enabled' in updates) {
|
|
182
|
+
if (updates.enabled && !gw.connected) {
|
|
183
|
+
needsReconnect = true;
|
|
184
|
+
} else if (!updates.enabled && gw.connected) {
|
|
185
|
+
await gw.disconnect();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
this._save(id);
|
|
190
|
+
|
|
191
|
+
if (needsReconnect) {
|
|
192
|
+
try {
|
|
193
|
+
await gw.connect();
|
|
194
|
+
} catch (err) {
|
|
195
|
+
console.log(`[Groove:Gateway] Failed to reconnect ${id}: ${err.message}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
this._broadcastGatewayStatus();
|
|
200
|
+
return gw.getStatus();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Delete a gateway.
|
|
205
|
+
*/
|
|
206
|
+
async delete(id) {
|
|
207
|
+
const gw = this.gateways.get(id);
|
|
208
|
+
if (!gw) throw new Error(`Gateway not found: ${id}`);
|
|
209
|
+
|
|
210
|
+
if (gw.connected) {
|
|
211
|
+
await gw.disconnect();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Remove credentials
|
|
215
|
+
for (const ck of gw.constructor.credentialKeys) {
|
|
216
|
+
try { this.daemon.credentials.deleteKey(`gateway:${id}:${ck.key}`); } catch { /* ignore */ }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
this.gateways.delete(id);
|
|
220
|
+
|
|
221
|
+
const filePath = resolve(this.gatewaysDir, `${id}.json`);
|
|
222
|
+
if (existsSync(filePath)) unlinkSync(filePath);
|
|
223
|
+
|
|
224
|
+
this.daemon.audit.log('gateway.delete', { id });
|
|
225
|
+
this._broadcastGatewayStatus();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* List all gateways with their status.
|
|
230
|
+
*/
|
|
231
|
+
list() {
|
|
232
|
+
return Array.from(this.gateways.values()).map((gw) => gw.getStatus());
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Get a specific gateway's status.
|
|
237
|
+
*/
|
|
238
|
+
get(id) {
|
|
239
|
+
const gw = this.gateways.get(id);
|
|
240
|
+
if (!gw) return null;
|
|
241
|
+
return gw.getStatus();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Send a test message through a gateway.
|
|
246
|
+
*/
|
|
247
|
+
async test(id) {
|
|
248
|
+
const gw = this.gateways.get(id);
|
|
249
|
+
if (!gw) throw new Error(`Gateway not found: ${id}`);
|
|
250
|
+
if (!gw.connected) throw new Error('Gateway is not connected');
|
|
251
|
+
|
|
252
|
+
await gw.send('\u2705 Groove gateway connected! Notifications will appear here.');
|
|
253
|
+
this.daemon.audit.log('gateway.test', { id });
|
|
254
|
+
return { ok: true };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Manually connect a gateway.
|
|
259
|
+
*/
|
|
260
|
+
async connect(id) {
|
|
261
|
+
const gw = this.gateways.get(id);
|
|
262
|
+
if (!gw) throw new Error(`Gateway not found: ${id}`);
|
|
263
|
+
if (gw.connected) return gw.getStatus();
|
|
264
|
+
|
|
265
|
+
await gw.connect();
|
|
266
|
+
this.daemon.audit.log('gateway.connect', { id, type: gw.config.type });
|
|
267
|
+
this._broadcastGatewayStatus();
|
|
268
|
+
return gw.getStatus();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Manually disconnect a gateway.
|
|
273
|
+
*/
|
|
274
|
+
async disconnect(id) {
|
|
275
|
+
const gw = this.gateways.get(id);
|
|
276
|
+
if (!gw) throw new Error(`Gateway not found: ${id}`);
|
|
277
|
+
if (!gw.connected) return gw.getStatus();
|
|
278
|
+
|
|
279
|
+
await gw.disconnect();
|
|
280
|
+
this.daemon.audit.log('gateway.disconnect', { id });
|
|
281
|
+
this._broadcastGatewayStatus();
|
|
282
|
+
return gw.getStatus();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Set a credential for a gateway.
|
|
287
|
+
*/
|
|
288
|
+
setCredential(id, key, value) {
|
|
289
|
+
if (!this.gateways.has(id)) throw new Error(`Gateway not found: ${id}`);
|
|
290
|
+
this.daemon.credentials.setKey(`gateway:${id}:${key}`, value);
|
|
291
|
+
this.daemon.audit.log('gateway.credential.set', { id, key });
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Delete a credential for a gateway.
|
|
296
|
+
*/
|
|
297
|
+
deleteCredential(id, key) {
|
|
298
|
+
if (!this.gateways.has(id)) throw new Error(`Gateway not found: ${id}`);
|
|
299
|
+
this.daemon.credentials.deleteKey(`gateway:${id}:${key}`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// -------------------------------------------------------------------
|
|
303
|
+
// Command Routing — chat command → daemon internals
|
|
304
|
+
// -------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Route a command from a chat gateway to the appropriate daemon method.
|
|
308
|
+
* Called by BaseGateway.handleCommand() after authorization check.
|
|
309
|
+
* Enforces commandPermission: 'full' (default) or 'read-only'.
|
|
310
|
+
*/
|
|
311
|
+
async routeCommand(gateway, command, args) {
|
|
312
|
+
// Permission level check
|
|
313
|
+
const permission = gateway.config.commandPermission || 'full';
|
|
314
|
+
if (permission === 'read-only' && WRITE_COMMANDS.has(command)) {
|
|
315
|
+
return { text: `Permission denied. This gateway is read-only.\nAllowed: ${[...READ_COMMANDS].map((c) => '/' + c).join(', ')}` };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
try {
|
|
319
|
+
switch (command) {
|
|
320
|
+
case 'status':
|
|
321
|
+
return this._cmdStatus();
|
|
322
|
+
case 'agents':
|
|
323
|
+
return this._cmdAgents();
|
|
324
|
+
case 'spawn':
|
|
325
|
+
return await this._cmdSpawn(args);
|
|
326
|
+
case 'kill':
|
|
327
|
+
return this._cmdKill(args);
|
|
328
|
+
case 'approve':
|
|
329
|
+
return this._cmdApprove(args);
|
|
330
|
+
case 'reject':
|
|
331
|
+
return this._cmdReject(args);
|
|
332
|
+
case 'rotate':
|
|
333
|
+
return await this._cmdRotate(args);
|
|
334
|
+
case 'teams':
|
|
335
|
+
return this._cmdTeams();
|
|
336
|
+
case 'schedules':
|
|
337
|
+
return this._cmdSchedules();
|
|
338
|
+
case 'help':
|
|
339
|
+
return this._cmdHelp();
|
|
340
|
+
default:
|
|
341
|
+
return { text: `Unknown command: /${command}\nType /help for available commands.` };
|
|
342
|
+
}
|
|
343
|
+
} catch (err) {
|
|
344
|
+
return { text: `Error: ${err.message}` };
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
_cmdStatus() {
|
|
349
|
+
const agents = this.daemon.registry.getAll();
|
|
350
|
+
const uptime = process.uptime() * 1000;
|
|
351
|
+
return { text: statusText(agents, uptime) };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
_cmdAgents() {
|
|
355
|
+
const agents = this.daemon.registry.getAll();
|
|
356
|
+
return { text: agentListText(agents) };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async _cmdSpawn(args) {
|
|
360
|
+
if (args.length === 0) {
|
|
361
|
+
return { text: 'Usage: /spawn <role> [--name <name>] [--prompt "task"]' };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const role = args[0];
|
|
365
|
+
let name, prompt;
|
|
366
|
+
|
|
367
|
+
// Parse --name and --prompt flags
|
|
368
|
+
for (let i = 1; i < args.length; i++) {
|
|
369
|
+
if (args[i] === '--name' && args[i + 1]) {
|
|
370
|
+
name = args[++i];
|
|
371
|
+
} else if (args[i] === '--prompt' && args[i + 1]) {
|
|
372
|
+
// Collect remaining args as prompt (may be quoted)
|
|
373
|
+
prompt = args.slice(i + 1).join(' ').replace(/^["']|["']$/g, '');
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const config = { role };
|
|
379
|
+
if (name) config.name = name;
|
|
380
|
+
if (prompt) config.prompt = prompt;
|
|
381
|
+
|
|
382
|
+
const agent = await this.daemon.processes.spawn(config);
|
|
383
|
+
return { text: `\u2705 Spawned ${agent.name || agent.id} (${role})` };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
_cmdKill(args) {
|
|
387
|
+
if (args.length === 0) return { text: 'Usage: /kill <agent-id>' };
|
|
388
|
+
const id = args[0];
|
|
389
|
+
this.daemon.processes.kill(id);
|
|
390
|
+
return { text: `\u26d4 Killed agent ${id}` };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
_cmdApprove(args) {
|
|
394
|
+
if (args.length === 0) return { text: 'Usage: /approve <approval-id>' };
|
|
395
|
+
this.daemon.supervisor.approve(args[0]);
|
|
396
|
+
return { text: `\u2705 Approved: ${args[0]}` };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
_cmdReject(args) {
|
|
400
|
+
if (args.length === 0) return { text: 'Usage: /reject <approval-id> [reason]' };
|
|
401
|
+
const reason = args.slice(1).join(' ') || undefined;
|
|
402
|
+
this.daemon.supervisor.reject(args[0], reason);
|
|
403
|
+
return { text: `\u274c Rejected: ${args[0]}${reason ? ` — ${reason}` : ''}` };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async _cmdRotate(args) {
|
|
407
|
+
if (args.length === 0) return { text: 'Usage: /rotate <agent-id>' };
|
|
408
|
+
await this.daemon.rotator.rotate(args[0]);
|
|
409
|
+
return { text: `\u{1f504} Rotating agent ${args[0]}...` };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
_cmdTeams() {
|
|
413
|
+
const teams = this.daemon.teams.list();
|
|
414
|
+
return { text: teamsText(teams) };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
_cmdSchedules() {
|
|
418
|
+
const schedules = this.daemon.scheduler.list();
|
|
419
|
+
return { text: schedulesText(schedules) };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
_cmdHelp() {
|
|
423
|
+
return {
|
|
424
|
+
text: [
|
|
425
|
+
'Groove Commands:',
|
|
426
|
+
'/status — daemon status + active agents',
|
|
427
|
+
'/agents — list all agents',
|
|
428
|
+
'/spawn <role> [--name X] [--prompt "Y"] — spawn agent',
|
|
429
|
+
'/kill <id> — kill agent',
|
|
430
|
+
'/approve <id> — approve pending request',
|
|
431
|
+
'/reject <id> [reason] — reject request',
|
|
432
|
+
'/rotate <id> — rotate agent context',
|
|
433
|
+
'/teams — list teams',
|
|
434
|
+
'/schedules — list schedules',
|
|
435
|
+
'/help — this message',
|
|
436
|
+
].join('\n'),
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// -------------------------------------------------------------------
|
|
441
|
+
// Event Routing — daemon broadcast → gateway notifications
|
|
442
|
+
// -------------------------------------------------------------------
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Route a daemon broadcast event to all connected gateways.
|
|
446
|
+
*/
|
|
447
|
+
_routeEvent(message) {
|
|
448
|
+
if (!message || !message.type) return;
|
|
449
|
+
if (NEVER_FORWARD.has(message.type)) return;
|
|
450
|
+
|
|
451
|
+
for (const gw of this.gateways.values()) {
|
|
452
|
+
if (!gw.connected || !gw.config.enabled) continue;
|
|
453
|
+
if (!this._shouldNotify(gw, message)) continue;
|
|
454
|
+
|
|
455
|
+
// Coalesce or send immediately
|
|
456
|
+
if (NEVER_COALESCE.has(message.type)) {
|
|
457
|
+
this._sendEvent(gw, message);
|
|
458
|
+
} else {
|
|
459
|
+
this._coalesceEvent(gw, message);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Check if a gateway should receive this event based on notification preferences.
|
|
466
|
+
*/
|
|
467
|
+
_shouldNotify(gw, message) {
|
|
468
|
+
const prefs = gw.config.notifications || { preset: 'critical' };
|
|
469
|
+
|
|
470
|
+
// Custom per-event overrides take priority
|
|
471
|
+
if (prefs.custom && message.type in prefs.custom) {
|
|
472
|
+
return prefs.custom[message.type];
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Use preset
|
|
476
|
+
const preset = PRESETS[prefs.preset || 'critical'];
|
|
477
|
+
if (!preset) return false;
|
|
478
|
+
|
|
479
|
+
// Special case: 'critical' preset only wants crashed agent:exit
|
|
480
|
+
if (prefs.preset === 'critical' && message.type === 'agent:exit') {
|
|
481
|
+
return message.status === 'crashed';
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return preset.has(message.type);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Format and send an event notification to a gateway.
|
|
489
|
+
*/
|
|
490
|
+
_sendEvent(gw, message) {
|
|
491
|
+
const text = eventToSummary(message);
|
|
492
|
+
if (!text) return;
|
|
493
|
+
|
|
494
|
+
const options = {};
|
|
495
|
+
|
|
496
|
+
// Add inline action buttons for approval requests (platform-specific)
|
|
497
|
+
if (message.type === 'approval:request' && message.data?.id) {
|
|
498
|
+
options.approvalId = message.data.id;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
gw.send(text, options).catch((err) => {
|
|
502
|
+
console.log(`[Groove:Gateway] Send failed (${gw.config.id}): ${err.message}`);
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Coalesce rapid events of the same type within a time window.
|
|
508
|
+
*/
|
|
509
|
+
_coalesceEvent(gw, message) {
|
|
510
|
+
const key = `${gw.config.id}:${message.type}`;
|
|
511
|
+
let bucket = this._coalesceTimers.get(key);
|
|
512
|
+
|
|
513
|
+
if (!bucket) {
|
|
514
|
+
bucket = { events: [], timer: null };
|
|
515
|
+
this._coalesceTimers.set(key, bucket);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
bucket.events.push(message);
|
|
519
|
+
|
|
520
|
+
// Reset the flush timer
|
|
521
|
+
if (bucket.timer) clearTimeout(bucket.timer);
|
|
522
|
+
bucket.timer = setTimeout(() => {
|
|
523
|
+
this._flushCoalesced(gw, key, bucket.events);
|
|
524
|
+
this._coalesceTimers.delete(key);
|
|
525
|
+
}, COALESCE_WINDOW);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Flush coalesced events — send as a single batch message.
|
|
530
|
+
*/
|
|
531
|
+
_flushCoalesced(gw, key, events) {
|
|
532
|
+
if (events.length === 0) return;
|
|
533
|
+
|
|
534
|
+
if (events.length === 1) {
|
|
535
|
+
this._sendEvent(gw, events[0]);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Batch: summarize multiple events of the same type
|
|
540
|
+
const type = events[0].type;
|
|
541
|
+
let text;
|
|
542
|
+
|
|
543
|
+
switch (type) {
|
|
544
|
+
case 'agent:exit': {
|
|
545
|
+
const groups = {};
|
|
546
|
+
for (const e of events) {
|
|
547
|
+
const s = e.status || 'unknown';
|
|
548
|
+
if (!groups[s]) groups[s] = [];
|
|
549
|
+
groups[s].push(e.agentId || 'unknown');
|
|
550
|
+
}
|
|
551
|
+
const parts = Object.entries(groups).map(([s, ids]) => `${ids.length} ${s}: ${ids.join(', ')}`);
|
|
552
|
+
text = `\u{1f4cb} Agent updates — ${parts.join(' | ')}`;
|
|
553
|
+
break;
|
|
554
|
+
}
|
|
555
|
+
case 'conflict:detected':
|
|
556
|
+
text = `\u26a0\ufe0f ${events.length} scope conflicts detected`;
|
|
557
|
+
break;
|
|
558
|
+
default: {
|
|
559
|
+
// Generic batch: send summaries joined
|
|
560
|
+
const summaries = events.map(eventToSummary).filter(Boolean);
|
|
561
|
+
text = summaries.join('\n');
|
|
562
|
+
break;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (text) {
|
|
567
|
+
gw.send(text).catch((err) => {
|
|
568
|
+
console.log(`[Groove:Gateway] Batch send failed (${gw.config.id}): ${err.message}`);
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// -------------------------------------------------------------------
|
|
574
|
+
// Gateway Status Broadcast to GUI
|
|
575
|
+
// -------------------------------------------------------------------
|
|
576
|
+
|
|
577
|
+
_broadcastGatewayStatus() {
|
|
578
|
+
if (this._originalBroadcast) {
|
|
579
|
+
this._originalBroadcast({
|
|
580
|
+
type: 'gateway:status',
|
|
581
|
+
data: this.list(),
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// -------------------------------------------------------------------
|
|
587
|
+
// Instantiation & Persistence
|
|
588
|
+
// -------------------------------------------------------------------
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Dynamically instantiate a gateway by type.
|
|
592
|
+
*/
|
|
593
|
+
async _instantiate(config) {
|
|
594
|
+
switch (config.type) {
|
|
595
|
+
case 'telegram': {
|
|
596
|
+
const { TelegramGateway } = await import('./telegram.js');
|
|
597
|
+
return new TelegramGateway(this.daemon, config);
|
|
598
|
+
}
|
|
599
|
+
case 'discord': {
|
|
600
|
+
try {
|
|
601
|
+
const { DiscordGateway } = await import('./discord.js');
|
|
602
|
+
return new DiscordGateway(this.daemon, config);
|
|
603
|
+
} catch (err) {
|
|
604
|
+
if (err.code === 'ERR_MODULE_NOT_FOUND') {
|
|
605
|
+
throw new Error('Discord gateway requires discord.js. Install with: npm i discord.js');
|
|
606
|
+
}
|
|
607
|
+
throw err;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
case 'slack': {
|
|
611
|
+
try {
|
|
612
|
+
const { SlackGateway } = await import('./slack.js');
|
|
613
|
+
return new SlackGateway(this.daemon, config);
|
|
614
|
+
} catch (err) {
|
|
615
|
+
if (err.code === 'ERR_MODULE_NOT_FOUND') {
|
|
616
|
+
throw new Error('Slack gateway requires @slack/bolt. Install with: npm i @slack/bolt');
|
|
617
|
+
}
|
|
618
|
+
throw err;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
default:
|
|
622
|
+
throw new Error(`Unknown gateway type: ${config.type}`);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
_save(id) {
|
|
627
|
+
const gw = this.gateways.get(id);
|
|
628
|
+
if (!gw) return;
|
|
629
|
+
const filePath = resolve(this.gatewaysDir, `${id}.json`);
|
|
630
|
+
writeFileSync(filePath, JSON.stringify(gw.config, null, 2));
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
_load() {
|
|
634
|
+
if (!existsSync(this.gatewaysDir)) return;
|
|
635
|
+
for (const file of readdirSync(this.gatewaysDir)) {
|
|
636
|
+
if (!file.endsWith('.json')) continue;
|
|
637
|
+
try {
|
|
638
|
+
const config = JSON.parse(readFileSync(resolve(this.gatewaysDir, file), 'utf8'));
|
|
639
|
+
const id = config.id || file.replace('.json', '');
|
|
640
|
+
config.id = id;
|
|
641
|
+
// Synchronous load — use TelegramGateway directly for known types
|
|
642
|
+
// Dynamic import is async, so we defer connection to start()
|
|
643
|
+
this._instantiateSync(config);
|
|
644
|
+
} catch (err) {
|
|
645
|
+
console.log(`[Groove:Gateway] Failed to load ${file}: ${err.message}`);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Synchronous instantiation for load-time (before start).
|
|
652
|
+
* Only Telegram is guaranteed available (no external deps).
|
|
653
|
+
* Discord/Slack will be instantiated in start() if their deps are present.
|
|
654
|
+
*/
|
|
655
|
+
_instantiateSync(config) {
|
|
656
|
+
// Store config for deferred async instantiation in start()
|
|
657
|
+
// Use a placeholder that holds config but isn't connected
|
|
658
|
+
const placeholder = {
|
|
659
|
+
config,
|
|
660
|
+
connected: false,
|
|
661
|
+
constructor: { type: config.type, displayName: config.type, credentialKeys: [] },
|
|
662
|
+
getStatus() {
|
|
663
|
+
return {
|
|
664
|
+
id: config.id,
|
|
665
|
+
type: config.type,
|
|
666
|
+
displayName: config.type,
|
|
667
|
+
connected: false,
|
|
668
|
+
enabled: config.enabled,
|
|
669
|
+
chatId: config.chatId || null,
|
|
670
|
+
notifications: config.notifications || { preset: 'critical' },
|
|
671
|
+
allowedUsers: (config.allowedUsers || []).length,
|
|
672
|
+
pending: true, // Not yet fully instantiated
|
|
673
|
+
};
|
|
674
|
+
},
|
|
675
|
+
};
|
|
676
|
+
this.gateways.set(config.id, placeholder);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Called during start() to replace placeholders with real gateway instances.
|
|
681
|
+
*/
|
|
682
|
+
async _materialize() {
|
|
683
|
+
for (const [id, entry] of this.gateways) {
|
|
684
|
+
if (entry.pending || !entry.connect) {
|
|
685
|
+
try {
|
|
686
|
+
const gw = await this._instantiate(entry.config);
|
|
687
|
+
this.gateways.set(id, gw);
|
|
688
|
+
} catch (err) {
|
|
689
|
+
console.log(`[Groove:Gateway] Failed to instantiate ${id}: ${err.message}`);
|
|
690
|
+
this.gateways.delete(id);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|