groove-dev 0.27.181 → 0.27.183

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 (63) hide show
  1. package/node_modules/@groove-dev/cli/package.json +1 -1
  2. package/node_modules/@groove-dev/daemon/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/scripts/repair-tokens.mjs +78 -0
  4. package/node_modules/@groove-dev/daemon/src/api.js +1 -1
  5. package/node_modules/@groove-dev/daemon/src/deliver.js +130 -0
  6. package/node_modules/@groove-dev/daemon/src/index.js +8 -3
  7. package/node_modules/@groove-dev/daemon/src/innerchat.js +213 -62
  8. package/node_modules/@groove-dev/daemon/src/journalist.js +15 -8
  9. package/node_modules/@groove-dev/daemon/src/process.js +59 -34
  10. package/node_modules/@groove-dev/daemon/src/providers/claude-code.js +9 -1
  11. package/node_modules/@groove-dev/daemon/src/registry.js +5 -1
  12. package/node_modules/@groove-dev/daemon/src/rename.js +72 -0
  13. package/node_modules/@groove-dev/daemon/src/rotator.js +62 -5
  14. package/node_modules/@groove-dev/daemon/src/routes/agents.js +22 -100
  15. package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +12 -9
  16. package/node_modules/@groove-dev/daemon/src/teams.js +7 -1
  17. package/node_modules/@groove-dev/daemon/src/tokentracker.js +46 -6
  18. package/node_modules/@groove-dev/daemon/test/claude-code-provider.test.js +52 -0
  19. package/node_modules/@groove-dev/daemon/test/innerchat.test.js +197 -111
  20. package/node_modules/@groove-dev/daemon/test/journalist.test.js +4 -2
  21. package/node_modules/@groove-dev/daemon/test/rename.test.js +108 -0
  22. package/node_modules/@groove-dev/daemon/test/rotator.test.js +131 -0
  23. package/node_modules/@groove-dev/daemon/test/tokentracker.test.js +4 -0
  24. package/node_modules/@groove-dev/gui/dist/assets/{index-DTFtRtkx.css → index-CiOy7wVS.css} +1 -1
  25. package/node_modules/@groove-dev/gui/dist/assets/{index-CTer01Vg.js → index-DPjGBQ5X.js} +225 -225
  26. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  27. package/node_modules/@groove-dev/gui/package.json +1 -1
  28. package/node_modules/@groove-dev/gui/src/components/agents/agent-panel.jsx +3 -68
  29. package/node_modules/@groove-dev/gui/src/components/agents/innerchat-relay.jsx +145 -0
  30. package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +3 -1
  31. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-pane.jsx +15 -1
  32. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-sidebar.jsx +33 -1
  33. package/node_modules/@groove-dev/gui/src/stores/groove.js +29 -44
  34. package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +27 -4
  35. package/package.json +1 -1
  36. package/packages/cli/package.json +1 -1
  37. package/packages/daemon/package.json +1 -1
  38. package/packages/daemon/scripts/repair-tokens.mjs +78 -0
  39. package/packages/daemon/src/api.js +1 -1
  40. package/packages/daemon/src/deliver.js +130 -0
  41. package/packages/daemon/src/index.js +8 -3
  42. package/packages/daemon/src/innerchat.js +213 -62
  43. package/packages/daemon/src/journalist.js +15 -8
  44. package/packages/daemon/src/process.js +59 -34
  45. package/packages/daemon/src/providers/claude-code.js +9 -1
  46. package/packages/daemon/src/registry.js +5 -1
  47. package/packages/daemon/src/rename.js +72 -0
  48. package/packages/daemon/src/rotator.js +62 -5
  49. package/packages/daemon/src/routes/agents.js +22 -100
  50. package/packages/daemon/src/routes/innerchat.js +12 -9
  51. package/packages/daemon/src/teams.js +7 -1
  52. package/packages/daemon/src/tokentracker.js +46 -6
  53. package/packages/gui/dist/assets/{index-DTFtRtkx.css → index-CiOy7wVS.css} +1 -1
  54. package/packages/gui/dist/assets/{index-CTer01Vg.js → index-DPjGBQ5X.js} +225 -225
  55. package/packages/gui/dist/index.html +2 -2
  56. package/packages/gui/package.json +1 -1
  57. package/packages/gui/src/components/agents/agent-panel.jsx +3 -68
  58. package/packages/gui/src/components/agents/innerchat-relay.jsx +145 -0
  59. package/packages/gui/src/components/chat/chat-messages.jsx +3 -1
  60. package/packages/gui/src/components/fleet/fleet-pane.jsx +15 -1
  61. package/packages/gui/src/components/fleet/fleet-sidebar.jsx +33 -1
  62. package/packages/gui/src/stores/groove.js +29 -44
  63. package/packages/gui/src/stores/slices/agents-slice.js +27 -4
