groove-dev 0.27.40 → 0.27.42
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/analyist/groove-security-audit.md +323 -0
- 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/adaptive.js +24 -5
- package/node_modules/@groove-dev/daemon/src/api.js +26 -8
- package/node_modules/@groove-dev/daemon/src/lockmanager.js +22 -12
- package/node_modules/@groove-dev/daemon/src/preview.js +30 -11
- package/node_modules/@groove-dev/daemon/src/process.js +26 -13
- package/node_modules/@groove-dev/daemon/src/teams.js +38 -9
- package/node_modules/@groove-dev/daemon/src/tool-executor.js +1 -1
- package/node_modules/@groove-dev/daemon/test/teams.test.js +13 -3
- package/node_modules/@groove-dev/gui/dist/assets/{index-zzVaD3-G.js → index-C1C2biHU.js} +250 -250
- 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/ui/toast.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/stores/groove.js +10 -5
- package/node_modules/@groove-dev/gui/src/views/agents.jsx +4 -8
- package/node_modules/@groove-dev/gui/src/views/settings.jsx +13 -0
- package/node_modules/@groove-dev/gui/vite.config.js +0 -3
- package/package.json +2 -3
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/adaptive.js +24 -5
- package/packages/daemon/src/api.js +26 -8
- package/packages/daemon/src/lockmanager.js +22 -12
- package/packages/daemon/src/preview.js +30 -11
- package/packages/daemon/src/process.js +26 -13
- package/packages/daemon/src/teams.js +38 -9
- package/packages/daemon/src/tool-executor.js +1 -1
- package/packages/gui/dist/assets/{index-zzVaD3-G.js → index-C1C2biHU.js} +250 -250
- package/packages/gui/dist/index.html +1 -1
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/components/ui/toast.jsx +1 -1
- package/packages/gui/src/stores/groove.js +10 -5
- package/packages/gui/src/views/agents.jsx +4 -8
- package/packages/gui/src/views/settings.jsx +13 -0
- package/packages/gui/vite.config.js +0 -3
- package/node_modules/@groove-dev/gui/src/lib/edition.js +0 -4
- package/packages/gui/src/lib/edition.js +0 -4
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
7
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
|
8
8
|
<title>Groove GUI</title>
|
|
9
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
+
<script type="module" crossorigin src="/assets/index-C1C2biHU.js"></script>
|
|
10
10
|
<link rel="modulepreload" crossorigin href="/assets/vendor-C0HXlhrU.js">
|
|
11
11
|
<link rel="modulepreload" crossorigin href="/assets/reactflow-BQPfi37R.js">
|
|
12
12
|
<link rel="modulepreload" crossorigin href="/assets/codemirror-BBL3i_JW.js">
|
|
@@ -36,7 +36,7 @@ const DURATIONS = {
|
|
|
36
36
|
function ToastItem({ toast }) {
|
|
37
37
|
const removeToast = useGrooveStore((s) => s.removeToast);
|
|
38
38
|
const Icon = ICONS[toast.type] || Info;
|
|
39
|
-
const duration = DURATIONS[toast.type];
|
|
39
|
+
const duration = toast.persistent ? 0 : (toast.duration ?? DURATIONS[toast.type]);
|
|
40
40
|
|
|
41
41
|
useEffect(() => {
|
|
42
42
|
if (!duration) return;
|
|
@@ -387,6 +387,7 @@ export const useGrooveStore = create((set, get) => ({
|
|
|
387
387
|
'Project ready to preview',
|
|
388
388
|
msg.url,
|
|
389
389
|
{ label: 'View Site', url: msg.url },
|
|
390
|
+
{ persistent: true },
|
|
390
391
|
);
|
|
391
392
|
break;
|
|
392
393
|
|
|
@@ -660,11 +661,13 @@ export const useGrooveStore = create((set, get) => ({
|
|
|
660
661
|
|
|
661
662
|
async deleteTeam(id) {
|
|
662
663
|
const team = get().teams.find((t) => t.id === id);
|
|
663
|
-
if (team?.isDefault) { get().addToast('warning', 'Cannot delete the default team'); return; }
|
|
664
664
|
try {
|
|
665
665
|
await api.delete(`/teams/${encodeURIComponent(id)}`);
|
|
666
|
-
// WS team:deleted handler removes from array and switches activeTeamId
|
|
667
|
-
|
|
666
|
+
// WS team:deleted handler removes from array and switches activeTeamId.
|
|
667
|
+
// Deleting the default team regenerates a fresh one server-side; the
|
|
668
|
+
// team:created event arrives separately so the list stays populated.
|
|
669
|
+
const wiped = team?.isDefault ? 'wiped' : 'deleted';
|
|
670
|
+
get().addToast('info', `Team "${team?.name}" ${wiped}`);
|
|
668
671
|
} catch (err) {
|
|
669
672
|
get().addToast('error', 'Failed to delete team', err.message);
|
|
670
673
|
}
|
|
@@ -758,9 +761,11 @@ export const useGrooveStore = create((set, get) => ({
|
|
|
758
761
|
|
|
759
762
|
// ── Toasts ────────────────────────────────────────────────
|
|
760
763
|
|
|
761
|
-
addToast(type, message, detail, action) {
|
|
764
|
+
addToast(type, message, detail, action, options = {}) {
|
|
762
765
|
const id = ++toastCounter;
|
|
763
|
-
|
|
766
|
+
const persistent = !!options.persistent;
|
|
767
|
+
const duration = options.duration;
|
|
768
|
+
set((s) => ({ toasts: [...s.toasts, { id, type, message, detail, action, persistent, duration }] }));
|
|
764
769
|
},
|
|
765
770
|
removeToast(id) {
|
|
766
771
|
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) }));
|
|
@@ -239,14 +239,10 @@ export function TeamTabBar() {
|
|
|
239
239
|
<ContextMenuItem onSelect={() => cloneTeam(team.id)}>
|
|
240
240
|
<Copy size={12} /> Clone
|
|
241
241
|
</ContextMenuItem>
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
<Trash2 size={12} /> Delete
|
|
247
|
-
</ContextMenuItem>
|
|
248
|
-
</>
|
|
249
|
-
)}
|
|
242
|
+
<ContextMenuSeparator />
|
|
243
|
+
<ContextMenuItem danger onSelect={() => deleteTeam(team.id)}>
|
|
244
|
+
<Trash2 size={12} /> {team.isDefault ? 'Wipe' : 'Delete'}
|
|
245
|
+
</ContextMenuItem>
|
|
250
246
|
</ContextMenuContent>
|
|
251
247
|
</ContextMenu>
|
|
252
248
|
);
|
|
@@ -1080,6 +1080,19 @@ export default function SettingsView() {
|
|
|
1080
1080
|
</select>
|
|
1081
1081
|
</ConfigCard>
|
|
1082
1082
|
|
|
1083
|
+
<ConfigCard icon={Cpu} label="Default Model" description="Model used for new agents. Auto routes by role.">
|
|
1084
|
+
<select
|
|
1085
|
+
value={config.defaultModel || ''}
|
|
1086
|
+
onChange={(e) => updateConfig('defaultModel', e.target.value || null)}
|
|
1087
|
+
className="w-full h-8 px-2.5 text-xs bg-surface-0 border border-border-subtle rounded-md text-text-0 font-mono focus:outline-none focus:ring-1 focus:ring-accent cursor-pointer"
|
|
1088
|
+
>
|
|
1089
|
+
<option value="">Auto (route by role)</option>
|
|
1090
|
+
{(providers.find((p) => p.id === (config.defaultProvider || 'claude-code'))?.models || []).map((m) => (
|
|
1091
|
+
<option key={m.id} value={m.id}>{m.name}</option>
|
|
1092
|
+
))}
|
|
1093
|
+
</select>
|
|
1094
|
+
</ConfigCard>
|
|
1095
|
+
|
|
1083
1096
|
<ConfigCard icon={FolderOpen} label="Working Directory" description="Default root directory for new agents.">
|
|
1084
1097
|
<div className="flex items-center gap-1.5">
|
|
1085
1098
|
<code className="flex-1 h-8 px-2 flex items-center bg-surface-0 border border-border-subtle rounded-md text-2xs font-mono text-text-2 truncate min-w-0">
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "groove-dev",
|
|
3
|
-
"version": "0.27.
|
|
3
|
+
"version": "0.27.42",
|
|
4
4
|
"description": "Open-source agent orchestration layer — the AI company OS. Local model agent engine (GGUF/Ollama/llama-server), HuggingFace model browser, MCP integrations (Slack, Gmail, Stripe, 15+), agent scheduling (cron), business roles (CMO, CFO, EA). GUI dashboard, multi-agent coordination, zero cold-start, infinite sessions. Works with Claude Code, Codex, Gemini CLI, Ollama, any local model.",
|
|
5
5
|
"license": "FSL-1.1-Apache-2.0",
|
|
6
6
|
"author": "Groove Dev <hello@groovedev.ai> (https://groovedev.ai)",
|
|
@@ -53,10 +53,9 @@
|
|
|
53
53
|
"dev:daemon": "npm run dev -w packages/daemon",
|
|
54
54
|
"dev:gui": "npm run dev -w packages/gui",
|
|
55
55
|
"build": "npm run build -w packages/gui",
|
|
56
|
-
"build:pro": "GROOVE_EDITION=pro npm run build -w packages/gui",
|
|
57
56
|
"start:desktop": "npm run start -w packages/desktop",
|
|
58
57
|
"build:desktop": "npm run build -w packages/desktop",
|
|
59
|
-
"dist:desktop": "npm run build
|
|
58
|
+
"dist:desktop": "npm run build && npm run dist -w packages/desktop",
|
|
60
59
|
"test": "node --test packages/daemon/test/*.test.js",
|
|
61
60
|
"prepublishOnly": "npm run build"
|
|
62
61
|
},
|
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
|
|
4
4
|
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
5
5
|
import { resolve } from 'path';
|
|
6
|
+
import { minimatch } from 'minimatch';
|
|
7
|
+
|
|
8
|
+
// Treat these scope entries as "unrestricted" — agent can touch any file
|
|
9
|
+
// under its workingDir without counting as a scope violation.
|
|
10
|
+
const UNRESTRICTED_SCOPE_PATTERNS = new Set(['**', '**/*', '*', '']);
|
|
6
11
|
|
|
7
12
|
const DEFAULT_THRESHOLD = 0.75;
|
|
8
13
|
const NUDGE_UP = 0.02; // Good session → allow more context
|
|
@@ -186,14 +191,28 @@ export class AdaptiveThresholds {
|
|
|
186
191
|
signals.toolFailures++;
|
|
187
192
|
}
|
|
188
193
|
|
|
189
|
-
// Scope violations: writes outside declared scope
|
|
194
|
+
// Scope violations: writes outside declared scope. Use real glob matching
|
|
195
|
+
// (the naive substring check flagged every write when scope was `["**"]`
|
|
196
|
+
// because `file.includes("**")` is always false — which tanked the
|
|
197
|
+
// quality score and triggered false-positive rotations). An unrestricted
|
|
198
|
+
// scope (`**`, `**/*`, empty pattern) skips the check entirely.
|
|
190
199
|
if (agentScope && agentScope.length > 0 && entry.input) {
|
|
191
200
|
if (entry.tool === 'Write' || entry.tool === 'Edit') {
|
|
192
201
|
const file = entry.input;
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
202
|
+
const unrestricted = agentScope.some((p) => UNRESTRICTED_SCOPE_PATTERNS.has(String(p).trim()));
|
|
203
|
+
if (!unrestricted) {
|
|
204
|
+
const inScope = agentScope.some((pattern) => {
|
|
205
|
+
try {
|
|
206
|
+
if (minimatch(file, pattern, { matchBase: true, dot: true })) return true;
|
|
207
|
+
// Also try matching the basename and any path suffix, since
|
|
208
|
+
// scope patterns are relative to the agent's workingDir and
|
|
209
|
+
// the recorded input may be absolute.
|
|
210
|
+
const idx = file.indexOf('/' + pattern.replace(/\/?\*\*\/?/g, '').replace(/^\//, ''));
|
|
211
|
+
return idx >= 0;
|
|
212
|
+
} catch { return true; } // if the pattern is malformed, don't penalize
|
|
213
|
+
});
|
|
214
|
+
if (!inScope) signals.scopeViolations++;
|
|
215
|
+
}
|
|
197
216
|
}
|
|
198
217
|
}
|
|
199
218
|
}
|
|
@@ -15,13 +15,15 @@ import { ROLE_INTEGRATIONS } from './process.js';
|
|
|
15
15
|
|
|
16
16
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
17
17
|
const pkgVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
18
|
-
const isPro = process.env.GROOVE_EDITION === 'pro';
|
|
19
18
|
|
|
20
19
|
let _daemon = null;
|
|
21
20
|
|
|
21
|
+
// Single source of truth for Pro features: the signed-in user's subscription
|
|
22
|
+
// status, populated by the daemon polling the backend with the stored JWT.
|
|
23
|
+
// There is no build-time "Pro edition" flag — one binary, account-gated.
|
|
22
24
|
function proOnly(req, res, next) {
|
|
23
25
|
const sub = _daemon?.subscriptionCache || {};
|
|
24
|
-
if (
|
|
26
|
+
if (sub.active) return next();
|
|
25
27
|
return res.status(403).json({
|
|
26
28
|
error: 'Pro subscription required',
|
|
27
29
|
edition: 'community',
|
|
@@ -129,6 +131,10 @@ export function createApi(app, daemon) {
|
|
|
129
131
|
const team = daemon.teams.get(config.teamId);
|
|
130
132
|
if (team?.workingDir) config.workingDir = team.workingDir;
|
|
131
133
|
}
|
|
134
|
+
// Inherit configured default model if the request didn't pick one
|
|
135
|
+
if (!config.model && daemon.config?.defaultModel) {
|
|
136
|
+
config.model = daemon.config.defaultModel;
|
|
137
|
+
}
|
|
132
138
|
const agent = await daemon.processes.spawn(config);
|
|
133
139
|
daemon.audit.log('agent.spawn', { id: agent.id, role: agent.role, provider: agent.provider });
|
|
134
140
|
res.status(201).json(agent);
|
|
@@ -295,7 +301,7 @@ export function createApi(app, daemon) {
|
|
|
295
301
|
// verify the path matches the scope or belongs to no one.
|
|
296
302
|
if (agent.scope && agent.scope.length > 0 && targets.length > 0) {
|
|
297
303
|
for (const target of targets) {
|
|
298
|
-
const conflict = daemon.locks.check(agentId, target);
|
|
304
|
+
const conflict = daemon.locks.check(agentId, target, agent.workingDir);
|
|
299
305
|
if (conflict.conflict) {
|
|
300
306
|
daemon.audit.log('knock.denied', { agentId, toolName, target, owner: conflict.owner, pattern: conflict.pattern });
|
|
301
307
|
daemon.broadcast({ type: 'knock:denied', agentId, agentName: agent.name, toolName, target, owner: conflict.owner, reason: 'scope_conflict' });
|
|
@@ -711,7 +717,7 @@ export function createApi(app, daemon) {
|
|
|
711
717
|
app.get('/api/edition', (req, res) => {
|
|
712
718
|
const sub = daemon.subscriptionCache || {};
|
|
713
719
|
res.json({
|
|
714
|
-
edition:
|
|
720
|
+
edition: sub.active ? 'pro' : 'community',
|
|
715
721
|
plan: sub.plan || 'community',
|
|
716
722
|
subscriptionActive: sub.active || false,
|
|
717
723
|
features: sub.features || [],
|
|
@@ -734,7 +740,7 @@ export function createApi(app, daemon) {
|
|
|
734
740
|
host: daemon.host,
|
|
735
741
|
port: daemon.port,
|
|
736
742
|
projectDir: daemon.projectDir,
|
|
737
|
-
edition:
|
|
743
|
+
edition: sub.active ? 'pro' : 'community',
|
|
738
744
|
});
|
|
739
745
|
});
|
|
740
746
|
|
|
@@ -2650,11 +2656,15 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
2650
2656
|
if (!found) {
|
|
2651
2657
|
return res.status(404).json({ error: 'No recommended team found. Run a planner first.' });
|
|
2652
2658
|
}
|
|
2659
|
+
const planPath = found.path;
|
|
2660
|
+
const planContents = readFileSync(planPath, 'utf8');
|
|
2653
2661
|
try {
|
|
2654
|
-
const raw = JSON.parse(
|
|
2662
|
+
const raw = JSON.parse(planContents);
|
|
2655
2663
|
|
|
2656
|
-
// Delete immediately after reading to prevent duplicate launches from poll races
|
|
2657
|
-
|
|
2664
|
+
// Delete immediately after reading to prevent duplicate launches from poll races.
|
|
2665
|
+
// If every spawn below fails, we'll restore the plan from planContents so the
|
|
2666
|
+
// user can retry without re-prompting the planner.
|
|
2667
|
+
try { unlinkSync(planPath); } catch { /* already gone */ }
|
|
2658
2668
|
|
|
2659
2669
|
// Support both old format (bare array) and new format ({ projectDir, agents, preview })
|
|
2660
2670
|
let agentConfigs;
|
|
@@ -2834,6 +2844,14 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
2834
2844
|
daemon.preview.stashPlan(defaultTeamId, previewBlock, projectWorkingDir);
|
|
2835
2845
|
}
|
|
2836
2846
|
|
|
2847
|
+
// Restore the plan if nothing actually spawned or was reused — deleting
|
|
2848
|
+
// it on a total failure leaves the team with no recovery path. A failed
|
|
2849
|
+
// spawn (scope collision, provider unavailable, etc.) should be retryable
|
|
2850
|
+
// once the user fixes the condition.
|
|
2851
|
+
if (spawned.length === 0 && reused.length === 0 && failed.length > 0) {
|
|
2852
|
+
try { writeFileSync(planPath, planContents); } catch { /* best-effort */ }
|
|
2853
|
+
}
|
|
2854
|
+
|
|
2837
2855
|
daemon.audit.log('team.launch', {
|
|
2838
2856
|
phase1: spawned.length, reused: reused.length, phase2Pending: phase2.length, failed: failed.length,
|
|
2839
2857
|
agents: [...spawned, ...reused].map((a) => a.role), projectDir: projectDir || null, preview: !!previewBlock,
|
|
@@ -18,7 +18,7 @@ const DEFAULT_OPERATION_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
|
18
18
|
export class LockManager {
|
|
19
19
|
constructor(grooveDir) {
|
|
20
20
|
this.path = resolve(grooveDir, 'locks.json');
|
|
21
|
-
this.locks = new Map(); // agentId ->
|
|
21
|
+
this.locks = new Map(); // agentId -> { patterns, workingDir }
|
|
22
22
|
this._compiledPatterns = new Map(); // agentId -> RegExp[]
|
|
23
23
|
this.operations = new Map(); // agentId -> { name, resources, acquiredAt, expiresAt }
|
|
24
24
|
this.load();
|
|
@@ -28,9 +28,11 @@ export class LockManager {
|
|
|
28
28
|
if (existsSync(this.path)) {
|
|
29
29
|
try {
|
|
30
30
|
const data = JSON.parse(readFileSync(this.path, 'utf8'));
|
|
31
|
-
for (const [id,
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
for (const [id, val] of Object.entries(data)) {
|
|
32
|
+
// Backward compat: old format stored just patterns array
|
|
33
|
+
const entry = Array.isArray(val) ? { patterns: val, workingDir: null } : val;
|
|
34
|
+
this.locks.set(id, entry);
|
|
35
|
+
this._compilePatterns(id, entry.patterns);
|
|
34
36
|
}
|
|
35
37
|
} catch {
|
|
36
38
|
// Start fresh
|
|
@@ -51,8 +53,8 @@ export class LockManager {
|
|
|
51
53
|
this._compiledPatterns.set(agentId, compiled);
|
|
52
54
|
}
|
|
53
55
|
|
|
54
|
-
register(agentId, patterns) {
|
|
55
|
-
this.locks.set(agentId, patterns);
|
|
56
|
+
register(agentId, patterns, workingDir = null) {
|
|
57
|
+
this.locks.set(agentId, { patterns, workingDir: workingDir || null });
|
|
56
58
|
this._compilePatterns(agentId, patterns);
|
|
57
59
|
this.save();
|
|
58
60
|
}
|
|
@@ -64,9 +66,13 @@ export class LockManager {
|
|
|
64
66
|
this.save();
|
|
65
67
|
}
|
|
66
68
|
|
|
67
|
-
|
|
69
|
+
// Scopes are per-team — only conflict with owners in the same workingDir.
|
|
70
|
+
// Pass workingDir=null to skip the filter (legacy behavior).
|
|
71
|
+
check(agentId, filePath, workingDir = null) {
|
|
68
72
|
for (const [ownerId, compiled] of this._compiledPatterns) {
|
|
69
73
|
if (ownerId === agentId) continue;
|
|
74
|
+
const ownerEntry = this.locks.get(ownerId);
|
|
75
|
+
if (workingDir && ownerEntry?.workingDir && ownerEntry.workingDir !== workingDir) continue;
|
|
70
76
|
for (const { pattern, re } of compiled) {
|
|
71
77
|
if (re && re.test(filePath)) {
|
|
72
78
|
return { conflict: true, owner: ownerId, pattern };
|
|
@@ -111,11 +117,13 @@ export class LockManager {
|
|
|
111
117
|
/**
|
|
112
118
|
* Find any currently-locked agent whose scope overlaps with candidateScope.
|
|
113
119
|
* Returns { overlap: true, owner, ... } for the first conflict, else {overlap:false}.
|
|
120
|
+
* Pass workingDir to limit the search to the same team folder (scopes are per-team).
|
|
114
121
|
*/
|
|
115
|
-
findOverlappingOwner(candidateScope) {
|
|
116
|
-
for (const [ownerId,
|
|
117
|
-
|
|
118
|
-
|
|
122
|
+
findOverlappingOwner(candidateScope, workingDir = null) {
|
|
123
|
+
for (const [ownerId, entry] of this.locks) {
|
|
124
|
+
if (workingDir && entry.workingDir && entry.workingDir !== workingDir) continue;
|
|
125
|
+
const res = LockManager.scopesOverlap(candidateScope, entry.patterns);
|
|
126
|
+
if (res.overlap) return { overlap: true, owner: ownerId, ownerScope: entry.patterns, ...res };
|
|
119
127
|
}
|
|
120
128
|
return { overlap: false };
|
|
121
129
|
}
|
|
@@ -140,7 +148,9 @@ export class LockManager {
|
|
|
140
148
|
}
|
|
141
149
|
|
|
142
150
|
getAll() {
|
|
143
|
-
|
|
151
|
+
const obj = {};
|
|
152
|
+
for (const [id, entry] of this.locks) obj[id] = entry.patterns;
|
|
153
|
+
return obj;
|
|
144
154
|
}
|
|
145
155
|
|
|
146
156
|
// --- Operation locks (coordination protocol) ---
|
|
@@ -76,28 +76,46 @@ export class PreviewService {
|
|
|
76
76
|
* preview upfront at launch time and hand it back when the team completes.
|
|
77
77
|
*/
|
|
78
78
|
async launch(teamId, workingDir, preview) {
|
|
79
|
+
this.daemon.audit?.log('preview.attempt', { teamId, workingDir, preview });
|
|
80
|
+
|
|
79
81
|
if (!preview || !preview.kind || preview.kind === 'none' || preview.kind === 'cli') {
|
|
80
|
-
|
|
82
|
+
const result = { launched: false, reason: preview?.kind || 'no_preview' };
|
|
83
|
+
this.daemon.audit?.log('preview.skipped', { teamId, reason: result.reason });
|
|
84
|
+
return result;
|
|
81
85
|
}
|
|
82
86
|
|
|
83
|
-
// Kill any existing preview for this team
|
|
84
87
|
await this.kill(teamId);
|
|
85
88
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
+
// Resolve cwd with a sensible fallback. The planner sometimes names the
|
|
90
|
+
// cwd after projectDir which is applied by api/launch → the actual project
|
|
91
|
+
// root. If that specific subdir doesn't exist, try workingDir itself.
|
|
92
|
+
const root = resolve(workingDir || this.daemon.projectDir);
|
|
93
|
+
const candidates = [];
|
|
94
|
+
if (preview.cwd) candidates.push(resolve(root, preview.cwd));
|
|
95
|
+
candidates.push(root);
|
|
96
|
+
const baseDir = candidates.find((p) => existsSync(p));
|
|
89
97
|
|
|
90
|
-
if (!
|
|
91
|
-
|
|
98
|
+
if (!baseDir) {
|
|
99
|
+
const result = { launched: false, reason: `cwd_missing: tried ${candidates.join(' and ')}` };
|
|
100
|
+
this.daemon.audit?.log('preview.failed', { teamId, reason: result.reason });
|
|
101
|
+
return result;
|
|
92
102
|
}
|
|
93
103
|
|
|
104
|
+
let result;
|
|
94
105
|
if (preview.kind === 'static-html') {
|
|
95
|
-
|
|
106
|
+
result = await this._launchStatic(teamId, baseDir, preview);
|
|
107
|
+
} else if (preview.kind === 'dev-server') {
|
|
108
|
+
result = await this._launchDevServer(teamId, baseDir, preview);
|
|
109
|
+
} else {
|
|
110
|
+
result = { launched: false, reason: `unknown_kind: ${preview.kind}` };
|
|
96
111
|
}
|
|
97
|
-
|
|
98
|
-
|
|
112
|
+
|
|
113
|
+
if (result.launched) {
|
|
114
|
+
this.daemon.audit?.log('preview.launched', { teamId, url: result.url, kind: result.kind, baseDir });
|
|
115
|
+
} else {
|
|
116
|
+
this.daemon.audit?.log('preview.failed', { teamId, reason: result.reason, baseDir });
|
|
99
117
|
}
|
|
100
|
-
return
|
|
118
|
+
return result;
|
|
101
119
|
}
|
|
102
120
|
|
|
103
121
|
_launchStatic(teamId, baseDir, preview) {
|
|
@@ -238,6 +256,7 @@ export class PreviewService {
|
|
|
238
256
|
if (entry.server) entry.server.close();
|
|
239
257
|
if (entry.proc) entry.proc.kill('SIGTERM');
|
|
240
258
|
} catch { /* best-effort */ }
|
|
259
|
+
this.daemon.audit?.log('preview.stopped', { teamId });
|
|
241
260
|
this.daemon.broadcast({ type: 'preview:stopped', teamId });
|
|
242
261
|
return true;
|
|
243
262
|
}
|
|
@@ -377,18 +377,21 @@ export class ProcessManager {
|
|
|
377
377
|
}
|
|
378
378
|
}
|
|
379
379
|
|
|
380
|
-
// Scope collision check: refuse to spawn if another running agent
|
|
381
|
-
// claims overlapping files.
|
|
382
|
-
//
|
|
380
|
+
// Scope collision check: refuse to spawn if another running agent in the
|
|
381
|
+
// SAME working directory already claims overlapping files. Scopes are
|
|
382
|
+
// relative patterns rooted at the agent's workingDir, so two agents in
|
|
383
|
+
// different workingDirs can't step on each other even if their patterns
|
|
384
|
+
// look identical. Oversight roles (planner, QC, security) and ambassadors
|
|
385
|
+
// bypass entirely since their job requires broad access.
|
|
383
386
|
const SCOPE_BYPASS_ROLES = new Set(['planner', 'fullstack', 'qc', 'pm', 'supervisor', 'security', 'ambassador']);
|
|
384
387
|
if (config.scope && config.scope.length > 0 && !SCOPE_BYPASS_ROLES.has(config.role) && !config.allowScopeOverlap) {
|
|
385
|
-
const conflict = locks.findOverlappingOwner(config.scope);
|
|
388
|
+
const conflict = locks.findOverlappingOwner(config.scope, config.workingDir);
|
|
386
389
|
if (conflict.overlap) {
|
|
387
390
|
const owner = registry.get(conflict.owner);
|
|
388
|
-
if (owner && owner.status === 'running') {
|
|
391
|
+
if (owner && owner.status === 'running' && owner.workingDir === config.workingDir) {
|
|
389
392
|
const ownerScope = Array.isArray(conflict.ownerScope) ? conflict.ownerScope.join(', ') : '';
|
|
390
393
|
throw new Error(
|
|
391
|
-
`Scope collision: ${config.role} scope [${config.scope.join(', ')}] overlaps with ${owner.name} (${owner.role}) which owns [${ownerScope}]. ` +
|
|
394
|
+
`Scope collision: ${config.role} scope [${config.scope.join(', ')}] overlaps with ${owner.name} (${owner.role}) which owns [${ownerScope}] in the same workspace. ` +
|
|
392
395
|
`Two agents cannot edit the same files. Either narrow the scope or wait for ${owner.name} to finish.`
|
|
393
396
|
);
|
|
394
397
|
}
|
|
@@ -460,7 +463,7 @@ export class ProcessManager {
|
|
|
460
463
|
|
|
461
464
|
// Register file locks for the agent's scope
|
|
462
465
|
if (agent.scope && agent.scope.length > 0) {
|
|
463
|
-
locks.register(agent.id, agent.scope);
|
|
466
|
+
locks.register(agent.id, agent.scope, agent.workingDir);
|
|
464
467
|
}
|
|
465
468
|
|
|
466
469
|
// Register ambassador with federation system
|
|
@@ -1098,16 +1101,25 @@ For normal file edits within your scope, proceed without review.
|
|
|
1098
1101
|
* - The daemon has a preview plan stashed for this team (planner wrote one).
|
|
1099
1102
|
* - No pending phase 2 groups for this team (QC hasn't spawned yet).
|
|
1100
1103
|
* - Every non-planner team agent is in a terminal state.
|
|
1101
|
-
* - At least one non-planner agent completed successfully
|
|
1102
|
-
*
|
|
1104
|
+
* - At least one non-planner agent completed successfully.
|
|
1105
|
+
*
|
|
1106
|
+
* The plan is intentionally NOT cleared on failure so the user can hit the
|
|
1107
|
+
* "Launch Preview" button manually after fixing whatever blocked the auto
|
|
1108
|
+
* attempt (wrong cwd, missing deps, etc). We guard against infinite re-fires
|
|
1109
|
+
* with _previewAttempted per teamId.
|
|
1103
1110
|
*/
|
|
1104
1111
|
_checkPreviewReady(teamId) {
|
|
1105
1112
|
const preview = this.daemon.preview;
|
|
1106
1113
|
if (!preview) return;
|
|
1114
|
+
if (!this._previewAttempted) this._previewAttempted = new Set();
|
|
1115
|
+
if (this._previewAttempted.has(teamId)) return;
|
|
1116
|
+
|
|
1107
1117
|
const plan = preview.getPlan(teamId);
|
|
1108
|
-
if (!plan)
|
|
1118
|
+
if (!plan) {
|
|
1119
|
+
this.daemon.audit?.log('preview.skipped', { teamId, reason: 'no_plan_stashed' });
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1109
1122
|
|
|
1110
|
-
// If a phase 2 group for this team is still pending, let it spawn first.
|
|
1111
1123
|
const pendingPhase2 = this.daemon._pendingPhase2 || [];
|
|
1112
1124
|
for (const group of pendingPhase2) {
|
|
1113
1125
|
for (const id of group.waitFor) {
|
|
@@ -1123,7 +1135,7 @@ For normal file edits within your scope, proceed without review.
|
|
|
1123
1135
|
const anyCompleted = teamAgents.some((a) => a.status === 'completed');
|
|
1124
1136
|
if (!allDone || !anyCompleted) return;
|
|
1125
1137
|
|
|
1126
|
-
|
|
1138
|
+
this._previewAttempted.add(teamId);
|
|
1127
1139
|
const workingDir = plan.workingDir;
|
|
1128
1140
|
preview.launch(teamId, workingDir, plan.preview).then((result) => {
|
|
1129
1141
|
if (!result.launched) {
|
|
@@ -1137,6 +1149,7 @@ For normal file edits within your scope, proceed without review.
|
|
|
1137
1149
|
}
|
|
1138
1150
|
}).catch((err) => {
|
|
1139
1151
|
console.error(`[Groove] Preview launch error for team ${teamId}:`, err.message);
|
|
1152
|
+
this.daemon.broadcast({ type: 'preview:failed', teamId, reason: err.message });
|
|
1140
1153
|
});
|
|
1141
1154
|
}
|
|
1142
1155
|
|
|
@@ -1510,7 +1523,7 @@ For normal file edits within your scope, proceed without review.
|
|
|
1510
1523
|
|
|
1511
1524
|
// Re-register locks
|
|
1512
1525
|
if (newAgent.scope && newAgent.scope.length > 0) {
|
|
1513
|
-
locks.register(newAgent.id, newAgent.scope);
|
|
1526
|
+
locks.register(newAgent.id, newAgent.scope, newAgent.workingDir);
|
|
1514
1527
|
}
|
|
1515
1528
|
|
|
1516
1529
|
// Spawn the resumed process
|
|
@@ -34,14 +34,30 @@ export class Teams {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
_ensureDefault() {
|
|
37
|
-
const
|
|
38
|
-
|
|
37
|
+
const defaultDir = resolve(this.daemon.projectDir, 'default');
|
|
38
|
+
const existing = [...this.teams.values()].find((t) => t.isDefault);
|
|
39
|
+
|
|
40
|
+
if (!existing) {
|
|
41
|
+
try { mkdirSync(defaultDir, { recursive: true }); } catch { /* may exist */ }
|
|
39
42
|
const id = randomUUID().slice(0, 8);
|
|
40
|
-
const team = {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
+
const team = {
|
|
44
|
+
id,
|
|
45
|
+
name: 'Default',
|
|
46
|
+
isDefault: true,
|
|
47
|
+
workingDir: defaultDir,
|
|
48
|
+
createdAt: new Date().toISOString(),
|
|
49
|
+
};
|
|
43
50
|
this.teams.set(id, team);
|
|
44
51
|
this._save();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Migrate legacy default teams that pointed at the project root — give them
|
|
56
|
+
// their own folder so generated files don't pile up alongside source code.
|
|
57
|
+
if (!existing.workingDir || existing.workingDir === this.daemon.projectDir) {
|
|
58
|
+
try { mkdirSync(defaultDir, { recursive: true }); } catch { /* may exist */ }
|
|
59
|
+
existing.workingDir = defaultDir;
|
|
60
|
+
this._save();
|
|
45
61
|
}
|
|
46
62
|
}
|
|
47
63
|
|
|
@@ -125,12 +141,13 @@ export class Teams {
|
|
|
125
141
|
}
|
|
126
142
|
|
|
127
143
|
/**
|
|
128
|
-
* Delete a team —
|
|
144
|
+
* Delete a team — kills its agents, removes its directory, drops it from the
|
|
145
|
+
* registry. Deleting the default team regenerates a fresh empty one so users
|
|
146
|
+
* can wipe accumulated state and keep working without restarting the daemon.
|
|
129
147
|
*/
|
|
130
148
|
delete(id) {
|
|
131
149
|
const team = this.teams.get(id);
|
|
132
150
|
if (!team) throw new Error('Team not found');
|
|
133
|
-
if (team.isDefault) throw new Error('Cannot delete the default team');
|
|
134
151
|
|
|
135
152
|
// Kill any running agents in this team
|
|
136
153
|
const agents = this.daemon.registry.getAll().filter((a) => a.teamId === id);
|
|
@@ -145,8 +162,13 @@ export class Teams {
|
|
|
145
162
|
this.daemon.registry.remove(agent.id);
|
|
146
163
|
}
|
|
147
164
|
|
|
148
|
-
// Remove the working directory
|
|
149
|
-
|
|
165
|
+
// Remove the team's working directory — refuse to nuke the project root
|
|
166
|
+
// (legacy default teams that were never migrated point there).
|
|
167
|
+
if (
|
|
168
|
+
team.workingDir &&
|
|
169
|
+
team.workingDir !== this.daemon.projectDir &&
|
|
170
|
+
existsSync(team.workingDir)
|
|
171
|
+
) {
|
|
150
172
|
try {
|
|
151
173
|
rmSync(team.workingDir, { recursive: true, force: true });
|
|
152
174
|
} catch (err) {
|
|
@@ -158,6 +180,13 @@ export class Teams {
|
|
|
158
180
|
this._save();
|
|
159
181
|
this.daemon.broadcast({ type: 'team:deleted', teamId: id });
|
|
160
182
|
|
|
183
|
+
// Always keep a default team available — regenerate one with a clean folder
|
|
184
|
+
if (team.isDefault) {
|
|
185
|
+
this._ensureDefault();
|
|
186
|
+
const fresh = this.getDefault();
|
|
187
|
+
if (fresh) this.daemon.broadcast({ type: 'team:created', team: fresh });
|
|
188
|
+
}
|
|
189
|
+
|
|
161
190
|
// Clean up orphaned logs immediately — don't wait for the 24h GC cycle
|
|
162
191
|
try { this.daemon._gc(); } catch { /* gc should never block deletion */ }
|
|
163
192
|
|
|
@@ -158,7 +158,7 @@ export class ToolExecutor {
|
|
|
158
158
|
_checkWriteScope(resolvedPath) {
|
|
159
159
|
if (!this.daemon?.locks) return;
|
|
160
160
|
const rel = relative(this.workingDir, resolvedPath);
|
|
161
|
-
const result = this.daemon.locks.check(this.agentId, rel);
|
|
161
|
+
const result = this.daemon.locks.check(this.agentId, rel, this.workingDir);
|
|
162
162
|
if (result.conflict) {
|
|
163
163
|
// Record conflict for supervisor + token savings
|
|
164
164
|
if (this.daemon.supervisor) {
|