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,121 @@
|
|
|
1
|
+
// GROOVE — Credential Storage (AES-256-GCM encrypted)
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
import { readFileSync, writeFileSync, existsSync, chmodSync } from 'fs';
|
|
5
|
+
import { resolve } from 'path';
|
|
6
|
+
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto';
|
|
7
|
+
import { hostname, homedir } from 'os';
|
|
8
|
+
|
|
9
|
+
const ALGORITHM = 'aes-256-gcm';
|
|
10
|
+
const SALT_PREFIX = 'groove-v1';
|
|
11
|
+
|
|
12
|
+
export class CredentialStore {
|
|
13
|
+
constructor(grooveDir) {
|
|
14
|
+
this.path = resolve(grooveDir, 'credentials.json');
|
|
15
|
+
this.data = {};
|
|
16
|
+
this.encryptionKey = this.deriveKey();
|
|
17
|
+
this.load();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Derive encryption key from machine-specific data
|
|
21
|
+
// Not unbreakable, but much better than base64 — credentials file is
|
|
22
|
+
// meaningless if copied to another machine or read without this process.
|
|
23
|
+
deriveKey() {
|
|
24
|
+
const machineId = `${SALT_PREFIX}:${homedir()}:${hostname()}`;
|
|
25
|
+
return scryptSync(machineId, 'groove-credential-salt', 32);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
load() {
|
|
29
|
+
if (existsSync(this.path)) {
|
|
30
|
+
try {
|
|
31
|
+
this.data = JSON.parse(readFileSync(this.path, 'utf8'));
|
|
32
|
+
} catch {
|
|
33
|
+
this.data = {};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
save() {
|
|
39
|
+
writeFileSync(this.path, JSON.stringify(this.data, null, 2));
|
|
40
|
+
try { chmodSync(this.path, 0o600); } catch { /* Windows */ }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
setKey(provider, key) {
|
|
44
|
+
if (!provider || typeof provider !== 'string') throw new Error('Provider required');
|
|
45
|
+
if (!key || typeof key !== 'string') throw new Error('Key required');
|
|
46
|
+
|
|
47
|
+
this.data[provider] = {
|
|
48
|
+
key: this.encrypt(key),
|
|
49
|
+
setAt: new Date().toISOString(),
|
|
50
|
+
};
|
|
51
|
+
this.save();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
getKey(provider) {
|
|
55
|
+
const entry = this.data[provider];
|
|
56
|
+
if (!entry) return null;
|
|
57
|
+
try {
|
|
58
|
+
return this.decrypt(entry.key);
|
|
59
|
+
} catch {
|
|
60
|
+
// Key was encrypted with different machine key, or corrupted
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
deleteKey(provider) {
|
|
66
|
+
delete this.data[provider];
|
|
67
|
+
this.save();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
listProviders() {
|
|
71
|
+
return Object.entries(this.data).map(([provider, entry]) => {
|
|
72
|
+
const key = this.getKey(provider);
|
|
73
|
+
return {
|
|
74
|
+
provider,
|
|
75
|
+
setAt: entry.setAt,
|
|
76
|
+
masked: key ? this.mask(key) : '(unable to decrypt)',
|
|
77
|
+
};
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
hasKey(provider) {
|
|
82
|
+
return !!this.data[provider];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
getEnvForProvider(provider, providerInfo) {
|
|
86
|
+
const key = this.getKey(provider);
|
|
87
|
+
if (!key || !providerInfo?.envKey) return {};
|
|
88
|
+
return { [providerInfo.envKey]: key };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
encrypt(text) {
|
|
92
|
+
const iv = randomBytes(16);
|
|
93
|
+
const cipher = createCipheriv(ALGORITHM, this.encryptionKey, iv);
|
|
94
|
+
let encrypted = cipher.update(text, 'utf8', 'hex');
|
|
95
|
+
encrypted += cipher.final('hex');
|
|
96
|
+
const authTag = cipher.getAuthTag();
|
|
97
|
+
// Format: iv:authTag:ciphertext
|
|
98
|
+
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
decrypt(encoded) {
|
|
102
|
+
const parts = encoded.split(':');
|
|
103
|
+
if (parts.length !== 3) {
|
|
104
|
+
// Legacy base64 format — migrate on read
|
|
105
|
+
return Buffer.from(encoded, 'base64').toString('utf8');
|
|
106
|
+
}
|
|
107
|
+
const [ivHex, authTagHex, ciphertext] = parts;
|
|
108
|
+
const iv = Buffer.from(ivHex, 'hex');
|
|
109
|
+
const authTag = Buffer.from(authTagHex, 'hex');
|
|
110
|
+
const decipher = createDecipheriv(ALGORITHM, this.encryptionKey, iv);
|
|
111
|
+
decipher.setAuthTag(authTag);
|
|
112
|
+
let decrypted = decipher.update(ciphertext, 'hex', 'utf8');
|
|
113
|
+
decrypted += decipher.final('utf8');
|
|
114
|
+
return decrypted;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
mask(key) {
|
|
118
|
+
if (!key || key.length < 8) return '****';
|
|
119
|
+
return key.slice(0, 4) + '...' + key.slice(-4);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// GROOVE — First-Run Detection & Setup
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
import { existsSync, writeFileSync, readFileSync } from 'fs';
|
|
5
|
+
import { resolve } from 'path';
|
|
6
|
+
import { listProviders } from './providers/index.js';
|
|
7
|
+
|
|
8
|
+
const DEFAULT_CONFIG = {
|
|
9
|
+
version: '0.1.0',
|
|
10
|
+
port: 31415,
|
|
11
|
+
journalistInterval: 120,
|
|
12
|
+
rotationThreshold: 0.75,
|
|
13
|
+
autoRotation: true,
|
|
14
|
+
qcThreshold: 4,
|
|
15
|
+
maxAgents: 10,
|
|
16
|
+
defaultProvider: 'claude-code',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function isFirstRun(grooveDir) {
|
|
20
|
+
return !existsSync(resolve(grooveDir, 'config.json'));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Show welcome banner on every startup
|
|
24
|
+
export function printWelcome(port) {
|
|
25
|
+
const providers = listProviders();
|
|
26
|
+
const installed = providers.filter((p) => p.installed);
|
|
27
|
+
const notInstalled = providers.filter((p) => !p.installed);
|
|
28
|
+
|
|
29
|
+
console.log('');
|
|
30
|
+
console.log(' ┌─────────────────────────────────────┐');
|
|
31
|
+
console.log(' │ Welcome to GROOVE │');
|
|
32
|
+
console.log(' │ Agent orchestration for AI coding │');
|
|
33
|
+
console.log(' └─────────────────────────────────────┘');
|
|
34
|
+
console.log('');
|
|
35
|
+
|
|
36
|
+
if (installed.length > 0) {
|
|
37
|
+
console.log(` Providers (${installed.length} ready):`);
|
|
38
|
+
for (const p of installed) {
|
|
39
|
+
console.log(` ✓ ${p.name}`);
|
|
40
|
+
}
|
|
41
|
+
} else {
|
|
42
|
+
console.log(' No AI providers detected.');
|
|
43
|
+
console.log(' Install at least one: npm i -g @anthropic-ai/claude-code');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (notInstalled.length > 0) {
|
|
47
|
+
console.log('');
|
|
48
|
+
console.log(' Available to install:');
|
|
49
|
+
for (const p of notInstalled) {
|
|
50
|
+
console.log(` · ${p.name.padEnd(18)} ${p.installCommand}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
console.log('');
|
|
55
|
+
console.log(` GUI: http://localhost:${port}`);
|
|
56
|
+
console.log(' Docs: https://docs.groovedev.ai');
|
|
57
|
+
console.log(' GitHub: https://github.com/grooveai-dev/groove');
|
|
58
|
+
console.log('');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function runFirstTimeSetup(grooveDir) {
|
|
62
|
+
// Write default config
|
|
63
|
+
const config = { ...DEFAULT_CONFIG };
|
|
64
|
+
|
|
65
|
+
// Auto-detect best default provider
|
|
66
|
+
const providers = listProviders();
|
|
67
|
+
const installed = providers.filter((p) => p.installed);
|
|
68
|
+
if (installed.length > 0) {
|
|
69
|
+
const preferred = ['claude-code', 'codex', 'gemini', 'aider', 'ollama'];
|
|
70
|
+
const best = preferred.find((id) => installed.some((p) => p.id === id));
|
|
71
|
+
if (best) config.defaultProvider = best;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
writeFileSync(resolve(grooveDir, 'config.json'), JSON.stringify(config, null, 2));
|
|
75
|
+
|
|
76
|
+
return config;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function loadConfig(grooveDir) {
|
|
80
|
+
const configPath = resolve(grooveDir, 'config.json');
|
|
81
|
+
if (!existsSync(configPath)) return { ...DEFAULT_CONFIG };
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const saved = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
85
|
+
return { ...DEFAULT_CONFIG, ...saved };
|
|
86
|
+
} catch {
|
|
87
|
+
return { ...DEFAULT_CONFIG };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function saveConfig(grooveDir, config) {
|
|
92
|
+
writeFileSync(resolve(grooveDir, 'config.json'), JSON.stringify(config, null, 2));
|
|
93
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// GROOVE Daemon — Entry Point
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
import { createServer } from 'http';
|
|
5
|
+
import { resolve } from 'path';
|
|
6
|
+
import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync } from 'fs';
|
|
7
|
+
import express from 'express';
|
|
8
|
+
import { WebSocketServer } from 'ws';
|
|
9
|
+
import { Registry } from './registry.js';
|
|
10
|
+
import { createApi } from './api.js';
|
|
11
|
+
import { ProcessManager } from './process.js';
|
|
12
|
+
import { StateManager } from './state.js';
|
|
13
|
+
import { Introducer } from './introducer.js';
|
|
14
|
+
import { LockManager } from './lockmanager.js';
|
|
15
|
+
import { Supervisor } from './supervisor.js';
|
|
16
|
+
import { Journalist } from './journalist.js';
|
|
17
|
+
import { TokenTracker } from './tokentracker.js';
|
|
18
|
+
import { Rotator } from './rotator.js';
|
|
19
|
+
import { AdaptiveThresholds } from './adaptive.js';
|
|
20
|
+
import { Teams } from './teams.js';
|
|
21
|
+
import { CredentialStore } from './credentials.js';
|
|
22
|
+
import { TaskClassifier } from './classifier.js';
|
|
23
|
+
import { ModelRouter } from './router.js';
|
|
24
|
+
import { ProjectManager } from './pm.js';
|
|
25
|
+
import { isFirstRun, runFirstTimeSetup, loadConfig, saveConfig, printWelcome } from './firstrun.js';
|
|
26
|
+
|
|
27
|
+
const DEFAULT_PORT = 31415;
|
|
28
|
+
|
|
29
|
+
export { loadConfig, saveConfig } from './firstrun.js';
|
|
30
|
+
|
|
31
|
+
export class Daemon {
|
|
32
|
+
constructor(options = {}) {
|
|
33
|
+
this.port = options.port !== undefined ? options.port : (parseInt(process.env.GROOVE_PORT, 10) || DEFAULT_PORT);
|
|
34
|
+
this.projectDir = options.projectDir || process.cwd();
|
|
35
|
+
this.grooveDir = options.grooveDir || resolve(this.projectDir, '.groove');
|
|
36
|
+
this.pidFile = resolve(this.grooveDir, 'daemon.pid');
|
|
37
|
+
|
|
38
|
+
// Ensure .groove directories exist
|
|
39
|
+
mkdirSync(resolve(this.grooveDir, 'logs'), { recursive: true });
|
|
40
|
+
mkdirSync(resolve(this.grooveDir, 'context'), { recursive: true });
|
|
41
|
+
|
|
42
|
+
// Initialize coordination file for agent knock protocol
|
|
43
|
+
const coordPath = resolve(this.grooveDir, 'coordination.md');
|
|
44
|
+
if (!existsSync(coordPath)) {
|
|
45
|
+
writeFileSync(coordPath, '# GROOVE Coordination\n\n*Agents write their intent here before shared/destructive actions.*\n\n<!-- No active operations -->\n');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// First-run detection
|
|
49
|
+
if (isFirstRun(this.grooveDir)) {
|
|
50
|
+
this.config = runFirstTimeSetup(this.grooveDir);
|
|
51
|
+
} else {
|
|
52
|
+
this.config = loadConfig(this.grooveDir);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Initialize core components
|
|
56
|
+
this.state = new StateManager(this.grooveDir);
|
|
57
|
+
this.registry = new Registry(this.state);
|
|
58
|
+
this.locks = new LockManager(this.grooveDir);
|
|
59
|
+
this.tokens = new TokenTracker(this.grooveDir);
|
|
60
|
+
this.processes = new ProcessManager(this);
|
|
61
|
+
this.introducer = new Introducer(this);
|
|
62
|
+
this.supervisor = new Supervisor(this);
|
|
63
|
+
this.journalist = new Journalist(this);
|
|
64
|
+
this.rotator = new Rotator(this);
|
|
65
|
+
this.adaptive = new AdaptiveThresholds(this.grooveDir);
|
|
66
|
+
this.teams = new Teams(this);
|
|
67
|
+
this.credentials = new CredentialStore(this.grooveDir);
|
|
68
|
+
this.classifier = new TaskClassifier();
|
|
69
|
+
this.router = new ModelRouter(this);
|
|
70
|
+
this.pm = new ProjectManager(this);
|
|
71
|
+
|
|
72
|
+
// HTTP + WebSocket server
|
|
73
|
+
this.app = express();
|
|
74
|
+
this.server = createServer(this.app);
|
|
75
|
+
this.wss = new WebSocketServer({
|
|
76
|
+
server: this.server,
|
|
77
|
+
maxPayload: 1024 * 1024, // 1MB max message
|
|
78
|
+
verifyClient: ({ req }) => {
|
|
79
|
+
const origin = req.headers.origin;
|
|
80
|
+
// Allow: no origin (CLI/native clients), localhost origins
|
|
81
|
+
if (!origin) return true;
|
|
82
|
+
const allowed = [
|
|
83
|
+
`http://localhost:${this.port}`,
|
|
84
|
+
`http://127.0.0.1:${this.port}`,
|
|
85
|
+
'http://localhost:3142',
|
|
86
|
+
];
|
|
87
|
+
return allowed.includes(origin);
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Wire up API routes
|
|
92
|
+
createApi(this.app, this);
|
|
93
|
+
|
|
94
|
+
// Broadcast registry changes over WebSocket
|
|
95
|
+
this.registry.on('change', () => {
|
|
96
|
+
this.broadcast({ type: 'state', data: this.registry.getAll() });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Send full state to new WebSocket clients
|
|
100
|
+
this.wss.on('connection', (ws) => {
|
|
101
|
+
ws.send(JSON.stringify({
|
|
102
|
+
type: 'state',
|
|
103
|
+
data: this.registry.getAll(),
|
|
104
|
+
}));
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Auto-update AGENTS_REGISTRY.md and CLAUDE.md GROOVE section on changes
|
|
108
|
+
this.registry.on('change', () => {
|
|
109
|
+
this.introducer.writeRegistryFile(this.projectDir);
|
|
110
|
+
this.introducer.injectGrooveSection(this.projectDir);
|
|
111
|
+
this.teams.onAgentChange();
|
|
112
|
+
this.supervisor.checkQcThreshold();
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
broadcast(message) {
|
|
117
|
+
const payload = JSON.stringify(message);
|
|
118
|
+
for (const client of this.wss.clients) {
|
|
119
|
+
if (client.readyState === 1) {
|
|
120
|
+
client.send(payload);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async start() {
|
|
126
|
+
// Check for existing daemon
|
|
127
|
+
if (existsSync(this.pidFile)) {
|
|
128
|
+
const existingPid = parseInt(readFileSync(this.pidFile, 'utf8'), 10);
|
|
129
|
+
try {
|
|
130
|
+
process.kill(existingPid, 0); // Signal 0 = check if alive
|
|
131
|
+
console.error(`GROOVE daemon already running (PID ${existingPid})`);
|
|
132
|
+
process.exit(1);
|
|
133
|
+
} catch {
|
|
134
|
+
// PID file is stale — previous daemon crashed
|
|
135
|
+
unlinkSync(this.pidFile);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Restore persisted state
|
|
140
|
+
this.state.load();
|
|
141
|
+
this.registry.restore(this.state.get('agents') || []);
|
|
142
|
+
|
|
143
|
+
return new Promise((resolvePromise) => {
|
|
144
|
+
this.server.listen(this.port, '127.0.0.1', () => {
|
|
145
|
+
writeFileSync(this.pidFile, String(process.pid));
|
|
146
|
+
|
|
147
|
+
printWelcome(this.port);
|
|
148
|
+
|
|
149
|
+
// Start background services
|
|
150
|
+
this.journalist.start();
|
|
151
|
+
this.rotator.start();
|
|
152
|
+
|
|
153
|
+
resolvePromise(this);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async stop() {
|
|
159
|
+
// Persist state before shutdown
|
|
160
|
+
this.state.set('agents', this.registry.getAll());
|
|
161
|
+
this.state.save();
|
|
162
|
+
|
|
163
|
+
// Stop background services
|
|
164
|
+
this.journalist.stop();
|
|
165
|
+
this.rotator.stop();
|
|
166
|
+
|
|
167
|
+
// Kill all agent processes
|
|
168
|
+
await this.processes.killAll();
|
|
169
|
+
|
|
170
|
+
// Clean up PID file
|
|
171
|
+
if (existsSync(this.pidFile)) {
|
|
172
|
+
unlinkSync(this.pidFile);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Clean up generated files
|
|
176
|
+
const registryPath = resolve(this.projectDir, 'AGENTS_REGISTRY.md');
|
|
177
|
+
if (existsSync(registryPath)) {
|
|
178
|
+
unlinkSync(registryPath);
|
|
179
|
+
}
|
|
180
|
+
this.introducer.removeGrooveSection(this.projectDir);
|
|
181
|
+
|
|
182
|
+
// Close server
|
|
183
|
+
return new Promise((resolvePromise) => {
|
|
184
|
+
this.wss.close(() => {
|
|
185
|
+
this.server.close(() => {
|
|
186
|
+
console.log('GROOVE daemon stopped.');
|
|
187
|
+
resolvePromise();
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Start daemon if run directly
|
|
195
|
+
const isDirectRun = process.argv[1] && import.meta.url === `file://${process.argv[1]}`;
|
|
196
|
+
if (isDirectRun) {
|
|
197
|
+
const daemon = new Daemon();
|
|
198
|
+
|
|
199
|
+
const shutdown = async () => {
|
|
200
|
+
await daemon.stop();
|
|
201
|
+
process.exit(0);
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
process.on('SIGINT', shutdown);
|
|
205
|
+
process.on('SIGTERM', shutdown);
|
|
206
|
+
|
|
207
|
+
daemon.start();
|
|
208
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// GROOVE — Introduction Protocol
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
import { writeFileSync, readFileSync, existsSync } from 'fs';
|
|
5
|
+
import { resolve } from 'path';
|
|
6
|
+
import { escapeMd } from './validate.js';
|
|
7
|
+
|
|
8
|
+
const GROOVE_SECTION_START = '<!-- GROOVE:START -->';
|
|
9
|
+
const GROOVE_SECTION_END = '<!-- GROOVE:END -->';
|
|
10
|
+
|
|
11
|
+
export class Introducer {
|
|
12
|
+
constructor(daemon) {
|
|
13
|
+
this.daemon = daemon;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
generateContext(newAgent, options = {}) {
|
|
17
|
+
const { taskNegotiation } = options;
|
|
18
|
+
const agents = this.daemon.registry.getAll();
|
|
19
|
+
// Include ALL agents (running + completed) so new agents know what the team did
|
|
20
|
+
const others = agents.filter((a) => a.id !== newAgent.id &&
|
|
21
|
+
(a.status === 'running' || a.status === 'starting' || a.status === 'completed'));
|
|
22
|
+
|
|
23
|
+
const lines = [
|
|
24
|
+
`# GROOVE Agent Context`,
|
|
25
|
+
``,
|
|
26
|
+
`You are **${newAgent.name}** (role: ${newAgent.role}), managed by GROOVE.`,
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
if (newAgent.scope && newAgent.scope.length > 0) {
|
|
30
|
+
lines.push(`Your file scope: \`${newAgent.scope.join('`, `')}\``);
|
|
31
|
+
} else {
|
|
32
|
+
lines.push(`You have no file scope restrictions.`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
lines.push('');
|
|
36
|
+
|
|
37
|
+
if (others.length === 0) {
|
|
38
|
+
lines.push('You are the only agent on this project right now.');
|
|
39
|
+
} else {
|
|
40
|
+
lines.push(`## Team (${others.length} other agent${others.length > 1 ? 's' : ''})`);
|
|
41
|
+
lines.push('');
|
|
42
|
+
|
|
43
|
+
// Collect all files created by teammates for the project files section
|
|
44
|
+
const allTeamFiles = [];
|
|
45
|
+
|
|
46
|
+
for (const other of others) {
|
|
47
|
+
const scope = other.scope?.length > 0 ? other.scope.join(', ') : 'unrestricted';
|
|
48
|
+
lines.push(`- **${other.name}** (${other.role}) — scope: ${scope} — ${other.status}`);
|
|
49
|
+
|
|
50
|
+
// Get files this agent created/modified
|
|
51
|
+
const files = this.daemon.journalist?.getAgentFiles(other) || [];
|
|
52
|
+
if (files.length > 0) {
|
|
53
|
+
const shown = files.slice(0, 15);
|
|
54
|
+
lines.push(` Files: ${shown.join(', ')}${files.length > 15 ? ` (+${files.length - 15} more)` : ''}`);
|
|
55
|
+
for (const f of files) {
|
|
56
|
+
allTeamFiles.push({ file: f, agent: other.name, role: other.role });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// For completed agents, include their final result summary
|
|
61
|
+
if (other.status === 'completed') {
|
|
62
|
+
const result = this.daemon.journalist?.getAgentResult(other) || '';
|
|
63
|
+
if (result) {
|
|
64
|
+
lines.push(` Result: ${result.slice(0, 500)}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
lines.push('');
|
|
70
|
+
lines.push(`## Coordination Rules`);
|
|
71
|
+
lines.push('');
|
|
72
|
+
lines.push(`- Stay within your file scope. Do NOT modify files owned by other agents.`);
|
|
73
|
+
lines.push(`- If you need changes outside your scope, document what you need — GROOVE will coordinate.`);
|
|
74
|
+
lines.push(`- Check AGENTS_REGISTRY.md for the latest team state.`);
|
|
75
|
+
|
|
76
|
+
// Project files section — tell the new agent what exists and what to read
|
|
77
|
+
if (allTeamFiles.length > 0) {
|
|
78
|
+
lines.push('');
|
|
79
|
+
lines.push(`## Project Files`);
|
|
80
|
+
lines.push('');
|
|
81
|
+
lines.push(`Your team has created the following files. **Read relevant ones before starting work** to understand what's been built and planned:`);
|
|
82
|
+
lines.push('');
|
|
83
|
+
|
|
84
|
+
// Group by agent for clarity
|
|
85
|
+
const byAgent = {};
|
|
86
|
+
for (const { file, agent, role } of allTeamFiles) {
|
|
87
|
+
const key = `${agent} (${role})`;
|
|
88
|
+
if (!byAgent[key]) byAgent[key] = [];
|
|
89
|
+
byAgent[key].push(file);
|
|
90
|
+
}
|
|
91
|
+
for (const [agent, files] of Object.entries(byAgent)) {
|
|
92
|
+
lines.push(`**${agent}:**`);
|
|
93
|
+
for (const f of files.slice(0, 20)) {
|
|
94
|
+
lines.push(`- ${f}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Task negotiation — when a duplicate role joins, include the work division
|
|
101
|
+
if (taskNegotiation) {
|
|
102
|
+
lines.push('');
|
|
103
|
+
lines.push(`## Task Assignment`);
|
|
104
|
+
lines.push('');
|
|
105
|
+
lines.push(`A task coordinator has analyzed the current team's work and assigned your focus area:`);
|
|
106
|
+
lines.push('');
|
|
107
|
+
lines.push(taskNegotiation);
|
|
108
|
+
lines.push('');
|
|
109
|
+
lines.push(`**Follow this assignment.** Focus on your assigned tasks and do NOT modify files that other same-role agents are actively working on.`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Knock protocol — coordination for destructive/shared actions
|
|
113
|
+
const running = others.filter((a) => a.status === 'running' || a.status === 'starting');
|
|
114
|
+
if (running.length > 0) {
|
|
115
|
+
lines.push('');
|
|
116
|
+
lines.push(`## Coordination Protocol`);
|
|
117
|
+
lines.push('');
|
|
118
|
+
lines.push(`Before performing shared/destructive actions (restart server, npm install/build, modify package.json, modify shared config), coordinate with your team:`);
|
|
119
|
+
lines.push(`1. Read \`.groove/coordination.md\` to check for active operations`);
|
|
120
|
+
lines.push(`2. Write your intent to \`.groove/coordination.md\` (e.g., "backend-1: restarting server")`);
|
|
121
|
+
lines.push(`3. Proceed only if no conflicting operations are active`);
|
|
122
|
+
lines.push(`4. Clear your entry from \`.groove/coordination.md\` when done`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Memory containment — prevent agents from reading/writing auto-memory
|
|
126
|
+
// which can contain stale context from unrelated sessions in the same dir
|
|
127
|
+
lines.push('');
|
|
128
|
+
lines.push(`## Memory Policy`);
|
|
129
|
+
lines.push('');
|
|
130
|
+
lines.push(`Ignore auto-memory. Do NOT read or write MEMORY.md or any files in the auto-memory directory.`);
|
|
131
|
+
lines.push(`GROOVE provides all your project context through handoff briefs, AGENTS_REGISTRY.md, and GROOVE_PROJECT_MAP.md.`);
|
|
132
|
+
lines.push(`Do NOT save memories — your state is managed by GROOVE's rotation and handoff system.`);
|
|
133
|
+
|
|
134
|
+
// Add reference to project map if it exists
|
|
135
|
+
const mapPath = resolve(this.daemon.projectDir, 'GROOVE_PROJECT_MAP.md');
|
|
136
|
+
if (existsSync(mapPath)) {
|
|
137
|
+
lines.push('');
|
|
138
|
+
lines.push(`Read GROOVE_PROJECT_MAP.md for current project context from The Journalist.`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return lines.join('\n');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
writeRegistryFile(projectDir) {
|
|
145
|
+
const agents = this.daemon.registry.getAll();
|
|
146
|
+
|
|
147
|
+
if (agents.length === 0) {
|
|
148
|
+
// Clean up if no agents
|
|
149
|
+
const regPath = resolve(projectDir, 'AGENTS_REGISTRY.md');
|
|
150
|
+
if (existsSync(regPath)) {
|
|
151
|
+
writeFileSync(regPath, '');
|
|
152
|
+
}
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const lines = [
|
|
157
|
+
`# AGENTS REGISTRY`,
|
|
158
|
+
``,
|
|
159
|
+
`*Auto-generated by GROOVE. Do not edit manually.*`,
|
|
160
|
+
``,
|
|
161
|
+
`| ID | Name | Role | Provider | Scope | Status |`,
|
|
162
|
+
`|----|------|------|----------|-------|--------|`,
|
|
163
|
+
];
|
|
164
|
+
|
|
165
|
+
for (const a of agents) {
|
|
166
|
+
const scope = a.scope?.length > 0 ? `\`${a.scope.join('`, `')}\`` : '-';
|
|
167
|
+
lines.push(`| ${escapeMd(a.id)} | ${escapeMd(a.name)} | ${escapeMd(a.role)} | ${escapeMd(a.provider)} | ${scope} | ${escapeMd(a.status)} |`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
lines.push('');
|
|
171
|
+
lines.push(`*Updated: ${new Date().toISOString()}*`);
|
|
172
|
+
|
|
173
|
+
writeFileSync(resolve(projectDir, 'AGENTS_REGISTRY.md'), lines.join('\n'));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
injectGrooveSection(projectDir) {
|
|
177
|
+
// Inject a GROOVE section into the project's CLAUDE.md.
|
|
178
|
+
// This section is delimited by markers so we can update it without
|
|
179
|
+
// clobbering the user's content.
|
|
180
|
+
const claudeMdPath = resolve(projectDir, 'CLAUDE.md');
|
|
181
|
+
const agents = this.daemon.registry.getAll();
|
|
182
|
+
const running = agents.filter((a) => a.status === 'running');
|
|
183
|
+
|
|
184
|
+
const grooveContent = [
|
|
185
|
+
GROOVE_SECTION_START,
|
|
186
|
+
'',
|
|
187
|
+
'## GROOVE Orchestration (auto-injected)',
|
|
188
|
+
'',
|
|
189
|
+
`Active agents: ${running.length}`,
|
|
190
|
+
'',
|
|
191
|
+
running.length > 0 ? '| Name | Role | Scope |' : '',
|
|
192
|
+
running.length > 0 ? '|------|------|-------|' : '',
|
|
193
|
+
...running.map((a) => `| ${a.name} | ${a.role} | ${a.scope?.join(', ') || '-'} |`),
|
|
194
|
+
'',
|
|
195
|
+
`See AGENTS_REGISTRY.md for full agent state.`,
|
|
196
|
+
'',
|
|
197
|
+
`**Memory policy:** Ignore auto-memory. Do not read or write MEMORY.md. GROOVE manages all context.`,
|
|
198
|
+
'',
|
|
199
|
+
GROOVE_SECTION_END,
|
|
200
|
+
].filter(Boolean).join('\n');
|
|
201
|
+
|
|
202
|
+
if (!existsSync(claudeMdPath)) {
|
|
203
|
+
return; // Don't create CLAUDE.md — it's the user's file
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let content = readFileSync(claudeMdPath, 'utf8');
|
|
207
|
+
|
|
208
|
+
const startIdx = content.indexOf(GROOVE_SECTION_START);
|
|
209
|
+
const endIdx = content.indexOf(GROOVE_SECTION_END);
|
|
210
|
+
|
|
211
|
+
if (startIdx !== -1 && endIdx !== -1) {
|
|
212
|
+
// Replace existing GROOVE section
|
|
213
|
+
content = content.slice(0, startIdx) + grooveContent + content.slice(endIdx + GROOVE_SECTION_END.length);
|
|
214
|
+
} else {
|
|
215
|
+
// Append GROOVE section
|
|
216
|
+
content = content.trimEnd() + '\n\n' + grooveContent + '\n';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
writeFileSync(claudeMdPath, content);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
removeGrooveSection(projectDir) {
|
|
223
|
+
const claudeMdPath = resolve(projectDir, 'CLAUDE.md');
|
|
224
|
+
if (!existsSync(claudeMdPath)) return;
|
|
225
|
+
|
|
226
|
+
let content = readFileSync(claudeMdPath, 'utf8');
|
|
227
|
+
const startIdx = content.indexOf(GROOVE_SECTION_START);
|
|
228
|
+
const endIdx = content.indexOf(GROOVE_SECTION_END);
|
|
229
|
+
|
|
230
|
+
if (startIdx !== -1 && endIdx !== -1) {
|
|
231
|
+
// Remove the GROOVE section and any surrounding blank lines
|
|
232
|
+
const before = content.slice(0, startIdx).replace(/\n+$/, '');
|
|
233
|
+
const after = content.slice(endIdx + GROOVE_SECTION_END.length).replace(/^\n+/, '');
|
|
234
|
+
content = before + (after ? '\n\n' + after : '') + '\n';
|
|
235
|
+
writeFileSync(claudeMdPath, content);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|