groove-dev 0.27.56 → 0.27.57

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 (32) hide show
  1. package/ai-chat/CHAT_MASTER_PLAN.md +25 -5
  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 +37 -9
  5. package/node_modules/@groove-dev/daemon/src/conversations.js +260 -20
  6. package/node_modules/@groove-dev/gui/dist/assets/index-C5WTeZO4.css +1 -0
  7. package/node_modules/@groove-dev/gui/dist/assets/{index-Bb8CIVBT.js → index-X58BAjGp.js} +1736 -1736
  8. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  9. package/node_modules/@groove-dev/gui/package.json +1 -1
  10. package/node_modules/@groove-dev/gui/src/components/chat/chat-header.jsx +28 -10
  11. package/node_modules/@groove-dev/gui/src/components/chat/chat-input.jsx +9 -23
  12. package/node_modules/@groove-dev/gui/src/components/chat/chat-view.jsx +17 -10
  13. package/node_modules/@groove-dev/gui/src/components/chat/conversation-list.jsx +18 -13
  14. package/node_modules/@groove-dev/gui/src/components/chat/model-picker.jsx +22 -10
  15. package/node_modules/@groove-dev/gui/src/stores/groove.js +68 -5
  16. package/package.json +1 -1
  17. package/packages/cli/package.json +1 -1
  18. package/packages/daemon/package.json +1 -1
  19. package/packages/daemon/src/api.js +37 -9
  20. package/packages/daemon/src/conversations.js +260 -20
  21. package/packages/gui/dist/assets/index-C5WTeZO4.css +1 -0
  22. package/packages/gui/dist/assets/{index-Bb8CIVBT.js → index-X58BAjGp.js} +1736 -1736
  23. package/packages/gui/dist/index.html +2 -2
  24. package/packages/gui/package.json +1 -1
  25. package/packages/gui/src/components/chat/chat-header.jsx +28 -10
  26. package/packages/gui/src/components/chat/chat-input.jsx +9 -23
  27. package/packages/gui/src/components/chat/chat-view.jsx +17 -10
  28. package/packages/gui/src/components/chat/conversation-list.jsx +18 -13
  29. package/packages/gui/src/components/chat/model-picker.jsx +22 -10
  30. package/packages/gui/src/stores/groove.js +68 -5
  31. package/node_modules/@groove-dev/gui/dist/assets/index-DOy_oMyr.css +0 -1
  32. package/packages/gui/dist/assets/index-DOy_oMyr.css +0 -1
@@ -6,12 +6,12 @@
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <link rel="icon" type="image/png" href="/favicon.png" />
8
8
  <title>Groove GUI</title>
9
- <script type="module" crossorigin src="/assets/index-Bb8CIVBT.js"></script>
9
+ <script type="module" crossorigin src="/assets/index-X58BAjGp.js"></script>
10
10
  <link rel="modulepreload" crossorigin href="/assets/vendor-C0HXlhrU.js">
11
11
  <link rel="modulepreload" crossorigin href="/assets/reactflow-BQPfi37R.js">
12
12
  <link rel="modulepreload" crossorigin href="/assets/codemirror-BBL3i_JW.js">
13
13
  <link rel="modulepreload" crossorigin href="/assets/xterm--7_ns2zW.js">
14
- <link rel="stylesheet" crossorigin href="/assets/index-DOy_oMyr.css">
14
+ <link rel="stylesheet" crossorigin href="/assets/index-C5WTeZO4.css">
15
15
  </head>
16
16
  <body>
17
17
  <div id="root"></div>
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/gui",
3
- "version": "0.27.56",
3
+ "version": "0.27.57",
4
4
  "description": "GROOVE GUI — visual agent control plane",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -1,12 +1,12 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
2
  import { useState, useRef, useEffect } from 'react';
3
- import { Pencil, Pin, PinOff, Trash2, Hash, MoreHorizontal } from 'lucide-react';
3
+ import { Pencil, Pin, PinOff, Trash2, Hash, MoreHorizontal, Zap, Bot } from 'lucide-react';
4
4
  import { useGrooveStore } from '../../stores/groove';
