groove-dev 0.27.111 → 0.27.113

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 (54) hide show
  1. package/TRAINING_DATA_v3.md +11 -0
  2. package/codex-test/offroad-nitro-racer/dist/assets/index-CuvdKK6U.js +44 -0
  3. package/codex-test/offroad-nitro-racer/dist/assets/index-DvHn2Thu.css +1 -0
  4. package/codex-test/offroad-nitro-racer/dist/index.html +23 -0
  5. package/codex-test/offroad-nitro-racer/index.html +21 -0
  6. package/codex-test/offroad-nitro-racer/package-lock.json +841 -0
  7. package/codex-test/offroad-nitro-racer/package.json +15 -0
  8. package/codex-test/offroad-nitro-racer/src/game/AI.ts +28 -0
  9. package/codex-test/offroad-nitro-racer/src/game/Audio.ts +63 -0
  10. package/codex-test/offroad-nitro-racer/src/game/Car.ts +247 -0
  11. package/codex-test/offroad-nitro-racer/src/game/Effects.ts +62 -0
  12. package/codex-test/offroad-nitro-racer/src/game/Game.ts +229 -0
  13. package/codex-test/offroad-nitro-racer/src/game/Input.ts +45 -0
  14. package/codex-test/offroad-nitro-racer/src/game/Renderer.ts +224 -0
  15. package/codex-test/offroad-nitro-racer/src/game/Track.ts +158 -0
  16. package/codex-test/offroad-nitro-racer/src/game/UI.ts +96 -0
  17. package/codex-test/offroad-nitro-racer/src/game/math.ts +42 -0
  18. package/codex-test/offroad-nitro-racer/src/main.ts +24 -0
  19. package/codex-test/offroad-nitro-racer/src/style.css +291 -0
  20. package/codex-test/offroad-nitro-racer/src/vite-env.d.ts +1 -0
  21. package/codex-test/offroad-nitro-racer/tsconfig.json +18 -0
  22. package/codex-test/offroad-nitro-racer/vite.config.ts +7 -0
  23. package/moe-training/client/consent.js +47 -55
  24. package/moe-training/client/parsers/codex.js +3 -3
  25. package/moe-training/client/parsers/gemini.js +2 -2
  26. package/moe-training/client/step-classifier.js +2 -2
  27. package/moe-training/test/client/consent.test.js +23 -20
  28. package/moe-training/test/client/step-classifier.test.js +63 -7
  29. package/node_modules/@groove-dev/cli/package.json +1 -1
  30. package/node_modules/@groove-dev/daemon/package.json +1 -1
  31. package/node_modules/@groove-dev/daemon/src/api.js +74 -69
  32. package/node_modules/@groove-dev/daemon/src/index.js +30 -18
  33. package/node_modules/@groove-dev/gui/dist/assets/{index-CHu5w3i3.js → index-BYh6iHqL.js} +3 -3
  34. package/node_modules/@groove-dev/gui/dist/index.html +1 -1
  35. package/node_modules/@groove-dev/gui/package.json +1 -1
  36. package/node_modules/@groove-dev/gui/src/components/preview/preview-workspace.jsx +3 -1
  37. package/node_modules/@groove-dev/gui/src/stores/groove.js +15 -0
  38. package/node_modules/moe-training/client/consent.js +47 -55
  39. package/node_modules/moe-training/client/parsers/codex.js +3 -3
  40. package/node_modules/moe-training/client/parsers/gemini.js +2 -2
  41. package/node_modules/moe-training/client/step-classifier.js +2 -2
  42. package/node_modules/moe-training/test/client/consent.test.js +23 -20
  43. package/node_modules/moe-training/test/client/step-classifier.test.js +63 -7
  44. package/package.json +1 -1
  45. package/packages/cli/package.json +1 -1
  46. package/packages/daemon/package.json +1 -1
  47. package/packages/daemon/src/api.js +74 -69
  48. package/packages/daemon/src/index.js +30 -18
  49. package/packages/gui/dist/assets/{index-CHu5w3i3.js → index-BYh6iHqL.js} +3 -3
  50. package/packages/gui/dist/index.html +1 -1
  51. package/packages/gui/package.json +1 -1
  52. package/packages/gui/src/components/preview/preview-workspace.jsx +3 -1
  53. package/packages/gui/src/stores/groove.js +15 -0
  54. package/TRAINING_DATA_v2.md +0 -9
