groove-dev 0.27.36 → 0.27.39

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 (37) 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 +78 -3
  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 +243 -0
  9. package/node_modules/@groove-dev/daemon/src/process.js +197 -7
  10. package/node_modules/@groove-dev/daemon/src/providers/claude-code.js +45 -15
  11. package/node_modules/@groove-dev/daemon/templates/knock-hook.cjs +44 -0
  12. package/node_modules/@groove-dev/gui/dist/assets/{index-Df4O6yJI.js → index-BRZ_leqO.js} +3 -3
  13. package/node_modules/@groove-dev/gui/dist/index.html +1 -1
  14. package/node_modules/@groove-dev/gui/package.json +1 -1
  15. package/node_modules/@groove-dev/gui/src/components/onboarding/setup-wizard.jsx +1 -1
  16. package/node_modules/@groove-dev/gui/src/components/ui/toast.jsx +12 -0
  17. package/node_modules/@groove-dev/gui/src/stores/groove.js +35 -2
  18. package/package.json +1 -1
  19. package/packages/cli/package.json +1 -1
  20. package/packages/daemon/package.json +1 -1
  21. package/packages/daemon/src/api.js +78 -3
  22. package/packages/daemon/src/index.js +3 -0
  23. package/packages/daemon/src/lockmanager.js +44 -0
  24. package/packages/daemon/src/memory.js +22 -5
  25. package/packages/daemon/src/preview.js +243 -0
  26. package/packages/daemon/src/process.js +197 -7
  27. package/packages/daemon/src/providers/claude-code.js +45 -15
  28. package/packages/daemon/templates/knock-hook.cjs +44 -0
  29. package/packages/gui/dist/assets/{index-Df4O6yJI.js → index-BRZ_leqO.js} +3 -3
  30. package/packages/gui/dist/index.html +1 -1
  31. package/packages/gui/package.json +1 -1
  32. package/packages/gui/src/components/onboarding/setup-wizard.jsx +1 -1
  33. package/packages/gui/src/components/ui/toast.jsx +12 -0
  34. package/packages/gui/src/stores/groove.js +35 -2
  35. package/.groove-staging/state.json +0 -3
  36. package/.groove-staging/timeline.json +0 -13
  37. package/plans/chat-persistence-refactor.md +0 -154
@@ -16,6 +16,8 @@ const COMPACTION_DROP_THRESHOLD = 0.25; // 25% drop from peak = natural compacti
16
16
  const COMPACTION_MIN_PEAK = 0.15; // Ignore compaction if peak was below 15%
17
17
  const MAX_BUFFER_SIZE = 1_048_576; // 1MB — discard oldest half if exceeded
18
18
  const STREAM_THROTTLE_MS = 250; // 4 broadcasts/sec per agent
19
+ const STALL_CHECK_INTERVAL_MS = 60_000; // Poll for stuck agents every 60s
20
+ const STALL_THRESHOLD_MS = 5 * 60_000; // 5 min of silence on a live PID = stalled stream
19
21
 
20
22
  // Role-specific prompt prefixes — applied during spawn regardless of entry point
21
23
  // (SpawnPanel, chat continue, CLI, API) for consistency
@@ -222,9 +224,26 @@ For MODE 1 (team creation):
222
224
  { "role": "frontend", "phase": 1, "scope": ["src/components/**", "src/views/**"], "prompt": "Build the frontend: [specific tasks]" },
223
225
  { "role": "backend", "phase": 1, "scope": ["src/api/**", "src/server/**"], "prompt": "Build the backend: [specific tasks]" },
224
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." }
225
- ]
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
+ }
226
236
  }
227
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
+
228
247
  For MODE 2 (task routing to existing team):
229
248
  Only include the agents that need to do work. Use their EXISTING role — the system will find and reuse them.
