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.
Files changed (39) hide show
  1. package/README.md +3 -3
  2. package/node_modules/@groove-dev/cli/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/package.json +1 -1
  4. package/node_modules/@groove-dev/daemon/src/api.js +91 -11
  5. package/node_modules/@groove-dev/daemon/src/index.js +3 -0
  6. package/node_modules/@groove-dev/daemon/src/lockmanager.js +44 -0
  7. package/node_modules/@groove-dev/daemon/src/memory.js +22 -5
  8. package/node_modules/@groove-dev/daemon/src/preview.js +249 -0
  9. package/node_modules/@groove-dev/daemon/src/process.js +145 -7
  10. package/node_modules/@groove-dev/daemon/src/providers/claude-code.js +37 -1
  11. package/node_modules/@groove-dev/daemon/src/rotator.js +18 -3
  12. package/node_modules/@groove-dev/daemon/templates/knock-hook.cjs +44 -0
  13. package/node_modules/@groove-dev/gui/dist/assets/{index-Df4O6yJI.js → index-zzVaD3-G.js} +3 -3
  14. package/node_modules/@groove-dev/gui/dist/index.html +1 -1
  15. package/node_modules/@groove-dev/gui/package.json +1 -1
  16. package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +2 -2
  17. package/node_modules/@groove-dev/gui/src/components/onboarding/setup-wizard.jsx +1 -1
  18. package/node_modules/@groove-dev/gui/src/components/ui/toast.jsx +12 -0
  19. package/node_modules/@groove-dev/gui/src/stores/groove.js +42 -4
  20. package/package.json +1 -1
  21. package/packages/cli/package.json +1 -1
  22. package/packages/daemon/package.json +1 -1
  23. package/packages/daemon/src/api.js +91 -11
  24. package/packages/daemon/src/index.js +3 -0
  25. package/packages/daemon/src/lockmanager.js +44 -0
  26. package/packages/daemon/src/memory.js +22 -5
  27. package/packages/daemon/src/preview.js +249 -0
  28. package/packages/daemon/src/process.js +145 -7
  29. package/packages/daemon/src/providers/claude-code.js +37 -1
  30. package/packages/daemon/src/rotator.js +18 -3
  31. package/packages/daemon/templates/knock-hook.cjs +44 -0
  32. package/packages/gui/dist/assets/{index-Df4O6yJI.js → index-zzVaD3-G.js} +3 -3
  33. package/packages/gui/dist/index.html +1 -1
  34. package/packages/gui/package.json +1 -1
  35. package/packages/gui/src/components/layout/activity-bar.jsx +2 -2
  36. package/packages/gui/src/components/onboarding/setup-wizard.jsx +1 -1
  37. package/packages/gui/src/components/ui/toast.jsx +12 -0
  38. package/packages/gui/src/stores/groove.js +42 -4
  39. 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) this.daemon.journalist.requestSynthesis('completion');
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
- this.daemon.journalist.requestSynthesis('completion');
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
- this.daemon.journalist.requestSynthesis('completion');
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 = 10; // Minimum classifier events before scoring
14
- const MIN_AGE_SEC = 120; // Minimum agent age before quality rotation
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
- const signals = this.daemon.adaptive.extractSignals(events, agent.scope);
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
+ });