groove-dev 0.27.113 → 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 (66) 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/node_modules/@groove-dev/cli/package.json +1 -1
  5. package/node_modules/@groove-dev/cli/src/commands/team.js +43 -1
  6. package/node_modules/@groove-dev/daemon/package.json +1 -1
  7. package/node_modules/@groove-dev/daemon/src/api.js +24 -0
  8. package/node_modules/@groove-dev/daemon/src/filewatcher.js +45 -0
  9. package/node_modules/@groove-dev/daemon/src/index.js +14 -2
  10. package/node_modules/@groove-dev/daemon/src/teams.js +100 -6
  11. package/node_modules/@groove-dev/daemon/src/tunnel-manager.js +75 -43
  12. package/node_modules/@groove-dev/gui/dist/assets/{index-BYh6iHqL.js → index-BKCiOUDb.js} +593 -593
  13. package/node_modules/@groove-dev/gui/dist/assets/index-D4Q72afD.css +1 -0
  14. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  15. package/node_modules/@groove-dev/gui/package.json +1 -1
  16. package/node_modules/@groove-dev/gui/src/components/agents/workspace-mode.jsx +0 -22
  17. package/node_modules/@groove-dev/gui/src/components/layout/status-bar.jsx +43 -45
  18. package/node_modules/@groove-dev/gui/src/components/settings/quick-connect.jsx +2 -1
  19. package/node_modules/@groove-dev/gui/src/stores/groove.js +42 -8
  20. package/node_modules/@groove-dev/gui/src/views/agents.jsx +31 -3
  21. package/node_modules/@groove-dev/gui/src/views/editor.jsx +1 -20
  22. package/node_modules/@groove-dev/gui/src/views/teams.jsx +106 -3
  23. package/package.json +1 -1
  24. package/packages/cli/package.json +1 -1
  25. package/packages/cli/src/commands/team.js +43 -1
  26. package/packages/daemon/package.json +1 -1
  27. package/packages/daemon/src/api.js +24 -0
  28. package/packages/daemon/src/filewatcher.js +45 -0
  29. package/packages/daemon/src/index.js +14 -2
  30. package/packages/daemon/src/teams.js +100 -6
  31. package/packages/daemon/src/tunnel-manager.js +75 -43
  32. package/packages/gui/dist/assets/{index-BYh6iHqL.js → index-BKCiOUDb.js} +593 -593
  33. package/packages/gui/dist/assets/index-D4Q72afD.css +1 -0
  34. package/packages/gui/dist/index.html +2 -2
  35. package/packages/gui/package.json +1 -1
  36. package/packages/gui/src/components/agents/workspace-mode.jsx +0 -22
  37. package/packages/gui/src/components/layout/status-bar.jsx +43 -45
  38. package/packages/gui/src/components/settings/quick-connect.jsx +2 -1
  39. package/packages/gui/src/stores/groove.js +42 -8
  40. package/packages/gui/src/views/agents.jsx +31 -3
  41. package/packages/gui/src/views/editor.jsx +1 -20
  42. package/packages/gui/src/views/teams.jsx +106 -3
  43. package/TRAINING_DATA_v3.md +0 -11
  44. package/codex-test/offroad-nitro-racer/dist/assets/index-CuvdKK6U.js +0 -44
  45. package/codex-test/offroad-nitro-racer/dist/assets/index-DvHn2Thu.css +0 -1
  46. package/codex-test/offroad-nitro-racer/dist/index.html +0 -23
  47. package/codex-test/offroad-nitro-racer/index.html +0 -21
  48. package/codex-test/offroad-nitro-racer/package-lock.json +0 -841
  49. package/codex-test/offroad-nitro-racer/package.json +0 -15
  50. package/codex-test/offroad-nitro-racer/src/game/AI.ts +0 -28
  51. package/codex-test/offroad-nitro-racer/src/game/Audio.ts +0 -63
  52. package/codex-test/offroad-nitro-racer/src/game/Car.ts +0 -247
  53. package/codex-test/offroad-nitro-racer/src/game/Effects.ts +0 -62
  54. package/codex-test/offroad-nitro-racer/src/game/Game.ts +0 -229
  55. package/codex-test/offroad-nitro-racer/src/game/Input.ts +0 -45
  56. package/codex-test/offroad-nitro-racer/src/game/Renderer.ts +0 -224
  57. package/codex-test/offroad-nitro-racer/src/game/Track.ts +0 -158
  58. package/codex-test/offroad-nitro-racer/src/game/UI.ts +0 -96
  59. package/codex-test/offroad-nitro-racer/src/game/math.ts +0 -42
  60. package/codex-test/offroad-nitro-racer/src/main.ts +0 -24
  61. package/codex-test/offroad-nitro-racer/src/style.css +0 -291
  62. package/codex-test/offroad-nitro-racer/src/vite-env.d.ts +0 -1
  63. package/codex-test/offroad-nitro-racer/tsconfig.json +0 -18
  64. package/codex-test/offroad-nitro-racer/vite.config.ts +0 -7
  65. package/node_modules/@groove-dev/gui/dist/assets/index-DAlSbVyK.css +0 -1
  66. package/packages/gui/dist/assets/index-DAlSbVyK.css +0 -1