230
249
  {
@@ -297,7 +316,44 @@ export class ProcessManager {
297
316
  this.pendingMessages = new Map(); // agentId -> { message, timestamp }
298
317
  this._streamThrottle = new Map(); // agentId -> { timer, pending }
299
318
  this._rotatingAgents = new Set(); // agentIds currently being rotated (rotator wrote handoff)
319
+ this._stalledAgents = new Set(); // agentIds already flagged as stalled (avoids duplicate broadcasts)
300
320
 
321
+ this._stallWatchdog = setInterval(() => this._checkStalls(), STALL_CHECK_INTERVAL_MS);
322
+ if (this._stallWatchdog.unref) this._stallWatchdog.unref();
323
+ }
324
+
325
+ _checkStalls() {
326
+ const { registry } = this.daemon;
327
+ const now = Date.now();
328
+ for (const agentId of this.handles.keys()) {
329
+ const agent = registry.get(agentId);
330
+ if (!agent || agent.status !== 'running') continue;
331
+ const lastActivity = agent.lastActivity ? new Date(agent.lastActivity).getTime() : now;
332
+ const silentMs = now - lastActivity;
333
+ if (silentMs < STALL_THRESHOLD_MS) {
334
+ if (this._stalledAgents.has(agentId)) {
335
+ this._stalledAgents.delete(agentId);
336
+ registry.update(agentId, { stalled: false });
337
+ }
338
+ continue;
339
+ }
340
+ if (this._stalledAgents.has(agentId)) continue;
341
+ this._stalledAgents.add(agentId);
342
+ registry.update(agentId, { stalled: true, stalledSince: new Date(lastActivity).toISOString() });
343
+ if (this.daemon.timeline) {
344
+ this.daemon.timeline.recordEvent('stall', {
345
+ agentId, agentName: agent.name, role: agent.role, silentMs,
346
+ });
347
+ }
348
+ this.daemon.broadcast({
349
+ type: 'agent:stalled',
350
+ agentId,
351
+ agentName: agent.name,
352
+ silentMs,
353
+ lastActivity: agent.lastActivity,
354
+ });
355
+ console.warn(`[Groove] Agent ${agent.name} (${agentId}) silent for ${Math.round(silentMs / 1000)}s — possible stalled API stream`);
356
+ }
301
357
  }
302
358
 
303
359
  async spawn(config) {
@@ -321,6 +377,24 @@ export class ProcessManager {
321
377
  }
322
378
  }
323
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
+
324
398
  // Clean stale recommended-team.json when spawning a new planner
325
399
  if (config.role === 'planner') {
326
400
  const dirs = [this.daemon.grooveDir];
@@ -616,7 +690,11 @@ For normal file edits within your scope, proceed without review.
616
690
 
617
691
  this.daemon.broadcast({ type: 'agent:exit', agentId: agent.id, code: code || 0, signal, status });
618
692
  if (this.daemon.integrations) this.daemon.integrations.refreshMcpJson();
619
- 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
+ }
620
698
  this._checkPhase2(agent.id);
621
699
 
622
700
  // Auto-trigger idle QC + process cross-scope handoffs
@@ -685,7 +763,7 @@ For normal file edits within your scope, proceed without review.
685
763
  // Spawn the process (use pipe for stdin if provider needs to send prompt via stdin)
686
764
  const proc = cpSpawn(command, args, {
687
765
  cwd: agent.workingDir || this.daemon.projectDir,
688
- 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) },
689
767
  stdio: [stdinData ? 'pipe' : 'ignore', 'pipe', 'pipe'],
690
768
  detached: false,
691
769
  });
@@ -746,6 +824,7 @@ For normal file edits within your scope, proceed without review.
746
824
  // Clean up per-agent maps to prevent unbounded growth in long sessions
747
825
  this.peakContextUsage.delete(agent.id);
748
826
  this.pendingMessages.delete(agent.id);
827
+ this._stalledAgents.delete(agent.id);
749
828
 
750
829
  // Release file-scope locks so they don't persist after agent death
751
830
  if (this.daemon.locks) this.daemon.locks.release(agent.id);
@@ -806,14 +885,28 @@ For normal file edits within your scope, proceed without review.
806
885
  }
807
886
  }
808
887
 
809
- // 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.
810
891
  if (finalStatus === 'completed' && this.daemon.journalist) {
811
- 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
+ }
812
898
  }
813
899
 
814
900
  // Phase 2 auto-spawn: check if all phase 1 agents for a team are done
815
901
  this._checkPhase2(agent.id);
816
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
+
817
910
  // Auto-trigger idle QC: if this agent modified files and there's an idle QC
818
911
  // in the same team, activate it to verify the changes
819
912
  if (finalStatus === 'completed') {
@@ -917,6 +1010,12 @@ For normal file edits within your scope, proceed without review.
917
1010
 
918
1011
  const updates = { lastActivity: new Date().toISOString() };
919
1012
 
1013
+ // Clear stall flag — output means the stream is alive again
1014
+ if (this._stalledAgents.has(agentId)) {
1015
+ this._stalledAgents.delete(agentId);
1016
+ updates.stalled = false;
1017
+ }
1018
+
920
1019
  // Token tracking — feed subsystems with full breakdown
921
1020
  if (output.tokensUsed !== undefined && output.tokensUsed > 0) {
922
1021
  updates.tokensUsed = agent.tokensUsed + output.tokensUsed;
@@ -993,6 +1092,54 @@ For normal file edits within your scope, proceed without review.
993
1092
  }
994
1093
  }
