groove-dev 0.27.87 → 0.27.88

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 (53) hide show
  1. package/CLAUDE.md +3 -2
  2. package/node_modules/@groove-dev/cli/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/package.json +1 -1
  4. package/node_modules/@groove-dev/daemon/src/api.js +115 -7
  5. package/node_modules/@groove-dev/daemon/src/conversations.js +29 -3
  6. package/node_modules/@groove-dev/daemon/src/providers/codex.js +28 -10
  7. package/node_modules/@groove-dev/daemon/src/registry.js +30 -0
  8. package/node_modules/@groove-dev/daemon/src/validate.js +23 -0
  9. package/node_modules/@groove-dev/gui/dist/assets/index-BSqk8cbI.css +1 -0
  10. package/node_modules/@groove-dev/gui/dist/assets/index-B_igwWvq.js +8642 -0
  11. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  12. package/node_modules/@groove-dev/gui/package.json +1 -1
  13. package/node_modules/@groove-dev/gui/src/components/agents/agent-file-tree.jsx +254 -0
  14. package/node_modules/@groove-dev/gui/src/components/agents/code-review.jsx +177 -0
  15. package/node_modules/@groove-dev/gui/src/components/agents/diff-viewer.jsx +148 -0
  16. package/node_modules/@groove-dev/gui/src/components/agents/workspace-mode.jsx +377 -0
  17. package/node_modules/@groove-dev/gui/src/components/chat/chat-input.jsx +117 -40
  18. package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +10 -13
  19. package/node_modules/@groove-dev/gui/src/components/chat/chat-view.jsx +26 -1
  20. package/node_modules/@groove-dev/gui/src/components/chat/conversation-list.jsx +14 -14
  21. package/node_modules/@groove-dev/gui/src/components/chat/model-picker.jsx +5 -0
  22. package/node_modules/@groove-dev/gui/src/stores/groove.js +132 -1
  23. package/node_modules/@groove-dev/gui/src/views/agents.jsx +22 -3
  24. package/package.json +1 -1
  25. package/packages/cli/package.json +1 -1
  26. package/packages/daemon/package.json +1 -1
  27. package/packages/daemon/src/api.js +115 -7
  28. package/packages/daemon/src/conversations.js +29 -3
  29. package/packages/daemon/src/providers/codex.js +28 -10
  30. package/packages/daemon/src/registry.js +30 -0
  31. package/packages/daemon/src/validate.js +23 -0
  32. package/packages/gui/dist/assets/index-BSqk8cbI.css +1 -0
  33. package/packages/gui/dist/assets/index-B_igwWvq.js +8642 -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/agent-file-tree.jsx +254 -0
  37. package/packages/gui/src/components/agents/code-review.jsx +177 -0
  38. package/packages/gui/src/components/agents/diff-viewer.jsx +148 -0
  39. package/packages/gui/src/components/agents/workspace-mode.jsx +377 -0
  40. package/packages/gui/src/components/chat/chat-input.jsx +117 -40
  41. package/packages/gui/src/components/chat/chat-messages.jsx +10 -13
  42. package/packages/gui/src/components/chat/chat-view.jsx +26 -1
  43. package/packages/gui/src/components/chat/conversation-list.jsx +14 -14
  44. package/packages/gui/src/components/chat/model-picker.jsx +5 -0
  45. package/packages/gui/src/stores/groove.js +132 -1
  46. package/packages/gui/src/views/agents.jsx +22 -3
  47. package/test/doomsday-clock/index.html +55 -0
  48. package/test/doomsday-clock/script.js +66 -0
  49. package/test/doomsday-clock/style.css +315 -0
  50. package/node_modules/@groove-dev/gui/dist/assets/index-BCQY8ojz.css +0 -1
  51. package/node_modules/@groove-dev/gui/dist/assets/index-C5e7KVGN.js +0 -8637
  52. package/packages/gui/dist/assets/index-BCQY8ojz.css +0 -1
  53. package/packages/gui/dist/assets/index-C5e7KVGN.js +0 -8637
