groove-dev 0.27.75 → 0.27.78

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 (97) hide show
  1. package/MOE_TRAINING_PIPELINE.md +216 -12
  2. package/moe-training/DEPLOY_CENTRAL_COMMAND.md +413 -0
  3. package/moe-training/client/consent.js +96 -0
  4. package/moe-training/client/envelope-builder.js +56 -0
  5. package/moe-training/client/index.js +10 -0
  6. package/moe-training/client/parsers/claude-code.js +110 -0
  7. package/moe-training/client/parsers/codex.js +80 -0
  8. package/moe-training/client/parsers/gemini.js +80 -0
  9. package/moe-training/client/parsers/grok.js +16 -0
  10. package/moe-training/client/parsers/index.js +20 -0
  11. package/moe-training/client/scrubber.js +126 -0
  12. package/moe-training/client/session-attestation.js +114 -0
  13. package/moe-training/client/step-classifier.js +51 -0
  14. package/moe-training/client/trajectory-capture.js +227 -0
  15. package/moe-training/client/transmission-queue.js +93 -0
  16. package/moe-training/package-lock.json +1266 -0
  17. package/moe-training/package.json +20 -0
  18. package/moe-training/server/enrichment.js +24 -0
  19. package/moe-training/server/index.js +119 -0
  20. package/moe-training/server/ledger.js +110 -0
  21. package/moe-training/server/routes/ingest.js +96 -0
  22. package/moe-training/server/routes/sessions.js +43 -0
  23. package/moe-training/server/routes/stats.js +31 -0
  24. package/moe-training/server/scoring.js +63 -0
  25. package/moe-training/server/session-registry.js +156 -0
  26. package/moe-training/server/stats.js +129 -0
  27. package/moe-training/server/stitcher.js +69 -0
  28. package/moe-training/server/storage.js +147 -0
  29. package/moe-training/server/verifier.js +102 -0
  30. package/moe-training/shared/constants.js +30 -0
  31. package/moe-training/shared/crypto.js +45 -0
  32. package/moe-training/shared/envelope-schema.js +220 -0
  33. package/moe-training/test/client/consent.test.js +121 -0
  34. package/moe-training/test/client/envelope-builder.test.js +107 -0
  35. package/moe-training/test/client/parsers/claude-code.test.js +119 -0
  36. package/moe-training/test/client/parsers/codex.test.js +83 -0
  37. package/moe-training/test/client/parsers/gemini.test.js +99 -0
  38. package/moe-training/test/client/scrubber.test.js +133 -0
  39. package/moe-training/test/client/session-attestation-security.test.js +95 -0
  40. package/moe-training/test/client/step-classifier.test.js +88 -0
  41. package/moe-training/test/integration/handshake.test.js +260 -0
  42. package/moe-training/test/server/ingest-security.test.js +166 -0
  43. package/moe-training/test/server/ledger.test.js +131 -0
  44. package/moe-training/test/server/scoring.test.js +242 -0
  45. package/moe-training/test/server/session-registry.test.js +125 -0
  46. package/moe-training/test/server/stitcher.test.js +157 -0
  47. package/moe-training/test/server/verifier.test.js +232 -0
  48. package/moe-training/test/shared/crypto.test.js +87 -0
  49. package/moe-training/test/shared/envelope-schema.test.js +351 -0
  50. package/node_modules/@groove-dev/cli/package.json +1 -1
  51. package/node_modules/@groove-dev/daemon/package.json +1 -1
  52. package/node_modules/@groove-dev/daemon/src/agent-loop.js +48 -5
  53. package/node_modules/@groove-dev/daemon/src/api.js +77 -0
  54. package/node_modules/@groove-dev/daemon/src/index.js +61 -0
  55. package/node_modules/@groove-dev/daemon/src/journalist.js +64 -21
  56. package/node_modules/@groove-dev/daemon/src/preview.js +14 -0
  57. package/node_modules/@groove-dev/daemon/src/process.js +203 -1
  58. package/node_modules/@groove-dev/daemon/src/providers/grok.js +15 -0
  59. package/node_modules/@groove-dev/daemon/src/state.js +20 -1
  60. package/node_modules/@groove-dev/gui/dist/assets/{index-CAT9SCJi.js → index-BJgEJ9lZ.js} +1700 -1704
  61. package/node_modules/@groove-dev/gui/dist/assets/index-kbR5tOHu.css +1 -0
  62. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  63. package/node_modules/@groove-dev/gui/package.json +1 -1
  64. package/node_modules/@groove-dev/gui/src/app.css +12 -0
  65. package/node_modules/@groove-dev/gui/src/components/chat/chat-input.jsx +32 -27
  66. package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +26 -24
  67. package/node_modules/@groove-dev/gui/src/components/preview/preview-toolbar.jsx +34 -6
  68. package/node_modules/@groove-dev/gui/src/components/preview/preview-workspace.jsx +19 -4
  69. package/node_modules/@groove-dev/gui/src/components/preview/screenshot-overlay.jsx +91 -57
  70. package/node_modules/@groove-dev/gui/src/stores/groove.js +32 -0
  71. package/node_modules/@groove-dev/gui/src/views/settings.jsx +167 -1
  72. package/package.json +1 -1
  73. package/packages/cli/package.json +1 -1
  74. package/packages/daemon/package.json +1 -1
  75. package/packages/daemon/src/agent-loop.js +48 -5
  76. package/packages/daemon/src/api.js +77 -0
  77. package/packages/daemon/src/index.js +61 -0
  78. package/packages/daemon/src/journalist.js +64 -21
  79. package/packages/daemon/src/preview.js +14 -0
  80. package/packages/daemon/src/process.js +203 -1
  81. package/packages/daemon/src/providers/grok.js +15 -0
  82. package/packages/daemon/src/state.js +20 -1
  83. package/packages/gui/dist/assets/{index-CAT9SCJi.js → index-BJgEJ9lZ.js} +1700 -1704
  84. package/packages/gui/dist/assets/index-kbR5tOHu.css +1 -0
  85. package/packages/gui/dist/index.html +2 -2
  86. package/packages/gui/package.json +1 -1
  87. package/packages/gui/src/app.css +12 -0
  88. package/packages/gui/src/components/chat/chat-input.jsx +32 -27
  89. package/packages/gui/src/components/chat/chat-messages.jsx +26 -24
  90. package/packages/gui/src/components/preview/preview-toolbar.jsx +34 -6
  91. package/packages/gui/src/components/preview/preview-workspace.jsx +19 -4
  92. package/packages/gui/src/components/preview/screenshot-overlay.jsx +91 -57
  93. package/packages/gui/src/stores/groove.js +32 -0
  94. package/packages/gui/src/views/settings.jsx +167 -1
  95. package/welcome.png +0 -0
  96. package/node_modules/@groove-dev/gui/dist/assets/index-CVzz6zyb.css +0 -1
  97. package/packages/gui/dist/assets/index-CVzz6zyb.css +0 -1