@@ -10,7 +10,7 @@ import { RootNode } from '../components/agents/root-node';
10
10
  import { cn } from '../lib/cn';
11
11
  import { Button } from '../components/ui/button';
12
12
  import { Badge } from '../components/ui/badge';
13
- import { Plus, Users, UserPlus, Zap, X, Check, Rocket, Server, Monitor, Code2, TestTube, Shield, Pencil, Copy, Trash2, ChevronDown, ChevronLeft, ChevronRight, FolderOpen, Eye, Settings2, Search, GripVertical, Cloud, FileText, Database, Megaphone, Calculator, UserCheck, Headphones, BarChart3, Pen, Presentation, Globe, MessageCircle, Save, Layers } from 'lucide-react';
13
+ import { Plus, Users, UserPlus, Zap, X, Check, Rocket, Server, Monitor, Code2, TestTube, Shield, Pencil, Copy, Trash2, ChevronDown, ChevronLeft, ChevronRight, FolderOpen, Eye, Settings2, Search, GripVertical, Cloud, FileText, Database, Megaphone, Calculator, UserCheck, Headphones, BarChart3, Pen, Presentation, Globe, MessageCircle, Save, Layers, Archive } from 'lucide-react';
14
14
  import { PreviewWorkspace } from '../components/preview/preview-workspace';
15
15
  import { WorkspaceMode } from '../components/agents/workspace-mode';
16
16
  import { ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuItem, ContextMenuSeparator } from '../components/ui/context-menu';