5
- import { cn } from '../../lib/cn';
6
- import { Badge } from '../ui/badge';
7
5
  import { fmtNum } from '../../lib/format';
6
+ import { ModelPicker } from './model-picker';
7
+ import { Tooltip } from '../ui/tooltip';
8
8
 
9
- export function ChatHeader({ conversation }) {
9
+ export function ChatHeader({ conversation, model, onModelChange, onModeChange }) {
10
10
  const renameConversation = useGrooveStore((s) => s.renameConversation);
11
11
  const pinConversation = useGrooveStore((s) => s.pinConversation);
12
12
  const deleteConversation = useGrooveStore((s) => s.deleteConversation);
@@ -47,6 +47,7 @@ export function ChatHeader({ conversation }) {
47
47
 
48
48
  const agent = useGrooveStore((s) => s.agents.find((a) => a.id === conversation.agentId));
49
49
  const tokens = agent?.tokensUsed || 0;
50
+ const mode = conversation.mode || 'api';
50
51
 
51
52
  return (
52
53
  <div className="h-11 flex items-center gap-3 px-4 border-b border-border bg-surface-1 flex-shrink-0">
@@ -72,12 +73,29 @@ export function ChatHeader({ conversation }) {
72
73
  )}
73
74
 
74
75
  <div className="flex items-center gap-2 flex-shrink-0">
75
- {conversation.model && (
76
- <Badge variant="accent" className="text-[9px]">{conversation.model}</Badge>
77
- )}
78
- {conversation.provider && (
79
- <Badge variant="default" className="text-[9px]">{conversation.provider}</Badge>
80
- )}
76
+ <div className="flex items-center h-7 rounded-lg bg-surface-3 border border-border-subtle p-0.5">
77
+ <Tooltip content="Lightweight — fast and cheap, no tools" side="bottom">
78
+ <button
79
+ onClick={() => onModeChange?.('api')}
80
+ className={`flex items-center gap-1 h-6 px-2 rounded-md text-2xs font-semibold font-sans transition-colors cursor-pointer ${mode === 'api' ? 'bg-accent/15 text-accent border border-accent/25' : 'text-text-3 hover:text-text-1'}`}
81
+ >
82
+ <Zap size={11} /> Chat
83
+ </button>
84
+ </Tooltip>
85
+ <Tooltip content="Full agent — tools, files, session resume" side="bottom">
86
+ <button
87
+ onClick={() => onModeChange?.('agent')}
88
+ className={`flex items-center gap-1 h-6 px-2 rounded-md text-2xs font-semibold font-sans transition-colors cursor-pointer ${mode === 'agent' ? 'bg-purple/15 text-purple border border-purple/25' : 'text-text-3 hover:text-text-1'}`}
89
+ >
90
+ <Bot size={11} /> Agent
91
+ </button>
92
+ </Tooltip>
93
+ </div>
94
+ <ModelPicker
95
+ value={model || { provider: conversation.provider, model: conversation.model }}
96
+ onChange={onModelChange}
97
+ disabled={false}
98
+ />
81
99
  {tokens > 0 && (
82
100
  <span className="text-2xs text-text-3 font-mono">{fmtNum(tokens)} tokens</span>
83
101
  )}
@@ -2,9 +2,8 @@
2
2
  import { useState, useRef, useEffect, useCallback } from 'react';
3
3
  import { Send, Loader2, Square, Paperclip } from 'lucide-react';
4
4
  import { cn } from '../../lib/cn';
5
- import { ModelPicker } from './model-picker';
6
5
 
7
- export function ChatInput({ onSend, onStop, onModelChange, model, sending, streaming, disabled }) {
6
+ export function ChatInput({ onSend, onStop, sending, streaming, disabled }) {
8
7
  const [input, setInput] = useState('');
9
8
  const textareaRef = useRef(null);
10
9
  const fileInputRef = useRef(null);
@@ -13,8 +12,7 @@ export function ChatInput({ onSend, onStop, onModelChange, model, sending, strea
13
12
  const el = textareaRef.current;
14
13
  if (!el) return;
15
14
  el.style.height = 'auto';
16
- const maxHeight = 6 * 24; // 6 lines
17
- el.style.height = Math.min(el.scrollHeight, maxHeight) + 'px';
15
+ el.style.height = Math.min(el.scrollHeight, 200) + 'px';
18
16
  }, []);
19
17
 
20
18
  useEffect(() => {
@@ -54,14 +52,8 @@ export function ChatInput({ onSend, onStop, onModelChange, model, sending, strea
54
52
  const canSend = input.trim() && !sending && !disabled;
55
53
 
56
54
  return (
57
- <div className="border-t border-border bg-surface-1 px-4 py-3">
58
- <div className="flex items-end gap-2">
59
- <ModelPicker
60
- value={model}
61
- onChange={onModelChange}
62
- disabled={isActive}
63
- />
64
-
55
+ <div className="px-4 py-3">
56
+ <div className="flex items-end gap-2 rounded-2xl bg-surface-3/60 border border-border-subtle px-3 py-2 focus-within:border-accent/30 transition-colors">
65
57
  <input
66
58
  ref={fileInputRef}
67
59
  type="file"
@@ -73,7 +65,7 @@ export function ChatInput({ onSend, onStop, onModelChange, model, sending, strea
73
65
  <button
74
66
  onClick={() => fileInputRef.current?.click()}
75
67
  disabled={disabled}
76
- className="w-9 h-9 flex items-center justify-center rounded-lg text-text-4 hover:text-text-1 hover:bg-surface-3 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
68
+ className="w-8 h-8 flex items-center justify-center rounded-lg text-text-4 hover:text-text-1 hover:bg-surface-4 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed flex-shrink-0"
77
69
  title="Attach file"
78
70
  >
79
71
  <Paperclip size={15} />
@@ -87,20 +79,14 @@ export function ChatInput({ onSend, onStop, onModelChange, model, sending, strea
87
79
  placeholder={disabled ? 'Select a model to start chatting...' : 'Send a message...'}
88
80
  disabled={disabled}
89
81
  rows={1}
90
- className={cn(
91
- 'flex-1 resize-none rounded-xl px-4 py-2.5 text-sm',
92
- 'bg-surface-0 border text-text-0 font-sans',
93
- 'placeholder:text-text-4',
94
- 'focus:outline-none focus:ring-1',
95
- 'border-border focus:ring-accent/40',
96
- 'disabled:opacity-50 disabled:cursor-not-allowed',
97
- )}
82
+ style={{ minHeight: '36px' }}
83
+ className="flex-1 resize-none bg-transparent text-sm text-text-0 font-sans placeholder:text-text-4 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed py-1.5"
98
84
  />
99
85
 
100
86
  {isActive ? (
101
87
  <button
102
88
  onClick={onStop}
103
- className="w-9 h-9 flex items-center justify-center rounded-xl bg-danger/80 text-white hover:bg-danger transition-all cursor-pointer shadow-lg shadow-danger/20"
89
+ className="w-8 h-8 flex items-center justify-center rounded-xl bg-danger/80 text-white hover:bg-danger transition-all cursor-pointer shadow-lg shadow-danger/20 flex-shrink-0"
104
90
  title="Stop generation"
105
91
  >
106
92
  <Square size={14} fill="currentColor" />
@@ -110,7 +96,7 @@ export function ChatInput({ onSend, onStop, onModelChange, model, sending, strea
110
96
  onClick={handleSend}
111
97
  disabled={!canSend}
112
98
  className={cn(
113
- 'w-9 h-9 flex items-center justify-center rounded-xl transition-all cursor-pointer',
99
+ 'w-8 h-8 flex items-center justify-center rounded-xl transition-all cursor-pointer flex-shrink-0',
114
100
  'disabled:opacity-20 disabled:cursor-not-allowed',
115
101
  canSend
116
102
  ? 'bg-accent/15 text-accent hover:bg-accent/25 border border-accent/25'
@@ -64,6 +64,8 @@ export function ChatView() {
64
64
  const setActiveConversation = useGrooveStore((s) => s.setActiveConversation);
65
65
  const sendChatMessage = useGrooveStore((s) => s.sendChatMessage);
66
66
  const stopAgent = useGrooveStore((s) => s.stopAgent);
67
+ const stopChatStreaming = useGrooveStore((s) => s.stopChatStreaming);
68
+ const setConversationMode = useGrooveStore((s) => s.setConversationMode);
67
69
 
68
70
  const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
69
71
 
@@ -73,21 +75,30 @@ export function ChatView() {
73
75
 
74
76
  const handleNewChat = useCallback(async (provider, model) => {
75
77
  const p = provider || 'claude-code';
76
- const m = model || 'sonnet';
78
+ const m = model || 'claude-sonnet-4-6';
77
79
  try {
78
- await createConversation(p, m);
80
+ await createConversation(p, m, 'api');
79
81
  } catch { /* toast handles */ }
80
82
  }, [createConversation]);
81
83
 
84
+ const handleModeChange = useCallback((mode) => {
85
+ if (!activeConversationId) return;
86
+ setConversationMode(activeConversationId, mode);
87
+ }, [activeConversationId, setConversationMode]);
88
+
82
89
  const handleSend = useCallback((text) => {
83
90
  if (!activeConversationId) return;
84
91
  sendChatMessage(activeConversationId, text);
85
92
  }, [activeConversationId, sendChatMessage]);
86
93
 
87
94
  const handleStop = useCallback(() => {
88
- if (!activeConversation?.agentId) return;
89
- stopAgent(activeConversation.agentId);
90
- }, [activeConversation, stopAgent]);
95
+ if (!activeConversation) return;
96
+ if (activeConversation.mode === 'agent' && activeConversation.agentId) {
97
+ stopAgent(activeConversation.agentId);
98
+ } else {
99
+ stopChatStreaming(activeConversationId);
100
+ }
101
+ }, [activeConversation, activeConversationId, stopAgent, stopChatStreaming]);
91
102
 
92
103
  const handleModelChange = useCallback(async (selection) => {
93
104
  if (activeConversationId) {
@@ -115,7 +126,7 @@ export function ChatView() {
115
126
  <div className="flex-1 flex flex-col min-w-0">
116
127
  {activeConversation ? (
117
128
  <>
118
- <ChatHeader conversation={activeConversation} />
129
+ <ChatHeader conversation={activeConversation} model={currentModel} onModelChange={handleModelChange} onModeChange={handleModeChange} />
119
130
  <ChatMessages
120
131
  messages={messages}
121
132
  isStreaming={isStreaming}
@@ -124,8 +135,6 @@ export function ChatView() {
124
135
  <ChatInput
125
136
  onSend={handleSend}
126
137
  onStop={handleStop}
127
- onModelChange={handleModelChange}
128
- model={currentModel}
129
138
  sending={sendingMessage}
130
139
  streaming={isStreaming}
131
140
  disabled={false}
@@ -144,8 +153,6 @@ export function ChatView() {
144
153
  });
145
154
  }}
146
155
  onStop={() => {}}
147
- onModelChange={handleModelChange}
148
- model={currentModel}
149
156
  sending={false}
150
157
  streaming={false}
151
158
  disabled={false}
@@ -1,11 +1,12 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
2
  import { useMemo } from 'react';
3
- import { Plus, MessageCircle, Pin, Pencil, PinOff, Trash2 } from 'lucide-react';
3
+ import { Plus, MessageCircle, Pin, Pencil, PinOff, Trash2, Zap, Bot } from 'lucide-react';
4
4
  import { useGrooveStore } from '../../stores/groove';
5
5
  import { cn } from '../../lib/cn';
6
6
  import { Badge } from '../ui/badge';
7
7
  import { timeAgo } from '../../lib/format';
8
8
  import { ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuItem, ContextMenuSeparator } from '../ui/context-menu';
9
+ import { formatModelName } from './model-picker';
9
10
 
10
11
  function groupByDate(conversations) {
11
12
  const now = new Date();
@@ -52,7 +53,11 @@ function ConversationItem({ conv, isActive, onSelect, onRename, onPin, onDelete
52
53
  <div className="flex-1 min-w-0">
53
54
  <div className="text-xs font-medium font-sans truncate">{conv.title || 'New Chat'}</div>
54
55
  <div className="flex items-center gap-1.5 mt-0.5">
55
- {conv.model && <Badge variant="default" className="text-[8px] px-1 py-0">{conv.model}</Badge>}
56
+ {conv.mode === 'agent'
57
+ ? <Bot size={9} className="text-purple flex-shrink-0" />
58
+ : <Zap size={9} className="text-accent flex-shrink-0" />
59
+ }
60
+ {conv.model && <Badge variant="default" className="text-[8px] px-1 py-0">{formatModelName(conv.model)}</Badge>}
56
61
  <span className="text-2xs text-text-4 font-sans">{timeAgo(conv.updatedAt || conv.createdAt)}</span>
57
62
  </div>
58
63
  </div>
@@ -117,17 +122,7 @@ export function ConversationList({ onNewChat }) {
117
122
 
118
123
  return (
119
124
  <div className="flex flex-col h-full">
120
- <div className="p-3">
121
- <button
122
- onClick={onNewChat}
123
- 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"
124
- >
125
- <Plus size={14} />
126
- New Chat
127
- </button>
128
- </div>
129
-
130
- <div className="flex-1 overflow-y-auto px-1.5 pb-3 space-y-0.5">
125
+ <div className="flex-1 overflow-y-auto px-1.5 pt-3 pb-3 space-y-0.5">
131
126
  {conversations.length === 0 ? (
132
127
  <div className="flex flex-col items-center justify-center py-16 text-center px-4">
133
128
  <MessageCircle size={24} className="text-text-4 mb-3" />
@@ -144,6 +139,16 @@ export function ConversationList({ onNewChat }) {
144
139
  </>
145
140
  )}
146
141
  </div>
142
+
143
+ <div className="p-3 border-t border-border-subtle">
144
+ <button
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"
147
+ >
148
+ <Plus size={14} />
149
+ New Chat
150
+ </button>
151
+ </div>
147
152
  </div>
148
153
  );
149
154
  }
@@ -5,6 +5,16 @@ import { useGrooveStore } from '../../stores/groove';
5
5
  import { cn } from '../../lib/cn';
6
6
  import { Badge } from '../ui/badge';
7
7
 
8
+ export function formatModelName(id) {
9
+ if (!id) return '';
10
+ return id
11
+ .replace(/^claude-/, '')
12
+ .replace(/-(\d)/, ' $1')
13
+ .split('-')
14
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
15
+ .join(' ');
16
+ }
17
+
8
18
  const TIER_CONFIG = {
9
19
  frontier: { label: 'Frontier', variant: 'purple', icon: Sparkles },
10
20
  mid: { label: 'Mid', variant: 'accent', icon: Zap },
@@ -50,7 +60,8 @@ export function ModelPicker({ value, onChange, disabled }) {
50
60
  return () => document.removeEventListener('mousedown', handleClick);
51
61
  }, [open]);
52
62
 
53
- const currentModel = value?.model || 'Select model';
63
+ const currentModel = value?.model || '';
64
+ const currentModelDisplay = currentModel ? formatModelName(currentModel) : 'Select model';
54
65
  const currentProvider = value?.provider || '';
55
66
  const isNetwork = currentProvider === 'groove-network';
56
67
 
@@ -67,12 +78,12 @@ export function ModelPicker({ value, onChange, disabled }) {
67
78
  )}
68
79
  >
69
80
  {isNetwork ? <Globe size={12} className="text-purple" /> : <Cpu size={12} className="text-text-3" />}
70
- <span className="text-text-1 max-w-[120px] truncate">{currentModel}</span>
81
+ <span className="text-text-1 max-w-[120px] truncate">{currentModelDisplay}</span>
71
82
  <ChevronDown size={12} className="text-text-4" />
72
83
  </button>
73
84
 
74
85
  {open && (
75
- <div className="absolute bottom-full left-0 mb-1 w-72 max-h-80 overflow-y-auto rounded-lg border border-border bg-surface-1 shadow-xl z-50">
86
+ <div className="absolute top-full left-0 mt-1 w-72 max-h-80 overflow-y-auto rounded-lg border border-border bg-surface-1 shadow-xl z-50">
76
87
  {providers.length === 0 && (
77
88
  <div className="px-4 py-6 text-center text-xs text-text-3 font-sans">No providers available</div>
78
89
  )}
@@ -87,16 +98,17 @@ export function ModelPicker({ value, onChange, disabled }) {
87
98
  {provider.name || provider.id}
88
99
  </div>
89
100
  {models.map((model) => {
90
- const modelName = typeof model === 'string' ? model : model.name || model.id;
91
- const tier = getTier(modelName);
101
+ const modelId = typeof model === 'string' ? model : model.id || model.name;
102
+ const modelDisplayName = typeof model === 'string' ? model : model.name || model.id;
103
+ const tier = getTier(modelId);
92
104
  const tierConfig = TIER_CONFIG[tier];
93
105
  const TierIcon = tierConfig.icon;
94
- const isActive = currentModel === modelName && currentProvider === provider.id;
106
+ const isActive = currentModel === modelId && currentProvider === provider.id;
95
107
  return (
96
108
  <button
97
- key={modelName}
109
+ key={modelId}
98
110
  onClick={() => {
99
- onChange({ provider: provider.id, model: modelName });
111
+ onChange({ provider: provider.id, model: modelId });
100
112
  setOpen(false);
101
113
  }}
102
114
  className={cn(
@@ -105,8 +117,8 @@ export function ModelPicker({ value, onChange, disabled }) {
105
117
  )}
106
118
  >
107
119
  <div className="flex-1 min-w-0">
108
- <div className="text-xs font-medium font-sans truncate">{modelName}</div>
109
- <div className="text-2xs text-text-4 font-sans">{getContextSize(modelName)} context</div>
120
+ <div className="text-xs font-medium font-sans truncate">{modelDisplayName}</div>
121
+ <div className="text-2xs text-text-4 font-sans">{getContextSize(modelId)} context</div>
110
122
  </div>
111
123
  <div className="flex items-center gap-1.5 flex-shrink-0">
112
124
  {isNetworkProvider && (
@@ -799,6 +799,49 @@ export const useGrooveStore = create((set, get) => ({
799
799
  }
800
800
  break;
801
801
  }
802
+
803
+ case 'conversation:chunk': {
804
+ const { conversationId, text } = msg.data || msg;
805
+ if (!conversationId || !text) break;
806
+ set((s) => {
807
+ const msgs = { ...s.conversationMessages };
808
+ if (!msgs[conversationId]) msgs[conversationId] = [];
809
+ const arr = [...msgs[conversationId]];
810
+ const last = arr[arr.length - 1];
811
+ if (last && last.from === 'assistant' && (Date.now() - last.timestamp) < 30000) {
812
+ arr[arr.length - 1] = { ...last, text: last.text + text, timestamp: Date.now() };
813
+ } else {
814
+ arr.push({ from: 'assistant', text, timestamp: Date.now() });
815
+ }
816
+ msgs[conversationId] = arr.slice(-200);
817
+ persistJSON('groove:conversationMessages', msgs);
818
+ return { conversationMessages: msgs, streamingConversationId: conversationId };
819
+ });
820
+ break;
821
+ }
822
+
823
+ case 'conversation:complete': {
824
+ const { conversationId } = msg.data || msg;
825
+ if (conversationId) {
826
+ set({ sendingMessage: false, streamingConversationId: null });
827
+ persistJSON('groove:conversationMessages', get().conversationMessages);
828
+ }
829
+ break;
830
+ }
831
+
832
+ case 'conversation:error': {
833
+ const { conversationId, error } = msg.data || msg;
834
+ if (conversationId) {
835
+ set((s) => {
836
+ const msgs = { ...s.conversationMessages };
837
+ if (!msgs[conversationId]) msgs[conversationId] = [];
838
+ msgs[conversationId] = [...msgs[conversationId], { from: 'system', text: `Error: ${error || 'Unknown error'}`, timestamp: Date.now() }];
839
+ persistJSON('groove:conversationMessages', msgs);
840
+ return { conversationMessages: msgs, sendingMessage: false, streamingConversationId: null };
841
+ });
842
+ }
843
+ break;
844
+ }
802
845
  }
803
846
  };
804
847
 
@@ -1604,11 +1647,11 @@ export const useGrooveStore = create((set, get) => ({
1604
1647
  } catch { /* endpoint may not exist yet */ }
1605
1648
  },
1606
1649
 
1607
- async createConversation(provider, model) {
1650
+ async createConversation(provider, model, mode = 'api') {
1608
1651
  try {
1609
- const conv = await api.post('/conversations', { provider, model });
1652
+ const conv = await api.post('/conversations', { provider, model, mode });
1610
1653
  set((s) => ({
1611
- conversations: [conv, ...s.conversations],
1654
+ conversations: [conv, ...s.conversations.filter((c) => c.id !== conv.id)],
1612
1655
  activeConversationId: conv.id,
1613
1656
  }));
1614
1657
  localStorage.setItem('groove:activeConversationId', conv.id);
@@ -1619,6 +1662,22 @@ export const useGrooveStore = create((set, get) => ({
1619
1662
  }
1620
1663
  },
1621
1664
 
1665
+ async setConversationMode(id, mode) {
1666
+ try {
1667
+ const conv = await api.patch(`/conversations/${encodeURIComponent(id)}`, { mode });
1668
+ set((s) => ({ conversations: s.conversations.map((c) => c.id === id ? { ...c, ...conv } : c) }));
1669
+ } catch (err) {
1670
+ get().addToast('error', 'Mode change failed', err.message);
1671
+ }
1672
+ },
1673
+
1674
+ async stopChatStreaming(conversationId) {
1675
+ try {
1676
+ await api.post(`/conversations/${encodeURIComponent(conversationId)}/stop`);
1677
+ set({ sendingMessage: false, streamingConversationId: null });
1678
+ } catch { /* ignore */ }
1679
+ },
1680
+
1622
1681
  async deleteConversation(id) {
1623
1682
  try {
1624
1683
  await api.delete(`/conversations/${encodeURIComponent(id)}`);
@@ -1675,8 +1734,12 @@ export const useGrooveStore = create((set, get) => ({
1675
1734
  });
1676
1735
 
1677
1736
  try {
1678
- await api.post(`/conversations/${encodeURIComponent(conversationId)}/message`, { message });
1679
- // Response arrives via agent:output WebSocket events
1737
+ const body = { message };
1738
+ if (conv.mode === 'api' || !conv.mode) {
1739
+ const history = get().conversationMessages[conversationId] || [];
1740
+ body.history = history.slice(0, -1);
1741
+ }
1742
+ await api.post(`/conversations/${encodeURIComponent(conversationId)}/message`, body);
1680
1743
  } catch (err) {
1681
1744
  set((s) => {
1682
1745
  const msgs = { ...s.conversationMessages };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "groove-dev",
3
- "version": "0.27.56",
3
+ "version": "0.27.57",
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.56",
3
+ "version": "0.27.57",
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.56",
3
+ "version": "0.27.57",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -818,12 +818,15 @@ export function createApi(app, daemon) {
818
818
 
819
819
  app.post('/api/conversations', async (req, res) => {
820
820
  try {
821
- const { provider, model, title } = req.body;
821
+ const { provider, model, title, mode } = req.body;
822
822
  if (!provider || typeof provider !== 'string') {
823
823
  return res.status(400).json({ error: 'provider is required' });
824
824
  }
825
- const conversation = await daemon.conversations.create(provider, model, title);
826
- daemon.audit.log('conversation.create', { id: conversation.id, provider, model });
825
+ if (mode && mode !== 'api' && mode !== 'agent') {
826
+ return res.status(400).json({ error: 'mode must be "api" or "agent"' });
827
+ }
828
+ const conversation = await daemon.conversations.create(provider, model, title, mode || 'api');
829
+ daemon.audit.log('conversation.create', { id: conversation.id, provider, model, mode: conversation.mode });
827
830
  res.status(201).json(conversation);
828
831
  } catch (err) {
829
832
  res.status(400).json({ error: err.message });
@@ -836,14 +839,20 @@ export function createApi(app, daemon) {
836
839
  res.json(conversation);
837
840
  });
838
841
 
839
- app.patch('/api/conversations/:id', (req, res) => {
842
+ app.patch('/api/conversations/:id', async (req, res) => {
840
843
  try {
841
844
  const conv = daemon.conversations.get(req.params.id);
842
845
  if (!conv) return res.status(404).json({ error: 'Conversation not found' });
843
846
  if (req.body.title !== undefined) daemon.conversations.rename(req.params.id, req.body.title);
844
847
  if (req.body.pinned !== undefined) daemon.conversations.pin(req.params.id, req.body.pinned);
845
848
  if (req.body.archived !== undefined) daemon.conversations.archive(req.params.id, req.body.archived);
846
- daemon.audit.log('conversation.update', { id: req.params.id });
849
+ if (req.body.mode !== undefined) {
850
+ if (req.body.mode !== 'api' && req.body.mode !== 'agent') {
851
+ return res.status(400).json({ error: 'mode must be "api" or "agent"' });
852
+ }
853
+ await daemon.conversations.setMode(req.params.id, req.body.mode);
854
+ }
855
+ daemon.audit.log('conversation.update', { id: req.params.id, mode: req.body.mode });
847
856
  res.json(daemon.conversations.get(req.params.id));
848
857
  } catch (err) {
849
858
  res.status(400).json({ error: err.message });
@@ -864,19 +873,27 @@ export function createApi(app, daemon) {
864
873
 
865
874
  app.post('/api/conversations/:id/message', async (req, res) => {
866
875
  try {
867
- const { message } = req.body;
876
+ const { message, history } = req.body;
868
877
  if (!message || typeof message !== 'string' || !message.trim()) {
869
878
  return res.status(400).json({ error: 'message is required' });
870
879
  }
871
880
  const conv = daemon.conversations.get(req.params.id);
872
881
  if (!conv) return res.status(404).json({ error: 'Conversation not found' });
873
882
 
874
- const agent = daemon.registry.get(conv.agentId);
875
- if (!agent) return res.status(400).json({ error: 'Agent no longer exists' });
876
-
877
883
  daemon.conversations.autoTitle(req.params.id, message.trim());
878
884
  daemon.conversations.touchUpdatedAt(req.params.id);
879
885
 
886
+ // API mode — lightweight headless streaming, no agent spawned
887
+ if (conv.mode === 'api' || !conv.agentId) {
888
+ await daemon.conversations.sendMessage(req.params.id, message.trim(), history || []);
889
+ daemon.audit.log('conversation.message', { id: req.params.id, mode: 'api' });
890
+ return res.json({ status: 'streaming', mode: 'api' });
891
+ }
892
+
893
+ // Agent mode — existing behavior
894
+ const agent = daemon.registry.get(conv.agentId);
895
+ if (!agent) return res.status(400).json({ error: 'Agent no longer exists' });
896
+
880
897
  // Record user feedback for journalist context
881
898
  if (daemon.journalist) daemon.journalist.recordUserFeedback(agent, message.trim());
882
899
 
@@ -951,6 +968,17 @@ export function createApi(app, daemon) {
951
968
  }
952
969
  });
953
970
 
971
+ app.post('/api/conversations/:id/stop', (req, res) => {
972
+ try {
973
+ const conv = daemon.conversations.get(req.params.id);
974
+ if (!conv) return res.status(404).json({ error: 'Conversation not found' });
975
+ daemon.conversations.stopStreaming(req.params.id);
976
+ res.json({ ok: true });
977
+ } catch (err) {
978
+ res.status(400).json({ error: err.message });
979
+ }
980
+ });
981
+
954
982
  // --- Approvals ---
955
983
 
956
984
  app.get('/api/approvals', (req, res) => {