@@ -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-CTer01Vg.js"></script>
9
+ <script type="module" crossorigin src="/assets/index-DPjGBQ5X.js"></script>
10
10
  <link rel="modulepreload" crossorigin href="/assets/vendor-26L3JoZv.js">
11
11
  <link rel="modulepreload" crossorigin href="/assets/reactflow-DoBZjiHE.js">
12
12
  <link rel="modulepreload" crossorigin href="/assets/codemirror-BYKpdS2W.js">
13
13
  <link rel="modulepreload" crossorigin href="/assets/xterm--7_ns2zW.js">
14
- <link rel="stylesheet" crossorigin href="/assets/index-DTFtRtkx.css">
14
+ <link rel="stylesheet" crossorigin href="/assets/index-CiOy7wVS.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.181",
3
+ "version": "0.27.183",
4
4
  "description": "GROOVE GUI — visual agent control plane",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -6,7 +6,8 @@ import { AgentFeed } from './agent-feed';
6
6
  import { AgentConfig } from './agent-config';
7
7
  import { AgentTelemetry } from './agent-telemetry';
8
8
  import { AgentMdFiles } from './agent-mdfiles';
9
- import { MessageSquare, Settings, Activity, FileText, Pencil, Check, X, SendHorizontal, ChevronDown } from 'lucide-react';
9
+ import { InnerChatRelay } from './innerchat-relay';
10
+ import { MessageSquare, Settings, Activity, FileText, Pencil, Check, X } from 'lucide-react';
10
11
  import { fmtNum, fmtUptime } from '../../lib/format';
11
12
  import { cn } from '../../lib/cn';
12
13
  import { roleColor } from '../../lib/status';
@@ -77,72 +78,6 @@ function InlineName({ agent }) {
77
78
  );
78
79
  }
79
80
 
80
- function InnerChatCompose({ fromAgent, onClose }) {
81
- const agents = useGrooveStore((s) => s.agents);
82
- const sendInnerChat = useGrooveStore((s) => s.sendInnerChat);
83
- const addToast = useGrooveStore((s) => s.addToast);
84
- const [toId, setToId] = useState('');
85
- const [message, setMessage] = useState('');
86
- const [sending, setSending] = useState(false);
87
-
88
- const others = agents.filter((a) => a.id !== fromAgent.id && (a.status === 'running' || a.status === 'starting' || a.status === 'completed'));
89
-
90
- async function handleSend() {
91
- if (!toId || !message.trim() || sending) return;
92
- setSending(true);
93
- try {
94
- await sendInnerChat(fromAgent.id, toId, message.trim());
95
- const target = agents.find((a) => a.id === toId);
96
- addToast('success', `Sent to ${target?.name || toId}`, 'InnerChat message delivered');
97
- setMessage('');
98
- onClose();
99
- } catch (err) {
100
- addToast('error', 'InnerChat failed', err.message);
101
- } finally {
102
- setSending(false);
103
- }
104
- }
105
-
106
- return (
107
- <div className="flex-shrink-0 border-t border-border-subtle bg-surface-0 px-3 py-2.5 space-y-2">
108
- <div className="flex items-center justify-between">
109
- <span className="text-2xs font-semibold text-warning font-sans">InnerChat — relay to agent</span>
110
- <button onClick={onClose} className="p-0.5 text-text-4 hover:text-text-1 cursor-pointer"><X size={12} /></button>
111
- </div>
112
- <div className="relative">
113
- <select
114
- value={toId}
115
- onChange={(e) => setToId(e.target.value)}
116
- className="w-full h-7 pl-2 pr-6 text-xs bg-surface-3 border border-border-subtle rounded text-text-0 font-sans focus:outline-none focus:border-accent/40 appearance-none cursor-pointer"
117
- >
118
- <option value="">Select target agent…</option>
119
- {others.map((a) => (
120
- <option key={a.id} value={a.id}>{a.name} ({a.role})</option>
121
- ))}
122
- </select>
123
- <ChevronDown size={10} className="absolute right-2 top-1/2 -translate-y-1/2 text-text-4 pointer-events-none" />
124
- </div>
125
- <div className="flex gap-1.5">
126
- <textarea
127
- value={message}
128
- onChange={(e) => setMessage(e.target.value)}
129
- onKeyDown={(e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) handleSend(); }}
130
- placeholder="Message to relay… (⌘↵ to send)"
131
- rows={3}
132
- className="flex-1 px-2 py-1.5 text-xs bg-surface-3 border border-border-subtle rounded text-text-0 font-sans placeholder:text-text-4 focus:outline-none focus:border-accent/40 resize-none"
133
- />
134
- <button
135
- onClick={handleSend}
136
- disabled={!toId || !message.trim() || sending}
137
- className="w-8 flex-shrink-0 flex items-center justify-center rounded bg-accent/15 text-accent hover:bg-accent/25 disabled:opacity-30 disabled:cursor-not-allowed transition-colors cursor-pointer"
138
- >
139
- <SendHorizontal size={14} />
140
- </button>
141
- </div>
142
- </div>
143
- );
144
- }
145
-
146
81
  export function AgentPanel() {
147
82
  const detailPanel = useGrooveStore((s) => s.detailPanel);
148
83
  const agents = useGrooveStore((s) => s.agents);
@@ -251,7 +186,7 @@ export function AgentPanel() {
251
186
  <AgentFeed agent={agent} />
252
187
  </div>
253
188
  {innerChatOpen && (
254
- <InnerChatCompose fromAgent={agent} onClose={() => setInnerChatOpen(false)} />
189
+ <InnerChatRelay fromAgent={agent} onClose={() => setInnerChatOpen(false)} />
255
190
  )}
256
191
  </>
257
192
  )}