995
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
+
996
1143
  _extractRecommendedTeam(agent, logPath) {
997
1144
  try {
998
1145
  const workDir = agent.workingDir || this.daemon.projectDir;
@@ -1163,6 +1310,13 @@ For normal file edits within your scope, proceed without review.
1163
1310
  try {
1164
1311
  const agentData = this.daemon.registry.get(agent.id);
1165
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
+
1166
1320
  let brief;
1167
1321
  try {
1168
1322
  brief = await this.daemon.journalist.generateHandoffBrief(agent, { reason: 'completed' });
@@ -1362,7 +1516,7 @@ For normal file edits within your scope, proceed without review.
1362
1516
  // Spawn the resumed process
1363
1517
  const proc = cpSpawn(command, args, {
1364
1518
  cwd: config.workingDir || this.daemon.projectDir,
1365
- 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) },
1366
1520
  stdio: ['ignore', 'pipe', 'pipe'],
1367
1521
  detached: false,
1368
1522
  });
@@ -1398,11 +1552,42 @@ For normal file edits within your scope, proceed without review.
1398
1552
  logStream.write(`[${new Date().toISOString()}] Process exited: code=${code} signal=${signal}\n`);
1399
1553
  logStream.end();
1400
1554
  this.handles.delete(newAgent.id);
1555
+ this._stalledAgents.delete(newAgent.id);
1401
1556
  const finalStatus = signal === 'SIGTERM' || signal === 'SIGKILL' ? 'killed' : code === 0 ? 'completed' : 'crashed';
1402
1557
  registry.update(newAgent.id, { status: finalStatus, pid: null });
1403
1558
  this.daemon.broadcast({ type: 'agent:exit', agentId: newAgent.id, code, signal, status: finalStatus });
1404
1559
  if (finalStatus === 'completed' && this.daemon.journalist) {
1405
- 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 */ }
1406
1591
  }
1407
1592
  });
1408
1593
 
@@ -1410,6 +1595,7 @@ For normal file edits within your scope, proceed without review.
1410
1595
  logStream.write(`[error] ${err.message}\n`);
1411
1596
  logStream.end();
1412
1597
  this.handles.delete(newAgent.id);
1598
+ this._stalledAgents.delete(newAgent.id);
1413
1599
  registry.update(newAgent.id, { status: 'crashed', pid: null });
1414
1600
  });
1415
1601
 
@@ -1548,6 +1734,10 @@ For normal file edits within your scope, proceed without review.
1548
1734
  async killAll() {
1549
1735
  const ids = Array.from(this.handles.keys());
1550
1736
  await Promise.all(ids.map((id) => this.kill(id)));
1737
+ if (this._stallWatchdog) {
1738
+ clearInterval(this._stallWatchdog);
1739
+ this._stallWatchdog = null;
1740
+ }
1551
1741
  }
1552
1742
 
1553
1743
  isRunning(agentId) {
@@ -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.
@@ -149,19 +185,22 @@ export class ClaudeCodeProvider extends Provider {
149
185
  const cacheReadTokens = usage?.cache_read_input_tokens || 0;
150
186
  const cacheCreationTokens = usage?.cache_creation_input_tokens || 0;
151
187
  const outputTokens = usage?.output_tokens || 0;
188
+ // tokensUsed = new processing tokens only (input + output). Cache reads are
189
+ // the same bytes re-read every turn and must NOT be accumulated — doing so
190
+ // inflated agent.tokensUsed ~50× and created the phantom "freeze at 1M".
191
+ // totalIn still drives contextUsage because cached bytes DO occupy context.
152
192
  const totalIn = inputTokens + cacheReadTokens + cacheCreationTokens;
153
193
  events.push({
154
194
  type: 'activity',
155
195
  subtype: 'assistant',
156
196
  data: data.message?.content || '',
157
- tokensUsed: totalIn + outputTokens,
197
+ tokensUsed: inputTokens + outputTokens,
158
198
  inputTokens,
159
199
  outputTokens,
160
200
  cacheReadTokens,
161
201
  cacheCreationTokens,
162
202
  model: data.message?.model,
163
203
  });
164
- // Compute context usage from assistant message usage
165
204
  if (totalIn > 0) {
166
205
  const modelId = data.message?.model || '';
167
206
  const modelMeta = ClaudeCodeProvider.models.find((m) => modelId.includes(m.id));
@@ -172,21 +211,12 @@ export class ClaudeCodeProvider extends Provider {
172
211
  });
173
212
  }
174
213
  } else if (data.type === 'result') {
175
- // Result has cumulative usage for the full session
176
- const usage = data.usage;
177
- const inputTokens = usage?.input_tokens || 0;
178
- const cacheReadTokens = usage?.cache_read_input_tokens || 0;
179
- const cacheCreationTokens = usage?.cache_creation_input_tokens || 0;
180
- const outputTokens = usage?.output_tokens || 0;
181
- const totalIn = inputTokens + cacheReadTokens + cacheCreationTokens;
214
+ // Result carries cumulative session usage per-turn counts were already
215
+ // accumulated from assistant events, so we do NOT emit tokensUsed here
216
+ // (that was the double-count). Only emit session-level metadata.
182
217
  events.push({
183
218
  type: 'result',
184
219
  data: data.result,
185
- tokensUsed: totalIn + outputTokens,
186
- inputTokens,
187
- outputTokens,
188
- cacheReadTokens,
189
- cacheCreationTokens,
190
220
  cost: data.total_cost_usd,
191
221
  duration: data.duration_ms,
192
222
  turns: data.num_turns,
@@ -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
+ });