groove-dev 0.27.112 → 0.27.115

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 (55) hide show
  1. package/CENTRAL_COMMAND_REBUILD.md +689 -0
  2. package/EMBEDDING_DIAGNOSTIC.md +197 -0
  3. package/TRAINING_DATA_v4.md +3 -0
  4. package/moe-training/client/parsers/codex.js +3 -3
  5. package/moe-training/client/parsers/gemini.js +2 -2
  6. package/moe-training/client/step-classifier.js +2 -2
  7. package/moe-training/test/client/step-classifier.test.js +63 -7
  8. package/node_modules/@groove-dev/cli/package.json +1 -1
  9. package/node_modules/@groove-dev/cli/src/commands/team.js +43 -1
  10. package/node_modules/@groove-dev/daemon/package.json +1 -1
  11. package/node_modules/@groove-dev/daemon/src/api.js +75 -15
  12. package/node_modules/@groove-dev/daemon/src/filewatcher.js +45 -0
  13. package/node_modules/@groove-dev/daemon/src/index.js +36 -10
  14. package/node_modules/@groove-dev/daemon/src/teams.js +100 -6
  15. package/node_modules/@groove-dev/daemon/src/tunnel-manager.js +75 -43
  16. package/node_modules/@groove-dev/gui/dist/assets/{index-CHu5w3i3.js → index-BKCiOUDb.js} +593 -593
  17. package/node_modules/@groove-dev/gui/dist/assets/index-D4Q72afD.css +1 -0
  18. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  19. package/node_modules/@groove-dev/gui/package.json +1 -1
  20. package/node_modules/@groove-dev/gui/src/components/agents/workspace-mode.jsx +0 -22
  21. package/node_modules/@groove-dev/gui/src/components/layout/status-bar.jsx +43 -45
  22. package/node_modules/@groove-dev/gui/src/components/preview/preview-workspace.jsx +3 -1
  23. package/node_modules/@groove-dev/gui/src/components/settings/quick-connect.jsx +2 -1
  24. package/node_modules/@groove-dev/gui/src/stores/groove.js +57 -8
  25. package/node_modules/@groove-dev/gui/src/views/agents.jsx +31 -3
  26. package/node_modules/@groove-dev/gui/src/views/editor.jsx +1 -20
  27. package/node_modules/@groove-dev/gui/src/views/teams.jsx +106 -3
  28. package/node_modules/moe-training/client/parsers/codex.js +3 -3
  29. package/node_modules/moe-training/client/parsers/gemini.js +2 -2
  30. package/node_modules/moe-training/client/step-classifier.js +2 -2
  31. package/node_modules/moe-training/test/client/step-classifier.test.js +63 -7
  32. package/package.json +1 -1
  33. package/packages/cli/package.json +1 -1
  34. package/packages/cli/src/commands/team.js +43 -1
  35. package/packages/daemon/package.json +1 -1
  36. package/packages/daemon/src/api.js +75 -15
  37. package/packages/daemon/src/filewatcher.js +45 -0
  38. package/packages/daemon/src/index.js +36 -10
  39. package/packages/daemon/src/teams.js +100 -6
  40. package/packages/daemon/src/tunnel-manager.js +75 -43
  41. package/packages/gui/dist/assets/{index-CHu5w3i3.js → index-BKCiOUDb.js} +593 -593
  42. package/packages/gui/dist/assets/index-D4Q72afD.css +1 -0
  43. package/packages/gui/dist/index.html +2 -2
  44. package/packages/gui/package.json +1 -1
  45. package/packages/gui/src/components/agents/workspace-mode.jsx +0 -22
  46. package/packages/gui/src/components/layout/status-bar.jsx +43 -45
  47. package/packages/gui/src/components/preview/preview-workspace.jsx +3 -1
  48. package/packages/gui/src/components/settings/quick-connect.jsx +2 -1
  49. package/packages/gui/src/stores/groove.js +57 -8
  50. package/packages/gui/src/views/agents.jsx +31 -3
  51. package/packages/gui/src/views/editor.jsx +1 -20
  52. package/packages/gui/src/views/teams.jsx +106 -3
  53. package/TRAINING_DATA_v2.md +0 -9
  54. package/node_modules/@groove-dev/gui/dist/assets/index-DAlSbVyK.css +0 -1
  55. package/packages/gui/dist/assets/index-DAlSbVyK.css +0 -1