@@ -88,11 +88,13 @@ export function TeamTabBar() {
88
88
  const renameTeam = useGrooveStore((s) => s.renameTeam);
89
89
  const cloneTeam = useGrooveStore((s) => s.cloneTeam);
90
90
  const reorderTeams = useGrooveStore((s) => s.reorderTeams);
91
+ const addToast = useGrooveStore((s) => s.addToast);
91
92
 
92
93
  const [creating, setCreating] = useState(false);
93
94
  const [newName, setNewName] = useState('');
94
95
  const [renamingId, setRenamingId] = useState(null);
95
96
  const [renameValue, setRenameValue] = useState('');
97
+ const [archiveConfirm, setArchiveConfirm] = useState(null);
96
98
  const submitting = useRef(false);
97
99
  const [dragId, setDragId] = useState(null);
98
100
  const [dragOverId, setDragOverId] = useState(null);
@@ -247,8 +249,15 @@ export function TeamTabBar() {
247
249
  <Copy size={12} /> Clone
248
250
  </ContextMenuItem>
249
251
  <ContextMenuSeparator />
250
- <ContextMenuItem danger onSelect={() => deleteTeam(team.id)}>
251
- <Trash2 size={12} /> {team.isDefault ? 'Wipe' : 'Delete'}
252
+ <ContextMenuItem danger onSelect={() => {
253
+ const teamAgents = agents.filter((a) => a.teamId === team.id);
254
+ if (teamAgents.some((a) => a.status === 'running' || a.status === 'starting')) {
255
+ addToast('error', 'Stop running agents first');
256
+ return;
257
+ }
258
+ setArchiveConfirm(team);
259
+ }}>
260
+ <Trash2 size={12} /> {team.isDefault ? 'Wipe' : 'Archive'}
252
261
  </ContextMenuItem>
253
262
  </ContextMenuContent>
254
263
  </ContextMenu>
@@ -295,6 +304,25 @@ export function TeamTabBar() {
295
304
  <ChevronRight size={14} />
296
305
  </button>
297
306
  )}
307
+
308
+ <Dialog open={!!archiveConfirm} onOpenChange={(open) => !open && setArchiveConfirm(null)}>
309
+ <DialogContent title="Archive Team" description="Confirm team archival">
310
+ <div className="px-5 py-4 space-y-3">
311
+ <p className="text-sm text-text-1 font-sans">
312
+ This will archive <span className="font-semibold text-text-0">{archiveConfirm?.name}</span>.
313
+ </p>
314
+ <p className="text-xs text-text-2 font-sans">
315
+ All files will be preserved and can be restored later from the Archived Teams section.
316
+ </p>
317
+ </div>
318
+ <div className="px-5 py-4 border-t border-border-subtle flex justify-end gap-2">
319
+ <Button variant="ghost" size="sm" onClick={() => setArchiveConfirm(null)}>Cancel</Button>
320
+ <Button variant="danger" size="sm" onClick={() => { deleteTeam(archiveConfirm.id); setArchiveConfirm(null); }}>
321
+ <Archive size={12} /> Archive
322
+ </Button>
323
+ </div>
324
+ </DialogContent>
325
+ </Dialog>
298
326
  </div>
299
327
  );
300
328
  }
@@ -8,8 +8,7 @@ import { MediaViewer, isMediaFile } from '../components/editor/media-viewer';
8
8
  import { EditorStatusBar } from '../components/editor/editor-status-bar';
9
9
  import { GotoLine } from '../components/editor/goto-line';
10
10
  import { Breadcrumbs } from '../components/editor/breadcrumbs';
11
- import { Code2, AlertTriangle, RefreshCw, X, Eye, FileCode, PanelLeftOpen } from 'lucide-react';
12
- import { Button } from '../components/ui/button';
11
+ import { Code2, Eye, FileCode, PanelLeftOpen } from 'lucide-react';
13
12
  import { api } from '../lib/api';
14
13
  import { cn } from '../lib/cn';
15
14
 
