groove-dev 0.27.37 → 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 +145 -7
- package/node_modules/@groove-dev/daemon/src/providers/claude-code.js +37 -1
- 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 +145 -7
- package/packages/daemon/src/providers/claude-code.js +37 -1
- 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/plans/chat-persistence-refactor.md +0 -154
|
@@ -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.
|
|
@@ -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
|
+
});
|