@@ -45,12 +45,20 @@ export function ChatView() {
45
45
  const conversationRoles = useGrooveStore((s) => s.conversationRoles);
46
46
  const setConversationRole = useGrooveStore((s) => s.setConversationRole);
47
47
 
48
+ const conversationReasoningEffort = useGrooveStore((s) => s.conversationReasoningEffort);
49
+ const setConversationReasoningEffort = useGrooveStore((s) => s.setConversationReasoningEffort);
50
+ const conversationVerbosity = useGrooveStore((s) => s.conversationVerbosity);
51
+ const setConversationVerbosity = useGrooveStore((s) => s.setConversationVerbosity);
52
+
48
53
  const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
49
54
  const [replyContext, setReplyContext] = useState(null);
50
55
 
51
56
  const activeRole = activeConversationId ? (conversationRoles?.[activeConversationId] || null) : null;
57
+ const activeReasoningEffort = activeConversationId ? (conversationReasoningEffort?.[activeConversationId] || 'medium') : 'medium';
58
+ const activeVerbosity = activeConversationId ? (conversationVerbosity?.[activeConversationId] || 'medium') : 'medium';
52
59
 
53
60
  const activeConversation = conversations.find((c) => c.id === activeConversationId) || null;
61
+ const isCodexProvider = activeConversation?.provider === 'codex';
54
62
  const messages = activeConversationId ? (conversationMessages[activeConversationId] || []) : [];
55
63
  const isStreaming = streamingConversationId === activeConversationId && sendingMessage;
56
64
  const currentModelIsImage = activeConversation ? isImageModel(activeConversation.model) : false;
@@ -108,6 +116,16 @@ export function ChatView() {
108
116
  setReplyContext(msg);
109
117
  }, []);
110
118
 
119
+ const handleReasoningEffortChange = useCallback((effort) => {
120
+ if (!activeConversationId) return;
121
+ setConversationReasoningEffort(activeConversationId, effort);
122
+ }, [activeConversationId, setConversationReasoningEffort]);
123
+
124
+ const handleVerbosityChange = useCallback((verbosity) => {
125
+ if (!activeConversationId) return;
126
+ setConversationVerbosity(activeConversationId, verbosity);
127
+ }, [activeConversationId, setConversationVerbosity]);
128
+
111
129
  const currentModel = activeConversation
112
130
  ? { provider: activeConversation.provider, model: activeConversation.model }
113
131
  : null;