@@ -25,11 +24,8 @@ const SIDEBAR_MAX = 400;
25
24
  export default function EditorView() {
26
25
  const activeFile = useGrooveStore((s) => s.editorActiveFile);
27
26
  const files = useGrooveStore((s) => s.editorFiles);
28
- const changedFiles = useGrooveStore((s) => s.editorChangedFiles);
29
27
  const updateFileContent = useGrooveStore((s) => s.updateFileContent);
30
28
  const saveFile = useGrooveStore((s) => s.saveFile);
31
- const reloadFile = useGrooveStore((s) => s.reloadFile);
32
- const dismissFileChange = useGrooveStore((s) => s.dismissFileChange);
33
29
  const sidebarWidth = useGrooveStore((s) => s.editorSidebarWidth);
34
30
  const setSidebarWidth = useGrooveStore((s) => s.setEditorSidebarWidth);
35
31
 
@@ -104,7 +100,6 @@ export default function EditorView() {
104
100
  const file = activeFile ? files[activeFile] : null;
105
101
  const isMedia = activeFile && isMediaFile(activeFile);
106
102
  const isHtml = activeFile && isHtmlFile(activeFile);
107
- const hasExternalChange = activeFile && changedFiles[activeFile];
108
103
 
109
104
  return (
110
105
  <div className="flex h-full">
@@ -151,20 +146,6 @@ export default function EditorView() {
151
146
  />
152
147
  )}
153
148
 
154
- {/* External change banner */}
155
- {hasExternalChange && (
156
- <div className="absolute top-0 left-0 right-0 z-10 flex items-center gap-2 px-4 py-2 bg-warning/10 border-b border-warning/20">
157
- <AlertTriangle size={14} className="text-warning" />
158
- <span className="text-xs text-warning font-sans flex-1">File modified externally</span>
159
- <Button variant="ghost" size="sm" onClick={() => reloadFile(activeFile)}>
160
- <RefreshCw size={12} /> Reload
161
- </Button>
162
- <Button variant="ghost" size="sm" onClick={() => dismissFileChange(activeFile)}>
163
- <X size={12} /> Dismiss
164
- </Button>
165
- </div>
166
- )}
167
-
168
149
  {/* Editor / Media / Empty */}
169
150
  {!activeFile && (
170
151
  <div className="w-full h-full flex items-center justify-center text-text-4 font-sans">
@@ -5,6 +5,7 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '../components/ui/tabs'
5
5
  import { Button } from '../components/ui/button';
6
6
  import { Badge } from '../components/ui/badge';
7
7
  import { StatusDot } from '../components/ui/status-dot';
8
+ import { Dialog, DialogContent } from '../components/ui/dialog';
8
9
  import { api } from '../lib/api';
9
10
  import { useToast } from '../lib/hooks/use-toast';
10
11
  import { fmtNum, fmtDollar, timeAgo, fmtUptime } from '../lib/format';
@@ -12,6 +13,7 @@ import { cn } from '../lib/cn';
12
13
  import {
13
14
  Clock, CheckCircle, XCircle, AlertTriangle, ShieldCheck, ShieldX,
14
15
  Users, Folder, Cpu, Trash2, Play, Pause, LayoutDashboard, ListChecks, Calendar,
16
+ Archive, RotateCcw, ChevronRight,
15
17
  } from 'lucide-react';
16
18
 
17
19
  // ── Team Dashboard ────────────────────────────────────────────
@@ -21,8 +23,18 @@ function TeamsDashboard() {
21
23
  const activeTeamId = useGrooveStore((s) => s.activeTeamId);
22
24
  const deleteTeam = useGrooveStore((s) => s.deleteTeam);
23
25
  const addToast = useGrooveStore((s) => s.addToast);
26
+ const archivedTeams = useGrooveStore((s) => s.archivedTeams);
27
+ const fetchArchivedTeams = useGrooveStore((s) => s.fetchArchivedTeams);
28
+ const restoreTeam = useGrooveStore((s) => s.restoreTeam);
29
+ const purgeTeam = useGrooveStore((s) => s.purgeTeam);
24
30
 
25
- if (teams.length === 0) {
31
+ const [archiveConfirm, setArchiveConfirm] = useState(null);
32
+ const [purgeConfirm, setPurgeConfirm] = useState(null);
33
+ const [archivedOpen, setArchivedOpen] = useState(false);
34
+
35
+ useEffect(() => { fetchArchivedTeams(); }, []);
36
+
37
+ if (teams.length === 0 && archivedTeams.length === 0) {
26
38
  return (
27
39
  <div className="flex-1 flex items-center justify-center">
28
40
  <div className="text-center space-y-2">
@@ -74,10 +86,10 @@ function TeamsDashboard() {
74
86
  addToast('error', 'Stop running agents first');
75
87
  return;
76
88
  }
77
- deleteTeam(team.id);
89
+ setArchiveConfirm(team);
78
90
  }}
79
91
  className="p-1.5 text-text-4 hover:text-danger rounded transition-colors cursor-pointer"
80
- title="Delete team"
92
+ title="Archive team"
81
93
  >
82
94
  <Trash2 size={13} />
83
95
  </button>
@@ -112,6 +124,97 @@ function TeamsDashboard() {
112
124
  );
113
125
  })}
114
126
  </div>
127
+
128
+ {/* Archived Teams */}
129
+ {archivedTeams.length > 0 && (
130
+ <div className="border-t border-border-subtle">
131
+ <button
132
+ onClick={() => setArchivedOpen(!archivedOpen)}
133
+ className="w-full flex items-center gap-2 px-5 py-3 text-left cursor-pointer hover:bg-surface-5/30 transition-colors"
134
+ >
135
+ <ChevronRight
136
+ size={12}
137
+ className={cn('text-text-4 transition-transform duration-200', archivedOpen && 'rotate-90')}
138
+ />
139
+ <Archive size={13} className="text-text-3" />
140
+ <span className="text-xs font-semibold text-text-2 font-sans uppercase tracking-wider flex-1">
141
+ Archived Teams
142
+ </span>
143
+ <span className="text-2xs font-mono text-text-4 bg-surface-4 px-1.5 py-0.5 rounded">
144
+ {archivedTeams.length}
145
+ </span>
146
+ </button>
147
+ {archivedOpen && (
148
+ <div className="px-4 pb-4 space-y-2">
149
+ {archivedTeams.map((at) => (
150
+ <div key={at.id} className="flex items-center gap-3 px-3 py-2.5 rounded-md bg-surface-0 border border-border-subtle">
151
+ <Archive size={13} className="text-text-4 flex-shrink-0" />
152
+ <div className="flex-1 min-w-0">
153
+ <span className="text-xs font-semibold text-text-1 font-sans">{at.originalName || at.name}</span>
154
+ {(at.deletedAt || at.archivedAt) && (
155
+ <div className="text-2xs text-text-4 font-mono mt-0.5">Archived {timeAgo(at.deletedAt || at.archivedAt)}</div>
156
+ )}
157
+ </div>
158
+ <button
159
+ onClick={() => restoreTeam(at.id)}
160
+ className="p-1.5 text-text-3 hover:text-accent rounded transition-colors cursor-pointer"
161
+ title="Restore team"
162
+ >
163
+ <RotateCcw size={13} />
164
+ </button>
165
+ <button
166
+ onClick={() => setPurgeConfirm(at)}
167
+ className="p-1.5 text-text-4 hover:text-danger rounded transition-colors cursor-pointer"
168
+ title="Permanently delete"
169
+ >
170
+ <Trash2 size={13} />
171
+ </button>
172
+ </div>
173
+ ))}
174
+ </div>
175
+ )}
176
+ </div>
177
+ )}
178
+
179
+ {/* Archive confirmation dialog */}
180
+ <Dialog open={!!archiveConfirm} onOpenChange={(open) => !open && setArchiveConfirm(null)}>
181
+ <DialogContent title="Archive Team" description="Confirm team archival">
182
+ <div className="px-5 py-4 space-y-3">
183
+ <p className="text-sm text-text-1 font-sans">
184
+ This will archive <span className="font-semibold text-text-0">{archiveConfirm?.name}</span>.
185
+ </p>
186
+ <p className="text-xs text-text-2 font-sans">
187
+ All files will be preserved and can be restored later from the Archived Teams section.
188
+ </p>
189
+ </div>
190
+ <div className="px-5 py-4 border-t border-border-subtle flex justify-end gap-2">
191
+ <Button variant="ghost" size="sm" onClick={() => setArchiveConfirm(null)}>Cancel</Button>
192
+ <Button variant="danger" size="sm" onClick={() => { deleteTeam(archiveConfirm.id); setArchiveConfirm(null); }}>
193
+ <Archive size={12} /> Archive
194
+ </Button>
195
+ </div>
196
+ </DialogContent>
197
+ </Dialog>
198
+
199
+ {/* Purge confirmation dialog */}
200
+ <Dialog open={!!purgeConfirm} onOpenChange={(open) => !open && setPurgeConfirm(null)}>
201
+ <DialogContent title="Permanently Delete" description="Confirm permanent deletion">
202
+ <div className="px-5 py-4 space-y-3">
203
+ <p className="text-sm text-text-1 font-sans">
204
+ Permanently delete <span className="font-semibold text-text-0">{purgeConfirm?.originalName || purgeConfirm?.name}</span>?
205
+ </p>
206
+ <p className="text-xs text-danger font-sans">
207
+ This cannot be undone. All team files will be permanently removed.
208
+ </p>
209
+ </div>
210
+ <div className="px-5 py-4 border-t border-border-subtle flex justify-end gap-2">
211
+ <Button variant="ghost" size="sm" onClick={() => setPurgeConfirm(null)}>Cancel</Button>
212
+ <Button variant="danger" size="sm" onClick={() => { purgeTeam(purgeConfirm.id); setPurgeConfirm(null); }}>
213
+ <Trash2 size={12} /> Delete Forever
214
+ </Button>
215
+ </div>
216
+ </DialogContent>
217
+ </Dialog>
115
218
  </div>
116
219
  );
117
220
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "groove-dev",
3
- "version": "0.27.113",
3
+ "version": "0.27.115",
4
4
  "description": "Open-source agent orchestration layer — the AI company OS. Local model agent engine (GGUF/Ollama/llama-server), HuggingFace model browser, MCP integrations (Slack, Gmail, Stripe, 15+), agent scheduling (cron), business roles (CMO, CFO, EA). GUI dashboard, multi-agent coordination, zero cold-start, infinite sessions. Works with Claude Code, Codex, Gemini CLI, Ollama, any local model.",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "author": "Groove Dev <hello@groovedev.ai> (https://groovedev.ai)",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.113",
3
+ "version": "0.27.115",
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,9 +1,17 @@
1
1
  // GROOVE CLI — team commands
2
2
  // FSL-1.1-Apache-2.0 — see LICENSE
3
3
 
4
+ import { createInterface } from 'readline';
4
5
  import chalk from 'chalk';
5
6
  import { apiCall } from '../client.js';
6
7
 
8
+ function confirm(prompt) {
9
+ return new Promise((resolve) => {
10
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
11
+ rl.question(prompt, (answer) => { rl.close(); resolve(answer.trim().toLowerCase() === 'y'); });
12
+ });
13
+ }
14
+
7
15
  export async function teamCreate(name) {
8
16
  try {
9
17
  const team = await apiCall('POST', '/api/teams', { name });
@@ -37,9 +45,43 @@ export async function teamList() {
37
45
  }
38
46
 
39
47
  export async function teamDelete(id) {
48
+ const ok = await confirm(` This will archive the team directory. Continue? [y/N] `);
49
+ if (!ok) {
50
+ console.log(chalk.dim(' Cancelled.'));
51
+ return;
52
+ }
40
53
  try {
41
54
  await apiCall('DELETE', `/api/teams/${encodeURIComponent(id)}`);
42
- console.log(chalk.green(` Deleted team "${id}"`));
55
+ console.log(chalk.green(` Archived team "${id}"`) + chalk.dim(' — restore with `groove team restore <id>`'));
56
+ } catch (err) {
57
+ console.error(chalk.red(' Failed:'), err.message);
58
+ process.exit(1);
59
+ }
60
+ }
61
+
62
+ export async function teamArchived() {
63
+ try {
64
+ const { archived } = await apiCall('GET', '/api/teams/archived');
65
+ if (archived.length === 0) {
66
+ console.log(chalk.dim(' No archived teams.'));
67
+ return;
68
+ }
69
+ console.log(chalk.bold(`\n Archived Teams (${archived.length})\n`));
70
+ for (const t of archived) {
71
+ const date = t.deletedAt ? new Date(t.deletedAt).toLocaleDateString() : 'unknown';
72
+ console.log(` ${chalk.bold(t.originalName || t.id)} — archive-id: ${t.id} — deleted ${date} — ${t.agentCount} agent(s)`);
73
+ }
74
+ console.log('');
75
+ } catch {
76
+ console.error(chalk.red(' Cannot connect to daemon.'));
77
+ process.exit(1);
78
+ }
79
+ }
80
+
81
+ export async function teamRestore(id) {
82
+ try {
83
+ const team = await apiCall('POST', `/api/teams/archived/${encodeURIComponent(id)}/restore`);
84
+ console.log(chalk.green(` Restored team "${team.name}"`) + ` (new id: ${team.id})`);
43
85
  } catch (err) {
44
86
  console.error(chalk.red(' Failed:'), err.message);
45
87
  process.exit(1);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/daemon",
3
- "version": "0.27.113",
3
+ "version": "0.27.115",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -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);
@@ -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
  }
@@ -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
  });
@@ -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();