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
@@ -15,6 +15,7 @@ import { listProviders, getProvider, clearInstallCache, getProviderMetadata, get
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';
18
+ import { ConsentManager } from '../../../moe-training/client/index.js';
18
19
  import { validateAgentConfig } from './validate.js';
19
20
  import { ROLE_INTEGRATIONS, wrapWithRoleReminder } from './process.js';
20
21
 
@@ -4402,6 +4403,81 @@ Keep responses concise. Help them think, don't lecture them about the system the
4402
4403
  res.json({ ok: true });
4403
4404
  });
4404
4405
 
4406
+ // --- Training Data ---
4407
+
4408
+ app.get('/api/training/status', (req, res) => {
4409
+ let userId = null;
4410
+ try { userId = ConsentManager.isCaptureEnabled() ? ConsentManager.getOrCreateUserId() : null; } catch (e) { /* no db yet */ }
4411
+ res.json({
4412
+ optedIn: !!daemon.config.training_opt_in,
4413
+ userId: userId ? userId.substring(0, 8) + '...' : null,
4414
+ captureActive: !!daemon.trajectoryCapture,
4415
+ sessionsCaptured: daemon.state.get('training_sessions_captured') || 0,
4416
+ envelopesSent: daemon.state.get('training_envelopes_sent') || 0,
4417
+ });
4418
+ });
4419
+
4420
+ app.post('/api/training/opt-in', async (req, res) => {
4421
+ const { enabled } = req.body;
4422
+ if (typeof enabled !== 'boolean') return res.status(400).json({ error: 'enabled must be boolean' });
4423
+
4424
+ daemon.config.training_opt_in = enabled;
4425
+ const { saveConfig } = await import('./firstrun.js');
4426
+ saveConfig(daemon.grooveDir, daemon.config);
4427
+
4428
+ if (enabled) {
4429
+ const userId = ConsentManager.getOrCreateUserId();
4430
+ const consent = new ConsentManager();
4431
+ try {
4432
+ consent.recordConsent(userId, true, '1.0');
4433
+ } finally {
4434
+ consent.close();
4435
+ }
4436
+ await daemon._initTrajectoryCapture();
4437
+ daemon.state.set('training_enrolled_at', new Date().toISOString());
4438
+ } else {
4439
+ if (daemon.trajectoryCapture) {
4440
+ try { await daemon.trajectoryCapture.shutdown(); } catch (e) { /* */ }
4441
+ daemon.trajectoryCapture = null;
4442
+ }
4443
+ try {
4444
+ const userId = ConsentManager.getOrCreateUserId();
4445
+ const consent = new ConsentManager();
4446
+ try {
4447
+ consent.revokeConsent(userId);
4448
+ } finally {
4449
+ consent.close();
4450
+ }
4451
+ } catch (e) { /* no user_id yet */ }
4452
+ }
4453
+
4454
+ daemon.broadcast({ type: 'training:status', data: { optedIn: enabled, captureActive: !!daemon.trajectoryCapture } });
4455
+ if (daemon.audit) daemon.audit.log('training.consent', { opt_in: enabled });
4456
+ res.json({ ok: true, optedIn: enabled });
4457
+ });
4458
+
4459
+ app.post('/api/training/opt-in/delete', async (req, res) => {
4460
+ try {
4461
+ daemon.config.training_opt_in = false;
4462
+ const { saveConfig } = await import('./firstrun.js');
4463
+ saveConfig(daemon.grooveDir, daemon.config);
4464
+ if (daemon.trajectoryCapture) {
4465
+ try { await daemon.trajectoryCapture.shutdown(); } catch (e) { /* */ }
4466
+ daemon.trajectoryCapture = null;
4467
+ }
4468
+ try {
4469
+ const userId = ConsentManager.getOrCreateUserId();
4470
+ const consent = new ConsentManager();
4471
+ try { consent.revokeConsent(userId); } finally { consent.close(); }
4472
+ } catch (e) { /* */ }
4473
+ daemon.broadcast({ type: 'training:status', data: { optedIn: false, captureActive: false } });
4474
+ if (daemon.audit) daemon.audit.log('training.delete', {});
4475
+ res.json({ ok: true, deleted: true });
4476
+ } catch (e) {
4477
+ res.status(500).json({ error: 'Failed to delete data' });
4478
+ }
4479
+ });
4480
+
4405
4481
  // --- Config ---
4406
4482
 