@@ -132,11 +132,11 @@ describe('StepClassifier', () => {
132
132
  assert.equal(StepClassifier.countUserInterventions(steps), 0);
133
133
  });
134
134
 
135
- it('reclassifies action with error content to error', () => {
135
+ it('never reclassifies action to error', () => {
136
136
  const classifier = new StepClassifier();
137
137
  const step = { type: 'action', content: 'Command failed with exit code 1' };
138
138
  const result = classifier.onStep(step);
139
- assert.equal(result.type, 'error');
139
+ assert.equal(result.type, 'action');
140
140
  });
141
141
 
142
142
  it('reclassifies observation with error content to error', () => {
@@ -215,19 +215,75 @@ describe('StepClassifier', () => {
215
215
  assert.equal(result.type, 'error');
216
216
  });
217
217
 
218
- it('preserves action type when is_error is false', () => {
218
+ it('does not reclassify observation containing bare word "error" in source code', () => {
219
219
  const classifier = new StepClassifier();
220
- const step = { type: 'action', content: 'Command failed with exit code 1', is_error: false };
220
+ const step = { type: 'observation', content: 'function handleError(err) { console.error(err); }' };
221
221
  const result = classifier.onStep(step);
222
- assert.equal(result.type, 'action');
222
+ assert.equal(result.type, 'observation');
223
223
  });
224
224
 
225
- it('still reclassifies action to error when is_error is not set', () => {
225
+ it('does not reclassify observation with "0 errors" or "found 0 vulnerabilities"', () => {
226
226
  const classifier = new StepClassifier();
227
- const step = { type: 'action', content: 'Command failed with exit code 1' };
227
+ const step = { type: 'observation', content: 'Build succeeded\n0 errors, 0 warnings\nfound 0 vulnerabilities' };
228
+ const result = classifier.onStep(step);
229
+ assert.equal(result.type, 'observation');
230
+ });
231
+
232
+ it('does not reclassify observation reading a file that mentions exceptions', () => {
233
+ const classifier = new StepClassifier();
234
+ const step = { type: 'observation', content: '{"scripts": {"build": "tsc && vite build"}, "name": "my-app"}' };
235
+ const result = classifier.onStep(step);
236
+ assert.equal(result.type, 'observation');
237
+ });
238
+
239
+ it('reclassifies observation with real TypeScript build error', () => {
240
+ const classifier = new StepClassifier();
241
+ const step = { type: 'observation', content: 'src/main.ts(1,8): error TS2882: Cannot find module' };
242
+ const result = classifier.onStep(step);
243
+ assert.equal(result.type, 'error');
244
+ });
245
+
246
+ it('reclassifies observation with Python traceback', () => {
247
+ const classifier = new StepClassifier();
248
+ const step = { type: 'observation', content: 'Traceback (most recent call last):\n File "main.py", line 5' };
249
+ const result = classifier.onStep(step);
250
+ assert.equal(result.type, 'error');
251
+ });
252
+
253
+ it('reclassifies observation with actual TypeError message', () => {
254
+ const classifier = new StepClassifier();
255
+ const step = { type: 'observation', content: 'TypeError: Cannot read properties of undefined (reading "map")' };
256
+ const result = classifier.onStep(step);
257
+ assert.equal(result.type, 'error');
258
+ });
259
+
260
+ it('reclassifies observation with exit code failure', () => {
261
+ const classifier = new StepClassifier();
262
+ const step = { type: 'observation', content: 'Process exited with exit code 1' };
263
+ const result = classifier.onStep(step);
264
+ assert.equal(result.type, 'error');
265
+ });
266
+
267
+ it('reclassifies observation with ModuleNotFoundError', () => {
268
+ const classifier = new StepClassifier();
269
+ const step = { type: 'observation', content: 'ModuleNotFoundError: No module named requests' };
228
270
  const result = classifier.onStep(step);
229
271
  assert.equal(result.type, 'error');
230
272
  });
273
+
274
+ it('preserves action type even with error keywords', () => {
275
+ const classifier = new StepClassifier();
276
+ const step = { type: 'action', content: 'Command failed with exit code 1' };
277
+ const result = classifier.onStep(step);
278
+ assert.equal(result.type, 'action');
279
+ });
280
+
281
+ it('preserves action type regardless of is_error flag', () => {
282
+ const classifier = new StepClassifier();
283
+ const step = { type: 'action', content: 'ENOENT: no such file', is_error: true };
284
+ const result = classifier.onStep(step);
285
+ assert.equal(result.type, 'action');
286
+ });
231
287
  });
232
288
 
233
289
  describe('StepClassifier.classifyIntent', () => {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.111",
3
+ "version": "0.27.113",
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.111",
3
+ "version": "0.27.113",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -3855,6 +3855,48 @@ Keep responses concise. Help them think, don't lecture them about the system the
3855
3855
  // --- Preview Proxy (same-origin iframe support) ---
3856
3856
  // Forwards HTTP requests to the dev server so the GUI can iframe the preview
3857
3857
  // without cross-origin issues. WebSocket upgrade for HMR is handled in index.js.
3858
+
3859
+ function rewriteAbsoluteUrls(body, proxyBase) {
3860
+ let out = body;
3861
+ // HTML attributes: src, href, action, poster
3862
+ out = out.replace(/((?:src|href|action|poster)\s*=\s*(["']))\/(?!\/|api\/preview\/)/g, `$1${proxyBase}/`);
3863
+ // JS imports: from '/' and import('/')
3864
+ out = out.replace(/(from\s+(["']))\/(?!\/|api\/preview\/)/g, `$1${proxyBase}/`);
3865
+ out = out.replace(/(import\s*\(\s*(["']))\/(?!\/|api\/preview\/)/g, `$1${proxyBase}/`);
3866
+ // CSS url()
3867
+ out = out.replace(/(url\s*\(\s*(["']?))\/(?!\/|api\/preview\/)/g, `$1${proxyBase}/`);
3868
+ return out;
3869
+ }
3870
+
3871
+ const REWRITABLE_TYPES = ['text/html', 'application/javascript', 'text/javascript', 'text/css'];
3872
+
3873
+ function handleProxyResponse(proxyRes, res, proxyBase) {
3874
+ const fwdHeaders = { ...proxyRes.headers };
3875
+ delete fwdHeaders['content-security-policy'];
3876
+ delete fwdHeaders['x-frame-options'];
3877
+
3878
+ const ct = (fwdHeaders['content-type'] || '').toLowerCase();
3879
+ const shouldRewrite = REWRITABLE_TYPES.some((t) => ct.includes(t));
3880
+
3881
+ if (!shouldRewrite) {
3882
+ res.writeHead(proxyRes.statusCode, fwdHeaders);
3883
+ proxyRes.pipe(res);
3884
+ return;
3885
+ }
3886
+
3887
+ const chunks = [];
3888
+ proxyRes.on('data', (c) => chunks.push(c));
3889
+ proxyRes.on('end', () => {
3890
+ let body = Buffer.concat(chunks).toString('utf8');
3891
+ body = rewriteAbsoluteUrls(body, proxyBase);
3892
+ const buf = Buffer.from(body, 'utf8');
3893
+ fwdHeaders['content-length'] = buf.length;
3894
+ delete fwdHeaders['transfer-encoding'];
3895
+ res.writeHead(proxyRes.statusCode, fwdHeaders);
3896
+ res.end(buf);
3897
+ });
3898
+ }
3899
+
3858
3900
  app.all('/api/preview/:teamId/proxy/*', (req, res) => {
3859
3901
  const entry = daemon.preview?.get(req.params.teamId);
3860
3902
  if (!entry || !entry.url) return res.status(404).json({ error: 'No active preview for this team' });
@@ -3865,9 +3907,11 @@ Keep responses concise. Help them think, don't lecture them about the system the
3865
3907
  const proxyPath = req.params[0] || '';
3866
3908
  const search = req.url.includes('?') ? '?' + req.url.split('?').slice(1).join('?') : '';
3867
3909
  const fullPath = '/' + proxyPath + search;
3910
+ const proxyBase = `/api/preview/${req.params.teamId}/proxy`;
3868
3911
 
3869
3912
  const headers = { ...req.headers };
3870
3913
  delete headers.host;
3914
+ delete headers['accept-encoding'];
3871
3915
  headers.host = targetUrl.host;
3872
3916
 
3873
3917
  const proxyReq = httpRequest({
@@ -3876,13 +3920,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
3876
3920
  path: fullPath,
3877
3921
  method: req.method,
3878
3922
  headers,
3879
- }, (proxyRes) => {
3880
- const fwdHeaders = { ...proxyRes.headers };
3881
- delete fwdHeaders['content-security-policy'];
3882
- delete fwdHeaders['x-frame-options'];
3883
- res.writeHead(proxyRes.statusCode, fwdHeaders);
3884
- proxyRes.pipe(res);
3885
- });
3923
+ }, (proxyRes) => handleProxyResponse(proxyRes, res, proxyBase));
3886
3924
 
3887
3925
  proxyReq.on('error', (err) => {
3888
3926
  if (!res.headersSent) res.status(502).json({ error: `Proxy error: ${err.message}` });
@@ -3899,9 +3937,11 @@ Keep responses concise. Help them think, don't lecture them about the system the
3899
3937
  try { targetUrl = new URL(entry.devUrl || entry.url); } catch { return res.status(500).json({ error: 'Invalid preview URL' }); }
3900
3938
 
3901
3939
  const search = req.url.includes('?') ? '?' + req.url.split('?').slice(1).join('?') : '';
3940
+ const proxyBase = `/api/preview/${req.params.teamId}/proxy`;
3902
3941
 
3903
3942
  const headers = { ...req.headers };
3904
3943
  delete headers.host;
3944
+ delete headers['accept-encoding'];
3905
3945
  headers.host = targetUrl.host;
3906
3946
 
3907
3947
  const proxyReq = httpRequest({
@@ -3910,13 +3950,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
3910
3950
  path: '/' + search,
3911
3951
  method: req.method,
3912
3952
  headers,
3913
- }, (proxyRes) => {
3914
- const fwdHeaders = { ...proxyRes.headers };
3915
- delete fwdHeaders['content-security-policy'];
3916
- delete fwdHeaders['x-frame-options'];
3917
- res.writeHead(proxyRes.statusCode, fwdHeaders);
3918
- proxyRes.pipe(res);
3919
- });
3953
+ }, (proxyRes) => handleProxyResponse(proxyRes, res, proxyBase));
3920
3954
 
3921
3955
  proxyReq.on('error', (err) => {
3922
3956
  if (!res.headersSent) res.status(502).json({ error: `Proxy error: ${err.message}` });
@@ -4855,9 +4889,13 @@ Keep responses concise. Help them think, don't lecture them about the system the
4855
4889
 
4856
4890
  app.get('/api/training/status', (req, res) => {
4857
4891
  let userId = null;
4858
- try { userId = ConsentManager.isCaptureEnabled() ? ConsentManager.getOrCreateUserId() : null; } catch (e) { /* no db yet */ }
4892
+ let optedIn = false;
4893
+ try {
4894
+ userId = ConsentManager.getOrCreateUserId();
4895
+ optedIn = ConsentManager.isCaptureEnabled();
4896
+ } catch (e) { /* */ }
4859
4897
  res.json({
4860
- optedIn: !!daemon.config.training_opt_in,
4898
+ optedIn,
4861
4899
  userId: userId ? userId.substring(0, 8) + '...' : null,
4862
4900
  captureActive: !!daemon.trajectoryCapture,
4863
4901
  sessionsCaptured: daemon.state.get('training_sessions_captured') || 0,
@@ -4869,52 +4907,23 @@ Keep responses concise. Help them think, don't lecture them about the system the
4869
4907
  const { enabled } = req.body;
4870
4908
  if (typeof enabled !== 'boolean') return res.status(400).json({ error: 'enabled must be boolean' });
4871
4909
 
4872
- const { saveConfig } = await import('./firstrun.js');
4873
-
4874
- if (enabled) {
4875
- try {
4876
- await import('better-sqlite3');
4877
- } catch (modErr) {
4878
- console.error('[training/opt-in] Native module load failed:', modErr);
4879
- return res.status(500).json({
4880
- error: 'Failed to enable data sharing',
4881
- detail: 'Native SQLite module (better-sqlite3) is not available. On remote instances, ensure build tools are installed (gcc, g++, make, python3) and run: npm rebuild better-sqlite3',
4882
- });
4883
- }
4884
- try {
4885
- const userId = ConsentManager.getOrCreateUserId();
4886
- const consent = new ConsentManager();
4887
- try {
4888
- consent.recordConsent(userId, true, '1.0');
4889
- } finally {
4890
- consent.close();
4891
- }
4892
- daemon.config.training_opt_in = true;
4893
- saveConfig(daemon.grooveDir, daemon.config);
4910
+ try {
4911
+ const userId = ConsentManager.getOrCreateUserId();
4912
+ const consent = new ConsentManager();
4913
+ if (enabled) {
4914
+ consent.recordConsent(userId, true, '1.0');
4894
4915
  await daemon._initTrajectoryCapture();
4895
4916
  daemon.state.set('training_enrolled_at', new Date().toISOString());
4896
- } catch (e) {
4897
- console.error('[training/opt-in] Failed to enable data sharing:', e);
4898
- daemon.config.training_opt_in = false;
4899
- saveConfig(daemon.grooveDir, daemon.config);
4900
- return res.status(500).json({ error: 'Failed to enable data sharing', detail: e.message });
4901
- }
4902
- } else {
4903
- daemon.config.training_opt_in = false;
4904
- saveConfig(daemon.grooveDir, daemon.config);
4905
- if (daemon.trajectoryCapture) {
4906
- try { await daemon.trajectoryCapture.shutdown(); } catch (e) { /* */ }
4907
- daemon.trajectoryCapture = null;
4908
- }
4909
- try {
4910
- const userId = ConsentManager.getOrCreateUserId();
4911
- const consent = new ConsentManager();
4912
- try {
4913
- consent.revokeConsent(userId);
4914
- } finally {
4915
- consent.close();
4917
+ } else {
4918
+ consent.revokeConsent(userId);
4919
+ if (daemon.trajectoryCapture) {
4920
+ try { await daemon.trajectoryCapture.shutdown(); } catch (e) { /* */ }
4921
+ daemon.trajectoryCapture = null;
4916
4922
  }
4917
- } catch (e) { /* no user_id yet */ }
4923
+ }
4924
+ } catch (e) {
4925
+ console.error('[training/opt-in] Failed to update data sharing:', e);
4926
+ return res.status(500).json({ error: 'Failed to update data sharing', detail: e.message });
4918
4927
  }
4919
4928
 
4920
4929
  daemon.broadcast({ type: 'training:status', data: { optedIn: enabled, captureActive: !!daemon.trajectoryCapture } });
@@ -4924,18 +4933,13 @@ Keep responses concise. Help them think, don't lecture them about the system the
4924
4933
 
4925
4934
  app.post('/api/training/opt-in/delete', async (req, res) => {
4926
4935
  try {
4927
- daemon.config.training_opt_in = false;
4928
- const { saveConfig } = await import('./firstrun.js');
4929
- saveConfig(daemon.grooveDir, daemon.config);
4936
+ const userId = ConsentManager.getOrCreateUserId();
4937
+ const consent = new ConsentManager();
4938
+ consent.revokeConsent(userId);
4930
4939
  if (daemon.trajectoryCapture) {
4931
4940
  try { await daemon.trajectoryCapture.shutdown(); } catch (e) { /* */ }
4932
4941
  daemon.trajectoryCapture = null;
4933
4942
  }
4934
- try {
4935
- const userId = ConsentManager.getOrCreateUserId();
4936
- const consent = new ConsentManager();
4937
- try { consent.revokeConsent(userId); } finally { consent.close(); }
4938
- } catch (e) { /* */ }
4939
4943
  daemon.broadcast({ type: 'training:status', data: { optedIn: false, captureActive: false } });
4940
4944
  if (daemon.audit) daemon.audit.log('training.delete', {});
4941
4945
  res.json({ ok: true, deleted: true });
@@ -4961,7 +4965,6 @@ Keep responses concise. Help them think, don't lecture them about the system the
4961
4965
  'port', 'journalistInterval', 'rotationThreshold', 'autoRotation',
4962
4966
  'qcThreshold', 'maxAgents', 'defaultProvider', 'defaultWorkingDir',
4963
4967
  'onboardingDismissed', 'defaultModel', 'defaultChatProvider', 'defaultChatModel',
4964
- 'training_opt_in',
4965
4968
  ];
4966
4969
  for (const key of Object.keys(req.body)) {
4967
4970
  if (!ALLOWED_KEYS.includes(key)) {
@@ -6214,13 +6217,15 @@ Keep responses concise. Help them think, don't lecture them about the system the
6214
6217
  stdio: ['ignore', 'pipe', 'pipe'],
6215
6218
  env: tagEnv,
6216
6219
  });
6220
+ daemon._networkCheckProc = proc;
6217
6221
  let stdout = '';
6218
6222
  let stderr = '';
6219
6223
  proc.stdout.on('data', (c) => { stdout += c.toString(); });
6220
6224
  proc.stderr.on('data', (c) => { stderr += c.toString(); });
6221
6225
  const timeout = setTimeout(() => { safeKill(proc, 'SIGTERM'); }, 10_000);
6222
- proc.on('error', () => { clearTimeout(timeout); resolvePromise(null); });
6226
+ proc.on('error', () => { clearTimeout(timeout); daemon._networkCheckProc = null; resolvePromise(null); });
6223
6227
  proc.on('close', (code) => {
6228
+ daemon._networkCheckProc = null;
6224
6229
  clearTimeout(timeout);
6225
6230
  if (code !== 0) return resolvePromise(null);
6226
6231
  const tags = [];
@@ -290,11 +290,11 @@ export class Daemon {
290
290
  });
291
291
 
292
292
  // Debounced file I/O for registry changes (at most once per 2s)
293
- let _registryIoTimer = null;
293
+ this._registryIoTimer = null;
294
294
  const _debouncedRegistryIo = () => {
295
- if (_registryIoTimer) return;
296
- _registryIoTimer = setTimeout(() => {
297
- _registryIoTimer = null;
295
+ if (this._registryIoTimer) return;
296
+ this._registryIoTimer = setTimeout(() => {
297
+ this._registryIoTimer = null;
298
298
  this.introducer.writeRegistryFile(this.projectDir);
299
299
  this.introducer.injectGrooveSection(this.projectDir);
300
300
  }, 2000);
@@ -666,17 +666,15 @@ export class Daemon {
666
666
  }
667
667
 
668
668
  async _initTrajectoryCapture() {
669
- if (!this.config.training_opt_in) return;
670
669
  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
- }
670
+ if (!ConsentManager.isCaptureEnabled()) return;
671
+ const pkgPath = new URL('../package.json', import.meta.url);
672
+ const version = JSON.parse(readFileSync(pkgPath, 'utf8')).version;
673
+ this.trajectoryCapture = new TrajectoryCapture({
674
+ centralCommandUrl: process.env.GROOVE_CENTRAL_URL || 'https://api.groovedev.ai',
675
+ grooveVersion: version,
676
+ });
677
+ this.trajectoryCapture.init();
680
678
  } catch (e) {
681
679
  // Training capture is never critical
682
680
  }
@@ -781,6 +779,11 @@ export class Daemon {
781
779
  if (this._stateSaveInterval) clearInterval(this._stateSaveInterval);
782
780
  if (this._classifierInterval) clearInterval(this._classifierInterval);
783
781
  if (this._subscriptionPollInterval) clearInterval(this._subscriptionPollInterval);
782
+ if (this._registryIoTimer) clearTimeout(this._registryIoTimer);
783
+ if (this._networkCheckProc) {
784
+ try { this._networkCheckProc.kill(); } catch { /* already exited */ }
785
+ this._networkCheckProc = null;
786
+ }
784
787
 
785
788
  // Clean up file watchers and terminal sessions
786
789
  this.fileWatcher.unwatchAll();
@@ -825,10 +828,19 @@ export class Daemon {
825
828
 
826
829
  // Close server
827
830
  return new Promise((resolvePromise) => {
828
- this.wss.close(() => {
829
- this.server.close(() => {
830
- console.log('GROOVE daemon stopped.');
831
- resolvePromise();
831
+ this.federationWss.close(() => {
832
+ this.wss.close(() => {
833
+ this.server.close(() => {
834
+ // Unref lingering handles (idle fetch/undici TLS pool connections,
835
+ // closed servers) so they don't prevent process exit in tests.
836
+ for (const h of process._getActiveHandles()) {
837
+ if (typeof h.unref === 'function' && h !== process.stdout && h !== process.stderr) {
838
+ h.unref();
839
+ }
840
+ }
841
+ console.log('GROOVE daemon stopped.');
842
+ resolvePromise();
843
+ });
832
844
  });
833
845
  });
834
846
  });