groove-dev 0.27.145 → 0.27.147

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/node_modules/@groove-dev/cli/package.json +1 -1
  2. package/node_modules/@groove-dev/daemon/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/src/api.js +12 -6
  4. package/node_modules/@groove-dev/daemon/src/conversations.js +41 -10
  5. package/node_modules/@groove-dev/daemon/src/introducer.js +20 -0
  6. package/node_modules/@groove-dev/daemon/src/process.js +262 -15
  7. package/node_modules/@groove-dev/daemon/src/providers/groove-network.js +1 -3
  8. package/node_modules/@groove-dev/daemon/src/rotator.js +15 -3
  9. package/node_modules/@groove-dev/daemon/src/routes/agents.js +43 -0
  10. package/node_modules/@groove-dev/daemon/templates/lab-general.json +12 -0
  11. package/node_modules/@groove-dev/daemon/templates/llama-cpp-setup.json +12 -0
  12. package/node_modules/@groove-dev/gui/dist/assets/index-BKbsE_hn.js +1011 -0
  13. package/node_modules/@groove-dev/gui/dist/assets/index-CEkPsSAm.css +1 -0
  14. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  15. package/node_modules/@groove-dev/gui/package.json +1 -1
  16. package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +132 -4
  17. package/node_modules/@groove-dev/gui/src/components/chat/chat-header.jsx +1 -8
  18. package/node_modules/@groove-dev/gui/src/components/chat/chat-input.jsx +135 -13
  19. package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +21 -4
  20. package/node_modules/@groove-dev/gui/src/components/chat/chat-view.jsx +6 -5
  21. package/node_modules/@groove-dev/gui/src/components/chat/model-picker.jsx +3 -3
  22. package/node_modules/@groove-dev/gui/src/components/lab/chat-playground.jsx +3 -3
  23. package/node_modules/@groove-dev/gui/src/components/lab/lab-assistant.jsx +9 -3
  24. package/node_modules/@groove-dev/gui/src/components/lab/metrics-panel.jsx +13 -3
  25. package/node_modules/@groove-dev/gui/src/components/lab/parameter-panel.jsx +5 -5
  26. package/node_modules/@groove-dev/gui/src/components/lab/runtime-config.jsx +1 -3
  27. package/node_modules/@groove-dev/gui/src/components/layout/app-shell.jsx +2 -0
  28. package/node_modules/@groove-dev/gui/src/components/layout/status-bar.jsx +24 -1
  29. package/node_modules/@groove-dev/gui/src/components/ui/question-modal.jsx +107 -0
  30. package/node_modules/@groove-dev/gui/src/components/ui/sheet.jsx +2 -2
  31. package/node_modules/@groove-dev/gui/src/stores/groove.js +32 -2
  32. package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +10 -1
  33. package/node_modules/@groove-dev/gui/src/stores/slices/chat-slice.js +1 -0
  34. package/node_modules/@groove-dev/gui/src/views/model-lab.jsx +27 -22
  35. package/package.json +1 -1
  36. package/packages/cli/package.json +1 -1
  37. package/packages/daemon/package.json +1 -1
  38. package/packages/daemon/src/api.js +12 -6
  39. package/packages/daemon/src/conversations.js +41 -10
  40. package/packages/daemon/src/introducer.js +20 -0
  41. package/packages/daemon/src/process.js +262 -15
  42. package/packages/daemon/src/providers/groove-network.js +1 -3
  43. package/packages/daemon/src/rotator.js +15 -3
  44. package/packages/daemon/src/routes/agents.js +43 -0
  45. package/packages/daemon/templates/lab-general.json +12 -0
  46. package/packages/daemon/templates/llama-cpp-setup.json +12 -0
  47. package/packages/gui/dist/assets/index-BKbsE_hn.js +1011 -0
  48. package/packages/gui/dist/assets/index-CEkPsSAm.css +1 -0
  49. package/packages/gui/dist/index.html +2 -2
  50. package/packages/gui/package.json +1 -1
  51. package/packages/gui/src/components/agents/spawn-wizard.jsx +132 -4
  52. package/packages/gui/src/components/chat/chat-header.jsx +1 -8
  53. package/packages/gui/src/components/chat/chat-input.jsx +135 -13
  54. package/packages/gui/src/components/chat/chat-messages.jsx +21 -4
  55. package/packages/gui/src/components/chat/chat-view.jsx +6 -5
  56. package/packages/gui/src/components/chat/model-picker.jsx +3 -3
  57. package/packages/gui/src/components/lab/chat-playground.jsx +3 -3
  58. package/packages/gui/src/components/lab/lab-assistant.jsx +9 -3
  59. package/packages/gui/src/components/lab/metrics-panel.jsx +13 -3
  60. package/packages/gui/src/components/lab/parameter-panel.jsx +5 -5
  61. package/packages/gui/src/components/lab/runtime-config.jsx +1 -3
  62. package/packages/gui/src/components/layout/app-shell.jsx +2 -0
  63. package/packages/gui/src/components/layout/status-bar.jsx +24 -1
  64. package/packages/gui/src/components/ui/question-modal.jsx +107 -0
  65. package/packages/gui/src/components/ui/sheet.jsx +2 -2
  66. package/packages/gui/src/stores/groove.js +32 -2
  67. package/packages/gui/src/stores/slices/agents-slice.js +10 -1
  68. package/packages/gui/src/stores/slices/chat-slice.js +1 -0
  69. package/packages/gui/src/views/model-lab.jsx +27 -22
  70. package/node_modules/@groove-dev/gui/dist/assets/index-Bxc0gU06.js +0 -1006
  71. package/node_modules/@groove-dev/gui/dist/assets/index-C0pztKBn.css +0 -1
  72. package/packages/gui/dist/assets/index-Bxc0gU06.js +0 -1006
  73. package/packages/gui/dist/assets/index-C0pztKBn.css +0 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.145",
