groove-dev 0.27.37 → 0.27.40
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/README.md +3 -3
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/api.js +91 -11
- package/node_modules/@groove-dev/daemon/src/index.js +3 -0
- package/node_modules/@groove-dev/daemon/src/lockmanager.js +44 -0
- package/node_modules/@groove-dev/daemon/src/memory.js +22 -5
- package/node_modules/@groove-dev/daemon/src/preview.js +249 -0
- package/node_modules/@groove-dev/daemon/src/process.js +145 -7
- package/node_modules/@groove-dev/daemon/src/providers/claude-code.js +37 -1
- package/node_modules/@groove-dev/daemon/src/rotator.js +18 -3
- package/node_modules/@groove-dev/daemon/templates/knock-hook.cjs +44 -0
- package/node_modules/@groove-dev/gui/dist/assets/{index-Df4O6yJI.js → index-zzVaD3-G.js} +3 -3
- package/node_modules/@groove-dev/gui/dist/index.html +1 -1
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +2 -2
- package/node_modules/@groove-dev/gui/src/components/onboarding/setup-wizard.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/components/ui/toast.jsx +12 -0
- package/node_modules/@groove-dev/gui/src/stores/groove.js +42 -4
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/api.js +91 -11
- package/packages/daemon/src/index.js +3 -0
- package/packages/daemon/src/lockmanager.js +44 -0
- package/packages/daemon/src/memory.js +22 -5
- package/packages/daemon/src/preview.js +249 -0
- package/packages/daemon/src/process.js +145 -7
- package/packages/daemon/src/providers/claude-code.js +37 -1
- package/packages/daemon/src/rotator.js +18 -3
- package/packages/daemon/templates/knock-hook.cjs +44 -0
- package/packages/gui/dist/assets/{index-Df4O6yJI.js → index-zzVaD3-G.js} +3 -3
- package/packages/gui/dist/index.html +1 -1
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/components/layout/activity-bar.jsx +2 -2
- package/packages/gui/src/components/onboarding/setup-wizard.jsx +1 -1
- package/packages/gui/src/components/ui/toast.jsx +12 -0
- package/packages/gui/src/stores/groove.js +42 -4
- package/plans/chat-persistence-refactor.md +0 -154
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// GROOVE — Preview Service
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
//
|
|
4
|
+
// Launches the one-click preview for a completed team. The planner writes a
|
|
5
|
+
// "preview" block in recommended-team.json describing how to run the project
|
|
6
|
+
// (dev-server command, static-html entry, or none). When the last phase
|
|
7
|
+
// agent completes, we spawn the command, parse the URL from stdout, and
|
|
8
|
+
// broadcast a preview:ready event so the GUI can show a View Site toast.
|
|
9
|
+
//
|
|
10
|
+
// One preview process per team. Starting a new preview for the same team
|
|
11
|
+
// kills the previous one. Previews are also killed on team delete and on
|
|
12
|
+
// daemon shutdown.
|
|
13
|
+
|
|
14
|
+
import { spawn as cpSpawn } from 'child_process';
|
|
15
|
+
import { resolve, extname } from 'path';
|
|
16
|
+
import { existsSync, readFileSync, statSync } from 'fs';
|
|
17
|
+
import { createServer } from 'http';
|
|
18
|
+
import { lookup as mimeLookup } from './mimetypes.js';
|
|
19
|
+
|
|
20
|
+
const READY_TIMEOUT_MS = 60_000; // give dev servers a minute to boot
|
|
21
|
+
const MAX_STDOUT_BYTES = 256 * 1024;
|
|
22
|
+
// Strip CSI/OSC/other ANSI escape sequences — Vite prints URLs with inline
|
|
23
|
+
// bold/color codes (e.g. "http://localhost:\x1b[1m5175\x1b[22m/") which would
|
|
24
|
+
// otherwise break port-number regexes.
|
|
25
|
+
const ANSI_REGEX = /[\u001B\u009B][[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d/#&.:=?%@~_]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g;
|
|
26
|
+
function stripAnsi(s) { return s.replace(ANSI_REGEX, ''); }
|
|
27
|
+
|
|
28
|
+
export class PreviewService {
|
|
29
|
+
constructor(daemon) {
|
|
30
|
+
this.daemon = daemon;
|
|
31
|
+
this.previews = new Map(); // teamId -> { proc?, server?, url, kind, startedAt }
|
|
32
|
+
this.pendingPlans = new Map(); // teamId -> { preview, workingDir }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Capture a preview plan at team launch time — api cleanup deletes the
|
|
37
|
+
* source file immediately after read, so the daemon is the only place the
|
|
38
|
+
* preview block survives.
|
|
39
|
+
*/
|
|
40
|
+
stashPlan(teamId, preview, workingDir) {
|
|
41
|
+
if (!teamId || !preview) return;
|
|
42
|
+
this.pendingPlans.set(teamId, { preview, workingDir });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
getPlan(teamId) {
|
|
46
|
+
return this.pendingPlans.get(teamId) || null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
clearPlan(teamId) {
|
|
50
|
+
this.pendingPlans.delete(teamId);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Read recommended-team.json for a given working directory and return the
|
|
55
|
+
* preview block (or null if none). We read from both the team working dir
|
|
56
|
+
* and the daemon .groove dir to cover the cases the api cleanup hits.
|
|
57
|
+
*/
|
|
58
|
+
getPlanPreview(workingDir) {
|
|
59
|
+
const candidates = [
|
|
60
|
+
workingDir ? resolve(workingDir, '.groove', 'recommended-team.json') : null,
|
|
61
|
+
resolve(this.daemon.grooveDir, 'recommended-team.json'),
|
|
62
|
+
].filter(Boolean);
|
|
63
|
+
for (const p of candidates) {
|
|
64
|
+
if (!existsSync(p)) continue;
|
|
65
|
+
try {
|
|
66
|
+
const data = JSON.parse(readFileSync(p, 'utf8'));
|
|
67
|
+
if (data && typeof data.preview === 'object') return data.preview;
|
|
68
|
+
} catch { /* malformed, keep looking */ }
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Preview blocks are embedded in the plan artifact, which /api/cleanup
|
|
75
|
+
* deletes as soon as the user clicks Launch Team. Callers should grab the
|
|
76
|
+
* preview upfront at launch time and hand it back when the team completes.
|
|
77
|
+
*/
|
|
78
|
+
async launch(teamId, workingDir, preview) {
|
|
79
|
+
if (!preview || !preview.kind || preview.kind === 'none' || preview.kind === 'cli') {
|
|
80
|
+
return { launched: false, reason: preview?.kind || 'no_preview' };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Kill any existing preview for this team
|
|
84
|
+
await this.kill(teamId);
|
|
85
|
+
|
|
86
|
+
const baseDir = preview.cwd
|
|
87
|
+
? resolve(workingDir || this.daemon.projectDir, preview.cwd)
|
|
88
|
+
: resolve(workingDir || this.daemon.projectDir);
|
|
89
|
+
|
|
90
|
+
if (!existsSync(baseDir)) {
|
|
91
|
+
return { launched: false, reason: `cwd_missing: ${baseDir}` };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (preview.kind === 'static-html') {
|
|
95
|
+
return this._launchStatic(teamId, baseDir, preview);
|
|
96
|
+
}
|
|
97
|
+
if (preview.kind === 'dev-server') {
|
|
98
|
+
return this._launchDevServer(teamId, baseDir, preview);
|
|
99
|
+
}
|
|
100
|
+
return { launched: false, reason: `unknown_kind: ${preview.kind}` };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
_launchStatic(teamId, baseDir, preview) {
|
|
104
|
+
const openPath = (preview.openPath || 'index.html').replace(/^\/+/, '');
|
|
105
|
+
const entryFile = resolve(baseDir, openPath);
|
|
106
|
+
if (!existsSync(entryFile)) {
|
|
107
|
+
return Promise.resolve({ launched: false, reason: `entry_missing: ${entryFile}` });
|
|
108
|
+
}
|
|
109
|
+
const server = createServer((req, res) => {
|
|
110
|
+
const url = decodeURIComponent((req.url || '/').split('?')[0]);
|
|
111
|
+
const rel = url === '/' ? openPath : url.replace(/^\/+/, '');
|
|
112
|
+
const filePath = resolve(baseDir, rel);
|
|
113
|
+
if (!filePath.startsWith(baseDir)) { res.statusCode = 403; return res.end(); }
|
|
114
|
+
if (!existsSync(filePath) || !statSync(filePath).isFile()) {
|
|
115
|
+
res.statusCode = 404; return res.end('Not found');
|
|
116
|
+
}
|
|
117
|
+
res.setHeader('Content-Type', mimeLookup(extname(filePath)) || 'application/octet-stream');
|
|
118
|
+
res.end(readFileSync(filePath));
|
|
119
|
+
});
|
|
120
|
+
return new Promise((done) => {
|
|
121
|
+
server.listen(0, '127.0.0.1', () => {
|
|
122
|
+
const port = server.address().port;
|
|
123
|
+
const url = `http://127.0.0.1:${port}/`;
|
|
124
|
+
this.previews.set(teamId, { server, url, kind: 'static-html', startedAt: Date.now() });
|
|
125
|
+
this._broadcastReady(teamId, url, 'static-html');
|
|
126
|
+
done({ launched: true, url, kind: 'static-html' });
|
|
127
|
+
});
|
|
128
|
+
server.on('error', (err) => done({ launched: false, reason: err.message }));
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
_launchDevServer(teamId, baseDir, preview) {
|
|
133
|
+
const command = String(preview.command || '').trim();
|
|
134
|
+
if (!command) {
|
|
135
|
+
return Promise.resolve({ launched: false, reason: 'no_command' });
|
|
136
|
+
}
|
|
137
|
+
const urlPattern = preview.urlPattern
|
|
138
|
+
? new RegExp(preview.urlPattern)
|
|
139
|
+
: /https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0):\d+/;
|
|
140
|
+
const readyText = preview.readyText || '';
|
|
141
|
+
|
|
142
|
+
// Run the command via the user's shell so pipelines, && chains, env var
|
|
143
|
+
// expansion, and shell builtins work as the planner wrote them.
|
|
144
|
+
const proc = cpSpawn('bash', ['-lc', command], {
|
|
145
|
+
cwd: baseDir,
|
|
146
|
+
env: { ...process.env, FORCE_COLOR: '0', CI: '' },
|
|
147
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
148
|
+
detached: false,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const entry = { proc, url: null, kind: 'dev-server', startedAt: Date.now(), command, baseDir };
|
|
152
|
+
this.previews.set(teamId, entry);
|
|
153
|
+
|
|
154
|
+
let stdoutBuf = '';
|
|
155
|
+
let stderrBuf = '';
|
|
156
|
+
let resolved = false;
|
|
157
|
+
|
|
158
|
+
return new Promise((done) => {
|
|
159
|
+
const finish = (result) => {
|
|
160
|
+
if (resolved) return;
|
|
161
|
+
resolved = true;
|
|
162
|
+
clearTimeout(timer);
|
|
163
|
+
if (result.launched) {
|
|
164
|
+
entry.url = result.url;
|
|
165
|
+
this._broadcastReady(teamId, result.url, 'dev-server');
|
|
166
|
+
} else {
|
|
167
|
+
// Failed to detect URL — keep the process? Probably kill it; the user
|
|
168
|
+
// will just see broken output otherwise.
|
|
169
|
+
try { proc.kill('SIGTERM'); } catch {}
|
|
170
|
+
this.previews.delete(teamId);
|
|
171
|
+
}
|
|
172
|
+
done(result);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const timer = setTimeout(() => {
|
|
176
|
+
const tail = stripAnsi(stderrBuf).slice(-400) || stripAnsi(stdoutBuf).slice(-400) || '(no output)';
|
|
177
|
+
finish({ launched: false, reason: `timeout waiting for url in stdout; last output: ${tail}` });
|
|
178
|
+
}, READY_TIMEOUT_MS);
|
|
179
|
+
|
|
180
|
+
const tryMatch = () => {
|
|
181
|
+
const combined = stripAnsi(stdoutBuf + '\n' + stderrBuf);
|
|
182
|
+
if (readyText && !combined.includes(readyText)) return;
|
|
183
|
+
const m = combined.match(urlPattern);
|
|
184
|
+
if (!m) return;
|
|
185
|
+
let url = m[0];
|
|
186
|
+
const openPath = (preview.openPath || '').replace(/^\/+/, '');
|
|
187
|
+
if (openPath) url = url.replace(/\/$/, '') + '/' + openPath;
|
|
188
|
+
finish({ launched: true, url, kind: 'dev-server' });
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
proc.stdout.on('data', (c) => {
|
|
192
|
+
stdoutBuf += c.toString();
|
|
193
|
+
if (stdoutBuf.length > MAX_STDOUT_BYTES) stdoutBuf = stdoutBuf.slice(-MAX_STDOUT_BYTES);
|
|
194
|
+
tryMatch();
|
|
195
|
+
});
|
|
196
|
+
proc.stderr.on('data', (c) => {
|
|
197
|
+
stderrBuf += c.toString();
|
|
198
|
+
if (stderrBuf.length > MAX_STDOUT_BYTES) stderrBuf = stderrBuf.slice(-MAX_STDOUT_BYTES);
|
|
199
|
+
tryMatch();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
proc.on('exit', (code, signal) => {
|
|
203
|
+
this.previews.delete(teamId);
|
|
204
|
+
if (!resolved) {
|
|
205
|
+
finish({ launched: false, reason: `process exited before url detected (code=${code} signal=${signal}); stderr tail: ${stderrBuf.slice(-400)}` });
|
|
206
|
+
} else {
|
|
207
|
+
this.daemon.broadcast({ type: 'preview:stopped', teamId, code, signal });
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
proc.on('error', (err) => {
|
|
211
|
+
if (!resolved) finish({ launched: false, reason: `spawn error: ${err.message}` });
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
_broadcastReady(teamId, url, kind) {
|
|
217
|
+
this.daemon.audit?.log('preview.ready', { teamId, url, kind });
|
|
218
|
+
this.daemon.broadcast({ type: 'preview:ready', teamId, url, kind });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
get(teamId) {
|
|
222
|
+
const entry = this.previews.get(teamId);
|
|
223
|
+
if (!entry) return null;
|
|
224
|
+
return { teamId, url: entry.url, kind: entry.kind, startedAt: entry.startedAt };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
list() {
|
|
228
|
+
return Array.from(this.previews.entries()).map(([teamId, e]) => ({
|
|
229
|
+
teamId, url: e.url, kind: e.kind, startedAt: e.startedAt,
|
|
230
|
+
}));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async kill(teamId) {
|
|
234
|
+
const entry = this.previews.get(teamId);
|
|
235
|
+
if (!entry) return false;
|
|
236
|
+
this.previews.delete(teamId);
|
|
237
|
+
try {
|
|
238
|
+
if (entry.server) entry.server.close();
|
|
239
|
+
if (entry.proc) entry.proc.kill('SIGTERM');
|
|
240
|
+
} catch { /* best-effort */ }
|
|
241
|
+
this.daemon.broadcast({ type: 'preview:stopped', teamId });
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async killAll() {
|
|
246
|
+
const ids = Array.from(this.previews.keys());
|
|
247
|
+
await Promise.all(ids.map((id) => this.kill(id)));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -224,9 +224,26 @@ For MODE 1 (team creation):
|
|
|
224
224
|
{ "role": "frontend", "phase": 1, "scope": ["src/components/**", "src/views/**"], "prompt": "Build the frontend: [specific tasks]" },
|
|
225
225
|
{ "role": "backend", "phase": 1, "scope": ["src/api/**", "src/server/**"], "prompt": "Build the backend: [specific tasks]" },
|
|
226
226
|
{ "role": "fullstack", "phase": 2, "scope": [], "prompt": "QC Senior Dev: Audit all changes from phase 1 agents. Verify correctness, fix issues, run tests, verify the build compiles (npm run build). Do NOT start long-running dev servers. Commit all changes." }
|
|
227
|
-
]
|
|
227
|
+
],
|
|
228
|
+
"preview": {
|
|
229
|
+
"kind": "dev-server",
|
|
230
|
+
"command": "npm run dev",
|
|
231
|
+
"cwd": "<projectDir>",
|
|
232
|
+
"urlPattern": "https?://(localhost|127\\.0\\.0\\.1):\\d+",
|
|
233
|
+
"readyText": "Local:",
|
|
234
|
+
"openPath": "/"
|
|
235
|
+
}
|
|
228
236
|
}
|
|
229
237
|
|
|
238
|
+
The "preview" block is how GROOVE launches a one-click preview for the user after the team finishes. Pick EXACTLY ONE kind based on what the project will produce:
|
|
239
|
+
|
|
240
|
+
- "dev-server" — web app, API, anything that needs a running process (Vite, Next, Express, FastAPI, Rails, etc.). Set command to the exact shell command to start it. Set cwd to the subdir containing the runnable project (relative to the team working dir), or "" if it runs at the root. Set urlPattern to a regex that matches the URL in the command's stdout. Set readyText to a short substring that signals the server is up (optional but helps). Set openPath to the path the user should land on ("/").
|
|
241
|
+
- "static-html" — slide deck, static site, anything where a browser opens index.html directly. Set command to "" and openPath to the relative path of the entry HTML (e.g. "index.html" or "slides/index.html"). GROOVE will serve the directory on a local port.
|
|
242
|
+
- "cli" — library, CLI tool, anything with no visible preview. Set command to "" and let the kind signal no auto-launch.
|
|
243
|
+
- "none" — explicitly no preview.
|
|
244
|
+
|
|
245
|
+
NEVER invent preview kinds. Use these four exact strings.
|
|
246
|
+
|
|
230
247
|
For MODE 2 (task routing to existing team):
|
|
231
248
|
Only include the agents that need to do work. Use their EXISTING role — the system will find and reuse them.
|
|
232
249
|
{
|
|
@@ -360,6 +377,24 @@ export class ProcessManager {
|
|
|
360
377
|
}
|
|
361
378
|
}
|
|
362
379
|
|
|
380
|
+
// Scope collision check: refuse to spawn if another running agent already
|
|
381
|
+
// claims overlapping files. Oversight roles (planner, QC, security) and
|
|
382
|
+
// the ambassador bypass since their job requires broad access.
|
|
383
|
+
const SCOPE_BYPASS_ROLES = new Set(['planner', 'fullstack', 'qc', 'pm', 'supervisor', 'security', 'ambassador']);
|
|
384
|
+
if (config.scope && config.scope.length > 0 && !SCOPE_BYPASS_ROLES.has(config.role) && !config.allowScopeOverlap) {
|
|
385
|
+
const conflict = locks.findOverlappingOwner(config.scope);
|
|
386
|
+
if (conflict.overlap) {
|
|
387
|
+
const owner = registry.get(conflict.owner);
|
|
388
|
+
if (owner && owner.status === 'running') {
|
|
389
|
+
const ownerScope = Array.isArray(conflict.ownerScope) ? conflict.ownerScope.join(', ') : '';
|
|
390
|
+
throw new Error(
|
|
391
|
+
`Scope collision: ${config.role} scope [${config.scope.join(', ')}] overlaps with ${owner.name} (${owner.role}) which owns [${ownerScope}]. ` +
|
|
392
|
+
`Two agents cannot edit the same files. Either narrow the scope or wait for ${owner.name} to finish.`
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
363
398
|
// Clean stale recommended-team.json when spawning a new planner
|
|
364
399
|
if (config.role === 'planner') {
|
|
365
400
|
const dirs = [this.daemon.grooveDir];
|
|
@@ -655,7 +690,11 @@ For normal file edits within your scope, proceed without review.
|
|
|
655
690
|
|
|
656
691
|
this.daemon.broadcast({ type: 'agent:exit', agentId: agent.id, code: code || 0, signal, status });
|
|
657
692
|
if (this.daemon.integrations) this.daemon.integrations.refreshMcpJson();
|
|
658
|
-
if (status === 'completed' && this.daemon.journalist)
|
|
693
|
+
if (status === 'completed' && this.daemon.journalist) {
|
|
694
|
+
const turns = agentData?.turns || 0;
|
|
695
|
+
const tok = agentData?.tokensUsed || 0;
|
|
696
|
+
if (turns > 1 || tok >= 100) this.daemon.journalist.requestSynthesis('completion');
|
|
697
|
+
}
|
|
659
698
|
this._checkPhase2(agent.id);
|
|
660
699
|
|
|
661
700
|
// Auto-trigger idle QC + process cross-scope handoffs
|
|
@@ -724,7 +763,7 @@ For normal file edits within your scope, proceed without review.
|
|
|
724
763
|
// Spawn the process (use pipe for stdin if provider needs to send prompt via stdin)
|
|
725
764
|
const proc = cpSpawn(command, args, {
|
|
726
765
|
cwd: agent.workingDir || this.daemon.projectDir,
|
|
727
|
-
env: { ...process.env, ...env, ...integrationEnv, GROOVE_AGENT_ID: agent.id, GROOVE_AGENT_NAME: agent.name },
|
|
766
|
+
env: { ...process.env, ...env, ...integrationEnv, GROOVE_AGENT_ID: agent.id, GROOVE_AGENT_NAME: agent.name, GROOVE_DAEMON_HOST: this.daemon.host || '127.0.0.1', GROOVE_DAEMON_PORT: String(this.daemon.port || 31415) },
|
|
728
767
|
stdio: [stdinData ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
|
729
768
|
detached: false,
|
|
730
769
|
});
|
|
@@ -846,14 +885,28 @@ For normal file edits within your scope, proceed without review.
|
|
|
846
885
|
}
|
|
847
886
|
}
|
|
848
887
|
|
|
849
|
-
// Trigger journalist synthesis on completion (event-driven, debounced)
|
|
888
|
+
// Trigger journalist synthesis on completion (event-driven, debounced).
|
|
889
|
+
// Skip trivial sessions — a greeting-only completion (user never gave a task)
|
|
890
|
+
// has nothing worth synthesizing and wastes a $0.04+ headless claude call.
|
|
850
891
|
if (finalStatus === 'completed' && this.daemon.journalist) {
|
|
851
|
-
|
|
892
|
+
const a = registry.get(agent.id);
|
|
893
|
+
const turns = a?.turns || 0;
|
|
894
|
+
const tok = a?.tokensUsed || 0;
|
|
895
|
+
if (turns > 1 || tok >= 100) {
|
|
896
|
+
this.daemon.journalist.requestSynthesis('completion');
|
|
897
|
+
}
|
|
852
898
|
}
|
|
853
899
|
|
|
854
900
|
// Phase 2 auto-spawn: check if all phase 1 agents for a team are done
|
|
855
901
|
this._checkPhase2(agent.id);
|
|
856
902
|
|
|
903
|
+
// Preview launch: when every agent in this team is in a terminal state,
|
|
904
|
+
// kick off the one-click preview (dev server or static serve) the planner
|
|
905
|
+
// staged in the team plan. Fires once per team launch.
|
|
906
|
+
if (finalStatus === 'completed' && agent.teamId) {
|
|
907
|
+
this._checkPreviewReady(agent.teamId);
|
|
908
|
+
}
|
|
909
|
+
|
|
857
910
|
// Auto-trigger idle QC: if this agent modified files and there's an idle QC
|
|
858
911
|
// in the same team, activate it to verify the changes
|
|
859
912
|
if (finalStatus === 'completed') {
|
|
@@ -1039,6 +1092,54 @@ For normal file edits within your scope, proceed without review.
|
|
|
1039
1092
|
}
|
|
1040
1093
|
}
|
|
1041
1094
|
|
|
1095
|
+
/**
|
|
1096
|
+
* Fire the one-click preview when the whole team has finished building.
|
|
1097
|
+
* Requirements:
|
|
1098
|
+
* - The daemon has a preview plan stashed for this team (planner wrote one).
|
|
1099
|
+
* - No pending phase 2 groups for this team (QC hasn't spawned yet).
|
|
1100
|
+
* - Every non-planner team agent is in a terminal state.
|
|
1101
|
+
* - At least one non-planner agent completed successfully (something to preview).
|
|
1102
|
+
* Clears the plan after launching so repeated completions don't re-fire.
|
|
1103
|
+
*/
|
|
1104
|
+
_checkPreviewReady(teamId) {
|
|
1105
|
+
const preview = this.daemon.preview;
|
|
1106
|
+
if (!preview) return;
|
|
1107
|
+
const plan = preview.getPlan(teamId);
|
|
1108
|
+
if (!plan) return;
|
|
1109
|
+
|
|
1110
|
+
// If a phase 2 group for this team is still pending, let it spawn first.
|
|
1111
|
+
const pendingPhase2 = this.daemon._pendingPhase2 || [];
|
|
1112
|
+
for (const group of pendingPhase2) {
|
|
1113
|
+
for (const id of group.waitFor) {
|
|
1114
|
+
const a = this.daemon.registry.get(id);
|
|
1115
|
+
if (a?.teamId === teamId) return;
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
const teamAgents = this.daemon.registry.getAll().filter((a) => a.teamId === teamId && a.role !== 'planner');
|
|
1120
|
+
if (teamAgents.length === 0) return;
|
|
1121
|
+
const terminal = new Set(['completed', 'crashed', 'stopped', 'killed']);
|
|
1122
|
+
const allDone = teamAgents.every((a) => terminal.has(a.status));
|
|
1123
|
+
const anyCompleted = teamAgents.some((a) => a.status === 'completed');
|
|
1124
|
+
if (!allDone || !anyCompleted) return;
|
|
1125
|
+
|
|
1126
|
+
preview.clearPlan(teamId);
|
|
1127
|
+
const workingDir = plan.workingDir;
|
|
1128
|
+
preview.launch(teamId, workingDir, plan.preview).then((result) => {
|
|
1129
|
+
if (!result.launched) {
|
|
1130
|
+
console.warn(`[Groove] Preview for team ${teamId} did not launch: ${result.reason}`);
|
|
1131
|
+
this.daemon.broadcast({
|
|
1132
|
+
type: 'preview:failed',
|
|
1133
|
+
teamId,
|
|
1134
|
+
kind: plan.preview?.kind,
|
|
1135
|
+
reason: result.reason,
|
|
1136
|
+
});
|
|
1137
|
+
}
|
|
1138
|
+
}).catch((err) => {
|
|
1139
|
+
console.error(`[Groove] Preview launch error for team ${teamId}:`, err.message);
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1042
1143
|
_extractRecommendedTeam(agent, logPath) {
|
|
1043
1144
|
try {
|
|
1044
1145
|
const workDir = agent.workingDir || this.daemon.projectDir;
|
|
@@ -1209,6 +1310,13 @@ For normal file edits within your scope, proceed without review.
|
|
|
1209
1310
|
try {
|
|
1210
1311
|
const agentData = this.daemon.registry.get(agent.id);
|
|
1211
1312
|
|
|
1313
|
+
// Skip sessions that did no meaningful work — a "greeting-only" completion
|
|
1314
|
+
// (agent introduced itself, user gave no task) should not overwrite the chain
|
|
1315
|
+
// with a useless brief. Gate on turns and tokens used in this session.
|
|
1316
|
+
const turns = agentData?.turns || 0;
|
|
1317
|
+
const tokens = agentData?.tokensUsed || 0;
|
|
1318
|
+
if (turns <= 1 && tokens < 100) return;
|
|
1319
|
+
|
|
1212
1320
|
let brief;
|
|
1213
1321
|
try {
|
|
1214
1322
|
brief = await this.daemon.journalist.generateHandoffBrief(agent, { reason: 'completed' });
|
|
@@ -1408,7 +1516,7 @@ For normal file edits within your scope, proceed without review.
|
|
|
1408
1516
|
// Spawn the resumed process
|
|
1409
1517
|
const proc = cpSpawn(command, args, {
|
|
1410
1518
|
cwd: config.workingDir || this.daemon.projectDir,
|
|
1411
|
-
env: { ...process.env, ...env, GROOVE_AGENT_ID: newAgent.id, GROOVE_AGENT_NAME: newAgent.name },
|
|
1519
|
+
env: { ...process.env, ...env, GROOVE_AGENT_ID: newAgent.id, GROOVE_AGENT_NAME: newAgent.name, GROOVE_DAEMON_HOST: this.daemon.host || '127.0.0.1', GROOVE_DAEMON_PORT: String(this.daemon.port || 31415) },
|
|
1412
1520
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1413
1521
|
detached: false,
|
|
1414
1522
|
});
|
|
@@ -1449,7 +1557,37 @@ For normal file edits within your scope, proceed without review.
|
|
|
1449
1557
|
registry.update(newAgent.id, { status: finalStatus, pid: null });
|
|
1450
1558
|
this.daemon.broadcast({ type: 'agent:exit', agentId: newAgent.id, code, signal, status: finalStatus });
|
|
1451
1559
|
if (finalStatus === 'completed' && this.daemon.journalist) {
|
|
1452
|
-
|
|
1560
|
+
const a = registry.get(newAgent.id);
|
|
1561
|
+
const turns = a?.turns || 0;
|
|
1562
|
+
const tok = a?.tokensUsed || 0;
|
|
1563
|
+
if (turns > 1 || tok >= 100) this.daemon.journalist.requestSynthesis('completion');
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
// Persist Layer 7 state for resumed-session completions too, not just fresh spawns.
|
|
1567
|
+
// Without this, every resume after the first loses its work from the handoff chain.
|
|
1568
|
+
if (finalStatus === 'completed' && !this._rotatingAgents.has(newAgent.id)) {
|
|
1569
|
+
this._writeCompletionHandoff(newAgent).catch(err =>
|
|
1570
|
+
console.error(`[Groove] Completion handoff failed for ${newAgent.name}:`, err.message));
|
|
1571
|
+
}
|
|
1572
|
+
if (this._rotatingAgents.has(newAgent.id)) {
|
|
1573
|
+
this._rotatingAgents.delete(newAgent.id);
|
|
1574
|
+
}
|
|
1575
|
+
if (this.daemon.memory && (finalStatus === 'completed' || finalStatus === 'crashed')) {
|
|
1576
|
+
try {
|
|
1577
|
+
const events = this.daemon.classifier?.agentWindows?.[newAgent.id] || [];
|
|
1578
|
+
const signals = events.length >= 6
|
|
1579
|
+
? this.daemon.adaptive.extractSignals(events, newAgent.scope)
|
|
1580
|
+
: null;
|
|
1581
|
+
const score = signals ? this.daemon.adaptive.scoreSession(signals) : null;
|
|
1582
|
+
const files = this.daemon.journalist?.getAgentFiles(newAgent) || [];
|
|
1583
|
+
this.daemon.memory.updateSpecialization(newAgent.id, {
|
|
1584
|
+
role: newAgent.role,
|
|
1585
|
+
qualityScore: score,
|
|
1586
|
+
filesTouched: files,
|
|
1587
|
+
signals,
|
|
1588
|
+
threshold: this.daemon.adaptive?.getThreshold(newAgent.provider, newAgent.role),
|
|
1589
|
+
});
|
|
1590
|
+
} catch { /* best-effort */ }
|
|
1453
1591
|
}
|
|
1454
1592
|
});
|
|
1455
1593
|
|
|
@@ -3,10 +3,13 @@
|
|
|
3
3
|
|
|
4
4
|
import { execSync, spawn as cpSpawn } from 'child_process';
|
|
5
5
|
import { writeFileSync, readFileSync, existsSync } from 'fs';
|
|
6
|
-
import { resolve } from 'path';
|
|
6
|
+
import { resolve, dirname } from 'path';
|
|
7
|
+
import { fileURLToPath } from 'url';
|
|
7
8
|
import { homedir } from 'os';
|
|
8
9
|
import { Provider } from './base.js';
|
|
9
10
|
|
|
11
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
|
|
10
13
|
export class ClaudeCodeProvider extends Provider {
|
|
11
14
|
static name = 'claude-code';
|
|
12
15
|
static displayName = 'Claude Code';
|
|
@@ -14,6 +17,7 @@ export class ClaudeCodeProvider extends Provider {
|
|
|
14
17
|
static authType = 'subscription';
|
|
15
18
|
static managesOwnContext = true; // Claude Code compacts context internally (~25-37% → 2-8%)
|
|
16
19
|
static models = [
|
|
20
|
+
{ id: 'claude-opus-4-7', name: 'Claude Opus 4.7', tier: 'heavy', contextWindow: 1_000_000 },
|
|
17
21
|
{ id: 'claude-opus-4-6', name: 'Claude Opus 4.6', tier: 'heavy', contextWindow: 1_000_000 },
|
|
18
22
|
{ id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', tier: 'medium', contextWindow: 200_000 },
|
|
19
23
|
{ id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5', tier: 'light', contextWindow: 200_000 },
|
|
@@ -52,12 +56,16 @@ export class ClaudeCodeProvider extends Provider {
|
|
|
52
56
|
// --dangerously-skip-permissions (autonomous operation)
|
|
53
57
|
// --output-format stream-json (structured stdout for parsing)
|
|
54
58
|
// --verbose (richer output for journalist)
|
|
59
|
+
// --settings {hooks:{PreToolUse:...}} (knock protocol enforcement)
|
|
55
60
|
//
|
|
56
61
|
// The initial prompt is passed as a positional argument.
|
|
57
62
|
// GROOVE context is injected via an append-only section in CLAUDE.md.
|
|
58
63
|
|
|
59
64
|
const args = ['--output-format', 'stream-json', '--verbose', '--dangerously-skip-permissions'];
|
|
60
65
|
|
|
66
|
+
const knockSettings = ClaudeCodeProvider._buildKnockSettings();
|
|
67
|
+
if (knockSettings) args.push('--settings', knockSettings);
|
|
68
|
+
|
|
61
69
|
if (agent.model) {
|
|
62
70
|
args.push('--model', agent.model);
|
|
63
71
|
}
|
|
@@ -83,11 +91,39 @@ export class ClaudeCodeProvider extends Provider {
|
|
|
83
91
|
// Resume a previous session — preserves full conversation history
|
|
84
92
|
// No cold start, no handoff brief needed
|
|
85
93
|
const args = ['--resume', sessionId, '--output-format', 'stream-json', '--verbose', '--dangerously-skip-permissions'];
|
|
94
|
+
const knockSettings = ClaudeCodeProvider._buildKnockSettings();
|
|
95
|
+
if (knockSettings) args.push('--settings', knockSettings);
|
|
86
96
|
if (model) args.push('--model', model);
|
|
87
97
|
if (prompt) args.push(prompt);
|
|
88
98
|
return { command: 'claude', args, env: {} };
|
|
89
99
|
}
|
|
90
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Build the --settings JSON that registers the GROOVE knock hook as a
|
|
103
|
+
* PreToolUse handler. The hook script forwards each Bash/Write/Edit tool
|
|
104
|
+
* call to the daemon, which decides allow/deny based on scope + active
|
|
105
|
+
* locks. Fails open if the daemon is unreachable.
|
|
106
|
+
*/
|
|
107
|
+
static _buildKnockSettings() {
|
|
108
|
+
try {
|
|
109
|
+
const hookPath = resolve(__dirname, '..', '..', 'templates', 'knock-hook.cjs');
|
|
110
|
+
if (!existsSync(hookPath)) return null;
|
|
111
|
+
const settings = {
|
|
112
|
+
hooks: {
|
|
113
|
+
PreToolUse: [
|
|
114
|
+
{
|
|
115
|
+
matcher: 'Bash|Write|Edit|NotebookEdit|MultiEdit',
|
|
116
|
+
hooks: [{ type: 'command', command: `node ${hookPath}`, timeout: 5 }],
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
return JSON.stringify(settings);
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
91
127
|
buildHeadlessCommand(prompt, model) {
|
|
92
128
|
// Pass prompt via stdin to avoid OS argument length limits.
|
|
93
129
|
// Long prompts (journalist synthesis with agent logs) can exceed ARG_MAX.
|
|
@@ -10,8 +10,10 @@ const DEFAULT_THRESHOLD = 0.65; // For non-self-managing providers (was 0.7
|
|
|
10
10
|
const HARD_CEILING = 0.80; // Force rotate (was 0.85) — only for non-self-managing
|
|
11
11
|
const CHECK_INTERVAL = 15_000;
|
|
12
12
|
const QUALITY_THRESHOLD = 40; // Score below this triggers quality rotation
|
|
13
|
-
const MIN_EVENTS =
|
|
14
|
-
const MIN_AGE_SEC =
|
|
13
|
+
const MIN_EVENTS = 20; // Minimum classifier events before scoring
|
|
14
|
+
const MIN_AGE_SEC = 300; // Minimum agent age before quality rotation (5 min)
|
|
15
|
+
const QUALITY_MIN_TOKENS = 20_000; // Minimum real token work before quality rotation can fire
|
|
16
|
+
const QUALITY_MIN_FILES = 3; // Or: 3 successful file writes proves the agent is productive
|
|
15
17
|
const SCORE_HISTORY_MAX = 40; // ~10 min at 15s intervals
|
|
16
18
|
const COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes between rotations per agent
|
|
17
19
|
const QUALITY_COOLDOWN_MS = 2 * 60 * 1000; // 2 minutes for quality degradation rotations
|
|
@@ -128,7 +130,20 @@ export class Rotator extends EventEmitter {
|
|
|
128
130
|
return { score: 70, signals: {}, hasEnoughData: false, ageSec: Math.round(ageSec), eventCount: events.length };
|
|
129
131
|
}
|
|
130
132
|
|
|
131
|
-
|
|
133
|
+
// Productive-work floor: don't even score an agent that hasn't produced
|
|
134
|
+
// enough to judge. A frontend agent scaffolding a project naturally emits
|
|
135
|
+
// noisy signals (npm install warnings, Write retries) in its first few
|
|
136
|
+
// minutes; killing it mid-scaffold destroys the context it was building.
|
|
137
|
+
// Only allow the score to gate rotation once EITHER substantial tokens
|
|
138
|
+
// have flowed OR the agent has already written multiple files successfully.
|
|
139
|
+
const tokens = agent.tokensUsed || 0;
|
|
140
|
+
const signalsEarly = this.daemon.adaptive.extractSignals(events, agent.scope);
|
|
141
|
+
const filesWritten = signalsEarly.filesWritten || 0;
|
|
142
|
+
if (tokens < QUALITY_MIN_TOKENS && filesWritten < QUALITY_MIN_FILES) {
|
|
143
|
+
return { score: 70, signals: signalsEarly, hasEnoughData: false, ageSec: Math.round(ageSec), eventCount: events.length, reason: 'below_productive_floor' };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const signals = signalsEarly;
|
|
132
147
|
let score = this.daemon.adaptive.scoreSession(signals);
|
|
133
148
|
|
|
134
149
|
if (ageSec > 1800) score -= 5;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// GROOVE — Claude Code PreToolUse hook for knock protocol enforcement.
|
|
3
|
+
// Reads a tool-use payload from stdin, forwards it to the daemon's /api/knock
|
|
4
|
+
// endpoint with the agent ID attached, and blocks the tool (exit 2) if the
|
|
5
|
+
// daemon denies. Fails open on any error so daemon hiccups don't break agents.
|
|
6
|
+
|
|
7
|
+
const http = require('http');
|
|
8
|
+
|
|
9
|
+
let input = '';
|
|
10
|
+
process.stdin.setEncoding('utf8');
|
|
11
|
+
process.stdin.on('data', (c) => { input += c; });
|
|
12
|
+
process.stdin.on('end', () => {
|
|
13
|
+
try {
|
|
14
|
+
const data = input ? JSON.parse(input) : {};
|
|
15
|
+
const agentId = process.env.GROOVE_AGENT_ID;
|
|
16
|
+
if (!agentId) { process.exit(0); }
|
|
17
|
+
const port = Number(process.env.GROOVE_DAEMON_PORT) || 31415;
|
|
18
|
+
const host = process.env.GROOVE_DAEMON_HOST || '127.0.0.1';
|
|
19
|
+
const body = JSON.stringify({ ...data, grooveAgentId: agentId });
|
|
20
|
+
const req = http.request({
|
|
21
|
+
host, port, path: '/api/knock', method: 'POST',
|
|
22
|
+
headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) },
|
|
23
|
+
}, (res) => {
|
|
24
|
+
let out = '';
|
|
25
|
+
res.on('data', (c) => { out += c; });
|
|
26
|
+
res.on('end', () => {
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(out);
|
|
29
|
+
if (parsed && parsed.allow === false) {
|
|
30
|
+
process.stderr.write(String(parsed.reason || 'Blocked by GROOVE PM: operation conflicts with another agent or violates scope rules.'));
|
|
31
|
+
process.exit(2);
|
|
32
|
+
}
|
|
33
|
+
} catch { /* fail open */ }
|
|
34
|
+
process.exit(0);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
req.on('error', () => process.exit(0));
|
|
38
|
+
req.setTimeout(3000, () => { try { req.destroy(); } catch {} process.exit(0); });
|
|
39
|
+
req.write(body);
|
|
40
|
+
req.end();
|
|
41
|
+
} catch {
|
|
42
|
+
process.exit(0);
|
|
43
|
+
}
|
|
44
|
+
});
|