groove-dev 0.8.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/CLAUDE.md +197 -0
- package/LICENSE +40 -0
- package/README.md +115 -0
- package/docs/GUI_DESIGN_SPEC.md +402 -0
- package/favicon.png +0 -0
- package/groove-logo-short.png +0 -0
- package/groove-logo.png +0 -0
- package/package.json +70 -0
- package/packages/cli/bin/groove.js +98 -0
- package/packages/cli/package.json +15 -0
- package/packages/cli/src/client.js +25 -0
- package/packages/cli/src/commands/agents.js +38 -0
- package/packages/cli/src/commands/approve.js +50 -0
- package/packages/cli/src/commands/config.js +35 -0
- package/packages/cli/src/commands/kill.js +15 -0
- package/packages/cli/src/commands/nuke.js +19 -0
- package/packages/cli/src/commands/providers.js +40 -0
- package/packages/cli/src/commands/rotate.js +16 -0
- package/packages/cli/src/commands/spawn.js +91 -0
- package/packages/cli/src/commands/start.js +31 -0
- package/packages/cli/src/commands/status.js +38 -0
- package/packages/cli/src/commands/stop.js +15 -0
- package/packages/cli/src/commands/team.js +77 -0
- package/packages/daemon/package.json +18 -0
- package/packages/daemon/src/adaptive.js +237 -0
- package/packages/daemon/src/api.js +533 -0
- package/packages/daemon/src/classifier.js +126 -0
- package/packages/daemon/src/credentials.js +121 -0
- package/packages/daemon/src/firstrun.js +93 -0
- package/packages/daemon/src/index.js +208 -0
- package/packages/daemon/src/introducer.js +238 -0
- package/packages/daemon/src/journalist.js +600 -0
- package/packages/daemon/src/lockmanager.js +58 -0
- package/packages/daemon/src/pm.js +108 -0
- package/packages/daemon/src/process.js +361 -0
- package/packages/daemon/src/providers/aider.js +72 -0
- package/packages/daemon/src/providers/base.js +38 -0
- package/packages/daemon/src/providers/claude-code.js +167 -0
- package/packages/daemon/src/providers/codex.js +68 -0
- package/packages/daemon/src/providers/gemini.js +62 -0
- package/packages/daemon/src/providers/index.js +38 -0
- package/packages/daemon/src/providers/ollama.js +94 -0
- package/packages/daemon/src/registry.js +89 -0
- package/packages/daemon/src/rotator.js +185 -0
- package/packages/daemon/src/router.js +132 -0
- package/packages/daemon/src/state.js +34 -0
- package/packages/daemon/src/supervisor.js +178 -0
- package/packages/daemon/src/teams.js +203 -0
- package/packages/daemon/src/terminal/base.js +27 -0
- package/packages/daemon/src/terminal/generic.js +27 -0
- package/packages/daemon/src/terminal/tmux.js +64 -0
- package/packages/daemon/src/tokentracker.js +124 -0
- package/packages/daemon/src/validate.js +122 -0
- package/packages/daemon/templates/api-builder.json +18 -0
- package/packages/daemon/templates/fullstack.json +18 -0
- package/packages/daemon/templates/monorepo.json +24 -0
- package/packages/gui/dist/assets/index-BO95Rm1F.js +73 -0
- package/packages/gui/dist/assets/index-CPzm9ZE9.css +1 -0
- package/packages/gui/dist/favicon.png +0 -0
- package/packages/gui/dist/groove-logo-short.png +0 -0
- package/packages/gui/dist/groove-logo.png +0 -0
- package/packages/gui/dist/index.html +13 -0
- package/packages/gui/index.html +12 -0
- package/packages/gui/package.json +22 -0
- package/packages/gui/public/favicon.png +0 -0
- package/packages/gui/public/groove-logo-short.png +0 -0
- package/packages/gui/public/groove-logo.png +0 -0
- package/packages/gui/src/App.jsx +215 -0
- package/packages/gui/src/components/AgentActions.jsx +347 -0
- package/packages/gui/src/components/AgentChat.jsx +479 -0
- package/packages/gui/src/components/AgentNode.jsx +117 -0
- package/packages/gui/src/components/AgentPanel.jsx +115 -0
- package/packages/gui/src/components/AgentStats.jsx +333 -0
- package/packages/gui/src/components/ApprovalQueue.jsx +156 -0
- package/packages/gui/src/components/EmptyState.jsx +100 -0
- package/packages/gui/src/components/SpawnPanel.jsx +515 -0
- package/packages/gui/src/components/TeamSelector.jsx +162 -0
- package/packages/gui/src/main.jsx +9 -0
- package/packages/gui/src/stores/groove.js +247 -0
- package/packages/gui/src/theme.css +67 -0
- package/packages/gui/src/views/AgentTree.jsx +148 -0
- package/packages/gui/src/views/CommandCenter.jsx +620 -0
- package/packages/gui/src/views/JournalistFeed.jsx +149 -0
- package/packages/gui/vite.config.js +19 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// GROOVE — Adaptive Model Router
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
5
|
+
import { resolve } from 'path';
|
|
6
|
+
import { getProvider } from './providers/index.js';
|
|
7
|
+
|
|
8
|
+
// Routing modes per agent
|
|
9
|
+
const MODES = {
|
|
10
|
+
FIXED: 'fixed', // User-selected model, no changes
|
|
11
|
+
AUTO: 'auto', // GROOVE picks the model based on task tier
|
|
12
|
+
AUTO_FLOOR: 'auto-floor', // Auto, but never below a floor model
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Role-based tier hints for new agents with no classifier data yet
|
|
16
|
+
const ROLE_HINTS = {
|
|
17
|
+
planner: 'heavy', // Planning is foundational — needs deep reasoning
|
|
18
|
+
docs: 'light',
|
|
19
|
+
testing: 'medium',
|
|
20
|
+
backend: 'medium',
|
|
21
|
+
frontend: 'medium',
|
|
22
|
+
devops: 'medium',
|
|
23
|
+
fullstack: 'heavy',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export class ModelRouter {
|
|
27
|
+
constructor(daemon) {
|
|
28
|
+
this.daemon = daemon;
|
|
29
|
+
this.profilesPath = resolve(daemon.grooveDir, 'routing-profiles.json');
|
|
30
|
+
this.agentModes = {}; // agentId -> { mode, fixedModel, floorModel }
|
|
31
|
+
this.costLog = []; // [{ agentId, model, tokens, tier, timestamp }]
|
|
32
|
+
this.profiles = {};
|
|
33
|
+
this.loadProfiles();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
loadProfiles() {
|
|
37
|
+
if (existsSync(this.profilesPath)) {
|
|
38
|
+
try {
|
|
39
|
+
this.profiles = JSON.parse(readFileSync(this.profilesPath, 'utf8'));
|
|
40
|
+
} catch {
|
|
41
|
+
this.profiles = {};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
saveProfiles() {
|
|
47
|
+
writeFileSync(this.profilesPath, JSON.stringify(this.profiles, null, 2));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Set routing mode for an agent
|
|
51
|
+
setMode(agentId, mode, options = {}) {
|
|
52
|
+
this.agentModes[agentId] = {
|
|
53
|
+
mode: mode || MODES.FIXED,
|
|
54
|
+
fixedModel: options.fixedModel || null,
|
|
55
|
+
floorModel: options.floorModel || null,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
getMode(agentId) {
|
|
60
|
+
return this.agentModes[agentId] || { mode: MODES.FIXED, fixedModel: null, floorModel: null };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Get the recommended model for an agent based on current task
|
|
64
|
+
recommend(agentId) {
|
|
65
|
+
const config = this.getMode(agentId);
|
|
66
|
+
const agent = this.daemon.registry.get(agentId);
|
|
67
|
+
if (!agent) return null;
|
|
68
|
+
|
|
69
|
+
const provider = getProvider(agent.provider);
|
|
70
|
+
if (!provider) return null;
|
|
71
|
+
const models = provider.constructor.models;
|
|
72
|
+
|
|
73
|
+
// Fixed mode — just return the locked model
|
|
74
|
+
if (config.mode === MODES.FIXED) {
|
|
75
|
+
const model = config.fixedModel
|
|
76
|
+
? models.find((m) => m.id === config.fixedModel) || models[0]
|
|
77
|
+
: models[0];
|
|
78
|
+
return { model, mode: MODES.FIXED, reason: 'Fixed model' };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Auto mode — use classifier
|
|
82
|
+
const classifier = this.daemon.classifier;
|
|
83
|
+
if (!classifier) {
|
|
84
|
+
return { model: models[0], mode: config.mode, reason: 'No classifier available' };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Use role-based hint when classifier has no activity data yet
|
|
88
|
+
const hasData = classifier.agentWindows[agentId]?.length > 0;
|
|
89
|
+
if (!hasData && agent.role && ROLE_HINTS[agent.role]) {
|
|
90
|
+
const hintTier = ROLE_HINTS[agent.role];
|
|
91
|
+
const match = models.find((m) => m.tier === hintTier) || models[0];
|
|
92
|
+
return { model: match, mode: config.mode, reason: `Role hint: ${agent.role} → ${hintTier}` };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const rec = classifier.getRecommendation(agentId, models);
|
|
96
|
+
|
|
97
|
+
// Auto-with-floor — ensure we don't go below floor
|
|
98
|
+
if (config.mode === MODES.AUTO_FLOOR && config.floorModel) {
|
|
99
|
+
const floorIdx = models.findIndex((m) => m.id === config.floorModel);
|
|
100
|
+
const recIdx = models.findIndex((m) => m.id === rec.model.id);
|
|
101
|
+
|
|
102
|
+
// Higher index = cheaper model. If rec is cheaper than floor, use floor.
|
|
103
|
+
if (floorIdx >= 0 && recIdx > floorIdx) {
|
|
104
|
+
const floor = models[floorIdx];
|
|
105
|
+
return { model: floor, mode: MODES.AUTO_FLOOR, reason: `Floor: ${floor.name}` };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { model: rec.model, mode: config.mode, reason: rec.reason };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Record a routing decision for cost tracking
|
|
113
|
+
recordUsage(agentId, modelId, tokens, tier) {
|
|
114
|
+
this.costLog.push({
|
|
115
|
+
agentId,
|
|
116
|
+
model: modelId,
|
|
117
|
+
tokens,
|
|
118
|
+
tier,
|
|
119
|
+
timestamp: new Date().toISOString(),
|
|
120
|
+
});
|
|
121
|
+
// Keep last 1000 entries
|
|
122
|
+
if (this.costLog.length > 1000) this.costLog = this.costLog.slice(-1000);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
getStatus() {
|
|
126
|
+
return {
|
|
127
|
+
agentModes: this.agentModes,
|
|
128
|
+
costLogSize: this.costLog.length,
|
|
129
|
+
modes: MODES,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// GROOVE — State Persistence
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
5
|
+
import { resolve } from 'path';
|
|
6
|
+
|
|
7
|
+
export class StateManager {
|
|
8
|
+
constructor(grooveDir) {
|
|
9
|
+
this.path = resolve(grooveDir, 'state.json');
|
|
10
|
+
this.data = {};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
load() {
|
|
14
|
+
if (existsSync(this.path)) {
|
|
15
|
+
try {
|
|
16
|
+
this.data = JSON.parse(readFileSync(this.path, 'utf8'));
|
|
17
|
+
} catch {
|
|
18
|
+
this.data = {};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
save() {
|
|
24
|
+
writeFileSync(this.path, JSON.stringify(this.data, null, 2));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
get(key) {
|
|
28
|
+
return this.data[key];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
set(key, value) {
|
|
32
|
+
this.data[key] = value;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// GROOVE — QC Supervisor + Approval System
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
import { writeFileSync, readFileSync, existsSync } from 'fs';
|
|
5
|
+
import { resolve } from 'path';
|
|
6
|
+
|
|
7
|
+
const QC_AUTO_SPAWN_THRESHOLD = 4; // Auto-spawn QC when this many agents running
|
|
8
|
+
|
|
9
|
+
export class Supervisor {
|
|
10
|
+
constructor(daemon) {
|
|
11
|
+
this.daemon = daemon;
|
|
12
|
+
this.pendingApprovals = new Map();
|
|
13
|
+
this.resolvedApprovals = [];
|
|
14
|
+
this.counter = 0;
|
|
15
|
+
this.qcActive = false;
|
|
16
|
+
this.conflicts = [];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// --- Approval System ---
|
|
20
|
+
|
|
21
|
+
requestApproval(agentId, action) {
|
|
22
|
+
const id = `approval-${Date.now()}-${++this.counter}`;
|
|
23
|
+
const agent = this.daemon.registry?.get(agentId);
|
|
24
|
+
|
|
25
|
+
const approval = {
|
|
26
|
+
id,
|
|
27
|
+
agentId,
|
|
28
|
+
agentName: agent?.name || agentId,
|
|
29
|
+
action,
|
|
30
|
+
status: 'pending',
|
|
31
|
+
requestedAt: new Date().toISOString(),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
this.pendingApprovals.set(id, approval);
|
|
35
|
+
this.daemon.broadcast({ type: 'approval:request', data: approval });
|
|
36
|
+
return approval;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
approve(approvalId) {
|
|
40
|
+
const approval = this.pendingApprovals.get(approvalId);
|
|
41
|
+
if (!approval) return null;
|
|
42
|
+
|
|
43
|
+
approval.status = 'approved';
|
|
44
|
+
approval.resolvedAt = new Date().toISOString();
|
|
45
|
+
this.pendingApprovals.delete(approvalId);
|
|
46
|
+
this.resolvedApprovals.push(approval);
|
|
47
|
+
if (this.resolvedApprovals.length > 200) this.resolvedApprovals = this.resolvedApprovals.slice(-200);
|
|
48
|
+
|
|
49
|
+
this.daemon.broadcast({ type: 'approval:resolved', data: approval });
|
|
50
|
+
return approval;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
reject(approvalId, reason) {
|
|
54
|
+
const approval = this.pendingApprovals.get(approvalId);
|
|
55
|
+
if (!approval) return null;
|
|
56
|
+
|
|
57
|
+
approval.status = 'rejected';
|
|
58
|
+
approval.reason = reason || '';
|
|
59
|
+
approval.resolvedAt = new Date().toISOString();
|
|
60
|
+
this.pendingApprovals.delete(approvalId);
|
|
61
|
+
this.resolvedApprovals.push(approval);
|
|
62
|
+
if (this.resolvedApprovals.length > 200) this.resolvedApprovals = this.resolvedApprovals.slice(-200);
|
|
63
|
+
|
|
64
|
+
this.daemon.broadcast({ type: 'approval:resolved', data: approval });
|
|
65
|
+
return approval;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
getPending() {
|
|
69
|
+
return Array.from(this.pendingApprovals.values());
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
getResolved() {
|
|
73
|
+
return this.resolvedApprovals;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
getApproval(id) {
|
|
77
|
+
return this.pendingApprovals.get(id) || this.resolvedApprovals.find((a) => a.id === id) || null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// --- Conflict Detection ---
|
|
81
|
+
|
|
82
|
+
recordConflict(agentId, filePath, ownerId) {
|
|
83
|
+
const agent = this.daemon.registry?.get(agentId);
|
|
84
|
+
const owner = this.daemon.registry?.get(ownerId);
|
|
85
|
+
|
|
86
|
+
const conflict = {
|
|
87
|
+
timestamp: new Date().toISOString(),
|
|
88
|
+
agentId,
|
|
89
|
+
agentName: agent?.name || agentId,
|
|
90
|
+
filePath,
|
|
91
|
+
ownerId,
|
|
92
|
+
ownerName: owner?.name || ownerId,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
this.conflicts.push(conflict);
|
|
96
|
+
if (this.conflicts.length > 200) this.conflicts = this.conflicts.slice(-200);
|
|
97
|
+
|
|
98
|
+
// Track in token savings
|
|
99
|
+
this.daemon.tokens?.recordConflictPrevented();
|
|
100
|
+
|
|
101
|
+
// Create an approval for the out-of-scope access
|
|
102
|
+
this.requestApproval(agentId, {
|
|
103
|
+
type: 'scope_violation',
|
|
104
|
+
description: `${agent?.name || agentId} tried to modify ${filePath} (owned by ${owner?.name || ownerId})`,
|
|
105
|
+
filePath,
|
|
106
|
+
ownerId,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Update GROOVE_CONFLICTS.md
|
|
110
|
+
this.writeConflictsFile();
|
|
111
|
+
|
|
112
|
+
this.daemon.broadcast({
|
|
113
|
+
type: 'conflict:detected',
|
|
114
|
+
data: conflict,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return conflict;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
getConflicts() {
|
|
121
|
+
return this.conflicts;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
writeConflictsFile() {
|
|
125
|
+
if (!this.daemon.projectDir) return;
|
|
126
|
+
if (this.conflicts.length === 0) return;
|
|
127
|
+
|
|
128
|
+
const lines = [
|
|
129
|
+
`# GROOVE Conflicts Log`,
|
|
130
|
+
``,
|
|
131
|
+
`*Auto-generated by QC Supervisor.*`,
|
|
132
|
+
``,
|
|
133
|
+
`| Time | Agent | Attempted File | Owner | Status |`,
|
|
134
|
+
`|------|-------|---------------|-------|--------|`,
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
for (const c of this.conflicts.slice(-50)) {
|
|
138
|
+
const time = new Date(c.timestamp).toLocaleTimeString();
|
|
139
|
+
lines.push(`| ${time} | ${c.agentName} | ${c.filePath} | ${c.ownerName} | blocked |`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
writeFileSync(resolve(this.daemon.projectDir, 'GROOVE_CONFLICTS.md'), lines.join('\n'));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// --- QC Auto-spawn ---
|
|
146
|
+
|
|
147
|
+
checkQcThreshold() {
|
|
148
|
+
if (this.qcActive) return false;
|
|
149
|
+
|
|
150
|
+
const running = this.daemon.registry?.getAll().filter(
|
|
151
|
+
(a) => a.status === 'running' && a.role !== 'qc'
|
|
152
|
+
) || [];
|
|
153
|
+
|
|
154
|
+
if (running.length >= QC_AUTO_SPAWN_THRESHOLD) {
|
|
155
|
+
this.qcActive = true;
|
|
156
|
+
this.daemon.broadcast({
|
|
157
|
+
type: 'qc:activated',
|
|
158
|
+
agentCount: running.length,
|
|
159
|
+
});
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
isQcActive() {
|
|
167
|
+
return this.qcActive;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
getStatus() {
|
|
171
|
+
return {
|
|
172
|
+
qcActive: this.qcActive,
|
|
173
|
+
pendingApprovals: this.getPending().length,
|
|
174
|
+
resolvedApprovals: this.resolvedApprovals.length,
|
|
175
|
+
conflicts: this.conflicts.length,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// GROOVE — Teams (Saved Agent Configurations)
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, unlinkSync } from 'fs';
|
|
5
|
+
import { resolve } from 'path';
|
|
6
|
+
import { validateTeamName, sanitizeForFilename, validateAgentConfig } from './validate.js';
|
|
7
|
+
|
|
8
|
+
export class Teams {
|
|
9
|
+
constructor(daemon) {
|
|
10
|
+
this.daemon = daemon;
|
|
11
|
+
this.teamsDir = resolve(daemon.grooveDir, 'teams');
|
|
12
|
+
this.activeTeam = null; // Name of the currently active team
|
|
13
|
+
this.autoSave = false;
|
|
14
|
+
|
|
15
|
+
mkdirSync(this.teamsDir, { recursive: true });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Save current agents as a team
|
|
19
|
+
save(name) {
|
|
20
|
+
validateTeamName(name);
|
|
21
|
+
|
|
22
|
+
const agents = this.daemon.registry.getAll();
|
|
23
|
+
if (agents.length === 0) throw new Error('No agents to save');
|
|
24
|
+
|
|
25
|
+
const team = {
|
|
26
|
+
name,
|
|
27
|
+
createdAt: new Date().toISOString(),
|
|
28
|
+
updatedAt: new Date().toISOString(),
|
|
29
|
+
agents: agents.map((a) => ({
|
|
30
|
+
role: a.role,
|
|
31
|
+
scope: a.scope,
|
|
32
|
+
provider: a.provider,
|
|
33
|
+
model: a.model,
|
|
34
|
+
prompt: a.prompt,
|
|
35
|
+
name: a.name,
|
|
36
|
+
})),
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const path = resolve(this.teamsDir, `${this.sanitizeName(name)}.json`);
|
|
40
|
+
writeFileSync(path, JSON.stringify(team, null, 2));
|
|
41
|
+
|
|
42
|
+
this.activeTeam = name;
|
|
43
|
+
this.autoSave = true;
|
|
44
|
+
|
|
45
|
+
return team;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Load a team — spawns all agents from config
|
|
49
|
+
async load(name) {
|
|
50
|
+
const team = this.get(name);
|
|
51
|
+
if (!team) throw new Error(`Team "${name}" not found`);
|
|
52
|
+
|
|
53
|
+
// Kill all running agents first
|
|
54
|
+
await this.daemon.processes.killAll();
|
|
55
|
+
|
|
56
|
+
// Clear registry of old entries
|
|
57
|
+
const old = this.daemon.registry.getAll();
|
|
58
|
+
for (const a of old) this.daemon.registry.remove(a.id);
|
|
59
|
+
|
|
60
|
+
// Spawn all agents from team config
|
|
61
|
+
const spawned = [];
|
|
62
|
+
for (const config of team.agents) {
|
|
63
|
+
try {
|
|
64
|
+
const agent = await this.daemon.processes.spawn(config);
|
|
65
|
+
spawned.push(agent);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
console.error(` Failed to spawn ${config.name || config.role}:`, err.message);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
this.activeTeam = name;
|
|
72
|
+
this.autoSave = true;
|
|
73
|
+
|
|
74
|
+
this.daemon.broadcast({
|
|
75
|
+
type: 'team:loaded',
|
|
76
|
+
name,
|
|
77
|
+
agentCount: spawned.length,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
return { name, agents: spawned };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Get a team definition by name
|
|
84
|
+
get(name) {
|
|
85
|
+
const path = resolve(this.teamsDir, `${this.sanitizeName(name)}.json`);
|
|
86
|
+
if (!existsSync(path)) return null;
|
|
87
|
+
try {
|
|
88
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// List all saved teams
|
|
95
|
+
list() {
|
|
96
|
+
if (!existsSync(this.teamsDir)) return [];
|
|
97
|
+
|
|
98
|
+
return readdirSync(this.teamsDir)
|
|
99
|
+
.filter((f) => f.endsWith('.json'))
|
|
100
|
+
.map((f) => {
|
|
101
|
+
try {
|
|
102
|
+
const data = JSON.parse(readFileSync(resolve(this.teamsDir, f), 'utf8'));
|
|
103
|
+
return {
|
|
104
|
+
name: data.name,
|
|
105
|
+
agents: data.agents?.length || 0,
|
|
106
|
+
createdAt: data.createdAt,
|
|
107
|
+
updatedAt: data.updatedAt,
|
|
108
|
+
};
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
.filter(Boolean);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Delete a team
|
|
117
|
+
delete(name) {
|
|
118
|
+
const path = resolve(this.teamsDir, `${this.sanitizeName(name)}.json`);
|
|
119
|
+
if (!existsSync(path)) throw new Error(`Team "${name}" not found`);
|
|
120
|
+
unlinkSync(path);
|
|
121
|
+
if (this.activeTeam === name) {
|
|
122
|
+
this.activeTeam = null;
|
|
123
|
+
this.autoSave = false;
|
|
124
|
+
}
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Export team as portable JSON string
|
|
129
|
+
export(name) {
|
|
130
|
+
const team = this.get(name);
|
|
131
|
+
if (!team) throw new Error(`Team "${name}" not found`);
|
|
132
|
+
return JSON.stringify(team, null, 2);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Import team from JSON string
|
|
136
|
+
import(jsonStr) {
|
|
137
|
+
let team;
|
|
138
|
+
try {
|
|
139
|
+
team = JSON.parse(jsonStr);
|
|
140
|
+
} catch {
|
|
141
|
+
throw new Error('Invalid JSON');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!team.name || !Array.isArray(team.agents)) {
|
|
145
|
+
throw new Error('Invalid team format: needs "name" and "agents" array');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
validateTeamName(team.name);
|
|
149
|
+
|
|
150
|
+
if (team.agents.length > 20) {
|
|
151
|
+
throw new Error('Too many agents in team (max 20)');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Validate each agent config
|
|
155
|
+
for (const a of team.agents) {
|
|
156
|
+
validateAgentConfig(a);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
team.updatedAt = new Date().toISOString();
|
|
160
|
+
const path = resolve(this.teamsDir, `${this.sanitizeName(team.name)}.json`);
|
|
161
|
+
writeFileSync(path, JSON.stringify(team, null, 2));
|
|
162
|
+
|
|
163
|
+
return team;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Auto-save: called when agents change while a team is active
|
|
167
|
+
onAgentChange() {
|
|
168
|
+
if (!this.activeTeam || !this.autoSave) return;
|
|
169
|
+
|
|
170
|
+
const agents = this.daemon.registry.getAll().filter(
|
|
171
|
+
(a) => a.status === 'running' || a.status === 'starting'
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
if (agents.length === 0) return;
|
|
175
|
+
|
|
176
|
+
const path = resolve(this.teamsDir, `${this.sanitizeName(this.activeTeam)}.json`);
|
|
177
|
+
if (!existsSync(path)) return;
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
const team = JSON.parse(readFileSync(path, 'utf8'));
|
|
181
|
+
team.updatedAt = new Date().toISOString();
|
|
182
|
+
team.agents = agents.map((a) => ({
|
|
183
|
+
role: a.role,
|
|
184
|
+
scope: a.scope,
|
|
185
|
+
provider: a.provider,
|
|
186
|
+
model: a.model,
|
|
187
|
+
prompt: a.prompt,
|
|
188
|
+
name: a.name,
|
|
189
|
+
}));
|
|
190
|
+
writeFileSync(path, JSON.stringify(team, null, 2));
|
|
191
|
+
} catch {
|
|
192
|
+
// Non-critical — don't break the flow
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
getActiveTeam() {
|
|
197
|
+
return this.activeTeam;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
sanitizeName(name) {
|
|
201
|
+
return sanitizeForFilename(name);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// GROOVE — Base Terminal Adapter
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
export class TerminalAdapter {
|
|
5
|
+
static name = 'base';
|
|
6
|
+
static displayName = 'Base Terminal';
|
|
7
|
+
|
|
8
|
+
static isAvailable() {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
createPane(options) {
|
|
13
|
+
throw new Error('Terminal adapter must implement createPane()');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
closePane(paneId) {
|
|
17
|
+
throw new Error('Terminal adapter must implement closePane()');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
sendKeys(paneId, keys) {
|
|
21
|
+
throw new Error('Terminal adapter must implement sendKeys()');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
notify(message) {
|
|
25
|
+
// Default: no-op
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// GROOVE — Generic Terminal Adapter (fallback)
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
import { TerminalAdapter } from './base.js';
|
|
5
|
+
|
|
6
|
+
export class GenericTerminal extends TerminalAdapter {
|
|
7
|
+
static name = 'generic';
|
|
8
|
+
static displayName = 'Generic Terminal';
|
|
9
|
+
|
|
10
|
+
static isAvailable() {
|
|
11
|
+
return true; // Always available as fallback
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
createPane(options) {
|
|
15
|
+
// In generic mode, agents run as background processes
|
|
16
|
+
// managed directly by the ProcessManager
|
|
17
|
+
return { type: 'background', id: options.agentId };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
closePane(paneId) {
|
|
21
|
+
// Process cleanup handled by ProcessManager
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
sendKeys(paneId, keys) {
|
|
25
|
+
// Not supported in generic mode
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// GROOVE — tmux Terminal Adapter
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
import { execFileSync } from 'child_process';
|
|
5
|
+
import { TerminalAdapter } from './base.js';
|
|
6
|
+
|
|
7
|
+
export class TmuxTerminal extends TerminalAdapter {
|
|
8
|
+
static name = 'tmux';
|
|
9
|
+
static displayName = 'tmux';
|
|
10
|
+
|
|
11
|
+
static isAvailable() {
|
|
12
|
+
try {
|
|
13
|
+
execFileSync('which', ['tmux'], { stdio: 'ignore' });
|
|
14
|
+
return !!process.env.TMUX;
|
|
15
|
+
} catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
createPane(options) {
|
|
21
|
+
try {
|
|
22
|
+
const output = execFileSync('tmux', [
|
|
23
|
+
'split-window', '-h', '-d', '-P', '-F', '#{pane_id}',
|
|
24
|
+
options.command || 'bash',
|
|
25
|
+
], { encoding: 'utf8' }).trim();
|
|
26
|
+
|
|
27
|
+
return { type: 'tmux', paneId: output };
|
|
28
|
+
} catch (err) {
|
|
29
|
+
console.error(' tmux: Failed to create pane:', err.message);
|
|
30
|
+
return { type: 'background', id: options.agentId };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
closePane(paneId) {
|
|
35
|
+
if (!this.isValidPaneId(paneId)) return;
|
|
36
|
+
try {
|
|
37
|
+
execFileSync('tmux', ['kill-pane', '-t', paneId], { stdio: 'ignore' });
|
|
38
|
+
} catch {
|
|
39
|
+
// Pane may already be closed
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
sendKeys(paneId, keys) {
|
|
44
|
+
if (!this.isValidPaneId(paneId)) return;
|
|
45
|
+
try {
|
|
46
|
+
execFileSync('tmux', ['send-keys', '-t', paneId, keys, 'Enter'], { stdio: 'ignore' });
|
|
47
|
+
} catch {
|
|
48
|
+
// Ignore errors
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
notify(message) {
|
|
53
|
+
try {
|
|
54
|
+
execFileSync('tmux', ['display-message', String(message).slice(0, 200)], { stdio: 'ignore' });
|
|
55
|
+
} catch {
|
|
56
|
+
// tmux not available or not in session
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
isValidPaneId(paneId) {
|
|
61
|
+
// tmux pane IDs are like %0, %1, %12
|
|
62
|
+
return typeof paneId === 'string' && /^%\d+$/.test(paneId);
|
|
63
|
+
}
|
|
64
|
+
}
|