groove-dev 0.21.7 → 0.22.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/src/gateways/formatter.js +95 -0
- package/node_modules/@groove-dev/daemon/src/gateways/manager.js +478 -15
- package/node_modules/@groove-dev/daemon/src/gateways/slack.js +1 -1
- package/node_modules/@groove-dev/gui/dist/assets/{index-BKfu8mGS.js → index-0uIdBqxW.js} +110 -105
- package/node_modules/@groove-dev/gui/dist/assets/{index-Dbnv4xDG.css → index-DoxX3EW2.css} +1 -1
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/src/components/layout/status-bar.jsx +11 -2
- package/node_modules/@groove-dev/gui/src/views/settings.jsx +15 -4
- package/package.json +1 -1
- package/packages/daemon/src/gateways/formatter.js +95 -0
- package/packages/daemon/src/gateways/manager.js +478 -15
- package/packages/daemon/src/gateways/slack.js +1 -1
- package/packages/gui/dist/assets/{index-BKfu8mGS.js → index-0uIdBqxW.js} +110 -105
- package/packages/gui/dist/assets/{index-Dbnv4xDG.css → index-DoxX3EW2.css} +1 -1
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/src/components/layout/status-bar.jsx +11 -2
- package/packages/gui/src/views/settings.jsx +15 -4
|
@@ -199,3 +199,98 @@ export function schedulesText(schedules) {
|
|
|
199
199
|
return `${status} ${s.name} — ${s.cronDescription || s.cron}${running}`;
|
|
200
200
|
}).join('\n');
|
|
201
201
|
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Format journalist brief for chat.
|
|
205
|
+
*/
|
|
206
|
+
export function briefText(status, lastSynthesis) {
|
|
207
|
+
const lines = ['\ud83d\udcf0 Project Brief'];
|
|
208
|
+
|
|
209
|
+
if (status) {
|
|
210
|
+
const state = status.synthesizing ? 'synthesizing...' : status.running ? 'active' : 'idle';
|
|
211
|
+
lines.push(`Journalist: ${state} | ${status.cycleCount || 0} cycles`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (lastSynthesis) {
|
|
215
|
+
if (lastSynthesis.summary) {
|
|
216
|
+
lines.push('');
|
|
217
|
+
lines.push(lastSynthesis.summary);
|
|
218
|
+
}
|
|
219
|
+
if (lastSynthesis.projectMap) {
|
|
220
|
+
const map = truncate(lastSynthesis.projectMap, 1500);
|
|
221
|
+
lines.push('');
|
|
222
|
+
lines.push(map);
|
|
223
|
+
}
|
|
224
|
+
} else {
|
|
225
|
+
lines.push('No synthesis available yet. Journalist runs after agents produce output.');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return lines.join('\n');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Format token usage summary for chat.
|
|
233
|
+
*/
|
|
234
|
+
export function tokensText(summary) {
|
|
235
|
+
if (!summary) return 'No token data available.';
|
|
236
|
+
|
|
237
|
+
const lines = [
|
|
238
|
+
'\ud83d\udcca Token Usage',
|
|
239
|
+
`Agents: ${summary.agentCount} | Turns: ${summary.totalTurns} | Session: ${formatDuration(summary.sessionDurationMs)}`,
|
|
240
|
+
`Tokens: ${formatTokens(summary.totalTokens)} (${formatTokens(summary.totalInputTokens)} in / ${formatTokens(summary.totalOutputTokens)} out)`,
|
|
241
|
+
`Cost: ${formatCost(summary.totalCostUsd)}`,
|
|
242
|
+
];
|
|
243
|
+
|
|
244
|
+
if (summary.cacheHitRate > 0) {
|
|
245
|
+
lines.push(`Cache: ${Math.round(summary.cacheHitRate * 100)}% hit rate (${formatTokens(summary.cacheReadTokens)} reads)`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (summary.savings && summary.savings.total > 0) {
|
|
249
|
+
lines.push('');
|
|
250
|
+
lines.push(`\ud83d\udcb0 Savings: ${formatTokens(summary.savings.total)} tokens (${summary.savings.percentage}%)`);
|
|
251
|
+
if (summary.savings.fromRotation > 0) lines.push(` Rotation: ${formatTokens(summary.savings.fromRotation)}`);
|
|
252
|
+
if (summary.savings.fromConflictPrevention > 0) lines.push(` Conflict prevention: ${formatTokens(summary.savings.fromConflictPrevention)}`);
|
|
253
|
+
if (summary.savings.fromColdStartSkip > 0) lines.push(` Cold-start skip: ${formatTokens(summary.savings.fromColdStartSkip)}`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return lines.join('\n');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Format agent log output for chat.
|
|
261
|
+
*/
|
|
262
|
+
export function logText(agentName, lines) {
|
|
263
|
+
if (!lines || lines.length === 0) return `No log output for ${agentName}.`;
|
|
264
|
+
return `\ud83d\udccb Log: ${agentName} (last ${lines.length} lines)\n\n${lines.join('\n')}`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Format a recommended team plan for chat approval.
|
|
269
|
+
*/
|
|
270
|
+
export function planText(agents, description) {
|
|
271
|
+
if (!agents || agents.length === 0) return 'Empty plan.';
|
|
272
|
+
|
|
273
|
+
const phase1 = agents.filter((a) => !a.phase || a.phase === 1);
|
|
274
|
+
const phase2 = agents.filter((a) => a.phase === 2);
|
|
275
|
+
|
|
276
|
+
const lines = [
|
|
277
|
+
`\ud83d\udccb Plan: ${truncate(description || 'New project', 200)}`,
|
|
278
|
+
'',
|
|
279
|
+
`Team: ${agents.length} agents (${phase1.length} builders${phase2.length > 0 ? `, ${phase2.length} QC` : ''})`,
|
|
280
|
+
'',
|
|
281
|
+
];
|
|
282
|
+
|
|
283
|
+
for (const a of phase1) {
|
|
284
|
+
const scope = a.scope?.length > 0 ? ` [${a.scope.join(', ')}]` : '';
|
|
285
|
+
const model = a.model && a.model !== 'auto' ? ` (${a.model})` : '';
|
|
286
|
+
lines.push(` Phase 1: ${a.role}${model}${scope}`);
|
|
287
|
+
if (a.prompt) lines.push(` ${truncate(a.prompt, 120)}`);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
for (const a of phase2) {
|
|
291
|
+
lines.push(` Phase 2: ${a.role} (auto-spawns after Phase 1)`);
|
|
292
|
+
if (a.prompt) lines.push(` ${truncate(a.prompt, 120)}`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return lines.join('\n');
|
|
296
|
+
}
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, unlinkSync } from 'fs';
|
|
5
5
|
import { resolve } from 'path';
|
|
6
6
|
import { randomUUID } from 'crypto';
|
|
7
|
-
import {
|
|
7
|
+
import { validateAgentConfig } from '../validate.js';
|
|
8
|
+
import { eventToSummary, agentListText, statusText, approvalsText, teamsText, schedulesText, briefText, tokensText, logText, planText, truncate, formatTokens } from './formatter.js';
|
|
8
9
|
|
|
9
10
|
const GATEWAY_TYPES = ['telegram', 'discord', 'slack'];
|
|
10
11
|
|
|
@@ -49,10 +50,13 @@ const NEVER_FORWARD = new Set(['state', 'agent:output', 'file:changed', 'ollama:
|
|
|
49
50
|
const COALESCE_WINDOW = 3000; // 3 seconds
|
|
50
51
|
const NEVER_COALESCE = new Set(['approval:request']); // Always send immediately
|
|
51
52
|
|
|
53
|
+
// Team lead role priority — first match wins
|
|
54
|
+
const LEAD_PRIORITY = ['qc', 'fullstack', 'lead', 'senior', 'pm', 'planner'];
|
|
55
|
+
|
|
52
56
|
// Commands that require 'full' permission (mutate state)
|
|
53
|
-
const WRITE_COMMANDS = new Set(['spawn', 'kill', 'approve', 'reject', 'rotate']);
|
|
57
|
+
const WRITE_COMMANDS = new Set(['spawn', 'kill', 'approve', 'reject', 'rotate', 'instruct', 'plan']);
|
|
54
58
|
// Commands allowed in 'read-only' mode
|
|
55
|
-
const READ_COMMANDS = new Set(['status', 'agents', 'teams', 'schedules', 'help']);
|
|
59
|
+
const READ_COMMANDS = new Set(['status', 'agents', 'teams', 'schedules', 'help', 'log', 'query', 'brief', 'tokens']);
|
|
56
60
|
|
|
57
61
|
export class GatewayManager {
|
|
58
62
|
constructor(daemon) {
|
|
@@ -61,6 +65,7 @@ export class GatewayManager {
|
|
|
61
65
|
mkdirSync(this.gatewaysDir, { recursive: true });
|
|
62
66
|
this.gateways = new Map(); // id -> gateway instance
|
|
63
67
|
this._coalesceTimers = new Map(); // eventType -> { timer, events[] }
|
|
68
|
+
this._pendingPlans = new Map(); // agentId -> { gatewayId, timestamp }
|
|
64
69
|
this._originalBroadcast = null;
|
|
65
70
|
this._load();
|
|
66
71
|
}
|
|
@@ -335,6 +340,18 @@ export class GatewayManager {
|
|
|
335
340
|
return this._cmdTeams();
|
|
336
341
|
case 'schedules':
|
|
337
342
|
return this._cmdSchedules();
|
|
343
|
+
case 'instruct':
|
|
344
|
+
return await this._cmdInstruct(args);
|
|
345
|
+
case 'query':
|
|
346
|
+
return await this._cmdQuery(args, gateway);
|
|
347
|
+
case 'log':
|
|
348
|
+
return this._cmdLog(args);
|
|
349
|
+
case 'plan':
|
|
350
|
+
return await this._cmdPlan(args, gateway);
|
|
351
|
+
case 'brief':
|
|
352
|
+
return this._cmdBrief();
|
|
353
|
+
case 'tokens':
|
|
354
|
+
return this._cmdTokens();
|
|
338
355
|
case 'help':
|
|
339
356
|
return this._cmdHelp();
|
|
340
357
|
default:
|
|
@@ -383,30 +400,159 @@ export class GatewayManager {
|
|
|
383
400
|
return { text: `\u2705 Spawned ${agent.name || agent.id} (${role})` };
|
|
384
401
|
}
|
|
385
402
|
|
|
403
|
+
// -------------------------------------------------------------------
|
|
404
|
+
// Team-First Resolver
|
|
405
|
+
// -------------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Resolve identifier to a team or agent.
|
|
409
|
+
* Priority: team name → team prefix → exact agent ID → agent name → agent prefix → role
|
|
410
|
+
*/
|
|
411
|
+
_resolveTarget(identifier) {
|
|
412
|
+
const agents = this.daemon.registry.getAll();
|
|
413
|
+
const teams = this.daemon.teams.list();
|
|
414
|
+
const lower = identifier.toLowerCase();
|
|
415
|
+
|
|
416
|
+
// 1. Team name (case-insensitive) — primary target
|
|
417
|
+
const team = teams.find((t) => t.name.toLowerCase() === lower);
|
|
418
|
+
if (team) {
|
|
419
|
+
return { type: 'team', team, agents: agents.filter((a) => a.teamId === team.id) };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// 2. Team name prefix
|
|
423
|
+
const teamPrefix = teams.filter((t) => t.name.toLowerCase().startsWith(lower));
|
|
424
|
+
if (teamPrefix.length === 1) {
|
|
425
|
+
return { type: 'team', team: teamPrefix[0], agents: agents.filter((a) => a.teamId === teamPrefix[0].id) };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// 3. Exact agent ID
|
|
429
|
+
const byId = agents.find((a) => a.id === identifier);
|
|
430
|
+
if (byId) return { type: 'agent', agent: byId };
|
|
431
|
+
|
|
432
|
+
// 4. Exact agent name (case-insensitive)
|
|
433
|
+
const byName = agents.find((a) => (a.name || '').toLowerCase() === lower);
|
|
434
|
+
if (byName) return { type: 'agent', agent: byName };
|
|
435
|
+
|
|
436
|
+
// 5. Agent name/ID prefix
|
|
437
|
+
const byPrefix = agents.filter((a) =>
|
|
438
|
+
a.id.toLowerCase().startsWith(lower) ||
|
|
439
|
+
(a.name || '').toLowerCase().startsWith(lower)
|
|
440
|
+
);
|
|
441
|
+
if (byPrefix.length === 1) return { type: 'agent', agent: byPrefix[0] };
|
|
442
|
+
|
|
443
|
+
// 6. Role match (only if one agent has that role)
|
|
444
|
+
const byRole = agents.filter((a) => (a.role || '').toLowerCase() === lower);
|
|
445
|
+
if (byRole.length === 1) return { type: 'agent', agent: byRole[0] };
|
|
446
|
+
|
|
447
|
+
// Ambiguous
|
|
448
|
+
if (byPrefix.length > 1 || teamPrefix.length > 1) {
|
|
449
|
+
const names = [
|
|
450
|
+
...teamPrefix.map((t) => `team:${t.name}`),
|
|
451
|
+
...byPrefix.map((a) => a.name || a.id),
|
|
452
|
+
];
|
|
453
|
+
return { type: 'ambiguous', matches: names };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Resolve to agent only (for kill, rotate — not team-aware).
|
|
461
|
+
*/
|
|
462
|
+
_resolveAgent(identifier) {
|
|
463
|
+
const result = this._resolveTarget(identifier);
|
|
464
|
+
if (!result) return { error: `Not found: ${identifier}. Try /agents or /teams.` };
|
|
465
|
+
if (result.type === 'ambiguous') return { error: `Ambiguous — did you mean: ${result.matches.join(', ')}?` };
|
|
466
|
+
if (result.type === 'team') return { error: `"${result.team.name}" is a team (${result.agents.length} agents). Use a specific agent name.` };
|
|
467
|
+
return { agent: result.agent };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Find the team lead — the senior agent who routes messages.
|
|
472
|
+
* Priority: QC > fullstack > lead > senior > PM > planner > first running
|
|
473
|
+
*/
|
|
474
|
+
_findTeamLead(agents) {
|
|
475
|
+
const running = agents.filter((a) => a.status === 'running' || a.status === 'starting');
|
|
476
|
+
if (running.length === 0) return null;
|
|
477
|
+
for (const keyword of LEAD_PRIORITY) {
|
|
478
|
+
const match = running.find((a) => (a.role || '').toLowerCase().includes(keyword));
|
|
479
|
+
if (match) return match;
|
|
480
|
+
}
|
|
481
|
+
return running[0];
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// -------------------------------------------------------------------
|
|
485
|
+
// Helpers
|
|
486
|
+
// -------------------------------------------------------------------
|
|
487
|
+
|
|
488
|
+
_readAgentLog(agent, lineCount = 20) {
|
|
489
|
+
const logDir = resolve(this.daemon.grooveDir, 'logs');
|
|
490
|
+
const name = (agent.name || agent.id).replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
491
|
+
const logPath = resolve(logDir, `${name}.log`);
|
|
492
|
+
if (!existsSync(logPath)) return null;
|
|
493
|
+
const content = readFileSync(logPath, 'utf8');
|
|
494
|
+
const allLines = content.split('\n').filter(Boolean);
|
|
495
|
+
return allLines.slice(-lineCount);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Find recommended-team.json — same logic as api.js findRecommendedTeam()
|
|
500
|
+
*/
|
|
501
|
+
_findRecommendedTeam() {
|
|
502
|
+
const agents = this.daemon.registry.getAll();
|
|
503
|
+
for (const agent of agents) {
|
|
504
|
+
if (agent.workingDir) {
|
|
505
|
+
const p = resolve(agent.workingDir, '.groove', 'recommended-team.json');
|
|
506
|
+
if (existsSync(p)) return p;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
const p = resolve(this.daemon.grooveDir, 'recommended-team.json');
|
|
510
|
+
if (existsSync(p)) return p;
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// -------------------------------------------------------------------
|
|
515
|
+
// Command Implementations
|
|
516
|
+
// -------------------------------------------------------------------
|
|
517
|
+
|
|
386
518
|
_cmdKill(args) {
|
|
387
|
-
if (args.length === 0) return { text: 'Usage: /kill <agent-
|
|
388
|
-
const
|
|
389
|
-
|
|
390
|
-
|
|
519
|
+
if (args.length === 0) return { text: 'Usage: /kill <agent-name>' };
|
|
520
|
+
const { agent, error } = this._resolveAgent(args[0]);
|
|
521
|
+
if (error) return { text: error };
|
|
522
|
+
this.daemon.processes.kill(agent.id);
|
|
523
|
+
return { text: `\u26d4 Killed ${agent.name || agent.id}` };
|
|
391
524
|
}
|
|
392
525
|
|
|
393
526
|
_cmdApprove(args) {
|
|
394
527
|
if (args.length === 0) return { text: 'Usage: /approve <approval-id>' };
|
|
528
|
+
// Check if approving a pending plan
|
|
529
|
+
const planId = args[0];
|
|
530
|
+
if (this._pendingPlans.has(planId)) {
|
|
531
|
+
return this._launchPlan(planId);
|
|
532
|
+
}
|
|
395
533
|
this.daemon.supervisor.approve(args[0]);
|
|
396
534
|
return { text: `\u2705 Approved: ${args[0]}` };
|
|
397
535
|
}
|
|
398
536
|
|
|
399
537
|
_cmdReject(args) {
|
|
400
538
|
if (args.length === 0) return { text: 'Usage: /reject <approval-id> [reason]' };
|
|
539
|
+
// Check if rejecting a pending plan
|
|
540
|
+
const planId = args[0];
|
|
541
|
+
if (this._pendingPlans.has(planId)) {
|
|
542
|
+
this._pendingPlans.delete(planId);
|
|
543
|
+
return { text: `\u274c Plan discarded.` };
|
|
544
|
+
}
|
|
401
545
|
const reason = args.slice(1).join(' ') || undefined;
|
|
402
546
|
this.daemon.supervisor.reject(args[0], reason);
|
|
403
547
|
return { text: `\u274c Rejected: ${args[0]}${reason ? ` — ${reason}` : ''}` };
|
|
404
548
|
}
|
|
405
549
|
|
|
406
550
|
async _cmdRotate(args) {
|
|
407
|
-
if (args.length === 0) return { text: 'Usage: /rotate <agent-
|
|
408
|
-
|
|
409
|
-
return { text:
|
|
551
|
+
if (args.length === 0) return { text: 'Usage: /rotate <agent-name>' };
|
|
552
|
+
const { agent, error } = this._resolveAgent(args[0]);
|
|
553
|
+
if (error) return { text: error };
|
|
554
|
+
await this.daemon.rotator.rotate(agent.id);
|
|
555
|
+
return { text: `\u{1f504} Rotating ${agent.name || agent.id}...` };
|
|
410
556
|
}
|
|
411
557
|
|
|
412
558
|
_cmdTeams() {
|
|
@@ -419,18 +565,330 @@ export class GatewayManager {
|
|
|
419
565
|
return { text: schedulesText(schedules) };
|
|
420
566
|
}
|
|
421
567
|
|
|
568
|
+
/**
|
|
569
|
+
* Instruct a team or agent. Team-first: routes to team lead.
|
|
570
|
+
*/
|
|
571
|
+
async _cmdInstruct(args) {
|
|
572
|
+
if (args.length < 2) return { text: 'Usage: /instruct <team> <message>' };
|
|
573
|
+
const target = args[0];
|
|
574
|
+
const message = args.slice(1).join(' ');
|
|
575
|
+
|
|
576
|
+
const result = this._resolveTarget(target);
|
|
577
|
+
if (!result) return { text: `Not found: ${target}. Try /teams to see available teams.` };
|
|
578
|
+
if (result.type === 'ambiguous') return { text: `Ambiguous — did you mean: ${result.matches.join(', ')}?` };
|
|
579
|
+
|
|
580
|
+
// Team target — route to team lead
|
|
581
|
+
if (result.type === 'team') {
|
|
582
|
+
const lead = this._findTeamLead(result.agents);
|
|
583
|
+
if (!lead) return { text: `No running agents in team ${result.team.name}. Use /plan to start a new project.` };
|
|
584
|
+
|
|
585
|
+
const resumed = !!lead.sessionId;
|
|
586
|
+
const newAgent = resumed
|
|
587
|
+
? await this.daemon.processes.resume(lead.id, message)
|
|
588
|
+
: await this.daemon.rotator.rotate(lead.id, { additionalPrompt: message });
|
|
589
|
+
|
|
590
|
+
this.daemon.audit.log('agent.instruct', { id: lead.id, newId: newAgent.id, resumed, source: 'gateway', team: result.team.name });
|
|
591
|
+
return { text: `\u2705 Sent to ${lead.name} (${result.team.name} lead): ${truncate(message, 100)}` };
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Direct agent target (fallback)
|
|
595
|
+
const agent = result.agent;
|
|
596
|
+
const resumed = !!agent.sessionId;
|
|
597
|
+
const newAgent = resumed
|
|
598
|
+
? await this.daemon.processes.resume(agent.id, message)
|
|
599
|
+
: await this.daemon.rotator.rotate(agent.id, { additionalPrompt: message });
|
|
600
|
+
|
|
601
|
+
this.daemon.audit.log('agent.instruct', { id: agent.id, newId: newAgent.id, resumed, source: 'gateway' });
|
|
602
|
+
return { text: `\u2705 Instructed ${agent.name || agent.id}: ${truncate(message, 100)}` };
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Query a team or agent — journalist synthesis, no disruption.
|
|
607
|
+
*/
|
|
608
|
+
async _cmdQuery(args, gateway) {
|
|
609
|
+
if (args.length < 2) return { text: 'Usage: /query <team> <question>' };
|
|
610
|
+
const target = args[0];
|
|
611
|
+
const question = args.slice(1).join(' ');
|
|
612
|
+
|
|
613
|
+
const result = this._resolveTarget(target);
|
|
614
|
+
if (!result) return { text: `Not found: ${target}. Try /teams to see available teams.` };
|
|
615
|
+
if (result.type === 'ambiguous') return { text: `Ambiguous — did you mean: ${result.matches.join(', ')}?` };
|
|
616
|
+
|
|
617
|
+
if (result.type === 'team') {
|
|
618
|
+
const active = result.agents.filter((a) => a.status === 'running' || a.status === 'completed');
|
|
619
|
+
if (active.length === 0) return { text: `No active agents in team ${result.team.name}.` };
|
|
620
|
+
|
|
621
|
+
await gateway.send(`\u{1f914} Querying team ${result.team.name} (${active.length} agents)...`).catch(() => {});
|
|
622
|
+
|
|
623
|
+
const agentContexts = active.map((a) => {
|
|
624
|
+
const activity = this.daemon.classifier?.agentWindows?.[a.id] || [];
|
|
625
|
+
const recent = activity.slice(-10).map((e) => e.data || e.text || '').join('\n');
|
|
626
|
+
return [
|
|
627
|
+
`Agent "${a.name}" (${a.role}, ${a.status})`,
|
|
628
|
+
`Scope: ${(a.scope || []).join(', ') || 'unrestricted'}`,
|
|
629
|
+
a.prompt ? `Task: ${a.prompt}` : '',
|
|
630
|
+
recent ? `Recent activity:\n${recent}` : '',
|
|
631
|
+
].filter(Boolean).join('\n');
|
|
632
|
+
}).join('\n\n');
|
|
633
|
+
|
|
634
|
+
const prompt = [
|
|
635
|
+
`You are answering a question about team "${result.team.name}" with ${active.length} agents.`,
|
|
636
|
+
`\nTeam members:\n${agentContexts}`,
|
|
637
|
+
`\nUser question: ${question}`,
|
|
638
|
+
'\nSynthesize a concise answer based on the team\'s collective context.',
|
|
639
|
+
].join('\n');
|
|
640
|
+
|
|
641
|
+
const response = await this.daemon.journalist.callHeadless(prompt);
|
|
642
|
+
return { text: `\ud83d\udcac Team ${result.team.name}:\n${truncate(response, 3000)}` };
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Single agent query
|
|
646
|
+
const agent = result.agent;
|
|
647
|
+
await gateway.send(`\u{1f914} Querying ${agent.name || agent.id}...`).catch(() => {});
|
|
648
|
+
|
|
649
|
+
const activity = this.daemon.classifier?.agentWindows?.[agent.id] || [];
|
|
650
|
+
const recentActivity = activity.slice(-20).map((e) => e.data || e.text || '').join('\n');
|
|
651
|
+
|
|
652
|
+
const prompt = [
|
|
653
|
+
`You are answering a question about agent "${agent.name}" (role: ${agent.role}).`,
|
|
654
|
+
`File scope: ${(agent.scope || []).join(', ') || 'unrestricted'}`,
|
|
655
|
+
`Provider: ${agent.provider}, Tokens used: ${agent.tokensUsed || 0}`,
|
|
656
|
+
agent.prompt ? `Original task: ${agent.prompt}` : '',
|
|
657
|
+
recentActivity ? `\nRecent activity:\n${recentActivity}` : '',
|
|
658
|
+
`\nUser question: ${question}`,
|
|
659
|
+
'\nAnswer concisely based on the agent context above.',
|
|
660
|
+
].filter(Boolean).join('\n');
|
|
661
|
+
|
|
662
|
+
const response = await this.daemon.journalist.callHeadless(prompt);
|
|
663
|
+
return { text: `\ud83d\udcac ${agent.name || agent.id}:\n${truncate(response, 3000)}` };
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* View logs for a team or agent.
|
|
668
|
+
*/
|
|
669
|
+
_cmdLog(args) {
|
|
670
|
+
if (args.length === 0) return { text: 'Usage: /log <team> [lines]' };
|
|
671
|
+
const target = args[0];
|
|
672
|
+
const lineCount = Math.min(parseInt(args[1], 10) || 20, 50);
|
|
673
|
+
|
|
674
|
+
const result = this._resolveTarget(target);
|
|
675
|
+
if (!result) return { text: `Not found: ${target}. Try /teams to see available teams.` };
|
|
676
|
+
if (result.type === 'ambiguous') return { text: `Ambiguous — did you mean: ${result.matches.join(', ')}?` };
|
|
677
|
+
|
|
678
|
+
if (result.type === 'team') {
|
|
679
|
+
const sections = [];
|
|
680
|
+
const perAgent = Math.max(Math.floor(lineCount / Math.max(result.agents.length, 1)), 5);
|
|
681
|
+
for (const agent of result.agents) {
|
|
682
|
+
const lines = this._readAgentLog(agent, perAgent);
|
|
683
|
+
if (lines && lines.length > 0) {
|
|
684
|
+
sections.push(`\u2014 ${agent.name || agent.id} (${agent.role}) \u2014\n${lines.join('\n')}`);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
if (sections.length === 0) return { text: `No logs for team ${result.team.name}.` };
|
|
688
|
+
return { text: `\ud83d\udccb Team ${result.team.name} logs:\n\n${sections.join('\n\n')}` };
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const agent = result.agent;
|
|
692
|
+
const lines = this._readAgentLog(agent, lineCount);
|
|
693
|
+
if (!lines) return { text: `No log file found for ${agent.name || agent.id}.` };
|
|
694
|
+
return { text: logText(agent.name || agent.id, lines) };
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// -------------------------------------------------------------------
|
|
698
|
+
// Plan → Approve → Build Flow
|
|
699
|
+
// -------------------------------------------------------------------
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Start a new project — spawns a planner, tracks it for gateway feedback.
|
|
703
|
+
*/
|
|
704
|
+
async _cmdPlan(args, gateway) {
|
|
705
|
+
if (args.length === 0) return { text: 'Usage: /plan <description of what to build>' };
|
|
706
|
+
const description = args.join(' ');
|
|
707
|
+
|
|
708
|
+
await gateway.send(`\u{1f4cb} Planning: ${truncate(description, 200)}\nSpawning planner agent...`).catch(() => {});
|
|
709
|
+
|
|
710
|
+
const agent = await this.daemon.processes.spawn({
|
|
711
|
+
role: 'planner',
|
|
712
|
+
prompt: description,
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
// Track this planner so we send results back to the right gateway
|
|
716
|
+
this._pendingPlans.set(agent.id, {
|
|
717
|
+
gatewayId: gateway.config.id,
|
|
718
|
+
description,
|
|
719
|
+
timestamp: Date.now(),
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
this.daemon.audit.log('gateway.plan', { agentId: agent.id, source: 'gateway', gatewayId: gateway.config.id });
|
|
723
|
+
return { text: `\u{1f9e0} Planner ${agent.name} is analyzing the codebase and building a team plan.\nYou'll get the plan here for approval when it's ready.` };
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* Called from _routeEvent when a planner agent completes.
|
|
728
|
+
* Reads recommended-team.json and sends plan to chat for approval.
|
|
729
|
+
*/
|
|
730
|
+
_handlePlannerComplete(agentId) {
|
|
731
|
+
const plan = this._pendingPlans.get(agentId);
|
|
732
|
+
if (!plan) return; // Not a gateway-initiated planner
|
|
733
|
+
|
|
734
|
+
const gw = this.gateways.get(plan.gatewayId);
|
|
735
|
+
if (!gw || !gw.connected) {
|
|
736
|
+
this._pendingPlans.delete(agentId);
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const teamPath = this._findRecommendedTeam();
|
|
741
|
+
if (!teamPath) {
|
|
742
|
+
gw.send('\u274c Planner finished but no team plan was generated. Try again with a more specific description.').catch(() => {});
|
|
743
|
+
this._pendingPlans.delete(agentId);
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
try {
|
|
748
|
+
const agents = JSON.parse(readFileSync(teamPath, 'utf8'));
|
|
749
|
+
if (!Array.isArray(agents) || agents.length === 0) {
|
|
750
|
+
gw.send('\u274c Planner generated an empty team plan.').catch(() => {});
|
|
751
|
+
this._pendingPlans.delete(agentId);
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Store the plan data for launch
|
|
756
|
+
plan.agents = agents;
|
|
757
|
+
plan.teamPath = teamPath;
|
|
758
|
+
|
|
759
|
+
const summary = planText(agents, plan.description);
|
|
760
|
+
const approveMsg = `\n\nApprove this plan?\n/approve ${agentId} — launch the team\n/reject ${agentId} — discard\n/plan <edits> — replan with changes`;
|
|
761
|
+
|
|
762
|
+
gw.send(summary + approveMsg, { planId: agentId }).catch(() => {});
|
|
763
|
+
} catch (err) {
|
|
764
|
+
gw.send(`\u274c Error reading plan: ${err.message}`).catch(() => {});
|
|
765
|
+
this._pendingPlans.delete(agentId);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Launch a team from an approved plan.
|
|
771
|
+
*/
|
|
772
|
+
_launchPlan(planId) {
|
|
773
|
+
const plan = this._pendingPlans.get(planId);
|
|
774
|
+
if (!plan || !plan.agents) return { text: 'Plan not found or already launched.' };
|
|
775
|
+
|
|
776
|
+
const agents = plan.agents;
|
|
777
|
+
const defaultDir = this.daemon.config?.defaultWorkingDir || undefined;
|
|
778
|
+
|
|
779
|
+
// Separate phases
|
|
780
|
+
const phase1 = agents.filter((a) => !a.phase || a.phase === 1);
|
|
781
|
+
let phase2 = agents.filter((a) => a.phase === 2);
|
|
782
|
+
|
|
783
|
+
// Auto-add QC if planner forgot
|
|
784
|
+
if (phase2.length === 0 && phase1.length >= 2) {
|
|
785
|
+
phase2 = [{
|
|
786
|
+
role: 'fullstack', phase: 2, scope: [],
|
|
787
|
+
prompt: 'QC Senior Dev: All builder agents have completed. Audit their changes for correctness, fix any issues, run tests, build the project, commit all changes, and launch. Output the localhost URL.',
|
|
788
|
+
}];
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// Spawn phase 1
|
|
792
|
+
const spawned = [];
|
|
793
|
+
const phase1Ids = [];
|
|
794
|
+
const spawnAll = async () => {
|
|
795
|
+
for (const config of phase1) {
|
|
796
|
+
try {
|
|
797
|
+
const validated = validateAgentConfig({
|
|
798
|
+
role: config.role,
|
|
799
|
+
scope: config.scope || [],
|
|
800
|
+
prompt: config.prompt || '',
|
|
801
|
+
provider: config.provider || 'claude-code',
|
|
802
|
+
model: config.model || 'auto',
|
|
803
|
+
permission: config.permission || 'auto',
|
|
804
|
+
workingDir: config.workingDir || defaultDir,
|
|
805
|
+
name: config.name || undefined,
|
|
806
|
+
});
|
|
807
|
+
const agent = await this.daemon.processes.spawn(validated);
|
|
808
|
+
spawned.push(agent);
|
|
809
|
+
phase1Ids.push(agent.id);
|
|
810
|
+
} catch (err) {
|
|
811
|
+
console.log(`[Groove:Gateway] Failed to spawn ${config.role}: ${err.message}`);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// Register phase 2 for auto-spawn
|
|
816
|
+
if (phase2.length > 0 && phase1Ids.length > 0) {
|
|
817
|
+
this.daemon._pendingPhase2 = this.daemon._pendingPhase2 || [];
|
|
818
|
+
this.daemon._pendingPhase2.push({
|
|
819
|
+
waitFor: phase1Ids,
|
|
820
|
+
agents: phase2.map((c) => ({
|
|
821
|
+
role: c.role, scope: c.scope || [], prompt: c.prompt || '',
|
|
822
|
+
provider: c.provider || 'claude-code', model: c.model || 'auto',
|
|
823
|
+
permission: c.permission || 'auto',
|
|
824
|
+
workingDir: c.workingDir || defaultDir,
|
|
825
|
+
name: c.name || undefined,
|
|
826
|
+
})),
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
this.daemon.audit.log('team.launch', {
|
|
831
|
+
phase1: spawned.length, phase2Pending: phase2.length,
|
|
832
|
+
agents: spawned.map((a) => a.role), source: 'gateway',
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
// Notify via gateway
|
|
836
|
+
const gw = this.gateways.get(plan.gatewayId);
|
|
837
|
+
if (gw?.connected) {
|
|
838
|
+
const names = spawned.map((a) => `${a.name} (${a.role})`).join(', ');
|
|
839
|
+
const msg = `\u{1f680} Team launched! ${spawned.length} agents building${phase2.length > 0 ? `, ${phase2.length} QC agents queued` : ''}.\n${names}`;
|
|
840
|
+
gw.send(msg).catch(() => {});
|
|
841
|
+
}
|
|
842
|
+
};
|
|
843
|
+
|
|
844
|
+
// Fire and forget — spawn is async but we return immediately
|
|
845
|
+
spawnAll().catch((err) => console.log(`[Groove:Gateway] Launch error: ${err.message}`));
|
|
846
|
+
this._pendingPlans.delete(planId);
|
|
847
|
+
|
|
848
|
+
return { text: `\u2705 Launching team (${phase1.length} agents)...` };
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// -------------------------------------------------------------------
|
|
852
|
+
// Intelligence + Help
|
|
853
|
+
// -------------------------------------------------------------------
|
|
854
|
+
|
|
855
|
+
_cmdBrief() {
|
|
856
|
+
const status = this.daemon.journalist.getStatus();
|
|
857
|
+
const lastSynthesis = this.daemon.journalist.getLastSynthesis();
|
|
858
|
+
return { text: briefText(status, lastSynthesis) };
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
_cmdTokens() {
|
|
862
|
+
const summary = this.daemon.tokens.getSummary();
|
|
863
|
+
return { text: tokensText(summary) };
|
|
864
|
+
}
|
|
865
|
+
|
|
422
866
|
_cmdHelp() {
|
|
423
867
|
return {
|
|
424
868
|
text: [
|
|
425
869
|
'Groove Commands:',
|
|
870
|
+
'',
|
|
871
|
+
'Talk to Teams:',
|
|
872
|
+
'/instruct <team> <message> — send to team lead',
|
|
873
|
+
'/query <team> <question> — ask without disrupting work',
|
|
874
|
+
'/log <team> [lines] — view team logs',
|
|
875
|
+
'/plan <description> — plan + build a new project',
|
|
876
|
+
'',
|
|
877
|
+
'Fleet:',
|
|
426
878
|
'/status — daemon status + active agents',
|
|
427
879
|
'/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
880
|
'/teams — list teams',
|
|
881
|
+
'/spawn <role> [--name X] [--prompt "Y"] — manual spawn',
|
|
882
|
+
'/kill <name> — kill agent',
|
|
883
|
+
'/rotate <name> — rotate agent context',
|
|
884
|
+
'',
|
|
885
|
+
'Intelligence:',
|
|
886
|
+
'/brief — journalist project summary',
|
|
887
|
+
'/tokens — token usage + savings',
|
|
888
|
+
'',
|
|
889
|
+
'Workflow:',
|
|
890
|
+
'/approve <id> — approve plan or request',
|
|
891
|
+
'/reject <id> [reason] — reject plan or request',
|
|
434
892
|
'/schedules — list schedules',
|
|
435
893
|
'/help — this message',
|
|
436
894
|
].join('\n'),
|
|
@@ -448,6 +906,11 @@ export class GatewayManager {
|
|
|
448
906
|
if (!message || !message.type) return;
|
|
449
907
|
if (NEVER_FORWARD.has(message.type)) return;
|
|
450
908
|
|
|
909
|
+
// Intercept planner completions for the plan→approve→build flow
|
|
910
|
+
if (message.type === 'agent:exit' && message.status === 'completed' && message.agentId) {
|
|
911
|
+
this._handlePlannerComplete(message.agentId);
|
|
912
|
+
}
|
|
913
|
+
|
|
451
914
|
for (const gw of this.gateways.values()) {
|
|
452
915
|
if (!gw.connected || !gw.config.enabled) continue;
|
|
453
916
|
if (!this._shouldNotify(gw, message)) continue;
|
|
@@ -109,7 +109,7 @@ export class SlackGateway extends BaseGateway {
|
|
|
109
109
|
const command = rawCommand.toLowerCase();
|
|
110
110
|
|
|
111
111
|
// Only respond to known commands
|
|
112
|
-
const known = ['status', 'agents', 'spawn', 'kill', 'approve', 'reject', 'rotate', 'teams', 'schedules', 'help'];
|
|
112
|
+
const known = ['status', 'agents', 'spawn', 'kill', 'approve', 'reject', 'rotate', 'teams', 'schedules', 'help', 'instruct', 'query', 'log', 'plan', 'brief', 'tokens'];
|
|
113
113
|
if (!known.includes(command)) return; // Not a command, ignore
|
|
114
114
|
|
|
115
115
|
const response = await this.handleCommand(command, args, message.user);
|