3
+ "version": "0.27.147",
4
4
  "description": "GROOVE CLI — manage AI coding agents from your terminal",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/daemon",
3
- "version": "0.27.145",
3
+ "version": "0.27.147",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -11,7 +11,7 @@ import { hostname, networkInterfaces, homedir } from 'os';
11
11
  import { StringDecoder } from 'string_decoder';
12
12
  import { request as httpRequest } from 'http';
13
13
  import { lookup as mimeLookup } from './mimetypes.js';
14
- import { listProviders, getProvider, clearInstallCache, getProviderMetadata, getProviderPath, setProviderPaths } from './providers/index.js';
14
+ import { listProviders, getProvider, clearInstallCache, getProviderMetadata, getProviderPath, setProviderPaths, isProviderInstalled } from './providers/index.js';
15
15
  import { OllamaProvider } from './providers/ollama.js';
16
16
  import { ClaudeCodeProvider } from './providers/claude-code.js';
17
17
  import { supportsSignalFlag, compareSemver, parseSemver } from './providers/groove-network.js';
@@ -1336,7 +1336,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
1336
1336
 
1337
1337
  app.post('/api/onboarding/set-default', async (req, res) => {
1338
1338
  const { provider, model } = req.body;
1339
- const validProviders = ['claude-code', 'codex', 'gemini', 'ollama'];
1339
+ const validProviders = ['claude-code', 'codex', 'gemini', 'grok', 'ollama', 'local'];
1340
1340
  if (!provider || !validProviders.includes(provider)) {
1341
1341
  return res.status(400).json({ error: `Invalid provider. Valid: ${validProviders.join(', ')}` });
1342
1342
  }
@@ -1721,8 +1721,9 @@ Keep responses concise. Help them think, don't lecture them about the system the
1721
1721
  app.post('/api/lab/assistant', async (req, res) => {
1722
1722
  try {
1723
1723
  const { backend, model } = req.body || {};
1724
- if (!backend || !['vllm', 'tgi', 'mlx'].includes(backend)) {
1725
- return res.status(400).json({ error: 'backend must be "vllm", "tgi", or "mlx"' });
1724
+ const validBackends = ['vllm', 'tgi', 'mlx', 'llama-cpp', 'lab-general'];
1725
+ if (!backend || !validBackends.includes(backend)) {
1726
+ return res.status(400).json({ error: `backend must be one of: ${validBackends.join(', ')}` });
1726
1727
  }
1727
1728
  const templatePath = resolve(__dirname, `../templates/${backend}-setup.json`);
1728
1729
  const template = JSON.parse(readFileSync(templatePath, 'utf8'));
@@ -1733,14 +1734,19 @@ Keep responses concise. Help them think, don't lecture them about the system the
1733
1734
  const desc = parts.join(', ');
1734
1735
  prompt = `The user has selected a local model: ${desc} (id: ${model.id}).\nUse this model for setup instead of recommending a different one. If this exact model isn't available in the runtime's format, find the closest equivalent (same base model, similar quantization).\n\n${prompt}`;
1735
1736
  }
1737
+ // Pick best available CLI provider: prefer user's default, fall back through tool-use capable providers
1738
+ const cliProviders = ['claude-code', 'codex', 'gemini'];
1739
+ const defaultProv = daemon.config.defaultProvider;
1740
+ let assistantProvider = cliProviders.includes(defaultProv) && isProviderInstalled(defaultProv)
1741
+ ? defaultProv
1742
+ : cliProviders.find((p) => isProviderInstalled(p)) || 'claude-code';
1736
1743
  const config = {
1737
1744
  role: 'lab-assistant',
1738
1745
  scope: agentConfig.scope || [],
1739
- provider: agentConfig.provider || daemon.config.defaultProvider,
1746
+ provider: assistantProvider,
1740
1747
  prompt,
1741
1748
  metadata: { labAssistant: true, backend },
1742
1749
  };
1743
- if (!config.provider) config.provider = daemon.config.defaultProvider;
1744
1750
  const agent = await daemon.processes.spawn(config);
1745
1751
  daemon.audit.log('lab.assistant.spawn', { id: agent.id, backend });
1746
1752
  res.status(201).json({ agentId: agent.id, backend });
@@ -475,31 +475,62 @@ export class ConversationManager {
475
475
 
476
476
  try {
477
477
  const json = JSON.parse(trimmed);
478
+ let matched = false;
478
479
  if (json.type === 'assistant' && json.message?.content) {
479
480
  for (const block of json.message.content) {
480
481
  if (block.type === 'text' && block.text) {
481
482
  emitChunk(block.text);
482
483
  }
483
484
  }
484
- continue;
485
+ matched = true;
485
486
  }
486
- if (json.type === 'content_block_delta' && json.delta?.text) {
487
+ if (!matched && json.type === 'content_block_delta' && json.delta?.text) {
487
488
  emitChunk(json.delta.text);
488
- continue;
489
+ matched = true;
489
490
  }
490
- if (json.type === 'result' && json.result) continue;
491
- if (json.type === 'token' && json.text != null) {
491
+ if (!matched && json.type === 'result' && json.result) {
492
+ matched = true;
493
+ }
494
+ if (!matched && json.type === 'token' && json.text != null) {
492
495
  emitChunk(json.text);
493
- continue;
496
+ matched = true;
494
497
  }
495
- if ((json.type === 'done' || json.type === 'complete' || json.type === 'result') && json.text) {
498
+ if (!matched && (json.type === 'done' || json.type === 'complete' || json.type === 'result') && json.text) {
496
499
  emitChunk(json.text);
497
- continue;
500
+ matched = true;
498
501
  }
499
- if (json.content?.[0]?.text) {
502
+ if (!matched && json.content?.[0]?.text) {
500
503
  emitChunk(json.content[0].text);
501
- continue;
504
+ matched = true;
505
+ }
506
+ // Fallback: use provider's parseOutput for provider-specific formats (Gemini tool_use, tool_result, Codex items, etc.)
507
+ if (!matched && provider.parseOutput) {
508
+ const parsed = provider.parseOutput(trimmed);
509
+ if (parsed) {
510
+ const blocks = Array.isArray(parsed.data) ? parsed.data : [];
511
+ for (const block of blocks) {
512
+ if (block.type === 'text' && block.text) {
513
+ emitChunk(block.text);
514
+ } else if (block.type === 'tool_use') {
515
+ const cmd = block.input?.command;
516
+ const path = block.input?.path;
517
+ const summary = cmd || path || (block.input && Object.keys(block.input).length > 0
518
+ ? Object.values(block.input)[0]
519
+ : null);
520
+ if (block.name || summary) {
521
+ this.daemon.broadcast({
522
+ type: 'conversation:tool',
523
+ data: { conversationId: id, name: block.name || 'Tool', summary: summary ? String(summary).slice(0, 120) : null },
524
+ });
525
+ }
526
+ }
527
+ }
528
+ if (!blocks.length && typeof parsed.data === 'string' && parsed.data) {
529
+ emitChunk(parsed.data);
530
+ }
531
+ }
502
532
  }
533
+ continue;
503
534
  } catch { /* not JSON */ }
504
535
 
505
536
  if (!trimmed.startsWith('{')) {
@@ -106,6 +106,26 @@ export class Introducer {
106
106
 
107
107
  if (others.length === 0) {
108
108
  lines.push('You are the only agent on this project right now.');
109
+
110
+ // Solo agents get full authority — no team coordination, no scope limits.
111
+ // Business roles and planners keep their restrictions (intentional by design).
112
+ const NO_SOLO_EXPAND = new Set([
113
+ 'planner', 'cmo', 'cfo', 'ea', 'support', 'analyst', 'home', 'chat', 'ambassador',
114
+ ]);
115
+ if (!NO_SOLO_EXPAND.has(newAgent.role)) {
116
+ lines.push('');
117
+ lines.push('## Solo Mode');
118
+ lines.push('');
119
+ lines.push('You are working alone — no team, no scope restrictions, no coordination needed. You have full authority to do whatever the task requires:');
120
+ lines.push('- Install dependencies (npm install, pip install, etc.)');
121
+ lines.push('- Start dev servers and long-running processes when needed');
122
+ lines.push('- Run tests, builds, and linters');
123
+ lines.push('- Create, modify, or delete any project files');
124
+ lines.push('- Commit and manage git operations');
125
+ lines.push('- Perform any shell commands necessary to complete your task');
126
+ lines.push('');
127
+ lines.push('You are not limited to your role\'s typical focus area. If the task requires work outside your specialty, handle it directly.');
128
+ }
109
129
  } else {
110
130
  lines.push(`## Team (${others.length} other agent${others.length > 1 ? 's' : ''})`);
111
131
  lines.push('');
@@ -345,6 +345,10 @@ export class ProcessManager {
345
345
  this._truncationFlagged = new Set(); // agentIds that have had any truncation in their session
346
346
  this._lastAssistantBlocks = new Map(); // agentId -> last assistant content blocks (for abandoned tool_use detection)
347
347
  this._previousCacheReadTokens = new Map(); // agentId -> previous turn's cacheReadTokens
348
+ this.pendingQuestions = new Map(); // agentId -> { id, agentId, agentName, questions, timestamp }
349
+ this._reviewTriggered = new Set(); // teamIds that have had review triggered (one round only)
350
+ this._pendingReviews = new Map(); // teamId -> { reviewAgentId }
351
+ this._reviewPending = new Set(); // teamIds with review in progress (blocks preview launch)
348
352
 
349
353
  this._stallWatchdog = setInterval(() => this._checkStalls(), STALL_CHECK_INTERVAL_MS);
350
354
  if (this._stallWatchdog.unref) this._stallWatchdog.unref();
@@ -503,6 +507,7 @@ export class ProcessManager {
503
507
 
504
508
  if (finalStatus === 'completed' && agent.role === 'planner') {
505
509
  this._extractRecommendedTeam(agent, logPath);
510
+ this._handleReviewComplete(agent);
506
511
  }
507
512
 
508
513
  if (finalStatus === 'completed') {
@@ -529,6 +534,10 @@ export class ProcessManager {
529
534
 
530
535
  this._checkPhase2(agent.id);
531
536
 
537
+ if (finalStatus === 'completed' && agent.role === 'fullstack' && agent.teamId) {
538
+ this._triggerReview(agent);
539
+ }
540
+
532
541
  if (agent.teamId) {
533
542
  this._checkPreviewReady(agent.teamId);
534
543
  }
@@ -593,6 +602,20 @@ export class ProcessManager {
593
602
  const hadResult = this._resultReceived.has(agent.id);
594
603
  this._resultReceived.delete(agent.id);
595
604
 
605
+ // Agent exited while waiting for user answer — keep it in "waiting" state
606
+ // instead of marking completed. The GUI will relay the user's answer and
607
+ // trigger a resume via POST /api/agents/:id/answer.
608
+ const pendingQ = this.pendingQuestions.get(agent.id);
609
+ if (pendingQ && code === 0) {
610
+ registry.update(agent.id, { status: 'waiting_for_input', pid: null });
611
+ this.daemon.broadcast({
612
+ type: 'agent:exit',
613
+ agentId: agent.id, code, signal,
614
+ status: 'waiting_for_input',
615
+ });
616
+ return;
617
+ }
618
+
596
619
  const finalStatus = hadResult ? 'completed' : signal === 'SIGTERM' || signal === 'SIGKILL' ? 'killed' : code === 0 ? 'completed' : 'crashed';
597
620
  registry.update(agent.id, { status: finalStatus, pid: null });
598
621
 
@@ -779,21 +802,58 @@ export class ProcessManager {
779
802
  );
780
803
  }
781
804
 
782
- // Pre-flight for local model providers: ensure Ollama server is running and model is installed
805
+ // Pre-flight for local model providers: ensure runtime/server is running
783
806
  if (providerName === 'local' || providerName === 'ollama') {
784
- try {
785
- await LocalProvider.ensureServerRunning();
786
- } catch (err) {
787
- const agent = registry.add({ ...config, provider: providerName, status: 'error' });
788
- registry.update(agent.id, { status: 'error', error: 'Ollama server failed to start' });
789
- this.daemon.broadcast({ type: 'model:error', agentId: agent.id, error: err.message });
790
- throw new Error('Ollama server failed to start: ' + err.message);
791
- }
792
- if (config.model && config.model !== 'auto') {
793
- const installed = OllamaProvider.getInstalledModels();
794
- const modelInstalled = installed.some((m) => m.id === config.model || config.model.startsWith(m.id.split(':')[0]));
795
- if (!modelInstalled) {
796
- throw new Error(`Model '${config.model}' is not installed. Pull it first with: ollama pull ${config.model}`);
807
+ // Auto-start lab runtime when model is runtime:<runtimeId>:<modelId>
808
+ if (config.model && config.model.startsWith('runtime:') && this.daemon.modelLab) {
809
+ const parts = config.model.split(':');
810
+ const runtimeId = parts[1];
811
+ const rt = this.daemon.modelLab.getRuntime(runtimeId);
812
+ if (rt) {
813
+ const status = await this.daemon.modelLab.getRuntimeStatus(rt);
814
+ if (!status.online) {
815
+ console.log(`[Groove] Auto-starting runtime '${rt.name}' for agent spawn`);
816
+ try {
817
+ await this.daemon.modelLab.startRuntime(runtimeId);
818
+ } catch (err) {
819
+ throw new Error(`Failed to auto-start runtime '${rt.name}': ${err.message}`);
820
+ }
821
+ }
822
+ }
823
+ } else if (config.model && config.model.startsWith('gguf:') && this.daemon.modelLab) {
824
+ // GGUF models are served by llama-cpp runtimes, not Ollama
825
+ const ggufId = config.model.slice(5);
826
+ const runtimes = this.daemon.modelLab.listRuntimes();
827
+ const rt = runtimes.find(r =>
828
+ r._localModelId === ggufId ||
829
+ r.models?.some(rm => rm.id === ggufId || rm.name === ggufId)
830
+ );
831
+ if (rt) {
832
+ const status = await this.daemon.modelLab.getRuntimeStatus(rt);
833
+ if (!status.online) {
834
+ console.log(`[Groove] Auto-starting runtime '${rt.name}' for GGUF model '${ggufId}'`);
835
+ try {
836
+ await this.daemon.modelLab.startRuntime(rt.id);
837
+ } catch (err) {
838
+ throw new Error(`Failed to auto-start runtime '${rt.name}': ${err.message}`);
839
+ }
840
+ }
841
+ }
842
+ } else {
843
+ try {
844
+ await LocalProvider.ensureServerRunning();
845
+ } catch (err) {
846
+ const agent = registry.add({ ...config, provider: providerName, status: 'error' });
847
+ registry.update(agent.id, { status: 'error', error: 'Ollama server failed to start' });
848
+ this.daemon.broadcast({ type: 'model:error', agentId: agent.id, error: err.message });
849
+ throw new Error('Ollama server failed to start: ' + err.message);
850
+ }
851
+ if (config.model && config.model !== 'auto') {
852
+ const installed = OllamaProvider.getInstalledModels();
853
+ const modelInstalled = installed.some((m) => m.id === config.model || config.model.startsWith(m.id.split(':')[0]));
854
+ if (!modelInstalled) {
855
+ throw new Error(`Model '${config.model}' is not installed. Pull it first with: ollama pull ${config.model}`);
856
+ }
797
857
  }
798
858
  }
799
859
  }
@@ -953,7 +1013,7 @@ export class ProcessManager {
953
1013
  if (!isOneShotProvider) {
954
1014
  // Apply role-specific prompt prefix so agents always get their role constraints
955
1015
  const rolePrompt = ROLE_PROMPTS[agent.role];
956
- if (rolePrompt) {
1016
+ if (rolePrompt && !config._skipRolePrompt) {
957
1017
  if (!spawnConfig.prompt) {
958
1018
  spawnConfig.prompt = rolePrompt + `IMPORTANT: No task has been assigned yet. You MUST wait for the user to tell you what to do.
959
1019
 
@@ -975,6 +1035,11 @@ DO: Introduce yourself in one sentence and ask the user what they would like you
975
1035
  IMPORTANT: No task has been assigned yet. You MUST wait for the user to tell you what to do. Do NOT start building, coding, or continuing previous work. Do NOT treat existing files or the project map as your task. Introduce yourself in one sentence and ask the user what they would like you to work on. Then wait.`;
976
1036
  }
977
1037
 
1038
+ // AskUserQuestion is unavailable in headless mode — instruct agents to use text output instead
1039
+ if (spawnConfig.prompt) {
1040
+ spawnConfig.prompt += '\n\nIMPORTANT: Do NOT use the AskUserQuestion tool. If you need user input or clarification, state your questions as regular text output — the user will see your output and respond via the chat interface.';
1041
+ }
1042
+
978
1043
  // Inject skill content into the prompt
979
1044
  if (config.skills?.length > 0 && this.daemon.skills) {
980
1045
  const skillSections = [];
@@ -1405,6 +1470,23 @@ For normal file edits within your scope, proceed without review.
1405
1470
 
1406
1471
  this._lastAssistantBlocks.set(agentId, blocks);
1407
1472
 
1473
+ // Detect AskUserQuestion tool calls — these cause the agent to exit
1474
+ // in headless mode because there's no terminal to collect input.
1475
+ // Intercept and relay to the GUI instead.
1476
+ const askBlock = blocks.find(b => b.type === 'tool_use' && b.name === 'AskUserQuestion');
1477
+ if (askBlock) {
1478
+ const questionData = {
1479
+ id: askBlock.id || `q_${Date.now()}`,
1480
+ agentId,
1481
+ agentName: agent.name,
1482
+ questions: askBlock.input?.questions || [{ question: askBlock.input?.question || JSON.stringify(askBlock.input || {}).slice(0, 500) }],
1483
+ timestamp: Date.now(),
1484
+ };
1485
+ this.pendingQuestions.set(agentId, questionData);
1486
+ registry.update(agentId, { pendingQuestion: true });
1487
+ this.daemon.broadcast({ type: 'agent:question', agentId, data: questionData });
1488
+ }
1489
+
1408
1490
  if (truncated) {
1409
1491
  this._truncationFlagged.add(agentId);
1410
1492
  const prev = agent.consecutiveTruncations || 0;
@@ -1538,6 +1620,7 @@ For normal file edits within your scope, proceed without review.
1538
1620
  * with _previewAttempted per teamId.
1539
1621
  */
1540
1622
  _checkPreviewReady(teamId) {
1623
+ if (this._reviewPending?.has(teamId)) return;
1541
1624
  const preview = this.daemon.preview;
1542
1625
  if (!preview) return;
1543
1626
  if (!this._previewAttempted) this._previewAttempted = new Set();
@@ -1788,6 +1871,170 @@ For normal file edits within your scope, proceed without review.
1788
1871
  }
1789
1872
  }
1790
1873
 
1874
+ /**
1875
+ * After the phase-2 fullstack QC completes in a planned team build,
1876
+ * auto-spawn a review planner that checks the implementation against
1877
+ * the original spec. One round only — _reviewTriggered guards re-entry.
1878
+ */
1879
+ _triggerReview(agent) {
1880
+ const teamId = agent.teamId;
1881
+ if (!teamId) return;
1882
+ if (this._reviewTriggered.has(teamId)) return;
1883
+
1884
+ const registry = this.daemon.registry;
1885
+ const teamAgents = registry.getAll().filter(a => a.teamId === teamId);
1886
+
1887
+ // Only trigger for planned team builds — must have a completed planner
1888
+ const planner = teamAgents.find(a => a.role === 'planner' &&
1889
+ (a.status === 'completed' || a.status === 'crashed' || a.status === 'stopped' || a.status === 'killed'));
1890
+ if (!planner) return;
1891
+
1892
+ // All non-planner agents must be done
1893
+ const hasRunning = teamAgents.some(a =>
1894
+ a.role !== 'planner' && (a.status === 'running' || a.status === 'starting'));
1895
+ if (hasRunning) return;
1896
+
1897
+ this._reviewTriggered.add(teamId);
1898
+ this._reviewPending.add(teamId);
1899
+
1900
+ const journalist = this.daemon.journalist;
1901
+ const originalSpec = planner.prompt || '';
1902
+ const plannerResult = journalist?.getAgentResult(planner) || '';
1903
+
1904
+ // Collect all files modified by non-planner team agents
1905
+ const allFiles = new Set();
1906
+ for (const a of teamAgents) {
1907
+ if (a.role === 'planner') continue;
1908
+ for (const f of (journalist?.getAgentFiles(a) || [])) allFiles.add(f);
1909
+ }
1910
+
1911
+ const reviewPrompt = `You are reviewing a completed team build against the original specification.
1912
+
1913
+ ## Original Task
1914
+ ${originalSpec.slice(0, 2000)}
1915
+
1916
+ ## Plan
1917
+ ${plannerResult.slice(0, 3000)}
1918
+
1919
+ ## Files Created/Modified
1920
+ ${[...allFiles].join('\n')}
1921
+
1922
+ Read the key files listed above and check:
1923
+ 1. Are all features from the spec implemented?
1924
+ 2. Are there bugs, missing error handling, or broken functionality?
1925
+ 3. Does the implementation match the architectural decisions in the plan?
1926
+
1927
+ Output a JSON block with your verdict:
1928
+ \`\`\`json
1929
+ { "pass": true, "issues": [] }
1930
+ \`\`\`
1931
+
1932
+ Or if issues are found:
1933
+ \`\`\`json
1934
+ {
1935
+ "pass": false,
1936
+ "issues": [
1937
+ { "file": "path/to/file", "problem": "description", "fix": "what to change" }
1938
+ ]
1939
+ }
1940
+ \`\`\`
1941
+
1942
+ If everything matches the spec, set pass: true with empty issues.
1943
+ Do NOT write .groove/recommended-team.json. Do NOT plan a new team. Just review and report.`;
1944
+
1945
+ const config = validateAgentConfig({
1946
+ role: 'planner',
1947
+ prompt: reviewPrompt,
1948
+ workingDir: agent.workingDir,
1949
+ teamId,
1950
+ scope: [],
1951
+ });
1952
+ config._skipRolePrompt = true;
1953
+
1954
+ this.spawn(config).then(reviewAgent => {
1955
+ this._pendingReviews.set(teamId, { reviewAgentId: reviewAgent.id });
1956
+ this.daemon.audit?.log('review.started', { teamId, reviewAgentId: reviewAgent.id });
1957
+ this.daemon.broadcast({ type: 'review:started', teamId, agentId: reviewAgent.id, name: reviewAgent.name });
1958
+ console.log(`[Groove] Review planner ${reviewAgent.name} spawned for team ${teamId}`);
1959
+ }).catch(err => {
1960
+ console.error(`[Groove] Review spawn failed: ${err.message}`);
1961
+ this._reviewPending.delete(teamId);
1962
+ this._pendingReviews.delete(teamId);
1963
+ });
1964
+ }
1965
+
1966
+ /**
1967
+ * When a review planner completes, extract its verdict.
1968
+ * If issues found, spawn a fullstack to fix them. If clean, let preview proceed.
1969
+ */
1970
+ _handleReviewComplete(agent) {
1971
+ const teamId = agent.teamId;
1972
+ if (!teamId) return;
1973
+
1974
+ const pending = this._pendingReviews.get(teamId);
1975
+ if (!pending || pending.reviewAgentId !== agent.id) return;
1976
+ this._pendingReviews.delete(teamId);
1977
+
1978
+ const journalist = this.daemon.journalist;
1979
+ const report = journalist?.getAgentResult(agent) || '';
1980
+
1981
+ // Try to parse the structured verdict from the report
1982
+ let pass = true;
1983
+ let issues = [];
1984
+ const jsonMatch = report.match(/```json\s*([\s\S]*?)```/) || report.match(/\{[\s\S]*?"pass"\s*:[\s\S]*?\}/);
1985
+ if (jsonMatch) {
1986
+ try {
1987
+ const parsed = JSON.parse(jsonMatch[1] || jsonMatch[0]);
1988
+ pass = parsed.pass === true;
1989
+ issues = Array.isArray(parsed.issues) ? parsed.issues : [];
1990
+ } catch { /* fall through to text analysis */ }
1991
+ }
1992
+
1993
+ // Fallback: if no JSON parsed, check for pass/fail signals in text
1994
+ if (!jsonMatch) {
1995
+ const lower = report.toLowerCase();
1996
+ pass = lower.includes('pass') && !lower.includes('fail') && !lower.includes('issue');
1997
+ }
1998
+
1999
+ if (pass || issues.length === 0) {
2000
+ this._reviewPending.delete(teamId);
2001
+ this.daemon.audit?.log('review.passed', { teamId });
2002
+ this.daemon.broadcast({ type: 'review:passed', teamId });
2003
+ console.log(`[Groove] Review passed for team ${teamId}`);
2004
+ if (agent.teamId) this._checkPreviewReady(agent.teamId);
2005
+ return;
2006
+ }
2007
+
2008
+ // Build fix prompt from issues
2009
+ const issuesList = issues.map((iss, i) =>
2010
+ `${i + 1}. **${iss.file || 'unknown'}**: ${iss.problem || ''}${iss.fix ? `\n Fix: ${iss.fix}` : ''}`
2011
+ ).join('\n');
2012
+
2013
+ const fixPrompt = `The QC review found the following issues. Fix each one:
2014
+
2015
+ ${issuesList}
2016
+
2017
+ After fixing all issues, run tests (npm test) and build (npm run build) to verify everything works.`;
2018
+
2019
+ const fixConfig = validateAgentConfig({
2020
+ role: 'fullstack',
2021
+ prompt: fixPrompt,
2022
+ workingDir: agent.workingDir,
2023
+ teamId,
2024
+ scope: [],
2025
+ });
2026
+
2027
+ this.spawn(fixConfig).then(fixAgent => {
2028
+ this._reviewPending.delete(teamId);
2029
+ this.daemon.audit?.log('review.fixStarted', { teamId, fixAgentId: fixAgent.id, issueCount: issues.length });
2030
+ this.daemon.broadcast({ type: 'review:fix-started', teamId, agentId: fixAgent.id, name: fixAgent.name, issueCount: issues.length });
2031
+ console.log(`[Groove] Fix fullstack ${fixAgent.name} spawned with ${issues.length} issues for team ${teamId}`);
2032
+ }).catch(err => {
2033
+ console.error(`[Groove] Review fix spawn failed: ${err.message}`);
2034
+ this._reviewPending.delete(teamId);
2035
+ });
2036
+ }
2037
+
1791
2038
  /**
1792
2039
  * Auto-trigger an idle QC agent in the same team when a teammate completes real work.
1793
2040
  * "Idle" = running fullstack agent that hasn't modified any files yet.
@@ -78,9 +78,7 @@ export class GrooveNetworkProvider extends Provider {
78
78
  static authType = 'none';
79
79
  static isOneShot = true;
80
80
 
81
- static models = [
82
- { id: 'Qwen/Qwen3-4B', name: 'Qwen 3 4B (Network)', context: 32768 },
83
- ];
81
+ static models = [];
84
82
 
85
83
  static isInstalled() {
86
84
  const cfg = getConfig();
@@ -60,11 +60,23 @@ export class Rotator extends EventEmitter {
60
60
  const rotateEvents = events.filter((e) => e.type === 'rotate');
61
61
  if (rotateEvents.length === 0) return;
62
62
 
63
- const existingTimestamps = new Set(this.rotationHistory.map((r) => r.timestamp));
63
+ const existingEntries = this.rotationHistory.map((r) => ({
64
+ agentId: r.agentId,
65
+ reason: r.reason,
66
+ ts: new Date(r.timestamp).getTime(),
67
+ }));
64
68
  let added = 0;
65
69
  for (const e of rotateEvents) {
66
- const ts = new Date(e.t).toISOString();
67
- if (existingTimestamps.has(ts)) continue;
70
+ const eventTs = typeof e.t === 'number' ? e.t : new Date(e.t).getTime();
71
+ const eventAgentId = e.oldAgentId || e.agentId;
72
+ const eventReason = e.reason || 'context_threshold';
73
+ const isDupe = existingEntries.some((existing) =>
74
+ existing.agentId === eventAgentId &&
75
+ existing.reason === eventReason &&
76
+ Math.abs(existing.ts - eventTs) < 10_000
77
+ );
78
+ if (isDupe) continue;
79
+ const ts = new Date(eventTs).toISOString();
68
80
  this.rotationHistory.push({
69
81
  agentId: e.oldAgentId || e.agentId,
70
82
  agentName: e.agentName || 'unknown',
@@ -12,6 +12,16 @@ export function registerAgentRoutes(app, daemon) {
12
12
  res.json(daemon.registry.getAll());
13
13
  });
14
14
 
15
+ // List all pending questions from agents awaiting user input
16
+ // (registered before :id route so Express doesn't capture "questions" as an id)
17
+ app.get('/api/agents/questions', (req, res) => {
18
+ const questions = [];
19
+ for (const [, q] of daemon.processes.pendingQuestions) {
20
+ questions.push(q);
21
+ }
22
+ res.json(questions);
23
+ });
24
+
15
25
  // Get single agent
16
26
  app.get('/api/agents/:id', (req, res) => {
17
27
  const agent = daemon.registry.get(req.params.id);
@@ -506,6 +516,39 @@ export function registerAgentRoutes(app, daemon) {
506
516
  }
507
517
  });
508
518
 
519
+ // Answer a pending question from an agent
520
+ app.post('/api/agents/:id/answer', async (req, res) => {
521
+ try {
522
+ const { answers } = req.body;
523
+ if (!answers || typeof answers !== 'object') {
524
+ return res.status(400).json({ error: 'answers object is required' });
525
+ }
526
+
527
+ const agent = daemon.registry.get(req.params.id);
528
+ if (!agent) return res.status(404).json({ error: 'Agent not found' });
529
+
530
+ const pending = daemon.processes.pendingQuestions.get(req.params.id);
531
+ if (!pending) return res.status(404).json({ error: 'No pending question for this agent' });
532
+
533
+ daemon.processes.pendingQuestions.delete(req.params.id);
534
+ daemon.registry.update(req.params.id, { pendingQuestion: false });
535
+ daemon.broadcast({ type: 'agent:question:resolved', agentId: req.params.id });
536
+
537
+ // Format the answers as a natural language message for session resume
538
+ const parts = Object.entries(answers).map(([q, a]) => `Q: ${q}\nA: ${a}`);
539
+ const resumeMessage = `The user answered your questions:\n\n${parts.join('\n\n')}\n\nContinue with your task based on these answers.`;
540
+
541
+ const newAgent = agent.sessionId
542
+ ? await daemon.processes.resume(req.params.id, resumeMessage)
543
+ : await daemon.rotator.rotate(req.params.id, { additionalPrompt: resumeMessage });
544
+
545
+ daemon.audit.log('agent.answer', { id: req.params.id, newId: newAgent.id });
546
+ res.json(newAgent);
547
+ } catch (err) {
548
+ res.status(400).json({ error: err.message });
549
+ }
550
+ });
551
+
509
552
  // Query an agent (headless one-shot, agent keeps running)
510
553
  // For agent loop agents: sends message directly to the loop
511
554
  app.post('/api/agents/:id/query', async (req, res) => {
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "lab-general",
3
+ "description": "General-purpose Lab Assistant for runtime management, model configuration, and optimization",
4
+ "agents": [
5
+ {
6
+ "role": "lab-assistant",
7
+ "scope": [],
8
+ "provider": "claude-code",
9
+ "prompt": "You are a GROOVE Lab Assistant — a general-purpose helper for local AI model inference. You help with runtime setup, model configuration, optimization, and troubleshooting.\n\nYou can help with:\n- Setting up inference runtimes (MLX, llama.cpp, vLLM, TGI, Ollama)\n- Model selection and downloading\n- Context window configuration and memory optimization\n- Prompt engineering and system prompt design\n- Performance tuning (quantization, batch size, GPU layers)\n- Troubleshooting server issues\n- Comparing model performance\n\n## System Info\n\nStart by understanding the user's environment if needed:\n```bash\nuname -m && sysctl -n machdep.cpu.brand_string 2>/dev/null; sysctl -n hw.memsize 2>/dev/null | awk '{print $0/1073741824 \" GB\"}'; python3 --version 2>/dev/null\n```\n\n## Runtime Management\n\nCheck existing runtimes:\n```bash\nDAEMON_PORT=$(cat ~/.groove/daemon.port 2>/dev/null || echo 31415)\ncurl -s http://localhost:$DAEMON_PORT/api/lab/runtimes | python3 -m json.tool 2>/dev/null || curl -s http://localhost:$DAEMON_PORT/api/lab/runtimes\n```\n\n## Registering Runtimes\n\nWhen setting up a new runtime, always register it with GROOVE so it can be managed from the UI:\n```bash\nDAEMON_PORT=$(cat ~/.groove/daemon.port 2>/dev/null || echo 31415)\ncurl -s -X POST http://localhost:$DAEMON_PORT/api/lab/runtimes \\\n -H 'Content-Type: application/json' \\\n -d '{\"name\":\"<NAME>\",\"type\":\"<TYPE>\",\"endpoint\":\"http://localhost:<PORT>\",\"launchConfig\":{\"command\":\"<CMD>\",\"args\":[...],\"port\":<PORT>}}'\n```\n\nBe conversational, explain your reasoning, and adapt to what the user needs. If they ask about model configs, context windows, or prompts — help with that directly without trying to set up a new runtime."
10
+ }
11
+ ]
12
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "llama-cpp-setup",
3
+ "description": "Lab Assistant for llama.cpp installation and configuration",
4
+ "agents": [
5
+ {
6
+ "role": "lab-assistant",
7
+ "scope": [],
8
+ "provider": "claude-code",
9
+ "prompt": "You are a GROOVE Lab Assistant. Your job is to help the user set up a llama.cpp inference server. Be conversational, report progress clearly, and explain each step.\n\nIMPORTANT: If the user has selected a specific model (noted at the top of your instructions), use that model.\n\n## Step 1 — System Recon\n\nRun these commands and report what you find:\n- `uname -sm` — OS and architecture\n- `sysctl -n hw.memsize 2>/dev/null | awk '{print $0/1073741824 \" GB\"}'` — RAM (macOS)\n- `llama-server --version 2>/dev/null || llama-cli --version 2>/dev/null || echo 'NOT INSTALLED'`\n- `df -h /` — disk space\n\nSummarize: system info, whether llama.cpp is installed, available memory.\n\n## Step 2 — Install llama.cpp\n\nIf not installed:\n- macOS: `brew install llama.cpp`\n- Linux: build from source or use pre-built binaries from GitHub releases\n\nVerify: `llama-server --version`\n\n## Step 3 — Model Selection\n\nIf a model was pre-selected, use it. Otherwise, recommend based on available RAM:\n- 8 GB — 1-3B Q8 or 7B Q4\n- 16 GB — 7-8B Q8 or 14B Q4\n- 32 GB — 14B Q8 or 32B Q4\n- 64 GB+ — 70B Q4\n\nCheck if the model GGUF file exists in `~/.groove/models/`:\n```bash\nls ~/.groove/models/*.gguf 2>/dev/null\n```\n\n## Step 4 — Launch Server\n\nCheck if port 8080 is available:\n```bash\nlsof -i :8080 -sTCP:LISTEN 2>/dev/null\n```\n\nLaunch:\n```bash\nnohup llama-server -m <MODEL_PATH> --port <PORT> --ctx-size 4096 -ngl 99 > /tmp/llama-server.log 2>&1 &\n```\n\nWait for server to be ready:\n```bash\nuntil curl -s http://localhost:<PORT>/v1/models > /dev/null 2>&1; do sleep 1; done && echo 'Server ready'\n```\n\n## Step 5 — Validation\n\nTest:\n```bash\ncurl -s http://localhost:<PORT>/v1/chat/completions -H 'Content-Type: application/json' -d '{\"model\":\"default\",\"messages\":[{\"role\":\"user\",\"content\":\"Hello\"}],\"max_tokens\":20}'\n```\n\n## Step 6 — Runtime Registration (MANDATORY)\n\n```bash\nDAEMON_PORT=$(cat ~/.groove/daemon.port 2>/dev/null || echo 31415)\ncurl -s -X POST http://localhost:$DAEMON_PORT/api/lab/runtimes \\\n -H 'Content-Type: application/json' \\\n -d '{\"name\":\"llama.cpp - <MODEL_SHORT>\",\"type\":\"llama-cpp\",\"endpoint\":\"http://localhost:<PORT>\",\"launchConfig\":{\"command\":\"llama-server\",\"args\":[\"-m\",\"<MODEL_PATH>\",\"--port\",\"<PORT>\",\"--ctx-size\",\"4096\",\"-ngl\",\"99\"],\"port\":<PORT>}}'\n```\n\n## Step 7 — Completion\n\nSummarize: Model, Server URL, Runtime ID, Port, GPU layers.\n\n## Error Handling\n\n- **Metal/CUDA not available**: Remove `-ngl 99` for CPU-only mode.\n- **Model too large**: Suggest smaller quantization or model.\n- **Port in use**: Try next available port."
10
+ }
11
+ ]
12
+ }