groove-dev 0.27.77 → 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 (79) hide show
  1. package/CLAUDE.md +0 -7
  2. package/MOE_TRAINING_PIPELINE.md +216 -12
  3. package/moe-training/DEPLOY_CENTRAL_COMMAND.md +413 -0
  4. package/moe-training/client/consent.js +96 -0
  5. package/moe-training/client/envelope-builder.js +56 -0
  6. package/moe-training/client/index.js +10 -0
  7. package/moe-training/client/parsers/claude-code.js +110 -0
  8. package/moe-training/client/parsers/codex.js +80 -0
  9. package/moe-training/client/parsers/gemini.js +80 -0
  10. package/moe-training/client/parsers/grok.js +16 -0
  11. package/moe-training/client/parsers/index.js +20 -0
  12. package/moe-training/client/scrubber.js +126 -0
  13. package/moe-training/client/session-attestation.js +114 -0
  14. package/moe-training/client/step-classifier.js +51 -0
  15. package/moe-training/client/trajectory-capture.js +227 -0
  16. package/moe-training/client/transmission-queue.js +93 -0
  17. package/moe-training/package-lock.json +1266 -0
  18. package/moe-training/package.json +20 -0
  19. package/moe-training/server/enrichment.js +24 -0
  20. package/moe-training/server/index.js +119 -0
  21. package/moe-training/server/ledger.js +110 -0
  22. package/moe-training/server/routes/ingest.js +96 -0
  23. package/moe-training/server/routes/sessions.js +43 -0
  24. package/moe-training/server/routes/stats.js +31 -0
  25. package/moe-training/server/scoring.js +63 -0
  26. package/moe-training/server/session-registry.js +156 -0
  27. package/moe-training/server/stats.js +129 -0
  28. package/moe-training/server/stitcher.js +69 -0
  29. package/moe-training/server/storage.js +147 -0
  30. package/moe-training/server/verifier.js +102 -0
  31. package/moe-training/shared/constants.js +30 -0
  32. package/moe-training/shared/crypto.js +45 -0
  33. package/moe-training/shared/envelope-schema.js +220 -0
  34. package/moe-training/test/client/consent.test.js +121 -0
  35. package/moe-training/test/client/envelope-builder.test.js +107 -0
  36. package/moe-training/test/client/parsers/claude-code.test.js +119 -0
  37. package/moe-training/test/client/parsers/codex.test.js +83 -0
  38. package/moe-training/test/client/parsers/gemini.test.js +99 -0
  39. package/moe-training/test/client/scrubber.test.js +133 -0
  40. package/moe-training/test/client/session-attestation-security.test.js +95 -0
  41. package/moe-training/test/client/step-classifier.test.js +88 -0
  42. package/moe-training/test/integration/handshake.test.js +260 -0
  43. package/moe-training/test/server/ingest-security.test.js +166 -0
  44. package/moe-training/test/server/ledger.test.js +131 -0
  45. package/moe-training/test/server/scoring.test.js +242 -0
  46. package/moe-training/test/server/session-registry.test.js +125 -0
  47. package/moe-training/test/server/stitcher.test.js +157 -0
  48. package/moe-training/test/server/verifier.test.js +232 -0
  49. package/moe-training/test/shared/crypto.test.js +87 -0
  50. package/moe-training/test/shared/envelope-schema.test.js +351 -0
  51. package/node_modules/@groove-dev/cli/package.json +1 -1
  52. package/node_modules/@groove-dev/daemon/package.json +1 -1
  53. package/node_modules/@groove-dev/daemon/src/agent-loop.js +48 -5
  54. package/node_modules/@groove-dev/daemon/src/api.js +77 -0
  55. package/node_modules/@groove-dev/daemon/src/index.js +61 -0
  56. package/node_modules/@groove-dev/daemon/src/journalist.js +64 -21
  57. package/node_modules/@groove-dev/daemon/src/process.js +199 -0
  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-BbmPDhuW.js → index-BJgEJ9lZ.js} +1677 -1677
  61. package/node_modules/@groove-dev/gui/dist/index.html +1 -1
  62. package/node_modules/@groove-dev/gui/package.json +1 -1
  63. package/node_modules/@groove-dev/gui/src/stores/groove.js +32 -0
  64. package/node_modules/@groove-dev/gui/src/views/settings.jsx +167 -1
  65. package/package.json +1 -1
  66. package/packages/cli/package.json +1 -1
  67. package/packages/daemon/package.json +1 -1
  68. package/packages/daemon/src/agent-loop.js +48 -5
  69. package/packages/daemon/src/api.js +77 -0
  70. package/packages/daemon/src/index.js +61 -0
  71. package/packages/daemon/src/journalist.js +64 -21
  72. package/packages/daemon/src/process.js +199 -0
  73. package/packages/daemon/src/providers/grok.js +15 -0
  74. package/packages/daemon/src/state.js +20 -1
  75. package/packages/gui/dist/assets/{index-BbmPDhuW.js → index-BJgEJ9lZ.js} +1677 -1677
  76. package/packages/gui/dist/index.html +1 -1
  77. package/packages/gui/package.json +1 -1
  78. package/packages/gui/src/stores/groove.js +32 -0
  79. package/packages/gui/src/views/settings.jsx +167 -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
  });
