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.
- 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 +78 -3
- 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 +243 -0
- package/node_modules/@groove-dev/daemon/src/process.js +197 -7
- package/node_modules/@groove-dev/daemon/src/providers/claude-code.js +45 -15
- package/node_modules/@groove-dev/daemon/templates/knock-hook.cjs +44 -0
- package/node_modules/@groove-dev/gui/dist/assets/{index-Df4O6yJI.js → index-BRZ_leqO.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/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 +35 -2
- 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 +78 -3
- 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 +243 -0
- package/packages/daemon/src/process.js +197 -7
- package/packages/daemon/src/providers/claude-code.js +45 -15
- package/packages/daemon/templates/knock-hook.cjs +44 -0
- package/packages/gui/dist/assets/{index-Df4O6yJI.js → index-BRZ_leqO.js} +3 -3
- package/packages/gui/dist/index.html +1 -1
- package/packages/gui/package.json +1 -1
- 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 +35 -2
- package/.groove-staging/state.json +0 -3
- package/.groove-staging/timeline.json +0 -13
- 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)
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
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
|
+
});
|