@@ -0,0 +1,145 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+ import { useState, useMemo } from 'react';
3
+ import { useGrooveStore } from '../../stores/groove';
4
+ import { SendHorizontal, ChevronDown, X } from 'lucide-react';
5
+ import { cn } from '../../lib/cn';
6
+
7
+ // Relay compose panel — pick any agent on any team and send a message on this
8
+ // agent's behalf. The reply is forwarded back automatically, so the user only
9
+ // drives the outbound hop.
10
+ export function InnerChatRelay({ fromAgent, onClose }) {
11
+ const agents = useGrooveStore((s) => s.agents);
12
+ const teams = useGrooveStore((s) => s.teams);
13
+ const threadsMap = useGrooveStore((s) => s.innerchatThreads);
14
+ const sendInnerChat = useGrooveStore((s) => s.sendInnerChat);
15
+ const addToast = useGrooveStore((s) => s.addToast);
16
+
17
+ const [toId, setToId] = useState('');
18
+ const [threadId, setThreadId] = useState('');
19
+ const [message, setMessage] = useState('');
20
+ const [sending, setSending] = useState(false);
21
+
22
+ // Every agent except this one, grouped by team. Stopped agents are included:
23
+ // delivery resumes them the same way a user chat message would.
24
+ const grouped = useMemo(() => {
25
+ const byTeam = new Map();
26
+ for (const a of agents) {
27
+ if (a.id === fromAgent.id) continue;
28
+ const key = a.teamId || 'untimed';
29
+ if (!byTeam.has(key)) byTeam.set(key, []);
30
+ byTeam.get(key).push(a);
31
+ }
32
+ return Array.from(byTeam.entries()).map(([teamId, list]) => ({
33
+ teamId,
34
+ name: teams.find((t) => t.id === teamId)?.name || 'No team',
35
+ agents: list.sort((a, b) => a.name.localeCompare(b.name)),
36
+ })).sort((a, b) => a.name.localeCompare(b.name));
37
+ }, [agents, teams, fromAgent.id]);
38
+
39
+ // Existing exchanges between this agent and the selected target.
40
+ const threads = useMemo(() => {
41
+ if (!toId) return [];
42
+ return Object.values(threadsMap)
43
+ .filter((t) => t.participants.some((p) => p.id === fromAgent.id)
44
+ && t.participants.some((p) => p.id === toId))
45
+ .sort((a, b) => b.updatedAt - a.updatedAt);
46
+ }, [threadsMap, toId, fromAgent.id]);
47
+
48
+ const target = agents.find((a) => a.id === toId);
49
+ const willResume = target && target.status !== 'running' && target.status !== 'starting';
50
+
51
+ async function handleSend() {
52
+ if (!toId || !message.trim() || sending) return;
53
+ setSending(true);
54
+ try {
55
+ await sendInnerChat(fromAgent.id, toId, message.trim(), threadId || null);
56
+ addToast('success', `Relayed to ${target?.name || toId}`,
57
+ willResume ? 'Target is being resumed to receive it' : undefined);
58
+ setMessage('');
59
+ onClose();
60
+ } catch {
61
+ // sendInnerChat already surfaced the error
62
+ } finally {
63
+ setSending(false);
64
+ }
65
+ }
66
+
67
+ return (
68
+ <div className="flex-shrink-0 border-t border-border-subtle bg-surface-0 px-3 py-2.5 space-y-2">
69
+ <div className="flex items-center justify-between">
70
+ <span className="text-2xs font-semibold text-warning font-sans">
71
+ Relay from {fromAgent.name}
72
+ </span>
73
+ <button onClick={onClose} className="p-0.5 text-text-4 hover:text-text-1 cursor-pointer">
74
+ <X size={12} />
75
+ </button>
76
+ </div>
77
+
78
+ <div className="relative">
79
+ <select
80
+ value={toId}
81
+ onChange={(e) => { setToId(e.target.value); setThreadId(''); }}
82
+ className="w-full h-7 pl-2 pr-6 text-xs bg-surface-3 border border-border-subtle rounded text-text-0 font-sans focus:outline-none focus:border-accent/40 appearance-none cursor-pointer"
83
+ >
84
+ <option value="">Select target agent…</option>
85
+ {grouped.map((g) => (
86
+ <optgroup key={g.teamId} label={g.name}>
87
+ {g.agents.map((a) => (
88
+ <option key={a.id} value={a.id}>
89
+ {a.name} ({a.role}){a.status === 'running' ? '' : ` — ${a.status}`}
90
+ </option>
91
+ ))}
92
+ </optgroup>
93
+ ))}
94
+ </select>
95
+ <ChevronDown size={10} className="absolute right-2 top-1/2 -translate-y-1/2 text-text-4 pointer-events-none" />
96
+ </div>
97
+
98
+ {threads.length > 0 && (
99
+ <div className="relative">
100
+ <select
101
+ value={threadId}
102
+ onChange={(e) => setThreadId(e.target.value)}
103
+ className="w-full h-7 pl-2 pr-6 text-xs bg-surface-3 border border-border-subtle rounded text-text-2 font-sans focus:outline-none focus:border-accent/40 appearance-none cursor-pointer"
104
+ >
105
+ <option value="">Start a new thread</option>
106
+ {threads.map((t) => (
107
+ <option key={t.id} value={t.id}>
108
+ Continue thread ({t.turns.length} turn{t.turns.length === 1 ? '' : 's'})
109
+ </option>
110
+ ))}
111
+ </select>
112
+ <ChevronDown size={10} className="absolute right-2 top-1/2 -translate-y-1/2 text-text-4 pointer-events-none" />
113
+ </div>
114
+ )}
115
+
116
+ <div className="flex gap-1.5">
117
+ <textarea
118
+ value={message}
119
+ onChange={(e) => setMessage(e.target.value)}
120
+ onKeyDown={(e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) handleSend(); }}
121
+ placeholder={`What should ${fromAgent.name} say? (⌘↵ to send)`}
122
+ rows={3}
123
+ className="flex-1 px-2 py-1.5 text-xs bg-surface-3 border border-border-subtle rounded text-text-0 font-sans placeholder:text-text-4 focus:outline-none focus:border-accent/40 resize-none"
124
+ />
125
+ <button
126
+ onClick={handleSend}
127
+ disabled={!toId || !message.trim() || sending}
128
+ className={cn(
129
+ 'w-8 flex-shrink-0 flex items-center justify-center rounded transition-colors cursor-pointer',
130
+ 'bg-accent/15 text-accent hover:bg-accent/25',
131
+ 'disabled:opacity-30 disabled:cursor-not-allowed',
132
+ )}
133
+ >
134
+ <SendHorizontal size={14} />
135
+ </button>
136
+ </div>
137
+
138
+ {willResume && (
139
+ <p className="text-2xs text-text-4 font-sans">
140
+ {target.name} is {target.status} — it will be resumed to receive this.
141
+ </p>
142
+ )}
143
+ </div>
144
+ );
145
+ }
@@ -399,7 +399,9 @@ function InnerChatMessage({ msg }) {
399
399
  <div className="max-w-[85%]">
400
400
  <div className="flex items-center gap-1.5 mb-1">
401
401
  <MessageSquare size={11} className="text-warning" />
402
- <span className="text-2xs font-sans font-medium text-warning">InnerChat</span>
402
+ <span className="text-2xs font-sans font-medium text-warning">
403
+ {msg.innerchat?.kind === 'reply' ? 'InnerChat reply' : 'InnerChat'}
404
+ </span>
403
405
  <span className="text-2xs font-sans text-text-3">from</span>
404
406
  <span className="text-2xs font-sans font-medium text-text-1">{senderName}</span>
405
407
  {senderRole && <span className="text-2xs font-sans text-text-3">{senderRole}</span>}
@@ -1,10 +1,11 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
2
  import { useEffect, useRef, useState } from 'react';
3
- import { X, PanelRight } from 'lucide-react';
3
+ import { X, PanelRight, MessageSquare } 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 { AgentFeed } from '../agents/agent-feed';
8
+ import { InnerChatRelay } from '../agents/innerchat-relay';
8
9
  import { fmtNum } from '../../lib/format';
9
10
 
10
11
  const STATUS_VARIANT = {
@@ -39,6 +40,7 @@ export function FleetPane({ agentId, paneIndex, readOnly = false }) {
39
40
  const openDetail = useGrooveStore((s) => s.openDetail);
40
41
  const detailPanel = useGrooveStore((s) => s.detailPanel);
41
42
  const isPanelOpen = detailPanel?.type === 'agent' && detailPanel?.agentId === resolvedId;
43
+ const [relayOpen, setRelayOpen] = useState(false);
42
44
 
43
45
  const lastAgentRef = useRef(liveAgent);
44
46
  const [gone, setGone] = useState(false);
@@ -95,6 +97,16 @@ export function FleetPane({ agentId, paneIndex, readOnly = false }) {
95
97
  )}>
96
98
  {ctxPct}%
97
99
  </span>
100
+ <button
101
+ onClick={() => setRelayOpen((v) => !v)}
102
+ className={cn(
103
+ 'p-1 rounded-md transition-colors cursor-pointer',
104
+ relayOpen ? 'text-warning' : 'text-text-3 hover:text-warning hover:bg-surface-3',
105
+ )}
106
+ title="Relay a message to another agent"
107
+ >
108
+ <MessageSquare size={14} />
109
+ </button>
98
110
  <button
99
111
  onClick={() => openDetail({ type: 'agent', agentId: resolvedId })}
100
112
  className={cn(
@@ -118,6 +130,8 @@ export function FleetPane({ agentId, paneIndex, readOnly = false }) {
118
130
  <div className="flex-1 min-h-0">
119
131
  <AgentFeed agent={agent} readOnly={readOnly} />
120
132
  </div>
133
+
134
+ {relayOpen && <InnerChatRelay fromAgent={agent} onClose={() => setRelayOpen(false)} />}
121
135
  </div>
122
136
  );
123
137
  }
@@ -26,6 +26,9 @@ export function FleetSidebar({ width }) {
26
26
  const openDetail = useGrooveStore((s) => s.openDetail);
27
27
  const addToast = useGrooveStore((s) => s.addToast);
28
28
 
29
+ const moveAgentToTeam = useGrooveStore((s) => s.moveAgentToTeam);
30
+
31
+ const [dropTeamId, setDropTeamId] = useState(null);
29
32
  const [confirmDeleteTeam, setConfirmDeleteTeam] = useState(null);
30
33
  const [renamingTeamId, setRenamingTeamId] = useState(null);
31
34
  const [renameValue, setRenameValue] = useState('');
@@ -125,6 +128,22 @@ export function FleetSidebar({ width }) {
125
128
  setRenamingTeamId(null);
126
129
  }
127
130
 
131
+ // Team headers accept agent rows dragged from any other team.
132
+ function handleDragOver(e, teamId) {
133
+ if (!e.dataTransfer.types.includes('application/x-fleet-agent')) return;
134
+ e.preventDefault();
135
+ e.dataTransfer.dropEffect = 'link';
136
+ if (dropTeamId !== teamId) setDropTeamId(teamId);
137
+ }
138
+
139
+ function handleDrop(e, teamId) {
140
+ const agentId = e.dataTransfer.getData('application/x-fleet-agent');
141
+ setDropTeamId(null);
142
+ if (!agentId) return;
143
+ e.preventDefault();
144
+ moveAgentToTeam(agentId, teamId);
145
+ }
146
+
128
147
  function startRename(e, team) {
129
148
  e.stopPropagation();
130
149
  setRenamingTeamId(team.id);
@@ -167,7 +186,20 @@ export function FleetSidebar({ width }) {
167
186
  const isConfirming = confirmDeleteTeam === team.id;
168
187
 
169
188
  return (
170
- <div key={team.id} className="mb-0.5">
189
+ <div
190
+ key={team.id}
191
+ className={cn(
192
+ 'mb-0.5 rounded-md transition-colors',
193
+ dropTeamId === team.id && 'bg-accent/10 ring-1 ring-accent/40',
194
+ )}
195
+ onDragOver={(e) => handleDragOver(e, team.id)}
196
+ onDragLeave={(e) => {
197
+ // Ignore moves onto descendants — they still count as "inside".
198
+ if (e.currentTarget.contains(e.relatedTarget)) return;
199
+ setDropTeamId((id) => (id === team.id ? null : id));
200
+ }}
201
+ onDrop={(e) => handleDrop(e, team.id)}
202
+ >
171
203
  {/* Team header */}
172
204
  <div className={cn(
173
205
  'w-full flex items-center gap-1 px-2 py-1.5 rounded-md hover:bg-surface-2 transition-colors group',
@@ -369,54 +369,39 @@ export const useGrooveStore = create((set, get) => ({
369
369
  break;
370
370
  }
371
371
 
372
- case 'innerchat:sent': {
373
- const ic = msg.data;
374
- get().addChatMessage(ic.to.id, 'innerchat', ic.message, false);
375
- set((s) => {
376
- const icMsgs = { ...s.innerchatMessages };
377
- icMsgs[ic.id] = ic;
378
- persistJSON('groove:innerchatMessages', icMsgs);
379
- return { innerchatMessages: icMsgs };
380
- });
381
- // Tag the chat message with innerchat metadata
382
- set((s) => {
383
- const history = { ...s.chatHistory };
384
- const arr = history[ic.to.id] || [];
385
- if (arr.length > 0) {
386
- const last = arr[arr.length - 1];
387
- if (last.from === 'innerchat' && last.text === ic.message) {
388
- arr[arr.length - 1] = { ...last, innerchat: { messageId: ic.id, fromAgent: ic.from } };
389
- history[ic.to.id] = arr;
390
- persistJSON('groove:chatHistory', history);
391
- return { chatHistory: history };
392
- }
393
- }
394
- return {};
395
- });
396
- break;
397
- }
372
+ case 'innerchat:turn': {
373
+ const { thread, turn } = msg.data;
374
+ if (turn.status === 'failed') {
375
+ get().addToast('error', `Relay to ${turn.to.name} failed`, turn.error);
376
+ }
398
377
 
399
- case 'innerchat:response': {
400
- const ic = msg.data;
401
- get().addChatMessage(ic.from.id, 'innerchat', ic.response, false);
378
+ // The turn lands in the recipient's chat, badged with who sent it.
402
379
  set((s) => {
403
- const icMsgs = { ...s.innerchatMessages };
404
- icMsgs[ic.id] = ic;
405
- persistJSON('groove:innerchatMessages', icMsgs);
406
- // Tag the response chat message with innerchat metadata
407
380
  const history = { ...s.chatHistory };
408
- const arr = history[ic.from.id] || [];
409
- if (arr.length > 0) {
410
- const last = arr[arr.length - 1];
411
- if (last.from === 'innerchat' && last.text === ic.response) {
412
- arr[arr.length - 1] = { ...last, innerchat: { messageId: ic.id, fromAgent: ic.to } };
413
- history[ic.from.id] = arr;
414
- persistJSON('groove:chatHistory', history);
415
- }
416
- }
417
- return { innerchatMessages: icMsgs, chatHistory: history };
381
+ const arr = [...(history[turn.to.id] || [])];
382
+ arr.push({
383
+ from: 'innerchat',
384
+ text: turn.text,
385
+ timestamp: turn.timestamp,
386
+ isQuery: false,
387
+ innerchat: {
388
+ turnId: turn.id,
389
+ threadId: thread.id,
390
+ kind: turn.kind,
391
+ fromAgent: turn.from,
392
+ },
393
+ });
394
+ history[turn.to.id] = arr.slice(-100);
395
+ persistJSON('groove:chatHistory', history);
396
+
397
+ const threads = { ...s.innerchatThreads, [thread.id]: thread };
398
+ persistJSON('groove:innerchatThreads', threads);
399
+ return { chatHistory: history, innerchatThreads: threads };
418
400
  });
419
- get().addToast('info', `${ic.to.name} replied to ${ic.from.name}`, 'InnerChat response received');
401
+
402
+ if (turn.kind === 'reply' && turn.status !== 'failed') {
403
+ get().addToast('info', `${turn.from.name} replied`, `Forwarded to ${turn.to.name}`);
404
+ }
420
405
  break;
421
406
  }
422
407
 
@@ -104,20 +104,43 @@ export const createAgentsSlice = (set, get) => ({
104
104
  }
105
105
  },
106
106
 
107
+ // Move an agent to another team. Teams are an organizational grouping —
108
+ // this re-labels the agent and does NOT relocate its working directory, so a
109
+ // running process keeps working where it is.
110
+ async moveAgentToTeam(agentId, teamId) {
111
+ const agent = get().agents.find((a) => a.id === agentId);
112
+ if (!agent || agent.teamId === teamId) return;
113
+ const team = get().teams.find((t) => t.id === teamId);
114
+ try {
115
+ await api.patch(`/agents/${encodeURIComponent(agentId)}`, { teamId });
116
+ get().addToast('success', `Moved ${agent.name} → ${team?.name || 'team'}`);
117
+ } catch (err) {
118
+ get().addToast('error', 'Move failed', err.message);
119
+ }
120
+ },
121
+
107
122
  // ── InnerChat ─────────────────────────────────────────────
108
123
 
109
- innerchatMessages: loadJSON('groove:innerchatMessages') || {},
124
+ innerchatThreads: loadJSON('groove:innerchatThreads') || {},
110
125
 
111
- async sendInnerChat(fromId, toId, message) {
126
+ // Relay a message from one agent to another. Pass threadId to continue an
127
+ // existing exchange rather than opening a new one.
128
+ async sendInnerChat(fromId, toId, message, threadId = null) {
112
129
  try {
113
- const msg = await api.post('/innerchat/send', { from: fromId, to: toId, message });
114
- return msg;
130
+ return await api.post('/innerchat/send', { from: fromId, to: toId, message, threadId });
115
131
  } catch (err) {
116
132
  get().addToast('error', 'InnerChat failed', err.message);
117
133
  throw err;
118
134
  }
119
135
  },
120
136
 
137
+ // Threads this agent is a participant in, most recent first.
138
+ innerchatThreadsFor(agentId) {
139
+ return Object.values(get().innerchatThreads)
140
+ .filter((t) => t.participants.some((p) => p.id === agentId))
141
+ .sort((a, b) => b.updatedAt - a.updatedAt);
142
+ },
143
+
121
144
  // ── Chat ──────────────────────────────────────────────────
122
145
 
123
146
  addChatMessage(agentId, from, text, isQuery = false, attachments = undefined) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "groove-dev",
3
- "version": "0.27.181",
3
+ "version": "0.27.183",
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.181",
3
+ "version": "0.27.183",
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.181",
3
+ "version": "0.27.183",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+ //
4
+ // One-off ledger repair for ~/.groove/tokens.json (2026-07 efficiency audit).
5
+ //
6
+ // Entry c3789b6f recorded $20,536.57 from a duplicate-ingestion loop (4,631
7
+ // session records in 380s, payloads duplicated up to 10×). The ingestion bug
8
+ // is fixed in claude-code.js, but the poisoned data still dominates every cost
9
+ // dashboard: recorded all-time spend ~$24K vs ~$3.5K real. This script:
10
+ // 1. Sets the poisoned entry's totalCostUsd to its audited real value (~$325)
11
+ // 2. Truncates every entry's sessions array to the last 500 records
12
+ // (matches SESSION_CAP in tokentracker.js; aggregates are untouched)
13
+ // 3. Writes a timestamped .bak of the original file first
14
+ //
15
+ // MUST run with the daemon STOPPED — the daemon holds the ledger in memory and
16
+ // rewrites the file, so a live edit gets clobbered. The script checks and aborts.
17
+ //
18
+ // Usage: node repair-tokens.mjs [path-to-tokens.json] [--entry c3789b6f] [--cost 325]
19
+
20
+ import { readFileSync, writeFileSync, copyFileSync, existsSync } from 'fs';
21
+ import { resolve } from 'path';
22
+ import { homedir } from 'os';
23
+
24
+ const args = process.argv.slice(2);
25
+ const flag = (name, dflt) => {
26
+ const i = args.indexOf(`--${name}`);
27
+ return i !== -1 && args[i + 1] ? args[i + 1] : dflt;
28
+ };
29
+ const tokensPath = args[0] && !args[0].startsWith('--')
30
+ ? resolve(args[0])
31
+ : resolve(homedir(), '.groove', 'tokens.json');
32
+ const entryId = flag('entry', 'c3789b6f');
33
+ const correctedCost = Number(flag('cost', '325'));
34
+ const SESSION_CAP = 500;
35
+
36
+ // Refuse to edit under a live daemon
37
+ try {
38
+ const res = await fetch('http://127.0.0.1:31415/api/health', { signal: AbortSignal.timeout(1500) });
39
+ if (res.ok) {
40
+ console.error('ABORT: GROOVE daemon is running on :31415. Stop it first (groove stop) — it rewrites tokens.json and will clobber this repair.');
41
+ process.exit(1);
42
+ }
43
+ } catch { /* no daemon — safe to proceed */ }
44
+
45
+ if (!existsSync(tokensPath)) {
46
+ console.error(`ABORT: ${tokensPath} not found. Pass the path explicitly: node repair-tokens.mjs /path/to/tokens.json`);
47
+ process.exit(1);
48
+ }
49
+
50
+ const backupPath = `${tokensPath}.${new Date().toISOString().replace(/[:.]/g, '-')}.bak`;
51
+ copyFileSync(tokensPath, backupPath);
52
+ console.log(`Backup written: ${backupPath}`);
53
+
54
+ const data = JSON.parse(readFileSync(tokensPath, 'utf8'));
55
+ const usage = data.usage || data;
56
+
57
+ const target = usage[entryId];
58
+ if (target) {
59
+ const before = target.totalCostUsd;
60
+ target.totalCostUsd = correctedCost;
61
+ target.note = `totalCostUsd corrected ${before} -> ${correctedCost} on ${new Date().toISOString().slice(0, 10)} (duplicate-ingestion inflation, see 2026-07 efficiency audit)`;
62
+ console.log(`Entry ${entryId}: totalCostUsd ${before} -> ${correctedCost}`);
63
+ } else {
64
+ console.warn(`Entry ${entryId} not found — skipping cost correction.`);
65
+ }
66
+
67
+ let trimmed = 0;
68
+ for (const entry of Object.values(usage)) {
69
+ if (Array.isArray(entry?.sessions) && entry.sessions.length > SESSION_CAP) {
70
+ trimmed += entry.sessions.length - SESSION_CAP;
71
+ entry.sessions.splice(0, entry.sessions.length - SESSION_CAP);
72
+ }
73
+ }
74
+ console.log(`Trimmed ${trimmed} session records across all entries (cap ${SESSION_CAP}).`);
75
+
76
+ writeFileSync(tokensPath, JSON.stringify(data, null, 2));
77
+ const sizeMb = (Buffer.byteLength(JSON.stringify(data)) / 1024 / 1024).toFixed(1);
78
+ console.log(`Repaired ledger written: ${tokensPath} (${sizeMb}MB)`);
@@ -1438,7 +1438,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
1438
1438
  'port', 'journalistInterval', 'rotationThreshold', 'autoRotation',
1439
1439
  'qcThreshold', 'maxAgents', 'defaultProvider', 'defaultWorkingDir',
1440
1440
  'onboardingDismissed', 'defaultModel', 'defaultChatProvider', 'defaultChatModel',
1441
- 'dataSharingDismissed',
1441
+ 'dataSharingDismissed', 'replayCeiling', 'velocityCeiling', 'journalistModelTier',
1442
1442
  ];
1443
1443
  for (const key of Object.keys(req.body)) {
1444
1444
  if (!ALLOWED_KEYS.includes(key)) {