@@ -466,6 +466,15 @@ export class ProcessManager {
466
466
  model: isAutoRouted ? null : config.model, // Set after routing
467
467
  });
468
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
+
469
478
  // Auto-route: let the router pick the model based on role/complexity
470
479
  if (isAutoRouted) {
471
480
  const { router } = this.daemon;
@@ -669,6 +678,12 @@ For normal file edits within your scope, proceed without review.
669
678
  const logPath = resolve(logDir, `${sanitizeFilename(agent.name)}.log`);
670
679
  const logStream = createWriteStream(logPath, { flags: 'a', mode: 0o600 });
671
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
+
672
687
  // ─── Agent Loop path (local models with built-in agentic runtime) ───
673
688
  if (provider.constructor.useAgentLoop) {
674
689
  const loopConfig = provider.getLoopConfig(spawnConfig);
@@ -878,6 +893,22 @@ For normal file edits within your scope, proceed without review.
878
893
  });
879
894
  }
880
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
+
881
912
  this.daemon.broadcast({
882
913
  type: 'agent:exit',
883
914
  agentId: agent.id,
@@ -1025,6 +1056,12 @@ For normal file edits within your scope, proceed without review.
1025
1056
  } catch (err) {
1026
1057
  console.error(`[Groove] parseOutput error for ${agentId}: ${err.message}`);
1027
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
+ }
1028
1065
  }
1029
1066
  }
1030
1067
 
@@ -1500,6 +1537,15 @@ For normal file edits within your scope, proceed without review.
1500
1537
  const agent = registry.get(agentId);
1501
1538
  if (!agent) throw new Error(`Agent ${agentId} not found`);
1502
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
+
1503
1549
  // If no session ID, fall back to rotation (handoff brief)
1504
1550
  if (!agent.sessionId) {
1505
1551
  return this.daemon.rotator.rotate(agentId, { additionalPrompt: message });
@@ -1645,6 +1691,155 @@ For normal file edits within your scope, proceed without review.
1645
1691
  return newAgent;
1646
1692
  }
1647
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
+
1648
1843
  /**
1649
1844
  * Stop the agent's current work without killing the agent.
1650
1845
  * The process is terminated but the agent stays in the registry with its
@@ -1752,6 +1947,10 @@ For normal file edits within your scope, proceed without review.
1752
1947
  const agent = this.daemon.registry.get(agentId);
1753
1948
  const wrapped = agent ? wrapWithRoleReminder(agent.role, message) : message;
1754
1949
 
1950
+ if (this.daemon.trajectoryCapture) {
1951
+ try { this.daemon.trajectoryCapture.onUserMessage(agentId, message); } catch (e) { /* fail silent */ }
1952
+ }
1953
+
1755
1954
  loop.sendMessage(wrapped).catch(() => {});
1756
1955
  return true;
1757
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
  }