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
|
@@ -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-BRZ_leqO.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">
|
|
@@ -19,7 +19,7 @@ const PROVIDERS = [
|
|
|
19
19
|
id: 'claude-code',
|
|
20
20
|
name: 'Claude Code',
|
|
21
21
|
subtitle: 'by Anthropic',
|
|
22
|
-
models: ['Opus 4.6', 'Sonnet 4.6', 'Haiku 4.5'],
|
|
22
|
+
models: ['Opus 4.7', 'Opus 4.6', 'Sonnet 4.6', 'Haiku 4.5'],
|
|
23
23
|
authType: 'Subscription or API key',
|
|
24
24
|
authModes: ['subscription', 'apikey'],
|
|
25
25
|
recommended: true,
|
|
@@ -64,6 +64,18 @@ function ToastItem({ toast }) {
|
|
|
64
64
|
<p className="text-xs text-text-3 font-sans mt-0.5">{toast.detail}</p>
|
|
65
65
|
)}
|
|
66
66
|
</div>
|
|
67
|
+
{toast.action?.url && (
|
|
68
|
+
<button
|
|
69
|
+
onClick={(e) => {
|
|
70
|
+
e.stopPropagation();
|
|
71
|
+
try { window.open(toast.action.url, '_blank', 'noopener'); } catch {}
|
|
72
|
+
removeToast(toast.id);
|
|
73
|
+
}}
|
|
74
|
+
className="text-xs font-medium text-accent hover:text-accent-hover bg-surface-5 hover:bg-surface-6 px-3 py-1.5 rounded transition-colors cursor-pointer flex-shrink-0 whitespace-nowrap"
|
|
75
|
+
>
|
|
76
|
+
{toast.action.label || 'Open'}
|
|
77
|
+
</button>
|
|
78
|
+
)}
|
|
67
79
|
<button
|
|
68
80
|
onClick={(e) => { e.stopPropagation(); removeToast(toast.id); }}
|
|
69
81
|
className="p-1.5 text-text-4 hover:text-text-1 hover:bg-surface-5 rounded transition-colors cursor-pointer flex-shrink-0 z-10"
|
|
@@ -381,6 +381,39 @@ export const useGrooveStore = create((set, get) => ({
|
|
|
381
381
|
get().addToast('info', `QC agent ${msg.name} auto-spawned`, 'Auditing phase 1 work');
|
|
382
382
|
break;
|
|
383
383
|
|
|
384
|
+
case 'preview:ready':
|
|
385
|
+
get().addToast(
|
|
386
|
+
'success',
|
|
387
|
+
'Project ready to preview',
|
|
388
|
+
msg.url,
|
|
389
|
+
{ label: 'View Site', url: msg.url },
|
|
390
|
+
);
|
|
391
|
+
break;
|
|
392
|
+
|
|
393
|
+
case 'preview:failed':
|
|
394
|
+
get().addToast(
|
|
395
|
+
'warning',
|
|
396
|
+
'Preview could not launch',
|
|
397
|
+
msg.reason ? String(msg.reason).slice(0, 200) : 'Unknown error',
|
|
398
|
+
);
|
|
399
|
+
break;
|
|
400
|
+
|
|
401
|
+
case 'preview:stopped':
|
|
402
|
+
break;
|
|
403
|
+
|
|
404
|
+
case 'agent:stalled': {
|
|
405
|
+
const name = msg.agentName || msg.agentId;
|
|
406
|
+
const secs = Math.round((msg.silentMs || 0) / 1000);
|
|
407
|
+
get().addToast('warning', `${name} may be stalled`, `No output for ${secs}s — API stream may be hung`);
|
|
408
|
+
break;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
case 'knock:denied': {
|
|
412
|
+
const name = msg.agentName || msg.agentId;
|
|
413
|
+
get().addToast('warning', `${name} blocked`, `${msg.toolName} on ${msg.target} — ${msg.reason || 'scope conflict'}`);
|
|
414
|
+
break;
|
|
415
|
+
}
|
|
416
|
+
|
|
384
417
|
case 'phase2:failed':
|
|
385
418
|
get().addToast('error', `QC agent failed to spawn`, msg.error || 'Unknown error');
|
|
386
419
|
break;
|
|
@@ -725,9 +758,9 @@ export const useGrooveStore = create((set, get) => ({
|
|
|
725
758
|
|
|
726
759
|
// ── Toasts ────────────────────────────────────────────────
|
|
727
760
|
|
|
728
|
-
addToast(type, message, detail) {
|
|
761
|
+
addToast(type, message, detail, action) {
|
|
729
762
|
const id = ++toastCounter;
|
|
730
|
-
set((s) => ({ toasts: [...s.toasts, { id, type, message, detail }] }));
|
|
763
|
+
set((s) => ({ toasts: [...s.toasts, { id, type, message, detail, action }] }));
|
|
731
764
|
},
|
|
732
765
|
removeToast(id) {
|
|
733
766
|
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) }));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "groove-dev",
|
|
3
|
-
"version": "0.27.
|
|
3
|
+
"version": "0.27.39",
|
|
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)",
|
|
@@ -268,6 +268,49 @@ export function createApi(app, daemon) {
|
|
|
268
268
|
res.json(daemon.locks.getAll());
|
|
269
269
|
});
|
|
270
270
|
|
|
271
|
+
// Knock protocol: Claude Code PreToolUse hook POSTs every Bash/Write/Edit
|
|
272
|
+
// tool call here. The daemon checks the target path (for file ops) against
|
|
273
|
+
// the agent's declared scope and against other agents' active locks, and
|
|
274
|
+
// allows or denies. Non-Claude providers don't hit this path.
|
|
275
|
+
app.post('/api/knock', (req, res) => {
|
|
276
|
+
const body = req.body || {};
|
|
277
|
+
const agentId = body.grooveAgentId;
|
|
278
|
+
const toolName = body.tool_name || body.toolName || '';
|
|
279
|
+
const toolInput = body.tool_input || body.toolInput || {};
|
|
280
|
+
|
|
281
|
+
// Unknown / no agent id → fail open (don't wedge an agent we can't identify)
|
|
282
|
+
if (!agentId) return res.json({ allow: true });
|
|
283
|
+
const agent = daemon.registry.get(agentId);
|
|
284
|
+
if (!agent) return res.json({ allow: true });
|
|
285
|
+
|
|
286
|
+
// Extract the target file paths from the tool input
|
|
287
|
+
const targets = [];
|
|
288
|
+
if (toolInput.file_path) targets.push(String(toolInput.file_path));
|
|
289
|
+
if (toolInput.path) targets.push(String(toolInput.path));
|
|
290
|
+
if (Array.isArray(toolInput.edits)) {
|
|
291
|
+
for (const e of toolInput.edits) if (e?.file_path) targets.push(String(e.file_path));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Scope guard: if agent has a declared scope and the op targets a path,
|
|
295
|
+
// verify the path matches the scope or belongs to no one.
|
|
296
|
+
if (agent.scope && agent.scope.length > 0 && targets.length > 0) {
|
|
297
|
+
for (const target of targets) {
|
|
298
|
+
const conflict = daemon.locks.check(agentId, target);
|
|
299
|
+
if (conflict.conflict) {
|
|
300
|
+
daemon.audit.log('knock.denied', { agentId, toolName, target, owner: conflict.owner, pattern: conflict.pattern });
|
|
301
|
+
daemon.broadcast({ type: 'knock:denied', agentId, agentName: agent.name, toolName, target, owner: conflict.owner, reason: 'scope_conflict' });
|
|
302
|
+
return res.json({
|
|
303
|
+
allow: false,
|
|
304
|
+
reason: `GROOVE PM: ${target} is owned by another agent (pattern ${conflict.pattern}). Use the handoff protocol (write .groove/handoffs/<role>.md) or request approval instead of editing it directly.`,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
daemon.audit.log('knock.allowed', { agentId, toolName, targets });
|
|
311
|
+
res.json({ allow: true });
|
|
312
|
+
});
|
|
313
|
+
|
|
271
314
|
// Coordination protocol — agents declare intent on shared resources
|
|
272
315
|
// (npm install, server restart, package.json edit) to prevent races.
|
|
273
316
|
// Returns 423 Locked if another agent holds a conflicting resource.
|
|
@@ -2613,14 +2656,16 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
2613
2656
|
// Delete immediately after reading to prevent duplicate launches from poll races
|
|
2614
2657
|
try { unlinkSync(found.path); } catch { /* already gone */ }
|
|
2615
2658
|
|
|
2616
|
-
// Support both old format (bare array) and new format ({ projectDir, agents })
|
|
2659
|
+
// Support both old format (bare array) and new format ({ projectDir, agents, preview })
|
|
2617
2660
|
let agentConfigs;
|
|
2618
2661
|
let projectDir = null;
|
|
2662
|
+
let previewBlock = null;
|
|
2619
2663
|
if (Array.isArray(raw)) {
|
|
2620
2664
|
agentConfigs = raw;
|
|
2621
2665
|
} else if (raw && Array.isArray(raw.agents)) {
|
|
2622
2666
|
agentConfigs = raw.agents;
|
|
2623
2667
|
projectDir = raw.projectDir || null;
|
|
2668
|
+
previewBlock = raw.preview || null;
|
|
2624
2669
|
} else {
|
|
2625
2670
|
return res.status(400).json({ error: 'Invalid recommended team format' });
|
|
2626
2671
|
}
|
|
@@ -2783,16 +2828,46 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
2783
2828
|
}
|
|
2784
2829
|
}
|
|
2785
2830
|
|
|
2831
|
+
// Stash the preview block so the daemon can launch it when the team
|
|
2832
|
+
// finishes. The plan file gets deleted seconds after this endpoint returns.
|
|
2833
|
+
if (previewBlock && daemon.preview && defaultTeamId) {
|
|
2834
|
+
daemon.preview.stashPlan(defaultTeamId, previewBlock, projectWorkingDir);
|
|
2835
|
+
}
|
|
2836
|
+
|
|
2786
2837
|
daemon.audit.log('team.launch', {
|
|
2787
2838
|
phase1: spawned.length, reused: reused.length, phase2Pending: phase2.length, failed: failed.length,
|
|
2788
|
-
agents: [...spawned, ...reused].map((a) => a.role), projectDir: projectDir || null,
|
|
2839
|
+
agents: [...spawned, ...reused].map((a) => a.role), projectDir: projectDir || null, preview: !!previewBlock,
|
|
2789
2840
|
});
|
|
2790
|
-
res.json({ launched: spawned.length, reused: reused.length, phase2Pending: phase2.length, agents: [...spawned, ...reused], failed, projectDir: projectDir || null });
|
|
2841
|
+
res.json({ launched: spawned.length, reused: reused.length, phase2Pending: phase2.length, agents: [...spawned, ...reused], failed, projectDir: projectDir || null, preview: previewBlock ? previewBlock.kind : null });
|
|
2791
2842
|
} catch (err) {
|
|
2792
2843
|
res.status(500).json({ error: err.message });
|
|
2793
2844
|
}
|
|
2794
2845
|
});
|
|
2795
2846
|
|
|
2847
|
+
// Preview service — one-click View Site for completed teams
|
|
2848
|
+
app.get('/api/preview', (req, res) => {
|
|
2849
|
+
res.json({ previews: daemon.preview?.list() || [] });
|
|
2850
|
+
});
|
|
2851
|
+
|
|
2852
|
+
app.get('/api/preview/:teamId', (req, res) => {
|
|
2853
|
+
const entry = daemon.preview?.get(req.params.teamId);
|
|
2854
|
+
if (!entry) return res.status(404).json({ error: 'No preview for this team' });
|
|
2855
|
+
res.json(entry);
|
|
2856
|
+
});
|
|
2857
|
+
|
|
2858
|
+
app.delete('/api/preview/:teamId', async (req, res) => {
|
|
2859
|
+
const killed = await daemon.preview?.kill(req.params.teamId);
|
|
2860
|
+
res.json({ stopped: !!killed });
|
|
2861
|
+
});
|
|
2862
|
+
|
|
2863
|
+
// Manually (re)launch the preview for a team using the stashed plan.
|
|
2864
|
+
app.post('/api/preview/:teamId/launch', async (req, res) => {
|
|
2865
|
+
const plan = daemon.preview?.getPlan(req.params.teamId);
|
|
2866
|
+
if (!plan) return res.status(404).json({ error: 'No preview plan stashed for this team' });
|
|
2867
|
+
const result = await daemon.preview.launch(req.params.teamId, plan.workingDir, plan.preview);
|
|
2868
|
+
res.json(result);
|
|
2869
|
+
});
|
|
2870
|
+
|
|
2796
2871
|
// Clean up stale artifacts (old plans, recommended teams, etc.)
|
|
2797
2872
|
app.post('/api/cleanup', (req, res) => {
|
|
2798
2873
|
let cleaned = 0;
|
|
@@ -16,6 +16,7 @@ import { Introducer } from './introducer.js';
|
|
|
16
16
|
import { LockManager } from './lockmanager.js';
|
|
17
17
|
import { Supervisor } from './supervisor.js';
|
|
18
18
|
import { Journalist } from './journalist.js';
|
|
19
|
+
import { PreviewService } from './preview.js';
|
|
19
20
|
import { TokenTracker } from './tokentracker.js';
|
|
20
21
|
import { Rotator } from './rotator.js';
|
|
21
22
|
import { AdaptiveThresholds } from './adaptive.js';
|
|
@@ -137,6 +138,7 @@ export class Daemon {
|
|
|
137
138
|
this.fileWatcher = new FileWatcher(this);
|
|
138
139
|
this.terminalManager = new TerminalManager(this);
|
|
139
140
|
this.gateways = new GatewayManager(this);
|
|
141
|
+
this.preview = new PreviewService(this);
|
|
140
142
|
this.modelManager = new ModelManager(this);
|
|
141
143
|
this.llamaServer = new LlamaServerManager(this);
|
|
142
144
|
this.mcpManager = new McpManager(this);
|
|
@@ -657,6 +659,7 @@ export class Daemon {
|
|
|
657
659
|
|
|
658
660
|
// Kill all agent processes, stop MCP servers, and stop inference servers
|
|
659
661
|
await this.processes.killAll();
|
|
662
|
+
if (this.preview) await this.preview.killAll();
|
|
660
663
|
this.mcpManager.stopAll();
|
|
661
664
|
await this.llamaServer.stopAll();
|
|
662
665
|
|
|
@@ -76,6 +76,50 @@ export class LockManager {
|
|
|
76
76
|
return { conflict: false };
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Prefix-based overlap test between two scope pattern sets.
|
|
81
|
+
* Two scopes overlap if any pair of patterns has a prefix containment
|
|
82
|
+
* relationship (one prefix is a parent dir of the other) or shares an
|
|
83
|
+
* identical prefix. An empty/broad pattern (e.g. `**`) always overlaps.
|
|
84
|
+
*
|
|
85
|
+
* Used at spawn time to block two agents claiming the same files.
|
|
86
|
+
* Intentionally conservative: returns overlap for ambiguous cases so
|
|
87
|
+
* collisions fail loud rather than silently.
|
|
88
|
+
*/
|
|
89
|
+
static scopesOverlap(patternsA, patternsB) {
|
|
90
|
+
if (!Array.isArray(patternsA) || !Array.isArray(patternsB)) return false;
|
|
91
|
+
if (patternsA.length === 0 || patternsB.length === 0) return false;
|
|
92
|
+
const prefixOf = (p) => {
|
|
93
|
+
const idx = p.search(/[*?[{]/);
|
|
94
|
+
const head = idx === -1 ? p : p.slice(0, idx);
|
|
95
|
+
return head.replace(/\/+$/, '');
|
|
96
|
+
};
|
|
97
|
+
for (const a of patternsA) {
|
|
98
|
+
const pa = prefixOf(a);
|
|
99
|
+
for (const b of patternsB) {
|
|
100
|
+
const pb = prefixOf(b);
|
|
101
|
+
if (pa === pb) return { overlap: true, a, b };
|
|
102
|
+
if (pa === '' || pb === '') return { overlap: true, a, b };
|
|
103
|
+
const longer = pa.length > pb.length ? pa : pb;
|
|
104
|
+
const shorter = pa.length > pb.length ? pb : pa;
|
|
105
|
+
if (longer.startsWith(shorter + '/')) return { overlap: true, a, b };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { overlap: false };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Find any currently-locked agent whose scope overlaps with candidateScope.
|
|
113
|
+
* Returns { overlap: true, owner, ... } for the first conflict, else {overlap:false}.
|
|
114
|
+
*/
|
|
115
|
+
findOverlappingOwner(candidateScope) {
|
|
116
|
+
for (const [ownerId, patterns] of this.locks) {
|
|
117
|
+
const res = LockManager.scopesOverlap(candidateScope, patterns);
|
|
118
|
+
if (res.overlap) return { overlap: true, owner: ownerId, ownerScope: patterns, ...res };
|
|
119
|
+
}
|
|
120
|
+
return { overlap: false };
|
|
121
|
+
}
|
|
122
|
+
|
|
79
123
|
purgeOrphans(aliveAgentIds) {
|
|
80
124
|
const alive = new Set(aliveAgentIds);
|
|
81
125
|
let purged = 0;
|
|
@@ -19,6 +19,12 @@ const MAX_HANDOFF_ROTATIONS = 25;
|
|
|
19
19
|
const MAX_DISCOVERIES = 1000;
|
|
20
20
|
const HANDOFF_BRIEF_MAX_CHARS = 4000;
|
|
21
21
|
|
|
22
|
+
// Legacy auto-extraction (since disabled) captured raw user prompts into a
|
|
23
|
+
// "user-directive" constraint. Existing entries still live in projects that
|
|
24
|
+
// predate the fix and would otherwise leak into every new agent's brief.
|
|
25
|
+
// Suppressed at render time so stored state doesn't need migration.
|
|
26
|
+
const SUPPRESSED_CONSTRAINT_CATEGORIES = new Set(['user-directive']);
|
|
27
|
+
|
|
22
28
|
function hashText(text) {
|
|
23
29
|
return createHash('sha1').update(text.trim().toLowerCase()).digest('hex').slice(0, 12);
|
|
24
30
|
}
|
|
@@ -76,6 +82,9 @@ export class MemoryStore {
|
|
|
76
82
|
|
|
77
83
|
addConstraint({ text, category = 'general' }) {
|
|
78
84
|
if (!text || typeof text !== 'string') return { added: false, error: 'text required' };
|
|
85
|
+
if (SUPPRESSED_CONSTRAINT_CATEGORIES.has(category)) {
|
|
86
|
+
return { added: false, error: `category "${category}" suppressed — not a real constraint` };
|
|
87
|
+
}
|
|
79
88
|
const trimmed = text.trim();
|
|
80
89
|
if (trimmed.length < 3) return { added: false, error: 'text too short' };
|
|
81
90
|
if (trimmed.length > 500) return { added: false, error: 'text too long (max 500 chars)' };
|
|
@@ -117,7 +126,8 @@ export class MemoryStore {
|
|
|
117
126
|
}
|
|
118
127
|
|
|
119
128
|
getConstraintsMarkdown(maxChars = 4000) {
|
|
120
|
-
const constraints = this.listConstraints()
|
|
129
|
+
const constraints = this.listConstraints()
|
|
130
|
+
.filter((c) => !SUPPRESSED_CONSTRAINT_CATEGORIES.has(c.category));
|
|
121
131
|
if (constraints.length === 0) return '';
|
|
122
132
|
const byCategory = {};
|
|
123
133
|
for (const c of constraints) {
|
|
@@ -165,13 +175,14 @@ export class MemoryStore {
|
|
|
165
175
|
const entries = [];
|
|
166
176
|
const blocks = content.split(/\n(?=## Rotation )/);
|
|
167
177
|
for (const block of blocks) {
|
|
168
|
-
const headerMatch = block.match(/^## Rotation (\d+) —
|
|
178
|
+
const headerMatch = block.match(/^## Rotation (\d+) — (\S+) \(([\w?-]+) →/);
|
|
169
179
|
if (!headerMatch) continue;
|
|
170
180
|
const body = block.replace(/\n---\s*$/, '').trim();
|
|
171
181
|
entries.push({
|
|
172
182
|
rotationN: parseInt(headerMatch[1], 10),
|
|
173
183
|
body,
|
|
174
|
-
|
|
184
|
+
timestamp: headerMatch[2] || null,
|
|
185
|
+
agentId: headerMatch[3] || null,
|
|
175
186
|
});
|
|
176
187
|
}
|
|
177
188
|
return entries;
|
|
@@ -184,8 +195,14 @@ export class MemoryStore {
|
|
|
184
195
|
if (!role || !entry) return false;
|
|
185
196
|
const chain = this.getHandoffChain(role, workingDir, teamId);
|
|
186
197
|
|
|
187
|
-
//
|
|
188
|
-
|
|
198
|
+
// Guard only the pathological double-write case: identical agent AND timestamp
|
|
199
|
+
// means the same event fired twice (e.g. race). A resumed agent that legitimately
|
|
200
|
+
// completes multiple sessions produces distinct timestamps and must be kept —
|
|
201
|
+
// that's how the chain records continuous work across resumes.
|
|
202
|
+
if (entry.agentId && entry.timestamp
|
|
203
|
+
&& chain.some(c => c.agentId === entry.agentId && c.timestamp === entry.timestamp)) {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
189
206
|
|
|
190
207
|
const nextN = (chain[0]?.rotationN || 0) + 1;
|
|
191
208
|
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// GROOVE — Preview Service
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
//
|
|
4
|
+
// Launches the one-click preview for a completed team. The planner writes a
|
|
5
|
+
// "preview" block in recommended-team.json describing how to run the project
|
|
6
|
+
// (dev-server command, static-html entry, or none). When the last phase
|
|
7
|
+
// agent completes, we spawn the command, parse the URL from stdout, and
|
|
8
|
+
// broadcast a preview:ready event so the GUI can show a View Site toast.
|
|
9
|
+
//
|
|
10
|
+
// One preview process per team. Starting a new preview for the same team
|
|
11
|
+
// kills the previous one. Previews are also killed on team delete and on
|
|
12
|
+
// daemon shutdown.
|
|
13
|
+
|
|
14
|
+
import { spawn as cpSpawn } from 'child_process';
|
|
15
|
+
import { resolve, extname } from 'path';
|
|
16
|
+
import { existsSync, readFileSync, statSync } from 'fs';
|
|
17
|
+
import { createServer } from 'http';
|
|
18
|
+
import { lookup as mimeLookup } from './mimetypes.js';
|
|
19
|
+
|
|
20
|
+
const READY_TIMEOUT_MS = 60_000; // give dev servers a minute to boot
|
|
21
|
+
const MAX_STDOUT_BYTES = 256 * 1024;
|
|
22
|
+
|
|
23
|
+
export class PreviewService {
|
|
24
|
+
constructor(daemon) {
|
|
25
|
+
this.daemon = daemon;
|
|
26
|
+
this.previews = new Map(); // teamId -> { proc?, server?, url, kind, startedAt }
|
|
27
|
+
this.pendingPlans = new Map(); // teamId -> { preview, workingDir }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Capture a preview plan at team launch time — api cleanup deletes the
|
|
32
|
+
* source file immediately after read, so the daemon is the only place the
|
|
33
|
+
* preview block survives.
|
|
34
|
+
*/
|
|
35
|
+
stashPlan(teamId, preview, workingDir) {
|
|
36
|
+
if (!teamId || !preview) return;
|
|
37
|
+
this.pendingPlans.set(teamId, { preview, workingDir });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
getPlan(teamId) {
|
|
41
|
+
return this.pendingPlans.get(teamId) || null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
clearPlan(teamId) {
|
|
45
|
+
this.pendingPlans.delete(teamId);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Read recommended-team.json for a given working directory and return the
|
|
50
|
+
* preview block (or null if none). We read from both the team working dir
|
|
51
|
+
* and the daemon .groove dir to cover the cases the api cleanup hits.
|
|
52
|
+
*/
|
|
53
|
+
getPlanPreview(workingDir) {
|
|
54
|
+
const candidates = [
|
|
55
|
+
workingDir ? resolve(workingDir, '.groove', 'recommended-team.json') : null,
|
|
56
|
+
resolve(this.daemon.grooveDir, 'recommended-team.json'),
|
|
57
|
+
].filter(Boolean);
|
|
58
|
+
for (const p of candidates) {
|
|
59
|
+
if (!existsSync(p)) continue;
|
|
60
|
+
try {
|
|
61
|
+
const data = JSON.parse(readFileSync(p, 'utf8'));
|
|
62
|
+
if (data && typeof data.preview === 'object') return data.preview;
|
|
63
|
+
} catch { /* malformed, keep looking */ }
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Preview blocks are embedded in the plan artifact, which /api/cleanup
|
|
70
|
+
* deletes as soon as the user clicks Launch Team. Callers should grab the
|
|
71
|
+
* preview upfront at launch time and hand it back when the team completes.
|
|
72
|
+
*/
|
|
73
|
+
async launch(teamId, workingDir, preview) {
|
|
74
|
+
if (!preview || !preview.kind || preview.kind === 'none' || preview.kind === 'cli') {
|
|
75
|
+
return { launched: false, reason: preview?.kind || 'no_preview' };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Kill any existing preview for this team
|
|
79
|
+
await this.kill(teamId);
|
|
80
|
+
|
|
81
|
+
const baseDir = preview.cwd
|
|
82
|
+
? resolve(workingDir || this.daemon.projectDir, preview.cwd)
|
|
83
|
+
: resolve(workingDir || this.daemon.projectDir);
|
|
84
|
+
|
|
85
|
+
if (!existsSync(baseDir)) {
|
|
86
|
+
return { launched: false, reason: `cwd_missing: ${baseDir}` };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (preview.kind === 'static-html') {
|
|
90
|
+
return this._launchStatic(teamId, baseDir, preview);
|
|
91
|
+
}
|
|
92
|
+
if (preview.kind === 'dev-server') {
|
|
93
|
+
return this._launchDevServer(teamId, baseDir, preview);
|
|
94
|
+
}
|
|
95
|
+
return { launched: false, reason: `unknown_kind: ${preview.kind}` };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
_launchStatic(teamId, baseDir, preview) {
|
|
99
|
+
const openPath = (preview.openPath || 'index.html').replace(/^\/+/, '');
|
|
100
|
+
const entryFile = resolve(baseDir, openPath);
|
|
101
|
+
if (!existsSync(entryFile)) {
|
|
102
|
+
return Promise.resolve({ launched: false, reason: `entry_missing: ${entryFile}` });
|
|
103
|
+
}
|
|
104
|
+
const server = createServer((req, res) => {
|
|
105
|
+
const url = decodeURIComponent((req.url || '/').split('?')[0]);
|
|
106
|
+
const rel = url === '/' ? openPath : url.replace(/^\/+/, '');
|
|
107
|
+
const filePath = resolve(baseDir, rel);
|
|
108
|
+
if (!filePath.startsWith(baseDir)) { res.statusCode = 403; return res.end(); }
|
|
109
|
+
if (!existsSync(filePath) || !statSync(filePath).isFile()) {
|
|
110
|
+
res.statusCode = 404; return res.end('Not found');
|
|
111
|
+
}
|
|
112
|
+
res.setHeader('Content-Type', mimeLookup(extname(filePath)) || 'application/octet-stream');
|
|
113
|
+
res.end(readFileSync(filePath));
|
|
114
|
+
});
|
|
115
|
+
return new Promise((done) => {
|
|
116
|
+
server.listen(0, '127.0.0.1', () => {
|
|
117
|
+
const port = server.address().port;
|
|
118
|
+
const url = `http://127.0.0.1:${port}/`;
|
|
119
|
+
this.previews.set(teamId, { server, url, kind: 'static-html', startedAt: Date.now() });
|
|
120
|
+
this._broadcastReady(teamId, url, 'static-html');
|
|
121
|
+
done({ launched: true, url, kind: 'static-html' });
|
|
122
|
+
});
|
|
123
|
+
server.on('error', (err) => done({ launched: false, reason: err.message }));
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
_launchDevServer(teamId, baseDir, preview) {
|
|
128
|
+
const command = String(preview.command || '').trim();
|
|
129
|
+
if (!command) {
|
|
130
|
+
return Promise.resolve({ launched: false, reason: 'no_command' });
|
|
131
|
+
}
|
|
132
|
+
const urlPattern = preview.urlPattern
|
|
133
|
+
? new RegExp(preview.urlPattern)
|
|
134
|
+
: /https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0):\d+/;
|
|
135
|
+
const readyText = preview.readyText || '';
|
|
136
|
+
|
|
137
|
+
// Run the command via the user's shell so pipelines, && chains, env var
|
|
138
|
+
// expansion, and shell builtins work as the planner wrote them.
|
|
139
|
+
const proc = cpSpawn('bash', ['-lc', command], {
|
|
140
|
+
cwd: baseDir,
|
|
141
|
+
env: { ...process.env, FORCE_COLOR: '0', CI: '' },
|
|
142
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
143
|
+
detached: false,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const entry = { proc, url: null, kind: 'dev-server', startedAt: Date.now(), command, baseDir };
|
|
147
|
+
this.previews.set(teamId, entry);
|
|
148
|
+
|
|
149
|
+
let stdoutBuf = '';
|
|
150
|
+
let stderrBuf = '';
|
|
151
|
+
let resolved = false;
|
|
152
|
+
|
|
153
|
+
return new Promise((done) => {
|
|
154
|
+
const finish = (result) => {
|
|
155
|
+
if (resolved) return;
|
|
156
|
+
resolved = true;
|
|
157
|
+
clearTimeout(timer);
|
|
158
|
+
if (result.launched) {
|
|
159
|
+
entry.url = result.url;
|
|
160
|
+
this._broadcastReady(teamId, result.url, 'dev-server');
|
|
161
|
+
} else {
|
|
162
|
+
// Failed to detect URL — keep the process? Probably kill it; the user
|
|
163
|
+
// will just see broken output otherwise.
|
|
164
|
+
try { proc.kill('SIGTERM'); } catch {}
|
|
165
|
+
this.previews.delete(teamId);
|
|
166
|
+
}
|
|
167
|
+
done(result);
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const timer = setTimeout(() => {
|
|
171
|
+
finish({ launched: false, reason: `timeout waiting for url in stdout; last stderr: ${stderrBuf.slice(-400)}` });
|
|
172
|
+
}, READY_TIMEOUT_MS);
|
|
173
|
+
|
|
174
|
+
const tryMatch = () => {
|
|
175
|
+
const combined = stdoutBuf + '\n' + stderrBuf;
|
|
176
|
+
if (readyText && !combined.includes(readyText)) return;
|
|
177
|
+
const m = combined.match(urlPattern);
|
|
178
|
+
if (!m) return;
|
|
179
|
+
let url = m[0];
|
|
180
|
+
const openPath = (preview.openPath || '').replace(/^\/+/, '');
|
|
181
|
+
if (openPath) url = url.replace(/\/$/, '') + '/' + openPath;
|
|
182
|
+
finish({ launched: true, url, kind: 'dev-server' });
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
proc.stdout.on('data', (c) => {
|
|
186
|
+
stdoutBuf += c.toString();
|
|
187
|
+
if (stdoutBuf.length > MAX_STDOUT_BYTES) stdoutBuf = stdoutBuf.slice(-MAX_STDOUT_BYTES);
|
|
188
|
+
tryMatch();
|
|
189
|
+
});
|
|
190
|
+
proc.stderr.on('data', (c) => {
|
|
191
|
+
stderrBuf += c.toString();
|
|
192
|
+
if (stderrBuf.length > MAX_STDOUT_BYTES) stderrBuf = stderrBuf.slice(-MAX_STDOUT_BYTES);
|
|
193
|
+
tryMatch();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
proc.on('exit', (code, signal) => {
|
|
197
|
+
this.previews.delete(teamId);
|
|
198
|
+
if (!resolved) {
|
|
199
|
+
finish({ launched: false, reason: `process exited before url detected (code=${code} signal=${signal}); stderr tail: ${stderrBuf.slice(-400)}` });
|
|
200
|
+
} else {
|
|
201
|
+
this.daemon.broadcast({ type: 'preview:stopped', teamId, code, signal });
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
proc.on('error', (err) => {
|
|
205
|
+
if (!resolved) finish({ launched: false, reason: `spawn error: ${err.message}` });
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
_broadcastReady(teamId, url, kind) {
|
|
211
|
+
this.daemon.audit?.log('preview.ready', { teamId, url, kind });
|
|
212
|
+
this.daemon.broadcast({ type: 'preview:ready', teamId, url, kind });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
get(teamId) {
|
|
216
|
+
const entry = this.previews.get(teamId);
|
|
217
|
+
if (!entry) return null;
|
|
218
|
+
return { teamId, url: entry.url, kind: entry.kind, startedAt: entry.startedAt };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
list() {
|
|
222
|
+
return Array.from(this.previews.entries()).map(([teamId, e]) => ({
|
|
223
|
+
teamId, url: e.url, kind: e.kind, startedAt: e.startedAt,
|
|
224
|
+
}));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async kill(teamId) {
|
|
228
|
+
const entry = this.previews.get(teamId);
|
|
229
|
+
if (!entry) return false;
|
|
230
|
+
this.previews.delete(teamId);
|
|
231
|
+
try {
|
|
232
|
+
if (entry.server) entry.server.close();
|
|
233
|
+
if (entry.proc) entry.proc.kill('SIGTERM');
|
|
234
|
+
} catch { /* best-effort */ }
|
|
235
|
+
this.daemon.broadcast({ type: 'preview:stopped', teamId });
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async killAll() {
|
|
240
|
+
const ids = Array.from(this.previews.keys());
|
|
241
|
+
await Promise.all(ids.map((id) => this.kill(id)));
|
|
242
|
+
}
|
|
243
|
+
}
|