@@ -1100,6 +1100,30 @@ export function createApi(app, daemon) {
1100
1100
  }
1101
1101
  });
1102
1102
 
1103
+ app.get('/api/teams/archived', (req, res) => {
1104
+ res.json({ archived: daemon.teams.listArchived() });
1105
+ });
1106
+
1107
+ app.post('/api/teams/archived/:id/restore', (req, res) => {
1108
+ try {
1109
+ const team = daemon.teams.restore(req.params.id);
1110
+ daemon.audit.log('team.restore', { archivedId: req.params.id, newId: team.id, name: team.name });
1111
+ res.json(team);
1112
+ } catch (err) {
1113
+ res.status(400).json({ error: err.message });
1114
+ }
1115
+ });
1116
+
1117
+ app.delete('/api/teams/archived/:id', (req, res) => {
1118
+ try {
1119
+ daemon.teams.purge(req.params.id);
1120
+ daemon.audit.log('team.purge', { archivedId: req.params.id });
1121
+ res.json({ ok: true });
1122
+ } catch (err) {
1123
+ res.status(400).json({ error: err.message });
1124
+ }
1125
+ });
1126
+
1103
1127
  app.patch('/api/teams/:id', (req, res) => {
1104
1128
  try {
1105
1129
  if (req.body.name) daemon.teams.rename(req.params.id, req.body.name);
@@ -3855,6 +3879,48 @@ Keep responses concise. Help them think, don't lecture them about the system the
3855
3879
  // --- Preview Proxy (same-origin iframe support) ---
3856
3880
  // Forwards HTTP requests to the dev server so the GUI can iframe the preview
3857
3881
  // without cross-origin issues. WebSocket upgrade for HMR is handled in index.js.
3882
+
3883
+ function rewriteAbsoluteUrls(body, proxyBase) {
3884
+ let out = body;
3885
+ // HTML attributes: src, href, action, poster
3886
+ out = out.replace(/((?:src|href|action|poster)\s*=\s*(["']))\/(?!\/|api\/preview\/)/g, `$1${proxyBase}/`);
3887
+ // JS imports: from '/' and import('/')
3888
+ out = out.replace(/(from\s+(["']))\/(?!\/|api\/preview\/)/g, `$1${proxyBase}/`);
3889
+ out = out.replace(/(import\s*\(\s*(["']))\/(?!\/|api\/preview\/)/g, `$1${proxyBase}/`);
3890
+ // CSS url()
3891
+ out = out.replace(/(url\s*\(\s*(["']?))\/(?!\/|api\/preview\/)/g, `$1${proxyBase}/`);
3892
+ return out;
3893
+ }
3894
+
3895
+ const REWRITABLE_TYPES = ['text/html', 'application/javascript', 'text/javascript', 'text/css'];
3896
+
3897
+ function handleProxyResponse(proxyRes, res, proxyBase) {
3898
+ const fwdHeaders = { ...proxyRes.headers };
3899
+ delete fwdHeaders['content-security-policy'];
3900
+ delete fwdHeaders['x-frame-options'];
3901
+
3902
+ const ct = (fwdHeaders['content-type'] || '').toLowerCase();
3903
+ const shouldRewrite = REWRITABLE_TYPES.some((t) => ct.includes(t));
3904
+
3905
+ if (!shouldRewrite) {
3906
+ res.writeHead(proxyRes.statusCode, fwdHeaders);
3907
+ proxyRes.pipe(res);
3908
+ return;
3909
+ }
3910
+
3911
+ const chunks = [];
3912
+ proxyRes.on('data', (c) => chunks.push(c));
3913
+ proxyRes.on('end', () => {
3914
+ let body = Buffer.concat(chunks).toString('utf8');
3915
+ body = rewriteAbsoluteUrls(body, proxyBase);
3916
+ const buf = Buffer.from(body, 'utf8');
3917
+ fwdHeaders['content-length'] = buf.length;
3918
+ delete fwdHeaders['transfer-encoding'];
3919
+ res.writeHead(proxyRes.statusCode, fwdHeaders);
3920
+ res.end(buf);
3921
+ });
3922
+ }
3923
+
3858
3924
  app.all('/api/preview/:teamId/proxy/*', (req, res) => {
3859
3925
  const entry = daemon.preview?.get(req.params.teamId);
3860
3926
  if (!entry || !entry.url) return res.status(404).json({ error: 'No active preview for this team' });
@@ -3865,9 +3931,11 @@ Keep responses concise. Help them think, don't lecture them about the system the
3865
3931
  const proxyPath = req.params[0] || '';
3866
3932
  const search = req.url.includes('?') ? '?' + req.url.split('?').slice(1).join('?') : '';
3867
3933
  const fullPath = '/' + proxyPath + search;
3934
+ const proxyBase = `/api/preview/${req.params.teamId}/proxy`;
3868
3935
 
3869
3936
  const headers = { ...req.headers };
3870
3937
  delete headers.host;
3938
+ delete headers['accept-encoding'];
3871
3939
  headers.host = targetUrl.host;
3872
3940
 
3873
3941
  const proxyReq = httpRequest({
@@ -3876,13 +3944,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
3876
3944
  path: fullPath,
3877
3945
  method: req.method,
3878
3946
  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
- });
3947
+ }, (proxyRes) => handleProxyResponse(proxyRes, res, proxyBase));
3886
3948
 
3887
3949
  proxyReq.on('error', (err) => {
3888
3950
  if (!res.headersSent) res.status(502).json({ error: `Proxy error: ${err.message}` });
@@ -3899,9 +3961,11 @@ Keep responses concise. Help them think, don't lecture them about the system the
3899
3961
  try { targetUrl = new URL(entry.devUrl || entry.url); } catch { return res.status(500).json({ error: 'Invalid preview URL' }); }
3900
3962
 
3901
3963
  const search = req.url.includes('?') ? '?' + req.url.split('?').slice(1).join('?') : '';
3964
+ const proxyBase = `/api/preview/${req.params.teamId}/proxy`;
3902
3965
 
3903
3966
  const headers = { ...req.headers };
3904
3967
  delete headers.host;
3968
+ delete headers['accept-encoding'];
3905
3969
  headers.host = targetUrl.host;
3906
3970
 
3907
3971
  const proxyReq = httpRequest({
@@ -3910,13 +3974,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
3910
3974
  path: '/' + search,
3911
3975
  method: req.method,
3912
3976
  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
- });
3977
+ }, (proxyRes) => handleProxyResponse(proxyRes, res, proxyBase));
3920
3978
 
3921
3979
  proxyReq.on('error', (err) => {
3922
3980
  if (!res.headersSent) res.status(502).json({ error: `Proxy error: ${err.message}` });
@@ -6183,13 +6241,15 @@ Keep responses concise. Help them think, don't lecture them about the system the
6183
6241
  stdio: ['ignore', 'pipe', 'pipe'],
6184
6242
  env: tagEnv,
6185
6243
  });
6244
+ daemon._networkCheckProc = proc;
6186
6245
  let stdout = '';
6187
6246
  let stderr = '';
6188
6247
  proc.stdout.on('data', (c) => { stdout += c.toString(); });
6189
6248
  proc.stderr.on('data', (c) => { stderr += c.toString(); });
6190
6249
  const timeout = setTimeout(() => { safeKill(proc, 'SIGTERM'); }, 10_000);
6191
- proc.on('error', () => { clearTimeout(timeout); resolvePromise(null); });
6250
+ proc.on('error', () => { clearTimeout(timeout); daemon._networkCheckProc = null; resolvePromise(null); });
6192
6251
  proc.on('close', (code) => {
6252
+ daemon._networkCheckProc = null;
6193
6253
  clearTimeout(timeout);
6194
6254
  if (code !== 0) return resolvePromise(null);
6195
6255
  const tags = [];
@@ -8,6 +8,7 @@ export class FileWatcher {
8
8
  constructor(daemon) {
9
9
  this.daemon = daemon;
10
10
  this.watchers = new Map(); // relPath → { watcher, timer }
11
+ this.dirWatchers = new Map(); // relPath → { watcher, timer }
11
12
  }
12
13
 
13
14
  watch(relPath) {
@@ -51,9 +52,53 @@ export class FileWatcher {
51
52
  this.watchers.delete(relPath);
52
53
  }
53
54
 
55
+ watchDir(relPath) {
56
+ if (typeof relPath !== 'string') return;
57
+ if (relPath && relPath.includes('..')) return;
58
+ if (this.dirWatchers.has(relPath)) return;
59
+
60
+ const fullPath = relPath ? resolve(this.daemon.projectDir, relPath) : this.daemon.projectDir;
61
+
62
+ try {
63
+ const watcher = watch(fullPath, () => {
64
+ const entry = this.dirWatchers.get(relPath);
65
+ if (!entry) return;
66
+
67
+ if (entry.timer) clearTimeout(entry.timer);
68
+ entry.timer = setTimeout(() => {
69
+ this.daemon.broadcast({
70
+ type: 'file:tree-changed',
71
+ path: relPath,
72
+ timestamp: Date.now(),
73
+ });
74
+ }, 300);
75
+ });
76
+
77
+ watcher.on('error', () => {
78
+ this.unwatchDir(relPath);
79
+ });
80
+
81
+ this.dirWatchers.set(relPath, { watcher, timer: null });
82
+ } catch {
83
+ // Directory doesn't exist or not watchable — ignore
84
+ }
85
+ }
86
+
87
+ unwatchDir(relPath) {
88
+ const entry = this.dirWatchers.get(relPath);
89
+ if (!entry) return;
90
+
91
+ if (entry.timer) clearTimeout(entry.timer);
92
+ try { entry.watcher.close(); } catch { /* already closed */ }
93
+ this.dirWatchers.delete(relPath);
94
+ }
95
+
54
96
  unwatchAll() {
55
97
  for (const [relPath] of this.watchers) {
56
98
  this.unwatch(relPath);
57
99
  }
100
+ for (const [relPath] of this.dirWatchers) {
101
+ this.unwatchDir(relPath);
102
+ }
58
103
  }
59
104
  }
@@ -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);
@@ -325,8 +325,9 @@ export class Daemon {
325
325
  data: enrichAgents(this.registry.getAll()),
326
326
  }));
327
327
 
328
- // Track which files this client is watching (for cleanup on disconnect)
328
+ // Track which files/dirs this client is watching (for cleanup on disconnect)
329
329
  const watchedFiles = new Set();
330
+ const watchedDirs = new Set();
330
331
 
331
332
  ws.on('message', (raw) => {
332
333
  try {
@@ -335,7 +336,7 @@ export class Daemon {
335
336
  // Validate message type against whitelist
336
337
  const VALID_WS_TYPES = new Set([
337
338
  'terminal:spawn', 'terminal:resize', 'terminal:input', 'terminal:close', 'terminal:kill', 'terminal:rename',
338
- 'editor:watch', 'editor:unwatch', 'editor:save',
339
+ 'editor:watch', 'editor:unwatch', 'editor:save', 'editor:watchdir', 'editor:unwatchdir',
339
340
  'ping'
340
341
  ]);
341
342
  if (!msg || typeof msg !== 'object' || !VALID_WS_TYPES.has(msg.type)) return;
@@ -351,6 +352,14 @@ export class Daemon {
351
352
  case 'editor:unwatch':
352
353
  if (msg.path) { this.fileWatcher.unwatch(msg.path); watchedFiles.delete(msg.path); }
353
354
  break;
355
+ case 'editor:watchdir':
356
+ if (typeof msg.path === 'string' && !msg.path.includes('..')) {
357
+ this.fileWatcher.watchDir(msg.path); watchedDirs.add(msg.path);
358
+ }
359
+ break;
360
+ case 'editor:unwatchdir':
361
+ if (typeof msg.path === 'string') { this.fileWatcher.unwatchDir(msg.path); watchedDirs.delete(msg.path); }
362
+ break;
354
363
  // Terminal
355
364
  case 'terminal:spawn': {
356
365
  if (msg.cwd !== undefined && (typeof msg.cwd !== 'string' || msg.cwd.includes('..'))) break;
@@ -389,6 +398,9 @@ export class Daemon {
389
398
  for (const path of watchedFiles) {
390
399
  this.fileWatcher.unwatch(path);
391
400
  }
401
+ for (const path of watchedDirs) {
402
+ this.fileWatcher.unwatchDir(path);
403
+ }
392
404
  this.terminalManager.cleanupClient(ws);
393
405
  });
394
406
  });
@@ -779,6 +791,11 @@ export class Daemon {
779
791
  if (this._stateSaveInterval) clearInterval(this._stateSaveInterval);
780
792
  if (this._classifierInterval) clearInterval(this._classifierInterval);
781
793
  if (this._subscriptionPollInterval) clearInterval(this._subscriptionPollInterval);
794
+ if (this._registryIoTimer) clearTimeout(this._registryIoTimer);
795
+ if (this._networkCheckProc) {
796
+ try { this._networkCheckProc.kill(); } catch { /* already exited */ }
797
+ this._networkCheckProc = null;
798
+ }
782
799
 
783
800
  // Clean up file watchers and terminal sessions
784
801
  this.fileWatcher.unwatchAll();
@@ -823,10 +840,19 @@ export class Daemon {
823
840
 
824
841
  // Close server
825
842
  return new Promise((resolvePromise) => {
826
- this.wss.close(() => {
827
- this.server.close(() => {
828
- console.log('GROOVE daemon stopped.');
829
- resolvePromise();
843
+ this.federationWss.close(() => {
844
+ this.wss.close(() => {
845
+ this.server.close(() => {
846
+ // Unref lingering handles (idle fetch/undici TLS pool connections,
847
+ // closed servers) so they don't prevent process exit in tests.
848
+ for (const h of process._getActiveHandles()) {
849
+ if (typeof h.unref === 'function' && h !== process.stdout && h !== process.stderr) {
850
+ h.unref();
851
+ }
852
+ }
853
+ console.log('GROOVE daemon stopped.');
854
+ resolvePromise();
855
+ });
830
856
  });
831
857
  });
832
858
  });
@@ -1,8 +1,8 @@
1
1
  // GROOVE — Teams (Live Agent Groups)
2
2
  // FSL-1.1-Apache-2.0 — see LICENSE
3
3
 
4
- import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, rmSync } from 'fs';
5
- import { resolve } from 'path';
4
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, rmSync, readdirSync, cpSync } from 'fs';
5
+ import { resolve, basename } from 'path';
6
6
  import { randomUUID } from 'crypto';
7
7
  import { validateTeamName } from './validate.js';
8
8
 
@@ -162,17 +162,39 @@ export class Teams {
162
162
  this.daemon.registry.remove(agent.id);
163
163
  }
164
164
 
165
- // Remove the team's working directory refuse to nuke the project root
166
- // (legacy default teams that were never migrated point there).
165
+ // Archive the team's working directory instead of deleting it
167
166
  if (
168
167
  team.workingDir &&
169
168
  team.workingDir !== this.daemon.projectDir &&
170
169
  existsSync(team.workingDir)
171
170
  ) {
172
171
  try {
173
- rmSync(team.workingDir, { recursive: true, force: true });
172
+ const archiveDir = resolve(this.daemon.grooveDir, 'archived-teams');
173
+ mkdirSync(archiveDir, { recursive: true });
174
+ const slug = basename(team.workingDir);
175
+ const archiveName = `${slug}-${Date.now()}`;
176
+ const archivePath = resolve(archiveDir, archiveName);
177
+
178
+ try {
179
+ renameSync(team.workingDir, archivePath);
180
+ } catch (err) {
181
+ if (err.code === 'EXDEV') {
182
+ cpSync(team.workingDir, archivePath, { recursive: true });
183
+ rmSync(team.workingDir, { recursive: true, force: true });
184
+ } else {
185
+ throw err;
186
+ }
187
+ }
188
+
189
+ const metadata = {
190
+ originalName: team.name,
191
+ originalId: team.id,
192
+ deletedAt: new Date().toISOString(),
193
+ agentCount: agents.length,
194
+ };
195
+ writeFileSync(resolve(archivePath, 'metadata.json'), JSON.stringify(metadata, null, 2));
174
196
  } catch (err) {
175
- console.log(`[Groove:Teams] Failed to remove directory: ${err.message}`);
197
+ console.log(`[Groove:Teams] Failed to archive directory: ${err.message}`);
176
198
  }
177
199
  }
178
200
 
@@ -193,6 +215,78 @@ export class Teams {
193
215
  return true;
194
216
  }
195
217
 
218
+ listArchived() {
219
+ const archiveDir = resolve(this.daemon.grooveDir, 'archived-teams');
220
+ if (!existsSync(archiveDir)) return [];
221
+ const entries = readdirSync(archiveDir, { withFileTypes: true });
222
+ const result = [];
223
+ for (const entry of entries) {
224
+ if (!entry.isDirectory()) continue;
225
+ const metaPath = resolve(archiveDir, entry.name, 'metadata.json');
226
+ try {
227
+ const meta = JSON.parse(readFileSync(metaPath, 'utf8'));
228
+ result.push({ id: entry.name, ...meta });
229
+ } catch {
230
+ result.push({ id: entry.name, originalName: entry.name, deletedAt: null, agentCount: 0 });
231
+ }
232
+ }
233
+ return result;
234
+ }
235
+
236
+ restore(archivedId) {
237
+ const archiveDir = resolve(this.daemon.grooveDir, 'archived-teams');
238
+ const archivePath = resolve(archiveDir, archivedId);
239
+ if (!existsSync(archivePath)) throw new Error('Archived team not found');
240
+
241
+ let meta = {};
242
+ const metaPath = resolve(archivePath, 'metadata.json');
243
+ try { meta = JSON.parse(readFileSync(metaPath, 'utf8')); } catch { /* use defaults */ }
244
+
245
+ const name = meta.originalName || archivedId;
246
+ const dirName = slugify(name);
247
+ let workingDir = resolve(this.daemon.projectDir, dirName);
248
+
249
+ if (existsSync(workingDir)) {
250
+ workingDir = resolve(this.daemon.projectDir, `${dirName}-${Date.now()}`);
251
+ }
252
+
253
+ try {
254
+ renameSync(archivePath, workingDir);
255
+ } catch (err) {
256
+ if (err.code === 'EXDEV') {
257
+ cpSync(archivePath, workingDir, { recursive: true });
258
+ rmSync(archivePath, { recursive: true, force: true });
259
+ } else {
260
+ throw err;
261
+ }
262
+ }
263
+
264
+ // Remove the metadata file from the restored directory
265
+ const restoredMetaPath = resolve(workingDir, 'metadata.json');
266
+ try { rmSync(restoredMetaPath); } catch { /* may not exist */ }
267
+
268
+ const id = randomUUID().slice(0, 8);
269
+ const team = {
270
+ id,
271
+ name,
272
+ isDefault: false,
273
+ workingDir,
274
+ createdAt: new Date().toISOString(),
275
+ };
276
+ this.teams.set(id, team);
277
+ this._save();
278
+ this.daemon.broadcast({ type: 'team:created', team });
279
+ return team;
280
+ }
281
+
282
+ purge(archivedId) {
283
+ const archiveDir = resolve(this.daemon.grooveDir, 'archived-teams');
284
+ const archivePath = resolve(archiveDir, archivedId);
285
+ if (!existsSync(archivePath)) throw new Error('Archived team not found');
286
+ rmSync(archivePath, { recursive: true, force: true });
287
+ return true;
288
+ }
289
+
196
290
  // Migrate old agents (teamName but no teamId) to default team
197
291
  migrateAgents() {
198
292
  const defaultTeam = this.getDefault();