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
@@ -39,6 +39,7 @@ export const useGrooveStore = create((set, get) => ({
39
39
 
40
40
  // ── Teams ─────────────────────────────────────────────────
41
41
  teams: [],
42
+ archivedTeams: [],
42
43
  activeTeamId: localStorage.getItem('groove:activeTeamId') || null,
43
44
 
44
45
  // ── Gateways ──────────────────────────────────────────────
@@ -171,7 +172,6 @@ export const useGrooveStore = create((set, get) => ({
171
172
 
172
173
  // ── Tunnels ────────────────────────────────────────────────
173
174
  savedTunnels: [],
174
- activeTunnelId: null,
175
175
 
176
176
  // ── GitHub Repo Import ────────────────────────────────────
177
177
  importedRepos: [],
@@ -224,6 +224,8 @@ export const useGrooveStore = create((set, get) => ({
224
224
  get().fetchBetaStatus();
225
225
  get().fetchNetworkInstallStatus();
226
226
  get().fetchTrainingStatus();
227
+ get().fetchActivePreviews();
228
+ ws.send(JSON.stringify({ type: 'editor:watchdir', path: '' }));
227
229
  if (!get().onboardingComplete) get().fetchOnboardingStatus();
228
230
  if (window.groove?.auth?.onSubscriptionStatus) {
229
231
  window.groove.auth.onSubscriptionStatus((data) => {
@@ -611,7 +613,6 @@ export const useGrooveStore = create((set, get) => ({
611
613
  case 'file:changed': {
612
614
  const savedAt = get().editorRecentSaves[msg.path];
613
615
  if (savedAt && Date.now() - savedAt < 2000) break;
614
- set((s) => ({ editorChangedFiles: { ...s.editorChangedFiles, [msg.path]: msg.timestamp } }));
615
616
  // Auto-capture workspace snapshot for diff viewer
616
617
  if (get().workspaceMode && msg.path && !get().workspaceSnapshots[msg.path]) {
617
618
  const existing = get().editorFiles[msg.path];
@@ -619,6 +620,14 @@ export const useGrooveStore = create((set, get) => ({
619
620
  get().captureSnapshot(msg.path, existing.content);
620
621
  }
621
622
  }
623
+ if (get().editorFiles[msg.path]) {
624
+ get().reloadFile(msg.path);
625
+ }
626
+ break;
627
+ }
628
+
629
+ case 'file:tree-changed': {
630
+ get().fetchTreeDir(msg.path || '');
622
631
  break;
623
632
  }
624
633
 
@@ -694,12 +703,10 @@ export const useGrooveStore = create((set, get) => ({
694
703
  break;
695
704
 
696
705
  case 'tunnel.connected':
697
- set({ activeTunnelId: msg.data?.id || null });
698
706
  get().fetchTunnels();
699
707
  break;
700
708
 
701
709
  case 'tunnel.disconnected':
702
- set({ activeTunnelId: null });
703
710
  get().fetchTunnels();
704
711
  break;
705
712
 
@@ -1164,8 +1171,9 @@ export const useGrooveStore = create((set, get) => ({
1164
1171
  // WS team:deleted handler removes from array and switches activeTeamId.
1165
1172
  // Deleting the default team regenerates a fresh one server-side; the
1166
1173
  // team:created event arrives separately so the list stays populated.
1167
- const wiped = team?.isDefault ? 'wiped' : 'deleted';
1168
- get().addToast('info', `Team "${team?.name}" ${wiped}`);
1174
+ const wiped = team?.isDefault ? 'wiped' : 'archived';
1175
+ get().addToast('success', `Team "${team?.name}" ${wiped}`, wiped === 'archived' ? 'Files preserved — restore anytime from Archived Teams' : undefined);
1176
+ get().fetchArchivedTeams();
1169
1177
  } catch (err) {
1170
1178
  get().addToast('error', 'Failed to delete team', err.message);
1171
1179
  }
@@ -1179,6 +1187,33 @@ export const useGrooveStore = create((set, get) => ({
1179
1187
  try { localStorage.setItem('groove:teamOrder', JSON.stringify(teams.map((t) => t.id))); } catch {}
1180
1188
  },
1181
1189
 
1190
+ async fetchArchivedTeams() {
1191
+ try {
1192
+ const data = await api.get('/teams/archived');
1193
+ set({ archivedTeams: data.archived || data.teams || [] });
1194
+ } catch { /* endpoint may not exist yet */ }
1195
+ },
1196
+
1197
+ async restoreTeam(archivedId) {
1198
+ try {
1199
+ await api.post(`/teams/archived/${encodeURIComponent(archivedId)}/restore`);
1200
+ get().addToast('success', 'Team restored');
1201
+ get().fetchArchivedTeams();
1202
+ } catch (err) {
1203
+ get().addToast('error', 'Failed to restore team', err.message);
1204
+ }
1205
+ },
1206
+
1207
+ async purgeTeam(archivedId) {
1208
+ try {
1209
+ await api.delete(`/teams/archived/${encodeURIComponent(archivedId)}`);
1210
+ get().addToast('info', 'Archived team permanently deleted');
1211
+ get().fetchArchivedTeams();
1212
+ } catch (err) {
1213
+ get().addToast('error', 'Failed to purge team', err.message);
1214
+ }
1215
+ },
1216
+
1182
1217
  async cloneTeam(id) {
1183
1218
  const team = get().teams.find((t) => t.id === id);
1184
1219
  if (!team) return;
@@ -1261,6 +1296,20 @@ export const useGrooveStore = create((set, get) => ({
1261
1296
 
1262
1297
  // ── Preview ──────────────────────────────────────────────
1263
1298
 
1299
+ async fetchActivePreviews() {
1300
+ try {
1301
+ const data = await api.get('/preview');
1302
+ const previews = data.previews || [];
1303
+ if (previews.length > 0) {
1304
+ const p = previews.sort((a, b) => (b.startedAt || 0) - (a.startedAt || 0))[0];
1305
+ set({
1306
+ previewState: { url: `/api/preview/${p.teamId}/proxy/`, teamId: p.teamId, kind: p.kind, deviceSize: 'desktop', screenshotMode: false },
1307
+ showPreviewInAgents: true,
1308
+ });
1309
+ }
1310
+ } catch {}
1311
+ },
1312
+
1264
1313
  openPreview(url, teamId, kind) {
1265
1314
  set({ previewState: { url, teamId, kind, deviceSize: 'desktop', screenshotMode: false }, previewChat: [], showPreviewInAgents: true });
1266
1315
  },
@@ -1853,7 +1902,6 @@ export const useGrooveStore = create((set, get) => ({
1853
1902
 
1854
1903
  async connectTunnel(id) {
1855
1904
  const result = await api.post(`/tunnels/${encodeURIComponent(id)}/connect`);
1856
- set({ activeTunnelId: id });
1857
1905
  get().fetchTunnels();
1858
1906
  if (result.localPort && result.name) {
1859
1907
  if (window.groove?.remote?.openWindow) {
@@ -1872,7 +1920,6 @@ export const useGrooveStore = create((set, get) => ({
1872
1920
  async disconnectTunnel(id) {
1873
1921
  const tunnel = get().savedTunnels.find(t => t.id === id);
1874
1922
  await api.post(`/tunnels/${encodeURIComponent(id)}/disconnect`);
1875
- set({ activeTunnelId: null });
1876
1923
  get().fetchTunnels();
1877
1924
  if (tunnel?.localPort && window.groove?.remote?.closeByPort) {
1878
1925
  window.groove.remote.closeByPort(tunnel.localPort);
@@ -2495,6 +2542,8 @@ export const useGrooveStore = create((set, get) => ({
2495
2542
  try {
2496
2543
  const data = await api.get(`/files/tree?path=${encodeURIComponent(dirPath)}`);
2497
2544
  set((s) => ({ editorTreeCache: { ...s.editorTreeCache, [dirPath]: data.entries || [] } }));
2545
+ const ws = get().ws;
2546
+ if (ws?.readyState === 1) ws.send(JSON.stringify({ type: 'editor:watchdir', path: dirPath }));
2498
2547
  } catch (err) {
2499
2548
  console.error('[file-tree] fetchTreeDir failed for', dirPath, err.message);
2500
2549
  set((s) => ({ editorTreeCache: { ...s.editorTreeCache, [dirPath]: [] } }));
@@ -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
  }
@@ -57,15 +57,15 @@ export class CodexParser {
57
57
  if (item.type === 'command_execution') {
58
58
  const rawOutput = item.aggregated_output || '';
59
59
  if (item.exit_code !== 0) {
60
- return { type: 'error', content: rawOutput.slice(0, 2000) || `Exit code: ${item.exit_code}` };
60
+ return { type: 'error', is_error: true, content: rawOutput.slice(0, 2000) || `Exit code: ${item.exit_code}` };
61
61
  }
62
62
  const obs = truncateObservation(rawOutput);
63
- return { type: 'observation', content: obs.content, truncated: obs.truncated, original_token_count: obs.original_token_count };
63
+ return { type: 'observation', is_error: false, content: obs.content, truncated: obs.truncated, original_token_count: obs.original_token_count };
64
64
  }
65
65
  if (item.type === 'file_edit' || item.type === 'file_write' || item.type === 'file_read') {
66
66
  const rawOutput = item.output || item.content || '';
67
67
  const obs = truncateObservation(rawOutput);
68
- return { type: 'observation', content: obs.content, truncated: obs.truncated, original_token_count: obs.original_token_count };
68
+ return { type: 'observation', is_error: false, content: obs.content, truncated: obs.truncated, original_token_count: obs.original_token_count };
69
69
  }
70
70
  return null;
71
71
  }
@@ -64,11 +64,11 @@ export class GeminiParser {
64
64
  const contentParts = Array.isArray(rawContent) ? rawContent : (typeof rawContent === 'string' ? [{ text: rawContent }] : rawContent ? [rawContent] : []);
65
65
  const rawText = contentParts.map((p) => p.text || '').join('');
66
66
  const obs = truncateObservation(rawText);
67
- return { type: 'observation', content: obs.content, truncated: obs.truncated, original_token_count: obs.original_token_count };
67
+ return { type: 'observation', is_error: false, content: obs.content, truncated: obs.truncated, original_token_count: obs.original_token_count };
68
68
  }
69
69
 
70
70
  case 'error': {
71
- return { type: 'error', content: jsonEvent.message || 'Unknown error' };
71
+ return { type: 'error', is_error: true, content: jsonEvent.message || 'Unknown error' };
72
72
  }
73
73
 
74
74
  case 'agent_end': {
@@ -1,6 +1,6 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
2
 
3
- const ERROR_SIGNAL_RE = /\b(?:error|Error|ERROR|exception|Exception|EXCEPTION|failed|FAILED|exit code [1-9]|ENOENT|EACCES|EPERM|TypeError|ReferenceError|SyntaxError|Cannot find|Module not found|Command failed|non-zero exit)\b/;
3
+ const ERROR_SIGNAL_RE = /(?:^|\n)\s*(?:error[ :\[]\S|Error[ :\[]\S|ERROR[ :\[]\S)|(?:exit code [1-9]|non-zero exit|ENOENT|EACCES|EPERM|Command failed)|(?:(?:TypeError|ReferenceError|SyntaxError|RangeError|URIError):\s)|(?:Cannot find module|Module not found|ModuleNotFoundError|ImportError:)|(?:FATAL|PANIC|Traceback \(most recent)|(?:error TS\d{4}:)/;
4
4
  const FIX_SIGNAL_RE = /\b(?:fix|correcting|I see the issue|let me fix|the (?:issue|problem|bug) (?:is|was)|instead I should|my mistake)\b/i;
5
5
 
6
6
  const CORRECTION_RE = /\b(?:no[,. ](?:that|not|don't|wrong)|that'?s (?:not|wrong|incorrect)|don'?t do that|stop (?:doing|that)|instead (?:of|do)|undo|revert|go back|try (?:again|differently)|you (?:broke|missed|forgot))\b/i;
@@ -54,7 +54,7 @@ export class StepClassifier {
54
54
 
55
55
  const content = step.content || '';
56
56
 
57
- if ((step.type === 'action' || step.type === 'observation') && step.is_error !== false && ERROR_SIGNAL_RE.test(content)) {
57
+ if (step.type === 'observation' && step.is_error !== false && ERROR_SIGNAL_RE.test(content)) {
58
58
  step.type = 'error';
59
59
  }
60
60
 
@@ -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', () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "groove-dev",
3
- "version": "0.27.112",
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.112",
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.112",
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",