4407
4483
  app.get('/api/config', (req, res) => {
@@ -4419,6 +4495,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
4419
4495
  'port', 'journalistInterval', 'rotationThreshold', 'autoRotation',
4420
4496
  'qcThreshold', 'maxAgents', 'defaultProvider', 'defaultWorkingDir',
4421
4497
  'onboardingDismissed', 'defaultModel', 'defaultChatProvider', 'defaultChatModel',
4498
+ 'training_opt_in',
4422
4499
  ];
4423
4500
  for (const key of Object.keys(req.body)) {
4424
4501
  if (!ALLOWED_KEYS.includes(key)) {
@@ -43,6 +43,7 @@ import { LlamaServerManager } from './llama-server.js';
43
43
  import { RepoImporter } from './repo-import.js';
44
44
  import { ConversationManager } from './conversations.js';
45
45
  import { Toys } from './toys.js';
46
+ import { TrajectoryCapture, ConsentManager } from '../../../moe-training/client/index.js';
46
47
  import { isFirstRun, runFirstTimeSetup, loadConfig, saveConfig, printWelcome } from './firstrun.js';
47
48
  import { bindDaemon as bindGrooveNetworkDaemon } from './providers/groove-network.js';
48
49
  import { setProviderPaths } from './providers/index.js';
@@ -151,6 +152,17 @@ export class Daemon {
151
152
  this.tunnelManager = new TunnelManager(this);
152
153
  this.repoImporter = new RepoImporter(this);
153
154
  this.toys = new Toys(this);
155
+ this.trajectoryCapture = null;
156
+
157
+ // Hook teams.delete to clean up agent-loop session files
158
+ const originalTeamDelete = this.teams.delete.bind(this.teams);
159
+ this.teams.delete = (id) => {
160
+ const agents = this.registry.getAll().filter(a => a.teamId === id);
161
+ const agentIds = agents.map(a => a.id);
162
+ const result = originalTeamDelete(id);
163
+ if (agentIds.length > 0) this.state.cleanupSessions(agentIds);
164
+ return result;
165
+ };
154
166
 
155
167
  // Subscription state (populated by Electron IPC or direct auth)
156
168
  this.authToken = null;
@@ -390,6 +402,20 @@ export class Daemon {
390
402
  client.send(payload);
391
403
  }
392
404
  }
405
+ if (this.trajectoryCapture && message.type) {
406
+ try {
407
+ if (['approval:request', 'approval:resolved', 'conflict:detected', 'qc:activated'].includes(message.type)) {
408
+ const agentId = message.data?.agentId || message.agentId;
409
+ if (agentId) {
410
+ this.trajectoryCapture.onCoordinationEvent(agentId, {
411
+ type: message.type,
412
+ data: message.data,
413
+ timestamp: Date.now(),
414
+ });
415
+ }
416
+ }
417
+ } catch (e) { /* fail silent */ }
418
+ }
393
419
  }
394
420
 
395
421
  async setAuthToken(token) {
@@ -527,6 +553,17 @@ export class Daemon {
527
553
  const purged = this.locks.purgeOrphans(runningIds);
528
554
  if (purged > 0) console.log(` Purged ${purged} orphaned lock(s) from previous session`);
529
555
 
556
+ // Mark agents with saved agent-loop sessions as resumable
557
+ const resumableIds = new Set(this.state.getResumableSessions());
558
+ if (resumableIds.size > 0) {
559
+ for (const agent of this.registry.getAll()) {
560
+ if (resumableIds.has(agent.id) && (agent.status === 'running' || agent.status === 'idle' || agent.status === 'completed')) {
561
+ this.registry.update(agent.id, { status: 'completed', hasSession: true, pid: null });
562
+ }
563
+ }
564
+ console.log(` ${resumableIds.size} agent-loop session(s) marked as resumable`);
565
+ }
566
+
530
567
  // Migrate old agents without teamId to default team
531
568
  this.teams.migrateAgents();
532
569
 
@@ -542,6 +579,7 @@ export class Daemon {
542
579
  printWelcome(this.port, this.host, this._firstRun);
543
580
 
544
581
  // Start background services
582
+ this._initTrajectoryCapture().catch(() => {});
545
583
  this.journalist.start();
546
584
  this.rotator.start();
547
585
  this.scheduler.start();
@@ -627,6 +665,23 @@ export class Daemon {
627
665
  });
628
666
  }
629
667
 
668
+ async _initTrajectoryCapture() {
669
+ if (!this.config.training_opt_in) return;
670
+ try {
671
+ if (ConsentManager.isCaptureEnabled()) {
672
+ const pkgPath = new URL('../package.json', import.meta.url);
673
+ const version = JSON.parse(readFileSync(pkgPath, 'utf8')).version;
674
+ this.trajectoryCapture = new TrajectoryCapture({
675
+ centralCommandUrl: process.env.GROOVE_CENTRAL_URL || 'https://api.groovedev.ai',
676
+ grooveVersion: version,
677
+ });
678
+ this.trajectoryCapture.init();
679
+ }
680
+ } catch (e) {
681
+ // Training capture is never critical
682
+ }
683
+ }
684
+
630
685
  _startGarbageCollector() {
631
686
  // Run once on startup, then every 24 hours
632
687
  this._gc();
@@ -737,6 +792,12 @@ export class Daemon {
737
792
  // Disconnect all SSH tunnels
738
793
  this.tunnelManager.shutdown();
739
794
 
795
+ // Shut down training capture
796
+ if (this.trajectoryCapture) {
797
+ try { await this.trajectoryCapture.shutdown(); } catch (e) { /* fail silent */ }
798
+ this.trajectoryCapture = null;
799
+ }
800
+
740
801
  // Kill all agent processes, stop MCP servers, and stop inference servers
741
802
  await this.processes.killAll();
742
803
  if (this.preview) await this.preview.killAll();
@@ -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
  }