@@ -540,27 +540,76 @@ export class Journalist {
540
540
  }
541
541
 
542
542
  async callHeadless(prompt, { trackAs = '__journalist__' } = {}) {
543
- // Find the best available provider for headless synthesis
544
- // Priority: claude-code (cheapest via Haiku) > gemini > codex > ollama
545
- const priority = ['claude-code', 'gemini', 'codex', 'ollama'];
543
+ const priority = ['claude-code', 'gemini', 'codex', 'grok', 'ollama'];
546
544
  const installed = getInstalledProviders();
547
- const providerId = priority.find((p) => installed.some((i) => i.id === p));
548
- if (!providerId) {
549
- throw new Error('No provider available for synthesis');
545
+
546
+ for (const providerId of priority) {
547
+ if (!installed.some((i) => i.id === providerId)) continue;
548
+
549
+ const provider = getProvider(providerId);
550
+ if (!provider) continue;
551
+
552
+ const selectedModel = provider.constructor.models?.find((m) => m.tier === 'medium')
553
+ || provider.constructor.models?.find((m) => m.tier === 'light')
554
+ || provider.constructor.models?.[0];
555
+ const modelId = selectedModel?.id || null;
556
+
557
+ // Try CLI headless command first
558
+ const headlessCmd = provider.buildHeadlessCommand(prompt, modelId);
559
+ if (headlessCmd) {
560
+ try {
561
+ return await this._execHeadlessCmd(headlessCmd, trackAs, modelId);
562
+ } catch {
563
+ continue;
564
+ }
565
+ }
566
+
567
+ // Fallback: streamChat for API-only providers (e.g. Grok)
568
+ if (typeof provider.streamChat === 'function') {
569
+ const apiKey = this.daemon.credentials?.getKey(providerId);
570
+ if (!apiKey) continue;
571
+
572
+ try {
573
+ const text = await new Promise((resolve, reject) => {
574
+ let collected = '';
575
+ const ctrl = provider.streamChat(
576
+ [{ role: 'user', content: prompt }],
577
+ modelId,
578
+ apiKey,
579
+ (chunk) => { collected += chunk; },
580
+ () => resolve(collected),
581
+ (err) => reject(err),
582
+ );
583
+ if (!ctrl) reject(new Error('streamChat unavailable'));
584
+ });
585
+
586
+ if (text) {
587
+ const estimatedInputTokens = Math.ceil(prompt.length / 4);
588
+ const estimatedOutputTokens = Math.ceil(text.length / 4);
589
+ if (this.daemon?.tokens) {
590
+ this.daemon.tokens.record(trackAs, {
591
+ tokens: estimatedInputTokens + estimatedOutputTokens,
592
+ inputTokens: estimatedInputTokens,
593
+ outputTokens: estimatedOutputTokens,
594
+ model: modelId,
595
+ estimatedCostUsd: 0,
596
+ });
597
+ }
598
+ return text;
599
+ }
600
+ } catch {
601
+ continue;
602
+ }
603
+ }
550
604
  }
551
- const provider = getProvider(providerId);
552
605
 
553
- // Pick medium tier for higher-quality synthesis (fewer but better cycles)
554
- const selectedModel = provider.constructor.models?.find((m) => m.tier === 'medium')
555
- || provider.constructor.models?.find((m) => m.tier === 'light')
556
- || provider.constructor.models?.[0];
557
- const modelId = selectedModel?.id || null;
606
+ throw new Error('No provider available for synthesis');
607
+ }
558
608
 
559
- const headlessCmd = provider.buildHeadlessCommand(prompt, modelId);
609
+ _execHeadlessCmd(headlessCmd, trackAs, modelId) {
560
610
  const { command, args, env, stdin: stdinData } = headlessCmd;
561
611
 
562
612
  return new Promise((resolve, reject) => {
563
- // Use spawn with stdin pipe if provider needs it (e.g., Ollama)
564
613
  if (stdinData) {
565
614
  let stdout = '';
566
615
  const proc = cpSpawn(command, args, {
@@ -576,7 +625,6 @@ export class Journalist {
576
625
  clearTimeout(timer);
577
626
  if (code !== 0) return reject(new Error(`Headless exited with code ${code}`));
578
627
  this._recordHeadlessUsage(stdout, trackAs, modelId);
579
- // Process stdout same as execFile path below
580
628
  const lines = stdout.split('\n');
581
629
  for (const line of lines) {
582
630
  try {
@@ -590,17 +638,14 @@ export class Journalist {
590
638
  return;
591
639
  }
592
640
 
593
- const proc = execFile(command, args, {
641
+ execFile(command, args, {
594
642
  env: { ...process.env, ...env },
595
643
  cwd: this.daemon.projectDir,
596
644
  maxBuffer: 1024 * 1024 * 5,
597
645
  timeout: 60_000,
598
646
  }, (err, stdout, stderr) => {
599
647
  if (err) return reject(err);
600
-
601
648
  this._recordHeadlessUsage(stdout, trackAs, modelId);
602
-
603
- // Parse stream-json output to extract the result text
604
649
  const lines = stdout.split('\n');
605
650
  for (const line of lines) {
606
651
  try {
@@ -610,8 +655,6 @@ export class Journalist {
610
655
  }
611
656
  } catch { /* skip */ }
612
657
  }
613
-
614
- // Fallback: return raw stdout
615
658
  resolve(stdout);
616
659
  });
617
660
  });
@@ -168,6 +168,20 @@ export class PreviewService {
168
168
  if (!command) {
169
169
  return Promise.resolve({ launched: false, reason: 'no_command' });
170
170
  }
171
+ // If command references an npm script, verify it exists in package.json
172
+ const npmRunMatch = command.match(/npm\s+run\s+(\S+)/);
173
+ if (npmRunMatch) {
174
+ const scriptName = npmRunMatch[1];
175
+ const pkgPath = resolve(baseDir, 'package.json');
176
+ try {
177
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
178
+ if (!pkg.scripts || !pkg.scripts[scriptName]) {
179
+ return Promise.resolve({ launched: false, reason: 'no_dev_script' });
180
+ }
181
+ } catch {
182
+ return Promise.resolve({ launched: false, reason: 'no_dev_script' });
183
+ }
184
+ }
171
185
  const urlPattern = preview.urlPattern
172
186
  ? new RegExp(preview.urlPattern)
173
187
  : /https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0):\d+/;
@@ -114,6 +114,9 @@ CRITICAL — NEVER DO THESE:
114
114
  - NEVER kill the daemon process. No "kill <pid>", "pkill groove", "killall node", etc.
115
115
  - NEVER run "./promote.sh", "./promote-local.sh", or any publish/deploy script.
116
116
  - NEVER start long-running dev servers (vite dev, npm start, next dev, etc.).
117
+ - NEVER use 'git add -f' or 'git add --force' to bypass .gitignore. If a file is gitignored, it should stay gitignored. Only stage files that git tracks normally. If .gitignore prevents staging, report it in your output — do NOT force-add.
118
+ - NEVER use 'git push --force' or 'git push -f'. Force-pushing can destroy shared history.
119
+ - NEVER modify .gitignore to include files that were previously excluded.
117
120
 
118
121
  Restarting the daemon destroys ALL other agents currently running in other teams. Verification is done via "npm run build" and "npm test", which exit cleanly. If code changes require a daemon restart to take effect, report that in your output so the user can restart manually — do NOT do it yourself.
119
122
 
@@ -463,6 +466,15 @@ export class ProcessManager {
463
466
  model: isAutoRouted ? null : config.model, // Set after routing
464
467
  });
465
468
 
469
+ if (this.daemon.trajectoryCapture) {
470
+ try {
471
+ const teamSize = registry.getAll().filter(a => a.status === 'active' || a.status === 'running' || a.status === 'starting').length;
472
+ this.daemon.trajectoryCapture.onAgentSpawn(
473
+ agent.id, providerName, config.model || null, config.role, teamSize
474
+ ).catch(() => {});
475
+ } catch (e) { /* fail silent */ }
476
+ }
477
+
466
478
  // Auto-route: let the router pick the model based on role/complexity
467
479
  if (isAutoRouted) {
468
480
  const { router } = this.daemon;
@@ -666,6 +678,12 @@ For normal file edits within your scope, proceed without review.
666
678
  const logPath = resolve(logDir, `${sanitizeFilename(agent.name)}.log`);
667
679
  const logStream = createWriteStream(logPath, { flags: 'a', mode: 0o600 });
668
680
 
681
+ // Inject API key from credential store for agent-loop providers
682
+ if (provider.constructor.useAgentLoop && this.daemon.credentials) {
683
+ const storedKey = this.daemon.credentials.getKey(providerName);
684
+ if (storedKey) spawnConfig.apiKey = storedKey;
685
+ }
686
+
669
687
  // ─── Agent Loop path (local models with built-in agentic runtime) ───
670
688
  if (provider.constructor.useAgentLoop) {
671
689
  const loopConfig = provider.getLoopConfig(spawnConfig);
@@ -875,6 +893,22 @@ For normal file edits within your scope, proceed without review.
875
893
  });
876
894
  }
877
895
 
896
+ if (this.daemon.trajectoryCapture) {
897
+ try {
898
+ if (finalStatus === 'completed') {
899
+ this.daemon.trajectoryCapture.onAgentComplete(agent.id, {
900
+ status: 'SUCCESS', exit_code: code, signal,
901
+ });
902
+ } else {
903
+ this.daemon.trajectoryCapture.onAgentCrash(agent.id,
904
+ signal ? 'Killed by signal ' + signal : 'Exit code ' + code
905
+ );
906
+ }
907
+ const count = (this.daemon.state.get('training_sessions_captured') || 0) + 1;
908
+ this.daemon.state.set('training_sessions_captured', count);
909
+ } catch (e) { /* fail silent */ }
910
+ }
911
+
878
912
  this.daemon.broadcast({
879
913
  type: 'agent:exit',
880
914
  agentId: agent.id,
@@ -1022,6 +1056,12 @@ For normal file edits within your scope, proceed without review.
1022
1056
  } catch (err) {
1023
1057
  console.error(`[Groove] parseOutput error for ${agentId}: ${err.message}`);
1024
1058
  }
1059
+ if (this.daemon.trajectoryCapture) {
1060
+ try {
1061
+ const parsed = JSON.parse(line);
1062
+ this.daemon.trajectoryCapture.onStdoutLine(agentId, parsed);
1063
+ } catch (e) { /* fail silent — non-JSON lines are expected */ }
1064
+ }
1025
1065
  }
1026
1066
  }
1027
1067
 
@@ -1161,7 +1201,7 @@ For normal file edits within your scope, proceed without review.
1161
1201
  const workingDir = plan.workingDir;
1162
1202
  preview.launch(teamId, workingDir, plan.preview).then((result) => {
1163
1203
  if (!result.launched) {
1164
- const intentionalSkips = new Set(['no_preview', 'cli', 'none']);
1204
+ const intentionalSkips = new Set(['no_preview', 'cli', 'none', 'no_command', 'no_dev_script']);
1165
1205
  if (intentionalSkips.has(result.reason)) {
1166
1206
  console.log(`[Groove] Preview for team ${teamId} intentionally skipped: ${result.reason}`);
1167
1207
  return;
@@ -1497,6 +1537,15 @@ For normal file edits within your scope, proceed without review.
1497
1537
  const agent = registry.get(agentId);
1498
1538
  if (!agent) throw new Error(`Agent ${agentId} not found`);
1499
1539
 
1540
+ // Agent-loop providers: resume from saved session file
1541
+ const resumeProvider = getProvider(agent.provider || 'claude-code');
1542
+ if (resumeProvider?.constructor?.useAgentLoop) {
1543
+ const sessionPath = resolve(this.daemon.grooveDir, 'sessions', `${agentId}.json`);
1544
+ if (existsSync(sessionPath)) {
1545
+ return this._resumeAgentLoop(agentId, agent, message, resumeProvider);
1546
+ }
1547
+ }
1548
+
1500
1549
  // If no session ID, fall back to rotation (handoff brief)
1501
1550
  if (!agent.sessionId) {
1502
1551
  return this.daemon.rotator.rotate(agentId, { additionalPrompt: message });
@@ -1642,6 +1691,155 @@ For normal file edits within your scope, proceed without review.
1642
1691
  return newAgent;
1643
1692
  }
1644
1693
 
1694
+ async _resumeAgentLoop(agentId, agent, message, provider) {
1695
+ const { registry, locks } = this.daemon;
1696
+ const config = { ...agent };
1697
+
1698
+ if (this.handles.has(agentId)) {
1699
+ await this.kill(agentId);
1700
+ }
1701
+ registry.remove(agentId);
1702
+ locks.release(agentId);
1703
+
1704
+ const newAgent = registry.add({
1705
+ role: config.role,
1706
+ scope: config.scope,
1707
+ provider: config.provider,
1708
+ model: config.model,
1709
+ prompt: config.prompt,
1710
+ permission: config.permission,
1711
+ workingDir: config.workingDir || undefined,
1712
+ name: config.name,
1713
+ teamId: config.teamId,
1714
+ });
1715
+
1716
+ if (config.tokensUsed > 0) {
1717
+ registry.update(newAgent.id, { tokensUsed: config.tokensUsed });
1718
+ }
1719
+
1720
+ if (newAgent.scope?.length > 0) {
1721
+ locks.register(newAgent.id, newAgent.scope, newAgent.workingDir);
1722
+ }
1723
+
1724
+ // Move session file from old agent ID to new agent ID
1725
+ const sessionsDir = resolve(this.daemon.grooveDir, 'sessions');
1726
+ const oldSessionPath = resolve(sessionsDir, `${agentId}.json`);
1727
+ const newSessionPath = resolve(sessionsDir, `${newAgent.id}.json`);
1728
+ if (oldSessionPath !== newSessionPath && existsSync(oldSessionPath)) {
1729
+ try {
1730
+ mkdirSync(sessionsDir, { recursive: true });
1731
+ copyFileSync(oldSessionPath, newSessionPath);
1732
+ unlinkSync(oldSessionPath);
1733
+ } catch { /* AgentLoop will start fresh if file is missing */ }
1734
+ }
1735
+
1736
+ const logDir = resolve(this.daemon.grooveDir, 'logs');
1737
+ mkdirSync(logDir, { recursive: true });
1738
+ const logPath = resolve(logDir, `${sanitizeFilename(newAgent.name)}.log`);
1739
+ const logStream = createWriteStream(logPath, { flags: 'a', mode: 0o600 });
1740
+ logStream.write(`[${new Date().toISOString()}] GROOVE resuming agent-loop session\n`);
1741
+
1742
+ // Inject API key
1743
+ const spawnConfig = { ...newAgent, ...config };
1744
+ if (this.daemon.credentials) {
1745
+ const storedKey = this.daemon.credentials.getKey(newAgent.provider);
1746
+ if (storedKey) spawnConfig.apiKey = storedKey;
1747
+ }
1748
+
1749
+ const loopConfig = provider.getLoopConfig(spawnConfig);
1750
+ const loop = new AgentLoop({ daemon: this.daemon, agent: newAgent, loopConfig, logStream });
1751
+
1752
+ this.handles.set(newAgent.id, { loop, logStream });
1753
+ registry.update(newAgent.id, { status: 'running' });
1754
+
1755
+ if (this.daemon.timeline) {
1756
+ this.daemon.timeline.recordEvent('spawn', {
1757
+ agentId: newAgent.id, agentName: newAgent.name, role: newAgent.role,
1758
+ provider: newAgent.provider, model: loopConfig.model,
1759
+ });
1760
+ }
1761
+
1762
+ loop.on('output', (output) => {
1763
+ this._handleAgentOutput(newAgent.id, output);
1764
+ });
1765
+
1766
+ loop.on('exit', ({ code, signal, status }) => {
1767
+ logStream.write(`[${new Date().toISOString()}] Agent loop exited: status=${status}\n`);
1768
+ logStream.end();
1769
+ this.handles.delete(newAgent.id);
1770
+
1771
+ const throttle = this._streamThrottle.get(newAgent.id);
1772
+ if (throttle?.timer) clearTimeout(throttle.timer);
1773
+ this._streamThrottle.delete(newAgent.id);
1774
+ this.peakContextUsage.delete(newAgent.id);
1775
+ this.pendingMessages.delete(newAgent.id);
1776
+ registry.update(newAgent.id, { status, pid: null });
1777
+
1778
+ const agentData = registry.get(newAgent.id);
1779
+
1780
+ if (this.daemon.timeline) {
1781
+ const evtType = status === 'completed' ? 'complete' : status === 'crashed' ? 'crash' : 'kill';
1782
+ this.daemon.timeline.recordEvent(evtType, {
1783
+ agentId: newAgent.id, agentName: newAgent.name, role: newAgent.role,
1784
+ finalTokens: agentData?.tokensUsed || 0, costUsd: agentData?.costUsd || 0,
1785
+ });
1786
+ }
1787
+
1788
+ this.daemon.broadcast({ type: 'agent:exit', agentId: newAgent.id, code: code || 0, signal, status });
1789
+ if (this.daemon.integrations) this.daemon.integrations.refreshMcpJson();
1790
+
1791
+ if (status === 'completed' && this.daemon.journalist) {
1792
+ const turns = agentData?.turns || 0;
1793
+ const tok = agentData?.tokensUsed || 0;
1794
+ if (turns > 1 || tok >= 100) this.daemon.journalist.requestSynthesis('completion');
1795
+ }
1796
+ this._checkPhase2(newAgent.id);
1797
+
1798
+ if (status === 'completed') {
1799
+ const files = this.daemon.journalist?.getAgentFiles(newAgent) || [];
1800
+ if (files.length > 0) this._triggerIdleQC(newAgent);
1801
+ this._processHandoffs(newAgent);
1802
+ if (this._rotatingAgents.has(newAgent.id)) {
1803
+ this._rotatingAgents.delete(newAgent.id);
1804
+ } else {
1805
+ this._writeCompletionHandoff(newAgent).catch(err =>
1806
+ console.error(`[Groove] Completion handoff failed for ${newAgent.name}:`, err.message));
1807
+ }
1808
+ }
1809
+ });
1810
+
1811
+ loop.on('error', ({ message: errMsg }) => {
1812
+ this.daemon.broadcast({
1813
+ type: 'agent:output', agentId: newAgent.id,
1814
+ data: { type: 'activity', subtype: 'error', data: errMsg },
1815
+ });
1816
+ });
1817
+
1818
+ loop.start();
1819
+ loop.sendMessage(message);
1820
+
1821
+ this.daemon.broadcast({
1822
+ type: 'rotation:complete',
1823
+ agentId: newAgent.id,
1824
+ agentName: newAgent.name,
1825
+ oldAgentId: agentId,
1826
+ reason: 'resume',
1827
+ tokensSaved: 0,
1828
+ });
1829
+
1830
+ return newAgent;
1831
+ }
1832
+
1833
+ cleanupTeamSessions(teamId) {
1834
+ const { registry } = this.daemon;
1835
+ const teamAgents = registry.getAll().filter(a => a.teamId === teamId);
1836
+ const sessionsDir = resolve(this.daemon.grooveDir, 'sessions');
1837
+ for (const agent of teamAgents) {
1838
+ const sessionPath = resolve(sessionsDir, `${agent.id}.json`);
1839
+ try { if (existsSync(sessionPath)) unlinkSync(sessionPath); } catch { /* non-fatal */ }
1840
+ }
1841
+ }
1842
+
1645
1843
  /**
1646
1844
  * Stop the agent's current work without killing the agent.
1647
1845
  * The process is terminated but the agent stays in the registry with its
@@ -1749,6 +1947,10 @@ For normal file edits within your scope, proceed without review.
1749
1947
  const agent = this.daemon.registry.get(agentId);
1750
1948
  const wrapped = agent ? wrapWithRoleReminder(agent.role, message) : message;
1751
1949
 
1950
+ if (this.daemon.trajectoryCapture) {
1951
+ try { this.daemon.trajectoryCapture.onUserMessage(agentId, message); } catch (e) { /* fail silent */ }
1952
+ }
1953
+
1752
1954
  loop.sendMessage(wrapped).catch(() => {});
1753
1955
  return true;
1754
1956
  }
@@ -37,6 +37,7 @@ export class GrokProvider extends Provider {
37
37
  { id: 'grok-3-mini', name: 'Grok 3 Mini', tier: 'light', maxContext: 131072, pricing: { input: 0.0003, output: 0.0005 } },
38
38
  { id: 'grok-imagine-image', name: 'Grok Imagine', tier: 'medium', type: 'image', pricing: { perImage: 0.07 } },
39
39
  ];
40
+ static useAgentLoop = true;
40
41
 
41
42
  static isInstalled() {
42
43
  return true; // API-only, no CLI needed
@@ -54,6 +55,20 @@ export class GrokProvider extends Provider {
54
55
  return null; // No CLI
55
56
  }
56
57
 
58
+ getLoopConfig(agent) {
59
+ return {
60
+ apiBase: 'https://api.x.ai/v1',
61
+ model: agent.model || 'grok-4-1-fast',
62
+ contextWindow: 131072,
63
+ temperature: 0.1,
64
+ maxResponseTokens: 16384,
65
+ stream: true,
66
+ apiKey: agent.apiKey,
67
+ headers: {},
68
+ introContext: agent.introContext || '',
69
+ };
70
+ }
71
+
57
72
  switchModel() {
58
73
  return false;
59
74
  }
@@ -1,12 +1,13 @@
1
1
  // GROOVE — State Persistence
2
2
  // FSL-1.1-Apache-2.0 — see LICENSE
3
3
 
4
- import { readFileSync, existsSync } from 'fs';
4
+ import { readFileSync, existsSync, readdirSync, unlinkSync } from 'fs';
5
5
  import { writeFile } from 'node:fs/promises';
6
6
  import { resolve } from 'path';
7
7
 
8
8
  export class StateManager {
9
9
  constructor(grooveDir) {
10
+ this.grooveDir = grooveDir;
10
11
  this.path = resolve(grooveDir, 'state.json');
11
12
  this.data = {};
12
13
  }
@@ -32,4 +33,22 @@ export class StateManager {
32
33
  set(key, value) {
33
34
  this.data[key] = value;
34
35
  }
36
+
37
+ getResumableSessions() {
38
+ const sessionsDir = resolve(this.grooveDir, 'sessions');
39
+ if (!existsSync(sessionsDir)) return [];
40
+ try {
41
+ return readdirSync(sessionsDir)
42
+ .filter(f => f.endsWith('.json'))
43
+ .map(f => f.replace('.json', ''));
44
+ } catch { return []; }
45
+ }
46
+
47
+ cleanupSessions(agentIds) {
48
+ const sessionsDir = resolve(this.grooveDir, 'sessions');
49
+ for (const id of agentIds) {
50
+ const sessionPath = resolve(sessionsDir, `${id}.json`);
51
+ try { if (existsSync(sessionPath)) unlinkSync(sessionPath); } catch { /* non-fatal */ }
52
+ }
53
+ }
35
54
  }