@@ -116,7 +134,7 @@ export function ChatView() {
116
134
  <div className="flex h-full bg-surface-0">
117
135
  {/* Conversation sidebar */}
118
136
  <div className={cn(
119
- 'flex-shrink-0 border-r border-accent/12 bg-surface-1 transition-all duration-200 overflow-hidden',
137
+ 'flex-shrink-0 border-r border-accent/12 bg-accent transition-all duration-200 overflow-hidden',
120
138
  sidebarCollapsed ? 'w-0' : 'w-64',
121
139
  )}>
122
140
  <ConversationList onNewChat={() => handleNewChat()} />
@@ -146,6 +164,13 @@ export function ChatView() {
146
164
  replyContext={replyContext}
147
165
  onClearReply={() => setReplyContext(null)}
148
166
  role={activeRole}
167
+ isCodex={isCodexProvider}
168
+ reasoningEffort={activeReasoningEffort}
169
+ onReasoningEffortChange={handleReasoningEffortChange}
170
+ verbosity={activeVerbosity}
171
+ onVerbosityChange={handleVerbosityChange}
172
+ mode={activeConversation?.mode || 'api'}
173
+ onModeChange={handleModeChange}
149
174
  />
150
175
  </>
151
176
  ) : (
@@ -31,7 +31,7 @@ function groupByDate(conversations) {
31
31
  function GroupLabel({ label }) {
32
32
  return (
33
33
  <div className="px-3 pt-4 pb-1.5">
34
- <span className="text-2xs font-semibold text-text-4 uppercase tracking-wider font-sans">{label}</span>
34
+ <span className="text-2xs font-semibold text-white/60 uppercase tracking-wider font-sans">{label}</span>
35
35
  </div>
36
36
  );
37
37
  }
@@ -45,23 +45,23 @@ function ConversationItem({ conv, isActive, onSelect, onRename, onPin, onDelete
45
45
  className={cn(
46
46
  'w-full flex items-center gap-2 px-3 py-2 text-left rounded-md transition-colors cursor-pointer group',
47
47
  isActive
48
- ? 'bg-accent/10 text-text-0'
49
- : 'text-text-2 hover:bg-surface-4 hover:text-text-1',
48
+ ? 'bg-white/15 text-white'
49
+ : 'text-white/80 hover:bg-white/10 hover:text-white',
50
50
  )}
51
51
  >
52
- <MessageCircle size={13} className={cn('flex-shrink-0', isActive ? 'text-accent' : 'text-text-4 group-hover:text-text-3')} />
52
+ <MessageCircle size={13} className={cn('flex-shrink-0', isActive ? 'text-white' : 'text-white/50 group-hover:text-white/70')} />
53
53
  <div className="flex-1 min-w-0">
54
- <div className="text-xs font-medium font-sans truncate">{conv.title || 'New Chat'}</div>
54
+ <div className="text-xs font-medium font-sans truncate text-white">{conv.title || 'New Chat'}</div>
55
55
  <div className="flex items-center gap-1.5 mt-0.5">
56
56
  {conv.mode === 'agent'
57
- ? <Bot size={9} className="text-purple flex-shrink-0" />
58
- : <Zap size={9} className="text-accent flex-shrink-0" />
57
+ ? <Bot size={9} className="text-white/70 flex-shrink-0" />
58
+ : <Zap size={9} className="text-white/70 flex-shrink-0" />
59
59
  }
60
60
  {conv.model && <Badge variant="default" className="text-[8px] px-1 py-0">{formatModelName(conv.model)}</Badge>}
61
- <span className="text-2xs text-text-4 font-sans">{timeAgo(conv.updatedAt || conv.createdAt)}</span>
61
+ <span className="text-2xs text-white/50 font-sans">{timeAgo(conv.updatedAt || conv.createdAt)}</span>
62
62
  </div>
63
63
  </div>
64
- {conv.pinned && <Pin size={10} className="text-accent flex-shrink-0" />}
64
+ {conv.pinned && <Pin size={10} className="text-white flex-shrink-0" />}
65
65
  </button>
66
66
  </ContextMenuTrigger>
67
67
  <ContextMenuContent>
@@ -125,9 +125,9 @@ export function ConversationList({ onNewChat }) {
125
125
  <div className="flex-1 overflow-y-auto px-1.5 pt-3 pb-3 space-y-0.5">
126
126
  {conversations.length === 0 ? (
127
127
  <div className="flex flex-col items-center justify-center py-16 text-center px-4">
128
- <MessageCircle size={24} className="text-text-4 mb-3" />
129
- <p className="text-xs text-text-3 font-sans">No conversations yet</p>
130
- <p className="text-2xs text-text-4 font-sans mt-1">Start a new chat to begin</p>
128
+ <MessageCircle size={24} className="text-white/50 mb-3" />
129
+ <p className="text-xs text-white/80 font-sans">No conversations yet</p>
130
+ <p className="text-2xs text-white/60 font-sans mt-1">Start a new chat to begin</p>
131
131
  </div>
132
132
  ) : (
133
133
  <>
@@ -140,10 +140,10 @@ export function ConversationList({ onNewChat }) {
140
140
  )}
141
141
  </div>
142
142
 
143
- <div className="p-3 border-t border-border-subtle">
143
+ <div className="p-3 border-t border-white/15">
144
144
  <button
145
145
  onClick={onNewChat}
146
- className="w-full flex items-center justify-center gap-2 h-9 rounded-lg bg-accent/15 text-accent text-xs font-semibold font-sans hover:bg-accent/25 transition-colors cursor-pointer border border-accent/20"
146
+ className="w-full flex items-center justify-center gap-2 h-9 rounded-lg bg-white/15 text-white text-xs font-semibold font-sans hover:bg-white/25 transition-colors cursor-pointer border border-white/20"
147
147
  >
148
148
  <Plus size={14} />
149
149
  New Chat
@@ -23,6 +23,10 @@ const TIER_CONFIG = {
23
23
 
24
24
  function getTier(model) {
25
25
  const name = (model || '').toLowerCase();
26
+ if (name.includes('gpt-5.5') || name.includes('gpt-5.4-pro')) return 'frontier';
27
+ if (name.includes('gpt-5.4-mini') || name.includes('gpt-5-mini')) return 'mid';
28
+ if (name.includes('gpt-5.4-nano') || name.includes('gpt-5-nano')) return 'fast';
29
+ if (name.includes('gpt-5.4')) return 'frontier';
26
30
  if (name.includes('opus') || name.includes('pro') || name.includes('o3') || name.includes('gpt-4o')) return 'frontier';
27
31
  if (name.includes('sonnet') || name.includes('flash') || name.includes('o4-mini')) return 'mid';
28
32
  return 'fast';
@@ -30,6 +34,7 @@ function getTier(model) {
30
34
 
31
35
  function getContextSize(model) {
32
36
  const name = (model || '').toLowerCase();
37
+ if (name.startsWith('gpt-5')) return '200k';
33
38
  if (name.includes('opus') || name.includes('sonnet')) return '200k';
34
39
  if (name.includes('haiku')) return '200k';
35
40
  if (name.includes('pro')) return '1M';
@@ -92,6 +92,8 @@ export const useGrooveStore = create((set, get) => ({
92
92
  sendingMessage: false,
93
93
  streamingConversationId: null,
94
94
  conversationRoles: loadJSON('groove:conversationRoles'),
95
+ conversationReasoningEffort: loadJSON('groove:conversationReasoningEffort'),
96
+ conversationVerbosity: loadJSON('groove:conversationVerbosity'),
95
97
 
96
98
  // ── Approvals ─────────────────────────────────────────────
97
99
  pendingApprovals: [],
@@ -173,6 +175,13 @@ export const useGrooveStore = create((set, get) => ({
173
175
  editorRecentSaves: {},
174
176
  editorSidebarWidth: Number(localStorage.getItem('groove:editorSidebarWidth')) || 240,
175
177
 
178
+ // ── Workspace Mode ────────────────────────────────────────
179
+ workspaceMode: localStorage.getItem('groove:workspaceMode') === 'true',
180
+ workspaceAgentId: null,
181
+ workspaceSnapshots: {},
182
+ workspaceReviewMode: false,
183
+ workspaceReviewFiles: [],
184
+
176
185
  // ── Onboarding ────────────────────────────────────────────
177
186
  onboardingComplete: localStorage.getItem('groove:onboardingComplete') === 'true',
178
187
 
@@ -451,6 +460,15 @@ export const useGrooveStore = create((set, get) => ({
451
460
  if (msg.error && msg.agentId) {
452
461
  get().addChatMessage(msg.agentId, 'system', `Crashed: ${msg.error}`);
453
462
  }
463
+ // Clear workspace if the exiting agent was the workspace target
464
+ if (get().workspaceAgentId === msg.agentId) {
465
+ const teamAgents = get().agents.filter(
466
+ (a) => a.id !== msg.agentId && a.teamId === get().activeTeamId,
467
+ );
468
+ const next = teamAgents.find((a) => a.status === 'running') || teamAgents[0];
469
+ set({ workspaceAgentId: next?.id || null });
470
+ }
471
+
454
472
  // Check for recommended team when planner completes
455
473
  if (agent?.role === 'planner' && msg.status === 'completed') {
456
474
  setTimeout(() => get().checkRecommendedTeam(), 1000);
@@ -567,6 +585,13 @@ export const useGrooveStore = create((set, get) => ({
567
585
  const savedAt = get().editorRecentSaves[msg.path];
568
586
  if (savedAt && Date.now() - savedAt < 2000) break;
569
587
  set((s) => ({ editorChangedFiles: { ...s.editorChangedFiles, [msg.path]: msg.timestamp } }));
588
+ // Auto-capture workspace snapshot for diff viewer
589
+ if (get().workspaceMode && msg.path && !get().workspaceSnapshots[msg.path]) {
590
+ const existing = get().editorFiles[msg.path];
591
+ if (existing?.content) {
592
+ get().captureSnapshot(msg.path, existing.content);
593
+ }
594
+ }
570
595
  break;
571
596
  }
572
597
 
@@ -2050,7 +2075,13 @@ export const useGrooveStore = create((set, get) => ({
2050
2075
  const conversationRoles = { ...s.conversationRoles };
2051
2076
  delete conversationRoles[id];
2052
2077
  persistJSON('groove:conversationRoles', conversationRoles);
2053
- return { conversations, conversationMessages, conversationRoles, activeConversationId };
2078
+ const conversationReasoningEffort = { ...s.conversationReasoningEffort };
2079
+ delete conversationReasoningEffort[id];
2080
+ persistJSON('groove:conversationReasoningEffort', conversationReasoningEffort);
2081
+ const conversationVerbosity = { ...s.conversationVerbosity };
2082
+ delete conversationVerbosity[id];
2083
+ persistJSON('groove:conversationVerbosity', conversationVerbosity);
2084
+ return { conversations, conversationMessages, conversationRoles, conversationReasoningEffort, conversationVerbosity, activeConversationId };
2054
2085
  });
2055
2086
  } catch (err) {
2056
2087
  get().addToast('error', 'Delete failed', err.message);
@@ -2093,6 +2124,24 @@ export const useGrooveStore = create((set, get) => ({
2093
2124
  });
2094
2125
  },
2095
2126
 
2127
+ setConversationReasoningEffort(id, effort) {
2128
+ set((s) => {
2129
+ const map = { ...s.conversationReasoningEffort };
2130
+ map[id] = effort || 'medium';
2131
+ persistJSON('groove:conversationReasoningEffort', map);
2132
+ return { conversationReasoningEffort: map };
2133
+ });
2134
+ },
2135
+
2136
+ setConversationVerbosity(id, verbosity) {
2137
+ set((s) => {
2138
+ const map = { ...s.conversationVerbosity };
2139
+ map[id] = verbosity || 'medium';
2140
+ persistJSON('groove:conversationVerbosity', map);
2141
+ return { conversationVerbosity: map };
2142
+ });
2143
+ },
2144
+
2096
2145
  async sendChatMessage(conversationId, message) {
2097
2146
  const conv = get().conversations.find((c) => c.id === conversationId);
2098
2147
  if (!conv) return;
@@ -2122,6 +2171,12 @@ export const useGrooveStore = create((set, get) => ({
2122
2171
  ...body.history,
2123
2172
  ];
2124
2173
  }
2174
+ const effort = get().conversationReasoningEffort?.[conversationId] || 'medium';
2175
+ const verbosity = get().conversationVerbosity?.[conversationId] || 'medium';
2176
+ if (conv.provider === 'codex') {
2177
+ body.reasoning_effort = effort;
2178
+ body.verbosity = verbosity;
2179
+ }
2125
2180
  await api.post(`/conversations/${encodeURIComponent(conversationId)}/message`, body);
2126
2181
  } catch (err) {
2127
2182
  set((s) => {
@@ -2303,6 +2358,82 @@ export const useGrooveStore = create((set, get) => ({
2303
2358
  }
2304
2359
  },
2305
2360
 
2361
+ // ── Workspace Mode ────────────────────────────────────────
2362
+
2363
+ setWorkspaceMode(on) {
2364
+ set({ workspaceMode: on });
2365
+ localStorage.setItem('groove:workspaceMode', String(on));
2366
+ if (on && !get().workspaceAgentId) {
2367
+ const teamAgents = get().agents.filter((a) => a.teamId === get().activeTeamId);
2368
+ const selected = get().detailPanel?.type === 'agent' ? get().detailPanel.agentId : null;
2369
+ const running = teamAgents.find((a) => a.status === 'running');
2370
+ set({ workspaceAgentId: selected || running?.id || teamAgents[0]?.id || null });
2371
+ }
2372
+ },
2373
+
2374
+ setWorkspaceAgent(id) {
2375
+ set({ workspaceAgentId: id });
2376
+ },
2377
+
2378
+ captureSnapshot(path, content) {
2379
+ set((s) => {
2380
+ if (s.workspaceSnapshots[path]) return s;
2381
+ const next = { ...s.workspaceSnapshots, [path]: content };
2382
+ const keys = Object.keys(next);
2383
+ if (keys.length > 200) {
2384
+ delete next[keys[0]];
2385
+ }
2386
+ return { workspaceSnapshots: next };
2387
+ });
2388
+ },
2389
+
2390
+ toggleReviewMode() {
2391
+ const st = get();
2392
+ if (st.workspaceReviewMode) {
2393
+ set({ workspaceReviewMode: false, workspaceReviewFiles: [] });
2394
+ return;
2395
+ }
2396
+ const agentId = st.workspaceAgentId;
2397
+ const log = st.activityLog[agentId] || [];
2398
+ const seen = new Set();
2399
+ const files = [];
2400
+ for (const entry of log) {
2401
+ const t = (entry.text || '').toLowerCase();
2402
+ if (!(t.includes('writ') || t.includes('edit') || t.includes('creat'))) continue;
2403
+ const match = entry.text.match(/(?:Write|Edit|Create|wrote|editing|writing)\S*\s+(\S+)/i);
2404
+ if (!match) continue;
2405
+ const path = match[1];
2406
+ if (seen.has(path)) continue;
2407
+ seen.add(path);
2408
+ files.push({ path, status: 'pending', comment: '' });
2409
+ }
2410
+ set({ workspaceReviewMode: true, workspaceReviewFiles: files });
2411
+ },
2412
+
2413
+ approveFile(path) {
2414
+ set((s) => ({
2415
+ workspaceReviewFiles: s.workspaceReviewFiles.map((f) =>
2416
+ f.path === path ? { ...f, status: 'approved' } : f,
2417
+ ),
2418
+ }));
2419
+ },
2420
+
2421
+ rejectFile(path) {
2422
+ set((s) => ({
2423
+ workspaceReviewFiles: s.workspaceReviewFiles.map((f) =>
2424
+ f.path === path ? { ...f, status: 'rejected' } : f,
2425
+ ),
2426
+ }));
2427
+ },
2428
+
2429
+ commentFile(path, comment) {
2430
+ set((s) => ({
2431
+ workspaceReviewFiles: s.workspaceReviewFiles.map((f) =>
2432
+ f.path === path ? { ...f, comment } : f,
2433
+ ),
2434
+ }));
2435
+ },
2436
+
2306
2437
  // ── Federation ────────────────────────────────────────────
2307
2438
 
2308
2439
  async fetchFederationStatus() {
@@ -12,6 +12,7 @@ import { Button } from '../components/ui/button';
12
12
  import { Badge } from '../components/ui/badge';
13
13
  import { Plus, Users, Zap, X, Check, Rocket, Server, Monitor, Code2, TestTube, Shield, Pencil, Copy, Trash2, ChevronDown, ChevronLeft, ChevronRight, FolderOpen, Radio, Eye } from 'lucide-react';
14
14
  import { PreviewWorkspace } from '../components/preview/preview-workspace';
15
+ import { WorkspaceMode } from '../components/agents/workspace-mode';
15
16
  import { ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuItem, ContextMenuSeparator } from '../components/ui/context-menu';
16
17
 
17
18
  const NODE_TYPES = { agentNode: AgentNode, rootNode: RootNode };
@@ -799,6 +800,8 @@ export default function AgentsView() {
799
800
  const showPreviewInAgents = useGrooveStore((s) => s.showPreviewInAgents);
800
801
  const previewState = useGrooveStore((s) => s.previewState);
801
802
  const togglePreviewInAgents = useGrooveStore((s) => s.togglePreviewInAgents);
803
+ const workspaceMode = useGrooveStore((s) => s.workspaceMode);
804
+ const setWorkspaceMode = useGrooveStore((s) => s.setWorkspaceMode);
802
805
 
803
806
  // Poll for recommended team while a planner is running
804
807
  useEffect(() => {
@@ -849,6 +852,8 @@ export default function AgentsView() {
849
852
  </div>
850
853
  ) : teamAgents.length === 0 ? (
851
854
  <EmptyState onPlanner={launchPlanner} onSpawn={() => openDetail({ type: 'spawn' })} />
855
+ ) : workspaceMode ? (
856
+ <WorkspaceMode />
852
857
  ) : showPreviewInAgents && previewState.url ? (
853
858
  <PreviewWorkspace embedded />
854
859
  ) : (
@@ -857,8 +862,8 @@ export default function AgentsView() {
857
862
  </ReactFlowProvider>
858
863
  )}
859
864
  </div>
860
- <RecommendedTeamCard />
861
- {!isLoading && teamAgents.length > 0 && (
865
+ {!workspaceMode && <RecommendedTeamCard />}
866
+ {!isLoading && teamAgents.length > 0 && !workspaceMode && (
862
867
  <button
863
868
  onClick={() => openDetail({ type: 'spawn' })}
864
869
  className="absolute bottom-4 left-4 z-40 flex items-center gap-1.5 h-8 px-4 rounded-md bg-accent/15 text-accent text-xs font-semibold font-sans hover:bg-accent/25 transition-colors cursor-pointer select-none shadow-lg shadow-black/10"
@@ -867,7 +872,21 @@ export default function AgentsView() {
867
872
  Spawn
868
873
  </button>
869
874
  )}
870
- {!isLoading && teamAgents.length > 0 && previewState.url && (
875
+ {!isLoading && teamAgents.length > 0 && (
876
+ <button
877
+ onClick={() => setWorkspaceMode(!workspaceMode)}
878
+ className={cn(
879
+ 'absolute bottom-4 z-40 flex items-center gap-1.5 h-8 px-4 rounded-md text-xs font-semibold font-sans transition-colors cursor-pointer select-none shadow-lg shadow-black/10',
880
+ previewState.url && !workspaceMode ? 'right-32' : 'right-4',
881
+ workspaceMode
882
+ ? 'bg-accent/15 text-accent hover:bg-accent/25'
883
+ : 'bg-purple/15 text-purple hover:bg-purple/25',
884
+ )}
885
+ >
886
+ {workspaceMode ? <><Users size={14} /> Tree</> : <><Code2 size={14} /> Workspace</>}
887
+ </button>
888
+ )}
889
+ {!isLoading && teamAgents.length > 0 && !workspaceMode && previewState.url && (
871
890
  <button
872
891
  onClick={togglePreviewInAgents}
873
892
  className="absolute bottom-4 right-4 z-40 flex items-center gap-1.5 h-8 px-4 rounded-md bg-info/15 text-info text-xs font-semibold font-sans hover:bg-info/25 transition-colors cursor-pointer select-none shadow-lg shadow-black/10"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "groove-dev",
3
- "version": "0.27.87",
3
+ "version": "0.27.88",
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.87",
3
+ "version": "0.27.88",
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.87",
3
+ "version": "0.27.88",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -16,7 +16,7 @@ import { OllamaProvider } from './providers/ollama.js';
16
16
  import { ClaudeCodeProvider } from './providers/claude-code.js';
17
17
  import { supportsSignalFlag, compareSemver, parseSemver } from './providers/groove-network.js';
18
18
  import { ConsentManager } from '../../../moe-training/client/index.js';
19
- import { validateAgentConfig } from './validate.js';
19
+ import { validateAgentConfig, validateReasoningEffort, validateVerbosity } from './validate.js';
20
20
  import { ROLE_INTEGRATIONS, wrapWithRoleReminder } from './process.js';
21
21
 
22
22
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -72,6 +72,9 @@ async function _executeApprovalRetry(daemon, approval) {
72
72
  }
73
73
  }
74
74
 
75
+ const FILE_READ_TOOLS = new Set(['Read', 'read_file']);
76
+ const FILE_WRITE_TOOLS = new Set(['Write', 'Edit', 'write_file', 'edit_file', 'create_file']);
77
+
75
78
  export function createApi(app, daemon) {
76
79
  _daemon = daemon;
77
80
 
@@ -325,6 +328,14 @@ export function createApi(app, daemon) {
325
328
  }
326
329
  }
327
330
 
331
+ // Track file operations for the files-touched API
332
+ if (targets.length > 0) {
333
+ const op = FILE_WRITE_TOOLS.has(toolName) ? 'write' : FILE_READ_TOOLS.has(toolName) ? 'read' : null;
334
+ if (op) {
335
+ for (const t of targets) daemon.registry.trackFileOp(agentId, t, op);
336
+ }
337
+ }
338
+
328
339
  daemon.audit.log('knock.allowed', { agentId, toolName, targets });
329
340
  res.json({ allow: true });
330
341
  });
@@ -1100,14 +1111,19 @@ export function createApi(app, daemon) {
1100
1111
 
1101
1112
  app.post('/api/conversations', async (req, res) => {
1102
1113
  try {
1103
- const { provider, model, title, mode } = req.body;
1114
+ const { provider, model, title, mode, reasoning_effort, verbosity } = req.body;
1104
1115
  if (!provider || typeof provider !== 'string') {
1105
1116
  return res.status(400).json({ error: 'provider is required' });
1106
1117
  }
1107
1118
  if (mode && mode !== 'api' && mode !== 'agent') {
1108
1119
  return res.status(400).json({ error: 'mode must be "api" or "agent"' });
1109
1120
  }
1110
- const conversation = await daemon.conversations.create(provider, model, title, mode || 'api');
1121
+ const validatedEffort = validateReasoningEffort(reasoning_effort);
1122
+ const validatedVerbosity = validateVerbosity(verbosity);
1123
+ const conversation = await daemon.conversations.create(provider, model, title, mode || 'api', {
1124
+ reasoningEffort: validatedEffort,
1125
+ verbosity: validatedVerbosity,
1126
+ });
1111
1127
  daemon.audit.log('conversation.create', { id: conversation.id, provider, model, mode: conversation.mode });
1112
1128
  res.status(201).json(conversation);
1113
1129
  } catch (err) {
@@ -1139,6 +1155,11 @@ export function createApi(app, daemon) {
1139
1155
  }
1140
1156
  await daemon.conversations.setMode(req.params.id, req.body.mode);
1141
1157
  }
1158
+ if (req.body.reasoning_effort !== undefined || req.body.verbosity !== undefined) {
1159
+ const validatedEffort = req.body.reasoning_effort !== undefined ? validateReasoningEffort(req.body.reasoning_effort) : undefined;
1160
+ const validatedVerbosity = req.body.verbosity !== undefined ? validateVerbosity(req.body.verbosity) : undefined;
1161
+ daemon.conversations.updateReasoningSettings(req.params.id, validatedEffort, validatedVerbosity);
1162
+ }
1142
1163
  daemon.audit.log('conversation.update', { id: req.params.id, provider: req.body.provider, model: req.body.model, mode: req.body.mode });
1143
1164
  res.json(daemon.conversations.get(req.params.id));
1144
1165
  } catch (err) {
@@ -1160,10 +1181,13 @@ export function createApi(app, daemon) {
1160
1181
 
1161
1182
  app.post('/api/conversations/:id/message', async (req, res) => {
1162
1183
  try {
1163
- const { message, history } = req.body;
1184
+ const { message, history, reasoning_effort, verbosity } = req.body;
1164
1185
  if (!message || typeof message !== 'string' || !message.trim()) {
1165
1186
  return res.status(400).json({ error: 'message is required' });
1166
1187
  }
1188
+ const validatedEffort = validateReasoningEffort(reasoning_effort);
1189
+ const validatedVerbosity = validateVerbosity(verbosity);
1190
+
1167
1191
  const conv = daemon.conversations.get(req.params.id);
1168
1192
  if (!conv) return res.status(404).json({ error: 'Conversation not found' });
1169
1193
 
@@ -1172,7 +1196,10 @@ export function createApi(app, daemon) {
1172
1196
 
1173
1197
  // API mode — lightweight headless streaming, no agent spawned
1174
1198
  if (conv.mode === 'api' || !conv.agentId) {
1175
- await daemon.conversations.sendMessage(req.params.id, message.trim(), history || []);
1199
+ await daemon.conversations.sendMessage(req.params.id, message.trim(), history || [], {
1200
+ reasoningEffort: validatedEffort,
1201
+ verbosity: validatedVerbosity,
1202
+ });
1176
1203
  daemon.audit.log('conversation.message', { id: req.params.id, mode: 'api' });
1177
1204
  return res.json({ status: 'streaming', mode: 'api' });
1178
1205
  }
@@ -3064,6 +3091,87 @@ Keep responses concise. Help them think, don't lecture them about the system the
3064
3091
  });
3065
3092
  });
3066
3093
 
3094
+ // Files touched by an agent during its session
3095
+ app.get('/api/agents/:id/files-touched', (req, res) => {
3096
+ const agent = daemon.registry.get(req.params.id);
3097
+ if (!agent) return res.status(404).json({ error: 'Agent not found' });
3098
+ const files = daemon.registry.getFilesTouched(req.params.id);
3099
+ res.json({ files, total: files.length });
3100
+ });
3101
+
3102
+ // Git diff — structured diff for a file, an agent's touched files, or all uncommitted changes
3103
+ app.get('/api/files/git-diff', (req, res) => {
3104
+ const rootDir = getEditorRoot();
3105
+ if (!rootDir) return res.status(400).json({ error: 'Editor root not set' });
3106
+
3107
+ let paths = [];
3108
+
3109
+ if (req.query.path) {
3110
+ const result = validateFilePath(req.query.path, rootDir);
3111
+ if (result.error) return res.status(400).json({ error: result.error });
3112
+ paths = [req.query.path];
3113
+ } else if (req.query.agentId) {
3114
+ const agent = daemon.registry.get(req.query.agentId);
3115
+ if (!agent) return res.status(404).json({ error: 'Agent not found' });
3116
+ paths = daemon.registry.getFilesTouched(req.query.agentId).map(f => f.path);
3117
+ if (paths.length === 0) return res.json({ diffs: [] });
3118
+ // Validate each path
3119
+ for (const p of paths) {
3120
+ if (p.startsWith('/') || p.includes('..') || p.includes('\0')) {
3121
+ return res.status(400).json({ error: 'Invalid path in agent files' });
3122
+ }
3123
+ }
3124
+ }
3125
+
3126
+ const args = ['diff'];
3127
+ const cachedArgs = ['diff', '--cached'];
3128
+ if (paths.length > 0) {
3129
+ args.push('--', ...paths);
3130
+ cachedArgs.push('--', ...paths);
3131
+ }
3132
+
3133
+ try {
3134
+ const unstaged = execFileSync('git', args, { cwd: rootDir, timeout: 15000, maxBuffer: 10 * 1024 * 1024 }).toString();
3135
+ const staged = execFileSync('git', cachedArgs, { cwd: rootDir, timeout: 15000, maxBuffer: 10 * 1024 * 1024 }).toString();
3136
+ const combined = (staged + '\n' + unstaged).trim();
3137
+ const diffs = parseDiffOutput(combined);
3138
+ res.json({ diffs });
3139
+ } catch (err) {
3140
+ if (err.status !== undefined) {
3141
+ return res.json({ diffs: [] });
3142
+ }
3143
+ res.status(500).json({ error: 'Failed to compute diff' });
3144
+ }
3145
+ });
3146
+
3147
+ function parseDiffOutput(raw) {
3148
+ if (!raw) return [];
3149
+ const fileDiffs = raw.split(/^diff --git /m).filter(Boolean);
3150
+ return fileDiffs.map(chunk => {
3151
+ const lines = chunk.split('\n');
3152
+ const headerMatch = lines[0]?.match(/a\/(.+?) b\/(.+)/);
3153
+ const filePath = headerMatch ? headerMatch[2] : 'unknown';
3154
+ let status = 'modified';
3155
+ if (lines.some(l => l.startsWith('new file'))) status = 'added';
3156
+ else if (lines.some(l => l.startsWith('deleted file'))) status = 'deleted';
3157
+ let additions = 0, deletions = 0;
3158
+ const hunks = [];
3159
+ let currentHunk = null;
3160
+ for (const line of lines) {
3161
+ if (line.startsWith('@@')) {
3162
+ if (currentHunk) hunks.push(currentHunk);
3163
+ currentHunk = { header: line, lines: [] };
3164
+ } else if (currentHunk) {
3165
+ currentHunk.lines.push(line);
3166
+ if (line.startsWith('+') && !line.startsWith('+++')) additions++;
3167
+ else if (line.startsWith('-') && !line.startsWith('---')) deletions++;
3168
+ }
3169
+ }
3170
+ if (currentHunk) hunks.push(currentHunk);
3171
+ return { path: filePath, status, hunks, additions, deletions, content: 'diff --git ' + chunk };
3172
+ });
3173
+ }
3174
+
3067
3175
  // File search — fuzzy filename matching for quick-open (Ctrl+P)
3068
3176
  app.get('/api/files/search', (req, res) => {
3069
3177
  const query = req.query.q;
@@ -4172,10 +4280,10 @@ Keep responses concise. Help them think, don't lecture them about the system the
4172
4280
 
4173
4281
  app.post('/api/tunnels', (req, res) => {
4174
4282
  try {
4175
- const { name, host, user, port, sshKeyPath, autoStart, autoConnect } = req.body;
4283
+ const { name, host, user, port, sshKeyPath, autoStart, autoConnect, projectDir } = req.body;
4176
4284
  if (!name || typeof name !== 'string') return res.status(400).json({ error: 'name is required (string)' });
4177
4285
  if (!host || typeof host !== 'string') return res.status(400).json({ error: 'host is required (string)' });
4178
- const result = daemon.tunnelManager.save({ name, host, user, port, sshKeyPath, autoStart, autoConnect });
4286
+ const result = daemon.tunnelManager.save({ name, host, user, port, sshKeyPath, autoStart, autoConnect, projectDir });
4179
4287
  res.json(result);
4180
4288
  } catch (err) {
4181
4289
  res.status(400).json({ error: err.message });