groove-dev 0.27.186 → 0.27.188
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.
- package/innerchat/Screenshot_2026-07-23_at_1.30.16_PM.png +0 -0
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/index.js +6 -0
- package/node_modules/@groove-dev/daemon/src/innerchat-docs.js +34 -13
- package/node_modules/@groove-dev/daemon/src/introducer.js +2 -2
- package/node_modules/@groove-dev/daemon/src/process.js +13 -1
- package/node_modules/@groove-dev/daemon/src/teams.js +36 -4
- package/node_modules/@groove-dev/daemon/src/watcher.js +230 -104
- package/node_modules/@groove-dev/daemon/test/teams.test.js +48 -1
- package/node_modules/@groove-dev/daemon/test/watcher.test.js +68 -10
- package/node_modules/@groove-dev/gui/dist/assets/index-Cat5pJUx.css +1 -0
- package/node_modules/@groove-dev/gui/dist/assets/{index-DOOaCFRS.js → index-fyGUwOq4.js} +222 -222
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +31 -3
- package/node_modules/@groove-dev/gui/src/components/agents/agent-panel.jsx +106 -10
- package/node_modules/@groove-dev/gui/src/lib/logpaths.js +1 -1
- package/node_modules/@groove-dev/gui/src/stores/groove.js +6 -6
- package/node_modules/@groove-dev/gui/src/stores/helpers.js +28 -0
- package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +20 -9
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/index.js +6 -0
- package/packages/daemon/src/innerchat-docs.js +34 -13
- package/packages/daemon/src/introducer.js +2 -2
- package/packages/daemon/src/process.js +13 -1
- package/packages/daemon/src/teams.js +36 -4
- package/packages/daemon/src/watcher.js +230 -104
- package/packages/gui/dist/assets/index-Cat5pJUx.css +1 -0
- package/packages/gui/dist/assets/{index-DOOaCFRS.js → index-fyGUwOq4.js} +222 -222
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/components/agents/agent-feed.jsx +31 -3
- package/packages/gui/src/components/agents/agent-panel.jsx +106 -10
- package/packages/gui/src/lib/logpaths.js +1 -1
- package/packages/gui/src/stores/groove.js +6 -6
- package/packages/gui/src/stores/helpers.js +28 -0
- package/packages/gui/src/stores/slices/agents-slice.js +20 -9
- package/node_modules/@groove-dev/gui/dist/assets/index-CU8L_r5f.css +0 -1
- package/packages/gui/dist/assets/index-CU8L_r5f.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-
|
|
9
|
+
<script type="module" crossorigin src="/assets/index-fyGUwOq4.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-
|
|
14
|
+
<link rel="stylesheet" crossorigin href="/assets/index-Cat5pJUx.css">
|
|
15
15
|
</head>
|
|
16
16
|
<body>
|
|
17
17
|
<div id="root"></div>
|
|
@@ -385,9 +385,37 @@ function InnerChatMessage({ msg, agent }) {
|
|
|
385
385
|
);
|
|
386
386
|
}
|
|
387
387
|
|
|
388
|
+
// Single-quote a value for safe use inside the shell command we inject.
|
|
389
|
+
function shq(s) {
|
|
390
|
+
return `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Quote a path but keep a leading ~/ OUTSIDE the quotes so the shell still
|
|
394
|
+
// expands it — a single-quoted ~ is a literal directory that doesn't exist,
|
|
395
|
+
// which is why `tail -f '~/x.log'` fails.
|
|
396
|
+
function shqPath(p) {
|
|
397
|
+
if (p === '~') return '~';
|
|
398
|
+
if (p.startsWith('~/')) return `~/${shq(p.slice(2))}`;
|
|
399
|
+
return shq(p);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Build a `tail -f` that actually resolves. Agents usually write a log path
|
|
403
|
+
// relative to wherever they ran it — often a bare filename several directories
|
|
404
|
+
// deep — but the terminal opens in a different cwd, so a naive `tail -f name`
|
|
405
|
+
// fails. Absolute paths are used as-is; anything else is resolved against the
|
|
406
|
+
// agent's working directory, falling back to a `find` by basename when the file
|
|
407
|
+
// sits below that directory.
|
|
408
|
+
function tailCommand(path, workdir) {
|
|
409
|
+
if (/^[/~]/.test(path) || !workdir) return `tail -f ${shqPath(path)}`;
|
|
410
|
+
const base = path.split('/').pop();
|
|
411
|
+
return `cd ${shqPath(workdir)} 2>/dev/null; `
|
|
412
|
+
+ `if [ -f ${shq(path)} ]; then tail -f ${shq(path)}; `
|
|
413
|
+
+ `else tail -f "$(find . -name ${shq(base)} -type f 2>/dev/null | head -1)"; fi`;
|
|
414
|
+
}
|
|
415
|
+
|
|
388
416
|
// One-click "tail" chips for any log paths the agent mentioned — saves asking
|
|
389
417
|
// "what's the log file?" and hand-copying it into a terminal.
|
|
390
|
-
function LogChips({ text }) {
|
|
418
|
+
function LogChips({ text, workdir }) {
|
|
391
419
|
const runInTerminal = useGrooveStore((s) => s.runInTerminal);
|
|
392
420
|
const paths = useMemo(() => extractLogPaths(text), [text]);
|
|
393
421
|
if (paths.length === 0) return null;
|
|
@@ -397,7 +425,7 @@ function LogChips({ text }) {
|
|
|
397
425
|
{paths.map((path) => (
|
|
398
426
|
<button
|
|
399
427
|
key={path}
|
|
400
|
-
onClick={() => runInTerminal(
|
|
428
|
+
onClick={() => runInTerminal(tailCommand(path, workdir))}
|
|
401
429
|
title={`tail -f ${path}`}
|
|
402
430
|
className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md bg-accent/10 hover:bg-accent/20 text-accent text-[11px] font-medium font-sans cursor-pointer transition-colors max-w-full"
|
|
403
431
|
>
|
|
@@ -430,7 +458,7 @@ function AgentMessage({ msg, agent, answeredTo }) {
|
|
|
430
458
|
<div className={cn('pl-3.5 py-1 border-l', answeredTo ? 'border-indigo/50' : 'border-accent')}>
|
|
431
459
|
<StructuredMessage text={collapsed ? msg.text.slice(0, 600) + '...' : msg.text} />
|
|
432
460
|
</div>
|
|
433
|
-
<LogChips text={msg.text} />
|
|
461
|
+
<LogChips text={msg.text} workdir={agent?.workingDir} />
|
|
434
462
|
{collapsed && (
|
|
435
463
|
<button
|
|
436
464
|
onClick={() => setCollapsed(false)}
|
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
2
|
-
import { useState, useRef } from 'react';
|
|
2
|
+
import { useState, useRef, useEffect, useMemo } from 'react';
|
|
3
3
|
import { useGrooveStore } from '../../stores/groove';
|
|
4
4
|
import { Badge } from '../ui/badge';
|
|
5
5
|
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 } from 'lucide-react';
|
|
9
|
+
import { MessageSquare, Settings, Activity, FileText, Pencil, Check, X, ChevronDown, Search } from 'lucide-react';
|
|
10
10
|
import { fmtNum, fmtUptime } from '../../lib/format';
|
|
11
11
|
import { cn } from '../../lib/cn';
|
|
12
12
|
import { roleColor } from '../../lib/status';
|
|
13
13
|
import { api } from '../../lib/api';
|
|
14
14
|
|
|
15
|
+
const SWITCH_STATUS_DOT = {
|
|
16
|
+
running: 'bg-accent', starting: 'bg-warning', completed: 'bg-info',
|
|
17
|
+
crashed: 'bg-danger', stopped: 'bg-text-4', killed: 'bg-text-4', rotating: 'bg-accent',
|
|
18
|
+
};
|
|
19
|
+
|
|
15
20
|
const STATUS_VARIANT = {
|
|
16
21
|
running: 'success', starting: 'warning', stopped: 'default',
|
|
17
22
|
crashed: 'danger', completed: 'accent', killed: 'default', rotating: 'purple',
|
|
@@ -28,11 +33,46 @@ const TABS = [
|
|
|
28
33
|
{ id: 'mdfiles', label: 'Files', icon: FileText },
|
|
29
34
|
];
|
|
30
35
|
|
|
31
|
-
|
|
36
|
+
// The agent name doubles as a switcher — click it to jump the panel to any
|
|
37
|
+
// other agent without closing the terminal or hunting through the tree. The
|
|
38
|
+
// hover pencil still renames in place.
|
|
39
|
+
function AgentSwitcher({ agent }) {
|
|
32
40
|
const addToast = useGrooveStore((s) => s.addToast);
|
|
41
|
+
const agents = useGrooveStore((s) => s.agents);
|
|
42
|
+
const teams = useGrooveStore((s) => s.teams);
|
|
43
|
+
const switchAgentPanel = useGrooveStore((s) => s.switchAgentPanel);
|
|
44
|
+
|
|
33
45
|
const [editing, setEditing] = useState(false);
|
|
34
46
|
const [name, setName] = useState(agent.name);
|
|
35
|
-
const
|
|
47
|
+
const [open, setOpen] = useState(false);
|
|
48
|
+
const [query, setQuery] = useState('');
|
|
49
|
+
const wrapRef = useRef(null);
|
|
50
|
+
|
|
51
|
+
// Close the dropdown on outside click or Escape.
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (!open) return;
|
|
54
|
+
const onDown = (e) => { if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false); };
|
|
55
|
+
const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
|
|
56
|
+
document.addEventListener('mousedown', onDown);
|
|
57
|
+
document.addEventListener('keydown', onKey);
|
|
58
|
+
return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey); };
|
|
59
|
+
}, [open]);
|
|
60
|
+
|
|
61
|
+
const grouped = useMemo(() => {
|
|
62
|
+
const q = query.trim().toLowerCase();
|
|
63
|
+
const byTeam = new Map();
|
|
64
|
+
for (const a of agents) {
|
|
65
|
+
if (q && !a.name.toLowerCase().includes(q) && !a.role.toLowerCase().includes(q)) continue;
|
|
66
|
+
const key = a.teamId || '_none';
|
|
67
|
+
if (!byTeam.has(key)) byTeam.set(key, []);
|
|
68
|
+
byTeam.get(key).push(a);
|
|
69
|
+
}
|
|
70
|
+
return Array.from(byTeam.entries()).map(([teamId, list]) => ({
|
|
71
|
+
teamId,
|
|
72
|
+
name: teams.find((t) => t.id === teamId)?.name || 'No team',
|
|
73
|
+
agents: list.sort((a, b) => a.name.localeCompare(b.name)),
|
|
74
|
+
})).sort((a, b) => a.name.localeCompare(b.name));
|
|
75
|
+
}, [agents, teams, query]);
|
|
36
76
|
|
|
37
77
|
async function save() {
|
|
38
78
|
const trimmed = name.trim();
|
|
@@ -47,11 +87,16 @@ function InlineName({ agent }) {
|
|
|
47
87
|
setEditing(false);
|
|
48
88
|
}
|
|
49
89
|
|
|
90
|
+
function pick(id) {
|
|
91
|
+
setOpen(false);
|
|
92
|
+
setQuery('');
|
|
93
|
+
if (id !== agent.id) switchAgentPanel(id);
|
|
94
|
+
}
|
|
95
|
+
|
|
50
96
|
if (editing) {
|
|
51
97
|
return (
|
|
52
98
|
<div className="flex items-center gap-1 flex-1 min-w-0">
|
|
53
99
|
<input
|
|
54
|
-
ref={inputRef}
|
|
55
100
|
value={name}
|
|
56
101
|
onChange={(e) => setName(e.target.value)}
|
|
57
102
|
onKeyDown={(e) => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setName(agent.name); setEditing(false); } }}
|
|
@@ -65,14 +110,61 @@ function InlineName({ agent }) {
|
|
|
65
110
|
}
|
|
66
111
|
|
|
67
112
|
return (
|
|
68
|
-
<div className="flex items-center gap-1.5 flex-1 min-w-0 group">
|
|
69
|
-
<
|
|
113
|
+
<div ref={wrapRef} className="relative flex items-center gap-1.5 flex-1 min-w-0 group">
|
|
114
|
+
<button
|
|
115
|
+
onClick={() => setOpen((v) => !v)}
|
|
116
|
+
className="flex items-center gap-1 min-w-0 cursor-pointer rounded px-1 -mx-1 py-0.5 hover:bg-surface-3 transition-colors"
|
|
117
|
+
title="Switch agent"
|
|
118
|
+
>
|
|
119
|
+
<h2 className="text-sm font-bold text-text-0 font-sans truncate">{agent.name}</h2>
|
|
120
|
+
<ChevronDown size={13} className={cn('text-text-4 flex-shrink-0 transition-transform', open && 'rotate-180')} />
|
|
121
|
+
</button>
|
|
70
122
|
<button
|
|
71
123
|
onClick={() => { setName(agent.name); setEditing(true); }}
|
|
72
|
-
className="p-0.5 text-text-4 opacity-0 group-hover:opacity-100 hover:text-text-1 cursor-pointer transition-opacity"
|
|
124
|
+
className="p-0.5 text-text-4 opacity-0 group-hover:opacity-100 hover:text-text-1 cursor-pointer transition-opacity flex-shrink-0"
|
|
125
|
+
title="Rename agent"
|
|
73
126
|
>
|
|
74
127
|
<Pencil size={10} />
|
|
75
128
|
</button>
|
|
129
|
+
|
|
130
|
+
{open && (
|
|
131
|
+
<div className="absolute top-full left-0 mt-1.5 w-72 max-h-[70vh] flex flex-col bg-surface-1 border border-border rounded-lg shadow-xl z-50 overflow-hidden">
|
|
132
|
+
<div className="flex items-center gap-1.5 px-2.5 py-2 border-b border-border-subtle">
|
|
133
|
+
<Search size={12} className="text-text-4 flex-shrink-0" />
|
|
134
|
+
<input
|
|
135
|
+
value={query}
|
|
136
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
137
|
+
placeholder="Switch to agent…"
|
|
138
|
+
autoFocus
|
|
139
|
+
className="flex-1 min-w-0 bg-transparent text-xs text-text-0 font-sans placeholder:text-text-4 focus:outline-none"
|
|
140
|
+
/>
|
|
141
|
+
</div>
|
|
142
|
+
<div className="overflow-y-auto py-1">
|
|
143
|
+
{grouped.length === 0 && (
|
|
144
|
+
<div className="px-3 py-3 text-xs text-text-4 font-sans text-center">No agents match</div>
|
|
145
|
+
)}
|
|
146
|
+
{grouped.map((g) => (
|
|
147
|
+
<div key={g.teamId}>
|
|
148
|
+
<div className="px-2.5 pt-1.5 pb-0.5 text-2xs font-semibold text-text-4 font-sans uppercase tracking-wide">{g.name}</div>
|
|
149
|
+
{g.agents.map((a) => (
|
|
150
|
+
<button
|
|
151
|
+
key={a.id}
|
|
152
|
+
onClick={() => pick(a.id)}
|
|
153
|
+
className={cn(
|
|
154
|
+
'w-full flex items-center gap-2 px-2.5 py-1.5 text-left cursor-pointer transition-colors',
|
|
155
|
+
a.id === agent.id ? 'bg-accent/10' : 'hover:bg-surface-3',
|
|
156
|
+
)}
|
|
157
|
+
>
|
|
158
|
+
<span className={cn('w-1.5 h-1.5 rounded-full flex-shrink-0', SWITCH_STATUS_DOT[a.status] || 'bg-text-4')} />
|
|
159
|
+
<span className={cn('text-xs font-sans truncate', a.id === agent.id ? 'text-accent font-medium' : 'text-text-1')}>{a.name}</span>
|
|
160
|
+
<span className="text-2xs text-text-4 font-sans ml-auto flex-shrink-0 capitalize">{a.role}</span>
|
|
161
|
+
</button>
|
|
162
|
+
))}
|
|
163
|
+
</div>
|
|
164
|
+
))}
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
)}
|
|
76
168
|
</div>
|
|
77
169
|
);
|
|
78
170
|
}
|
|
@@ -81,6 +173,7 @@ export function AgentPanel() {
|
|
|
81
173
|
const detailPanel = useGrooveStore((s) => s.detailPanel);
|
|
82
174
|
const agents = useGrooveStore((s) => s.agents);
|
|
83
175
|
const activeTeamId = useGrooveStore((s) => s.activeTeamId);
|
|
176
|
+
const activeView = useGrooveStore((s) => s.activeView);
|
|
84
177
|
const addToast = useGrooveStore((s) => s.addToast);
|
|
85
178
|
const [activeTab, setActiveTab] = useState('command');
|
|
86
179
|
const cachedAgentRef = useRef(null);
|
|
@@ -93,7 +186,10 @@ export function AgentPanel() {
|
|
|
93
186
|
const isAlive = liveAgent?.status === 'running' || liveAgent?.status === 'starting';
|
|
94
187
|
|
|
95
188
|
if (!agent) return null;
|
|
96
|
-
|
|
189
|
+
// The team guard hides a stale panel when switching teams in the Agents view.
|
|
190
|
+
// Fleet is cross-team by design — an agent selected there legitimately belongs
|
|
191
|
+
// to another team, so applying the guard would blank a valid panel.
|
|
192
|
+
if (activeView !== 'fleet' && activeTeamId && agent.teamId && agent.teamId !== activeTeamId) return null;
|
|
97
193
|
|
|
98
194
|
const ctxPct = Math.round((agent.contextUsage || 0) * 100);
|
|
99
195
|
const spawned = agent.spawnedAt || agent.createdAt;
|
|
@@ -109,7 +205,7 @@ export function AgentPanel() {
|
|
|
109
205
|
<div className="pl-4 pr-10 pt-3 pb-2">
|
|
110
206
|
{/* Name + status row — pr-10 gives room for the detail panel X button */}
|
|
111
207
|
<div className="flex items-center gap-2">
|
|
112
|
-
<
|
|
208
|
+
<AgentSwitcher agent={agent} />
|
|
113
209
|
<Badge variant={STATUS_VARIANT[agent.status] || 'default'} dot={isAlive ? 'pulse' : undefined} className="text-2xs flex-shrink-0">
|
|
114
210
|
{STATUS_LABEL[agent.status] || agent.status}
|
|
115
211
|
</Badge>
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// match, and results are de-duped by full path.
|
|
11
11
|
|
|
12
12
|
const TAIL_RE = /tail\s+(?:-[a-zA-Z]+\s+)*([~/]?[\w./-]+)/g;
|
|
13
|
-
const DOTLOG_RE = /(?:^|[\s'"`(=])(
|
|
13
|
+
const DOTLOG_RE = /(?:^|[\s'"`(=])((?:~\/|\/)?(?:[\w.-]+\/)*[\w.-]+\.log)\b/g;
|
|
14
14
|
|
|
15
15
|
function clean(p) {
|
|
16
16
|
return p.replace(/^['"`]+/, '').replace(/['"`.,;:)]+$/, '');
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { create } from 'zustand';
|
|
5
5
|
import { api } from '../lib/api';
|
|
6
|
-
import { persistJSON } from './helpers.js';
|
|
6
|
+
import { persistJSON, persistChatHistory } from './helpers.js';
|
|
7
7
|
import { createUiSlice } from './slices/ui-slice.js';
|
|
8
8
|
import { createAgentsSlice } from './slices/agents-slice.js';
|
|
9
9
|
import { createTeamsSlice } from './slices/teams-slice.js';
|
|
@@ -20,11 +20,11 @@ const WS_URL = `ws://${window.location.hostname}:${window.location.port || 31415
|
|
|
20
20
|
|
|
21
21
|
let plannerPollInterval = null;
|
|
22
22
|
|
|
23
|
-
//
|
|
23
|
+
// Record the store version for potential future migrations. Chat history and
|
|
24
|
+
// activity log are USER DATA and must survive version changes — never wipe them
|
|
25
|
+
// here. If a schema ever needs migrating, transform in place, don't delete.
|
|
24
26
|
const STORE_VERSION = '0.22.28';
|
|
25
27
|
if (localStorage.getItem('groove:storeVersion') !== JSON.stringify(STORE_VERSION)) {
|
|
26
|
-
localStorage.removeItem('groove:chatHistory');
|
|
27
|
-
localStorage.removeItem('groove:activityLog');
|
|
28
28
|
persistJSON('groove:storeVersion', STORE_VERSION);
|
|
29
29
|
}
|
|
30
30
|
|
|
@@ -264,7 +264,7 @@ export const useGrooveStore = create((set, get) => ({
|
|
|
264
264
|
|
|
265
265
|
history[agentId] = arr.slice(-100);
|
|
266
266
|
set({ chatHistory: history });
|
|
267
|
-
|
|
267
|
+
persistChatHistory(history);
|
|
268
268
|
}
|
|
269
269
|
|
|
270
270
|
const conv = get().conversations.find((c) => c.agentId === agentId);
|
|
@@ -402,7 +402,7 @@ export const useGrooveStore = create((set, get) => ({
|
|
|
402
402
|
push(turn.to.id, entry(turn.from, 'in'));
|
|
403
403
|
}
|
|
404
404
|
|
|
405
|
-
|
|
405
|
+
persistChatHistory(history);
|
|
406
406
|
|
|
407
407
|
const answers = { ...s.innerchatAnswers };
|
|
408
408
|
if (turn.kind === 'answer') {
|
|
@@ -8,3 +8,31 @@ export function loadJSON(key, fallback = {}) {
|
|
|
8
8
|
export function persistJSON(key, value) {
|
|
9
9
|
try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* quota */ }
|
|
10
10
|
}
|
|
11
|
+
|
|
12
|
+
// Base64 image data must never reach localStorage — it blows the ~5MB quota,
|
|
13
|
+
// after which every write silently fails and history stops persisting. Keep the
|
|
14
|
+
// attachment metadata, drop the payload.
|
|
15
|
+
export function stripAttachments(history) {
|
|
16
|
+
const out = {};
|
|
17
|
+
for (const [id, msgs] of Object.entries(history || {})) {
|
|
18
|
+
out[id] = (msgs || []).map((m) =>
|
|
19
|
+
m.attachments?.length
|
|
20
|
+
? { ...m, attachments: m.attachments.map(({ dataUrl, ...rest }) => rest) }
|
|
21
|
+
: m);
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The single writer for chat history. Strips attachments and refuses to
|
|
27
|
+
// overwrite a populated store with an empty object — a blank write is almost
|
|
28
|
+
// always a startup race or partial state, and losing chat is never acceptable.
|
|
29
|
+
export function persistChatHistory(history) {
|
|
30
|
+
const clean = stripAttachments(history);
|
|
31
|
+
try {
|
|
32
|
+
if (Object.keys(clean).length === 0) {
|
|
33
|
+
const existing = localStorage.getItem('groove:chatHistory');
|
|
34
|
+
if (existing && existing !== '{}' && existing !== 'null') return;
|
|
35
|
+
}
|
|
36
|
+
localStorage.setItem('groove:chatHistory', JSON.stringify(clean));
|
|
37
|
+
} catch { /* quota — keep the last good value rather than clobbering it */ }
|
|
38
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
2
2
|
|
|
3
3
|
import { api } from '../../lib/api';
|
|
4
|
-
import { loadJSON, persistJSON } from '../helpers.js';
|
|
4
|
+
import { loadJSON, persistJSON, persistChatHistory } from '../helpers.js';
|
|
5
5
|
|
|
6
6
|
export const createAgentsSlice = (set, get) => ({
|
|
7
7
|
// ── Agent data ────────────────────────────────────────────
|
|
@@ -85,7 +85,7 @@ export const createAgentsSlice = (set, get) => ({
|
|
|
85
85
|
delete chatHistory[id];
|
|
86
86
|
delete activityLog[id];
|
|
87
87
|
delete tokenTimeline[id];
|
|
88
|
-
|
|
88
|
+
persistChatHistory(chatHistory);
|
|
89
89
|
persistJSON('groove:activityLog', activityLog);
|
|
90
90
|
return { chatHistory, activityLog, tokenTimeline };
|
|
91
91
|
});
|
|
@@ -95,6 +95,22 @@ export const createAgentsSlice = (set, get) => ({
|
|
|
95
95
|
}
|
|
96
96
|
},
|
|
97
97
|
|
|
98
|
+
// Switch the detail panel to another agent without disturbing the rest of
|
|
99
|
+
// the layout (terminal stays open). Aligns activeTeamId to the target so the
|
|
100
|
+
// panel's team guard passes even when switching across teams.
|
|
101
|
+
switchAgentPanel(agentId) {
|
|
102
|
+
const agent = get().agents.find((a) => a.id === agentId);
|
|
103
|
+
if (!agent) return;
|
|
104
|
+
const tid = agent.teamId || get().activeTeamId;
|
|
105
|
+
const panel = { type: 'agent', agentId };
|
|
106
|
+
set((s) => ({
|
|
107
|
+
activeTeamId: tid,
|
|
108
|
+
detailPanel: panel,
|
|
109
|
+
teamDetailPanels: { ...s.teamDetailPanels, [tid]: panel },
|
|
110
|
+
}));
|
|
111
|
+
if (tid) localStorage.setItem('groove:activeTeamId', tid);
|
|
112
|
+
},
|
|
113
|
+
|
|
98
114
|
async rotateAgent(id) {
|
|
99
115
|
try {
|
|
100
116
|
return await api.post(`/agents/${encodeURIComponent(id)}/rotate`);
|
|
@@ -156,12 +172,7 @@ export const createAgentsSlice = (set, get) => ({
|
|
|
156
172
|
const msg = { from, text, timestamp: Date.now(), isQuery };
|
|
157
173
|
if (attachments?.length) msg.attachments = attachments;
|
|
158
174
|
history[agentId] = [...history[agentId].slice(-100), msg];
|
|
159
|
-
|
|
160
|
-
forStorage[agentId] = forStorage[agentId].map((m) => {
|
|
161
|
-
if (!m.attachments?.length) return m;
|
|
162
|
-
return { ...m, attachments: m.attachments.map(({ dataUrl, ...rest }) => rest) };
|
|
163
|
-
});
|
|
164
|
-
persistJSON('groove:chatHistory', forStorage);
|
|
175
|
+
persistChatHistory(history);
|
|
165
176
|
return { chatHistory: history };
|
|
166
177
|
});
|
|
167
178
|
},
|
|
@@ -237,7 +248,7 @@ export const createAgentsSlice = (set, get) => ({
|
|
|
237
248
|
next.add(newAgent.id);
|
|
238
249
|
return { thinkingAgents: next };
|
|
239
250
|
});
|
|
240
|
-
if (get().chatHistory[newAgent.id]?.length)
|
|
251
|
+
if (get().chatHistory[newAgent.id]?.length) persistChatHistory(get().chatHistory);
|
|
241
252
|
if (get().activityLog[newAgent.id]?.length) persistJSON('groove:activityLog', get().activityLog);
|
|
242
253
|
if (get().labAssistantAgentId === id) {
|
|
243
254
|
localStorage.setItem('groove:labAssistantAgentId', newAgent.id);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "groove-dev",
|
|
3
|
-
"version": "0.27.
|
|
3
|
+
"version": "0.27.188",
|
|
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)",
|
|
@@ -638,6 +638,12 @@ export class Daemon {
|
|
|
638
638
|
}
|
|
639
639
|
}
|
|
640
640
|
|
|
641
|
+
// Reconnect to watches whose detached jobs survived this restart, so a
|
|
642
|
+
// "notify me when the training finishes" watch still fires afterward.
|
|
643
|
+
try { this.watcher.restore(); } catch (err) {
|
|
644
|
+
console.error('[startup] Failed to restore watches:', err.message);
|
|
645
|
+
}
|
|
646
|
+
|
|
641
647
|
// Restore auth token from stored config so subscription polling works after restart
|
|
642
648
|
const storedToken = this.skills.getToken();
|
|
643
649
|
if (storedToken) {
|
|
@@ -70,27 +70,48 @@ export function innerChatInstructions(port = 31415, agentName = 'YOUR_NAME') {
|
|
|
70
70
|
export function watchInstructions(port = 31415, agentName = 'YOUR_NAME') {
|
|
71
71
|
const nameHint = agentName === 'YOUR_NAME' ? ' ($GROOVE_AGENT_NAME)' : '';
|
|
72
72
|
return [
|
|
73
|
-
'##
|
|
73
|
+
'## Running Long Jobs & Getting Notified (Watch)',
|
|
74
74
|
'',
|
|
75
|
-
'
|
|
76
|
-
|
|
77
|
-
'
|
|
78
|
-
'
|
|
75
|
+
'> **A job you start yourself dies when your turn ends.** Anything you launch in Bash —',
|
|
76
|
+
"> even backgrounded with `&`, `nohup`, or `disown` — is a child of your session process.",
|
|
77
|
+
'> When your turn ends or your context rotates, that process is torn down and the job dies',
|
|
78
|
+
'> with it. Do NOT start a long-running job (training, build, server, benchmark) directly',
|
|
79
|
+
"> in Bash and expect it to keep running. It won't.",
|
|
80
|
+
'',
|
|
81
|
+
'Instead, hand the command to a Watch. The DAEMON runs it fully detached — independent of',
|
|
82
|
+
'your turn, surviving turn-end, context rotation, and even a daemon restart — and RESUMES',
|
|
83
|
+
'you with the exit code and output when it finishes:',
|
|
79
84
|
'',
|
|
80
85
|
'```bash',
|
|
81
86
|
`curl -s http://localhost:${port}/api/watch -X POST -H 'Content-Type: application/json' \\`,
|
|
82
|
-
` -d '{"agent":"${agentName}","label":"
|
|
87
|
+
` -d '{"agent":"${agentName}","label":"model training","command":"python train.py"}'`,
|
|
83
88
|
'```',
|
|
84
89
|
'',
|
|
85
|
-
'This returns immediately with a `watchId`. **End your turn** —
|
|
86
|
-
'
|
|
90
|
+
'This returns immediately with a `watchId`. **End your turn** — the job keeps running under',
|
|
91
|
+
'the daemon and you will be woken with the result when it completes.',
|
|
87
92
|
'',
|
|
88
|
-
'-
|
|
89
|
-
' it
|
|
90
|
-
'-
|
|
91
|
-
`
|
|
93
|
+
'- Use `command` for anything that must outlive your turn. Let the daemon run it — do not',
|
|
94
|
+
' launch it yourself and then watch it; a self-launched job is already doomed.',
|
|
95
|
+
'- Use `until` ONLY for something the daemon or another durable process is already running',
|
|
96
|
+
` (not a Bash job you started): \`-d '{"agent":"${agentName}","label":"server up","until":"curl -sf localhost:3000/health"}'\`.`,
|
|
92
97
|
`- \`agent\` must be your own name${nameHint}.`,
|
|
93
|
-
'- Watches time out (default 30 min
|
|
98
|
+
'- Watches time out (default 30 min, up to 24h — pass `timeoutMs` for long trainings).',
|
|
94
99
|
'- Do not poll or sleep-loop waiting yourself — set the watch and end the turn. That is the whole point.',
|
|
95
100
|
];
|
|
96
101
|
}
|
|
102
|
+
|
|
103
|
+
// Log-path convention. The GUI turns log paths in a message into a one-click
|
|
104
|
+
// "tail" button; a bare or relative filename can't be located reliably (there
|
|
105
|
+
// may be several with the same name in different directories), so agents must
|
|
106
|
+
// give the absolute path.
|
|
107
|
+
export function logFileInstructions() {
|
|
108
|
+
return [
|
|
109
|
+
'## Mentioning Log Files',
|
|
110
|
+
'',
|
|
111
|
+
'When you start a process that writes to a log the user may want to watch, state the',
|
|
112
|
+
"log's ABSOLUTE path — e.g. `Logging to /home/you/project/runs/train.log` — not a bare",
|
|
113
|
+
'or relative filename. The GUI turns that path into a one-click "tail" button for the',
|
|
114
|
+
'user; a bare name like `train.log` cannot be located reliably (several files may share',
|
|
115
|
+
'it), so always resolve it to a full path first (`realpath` / `readlink -f` if unsure).',
|
|
116
|
+
];
|
|
117
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { writeFileSync, readFileSync, existsSync, readdirSync, statSync } from 'fs';
|
|
5
5
|
import { resolve, dirname, basename } from 'path';
|
|
6
6
|
import { escapeMd } from './validate.js';
|
|
7
|
-
import { innerChatInstructions, watchInstructions } from './innerchat-docs.js';
|
|
7
|
+
import { innerChatInstructions, watchInstructions, logFileInstructions } from './innerchat-docs.js';
|
|
8
8
|
|
|
9
9
|
const GROOVE_SECTION_START = '<!-- GROOVE:START -->';
|
|
10
10
|
const GROOVE_SECTION_END = '<!-- GROOVE:END -->';
|
|
@@ -561,7 +561,7 @@ export class Introducer {
|
|
|
561
561
|
// compaction — the spawn prompt alone can scroll out of a long session.
|
|
562
562
|
_innerChatSection() {
|
|
563
563
|
const port = this.daemon.port || 31415;
|
|
564
|
-
return [...innerChatInstructions(port), '', ...watchInstructions(port)];
|
|
564
|
+
return [...innerChatInstructions(port), '', ...watchInstructions(port), '', ...logFileInstructions()];
|
|
565
565
|
}
|
|
566
566
|
|
|
567
567
|
writeRegistryFile(projectDir) {
|
|
@@ -10,7 +10,7 @@ import { LocalProvider } from './providers/local.js';
|
|
|
10
10
|
import { OllamaProvider } from './providers/ollama.js';
|
|
11
11
|
import { AgentLoop } from './agent-loop.js';
|
|
12
12
|
import { validateAgentConfig } from './validate.js';
|
|
13
|
-
import { innerChatInstructions, watchInstructions } from './innerchat-docs.js';
|
|
13
|
+
import { innerChatInstructions, watchInstructions, logFileInstructions } from './innerchat-docs.js';
|
|
14
14
|
|
|
15
15
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
16
16
|
const SLIDES_ENGINE_SRC = resolve(__dirname, '../templates/groove-slides.cjs');
|
|
@@ -1174,6 +1174,8 @@ For normal file edits within your scope, proceed without review.
|
|
|
1174
1174
|
...innerChatInstructions(port, agent.name),
|
|
1175
1175
|
'',
|
|
1176
1176
|
...watchInstructions(port, agent.name),
|
|
1177
|
+
'',
|
|
1178
|
+
...logFileInstructions(),
|
|
1177
1179
|
].join('\n') + '\n\n';
|
|
1178
1180
|
if (spawnConfig.prompt.startsWith('# Handoff Brief')) {
|
|
1179
1181
|
spawnConfig.prompt += '\n\n' + capabilities.trim();
|
|
@@ -2626,6 +2628,9 @@ After fixing all issues, run tests (npm test) and build (npm run build) to verif
|
|
|
2626
2628
|
// Let the aborted process finish tearing down before re-spawning; resume()
|
|
2627
2629
|
// kills the current handle and re-registers under a new agent id.
|
|
2628
2630
|
setTimeout(() => {
|
|
2631
|
+
// The user may have paused/killed during this 500ms window — stop()/kill()
|
|
2632
|
+
// clear the pending message to cancel exactly this in-flight retry.
|
|
2633
|
+
if (!this._pendingUserMessage.has(agentId)) return;
|
|
2629
2634
|
this.resume(agentId, pending.message).catch((err) => {
|
|
2630
2635
|
this.daemon.broadcast({
|
|
2631
2636
|
type: 'agent:output',
|
|
@@ -3058,6 +3063,13 @@ After fixing all issues, run tests (npm test) and build (npm run build) to verif
|
|
|
3058
3063
|
const handle = this.handles.get(agentId);
|
|
3059
3064
|
if (!handle) return;
|
|
3060
3065
|
|
|
3066
|
+
// A user pause aborts the current turn, which the CLI reports as a dropped
|
|
3067
|
+
// turn (isError, apiDurationMs === 0). Clear the pending message FIRST so
|
|
3068
|
+
// _retryDroppedTurn no-ops — otherwise the stop is auto-retried and the
|
|
3069
|
+
// agent resumes, forcing the user to click Pause twice.
|
|
3070
|
+
this._pendingUserMessage.delete(agentId);
|
|
3071
|
+
this._retryAttemptCarry.delete(agentId);
|
|
3072
|
+
|
|
3061
3073
|
const { proc, loop } = handle;
|
|
3062
3074
|
|
|
3063
3075
|
if (loop) {
|
|
@@ -230,6 +230,21 @@ export class Teams {
|
|
|
230
230
|
originalWorkingDir: team.workingDir,
|
|
231
231
|
};
|
|
232
232
|
writeFileSync(resolve(archivePath, 'metadata.json'), JSON.stringify(metadata, null, 2));
|
|
233
|
+
} else if (
|
|
234
|
+
team.workingDir &&
|
|
235
|
+
team.workingDir !== this.daemon.projectDir &&
|
|
236
|
+
existsSync(team.workingDir) &&
|
|
237
|
+
this._dirHasExternalOccupants(team.workingDir)
|
|
238
|
+
) {
|
|
239
|
+
// A moved agent still lives here — archive metadata only, leave the
|
|
240
|
+
// directory in place so that agent keeps working.
|
|
241
|
+
console.log(`[Groove:Teams] Archiving ${team.name} metadata only: agents from other teams still use its directory`);
|
|
242
|
+
mkdirSync(archivePath, { recursive: true });
|
|
243
|
+
writeFileSync(resolve(archivePath, 'metadata.json'), JSON.stringify({
|
|
244
|
+
originalName: team.name, originalId: team.id, mode: team.mode || 'sandbox',
|
|
245
|
+
deletedAt: new Date().toISOString(), agentCount: agents.length,
|
|
246
|
+
originalWorkingDir: team.workingDir, directoryRetained: true,
|
|
247
|
+
}, null, 2));
|
|
233
248
|
} else if (
|
|
234
249
|
team.workingDir &&
|
|
235
250
|
team.workingDir !== this.daemon.projectDir &&
|
|
@@ -281,10 +296,16 @@ export class Teams {
|
|
|
281
296
|
team.workingDir !== this.daemon.projectDir &&
|
|
282
297
|
existsSync(team.workingDir)
|
|
283
298
|
) {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
console.log(`[Groove:Teams]
|
|
299
|
+
if (this._dirHasExternalOccupants(team.workingDir)) {
|
|
300
|
+
// An agent moved to another team still lives here — keep the directory
|
|
301
|
+
// so it doesn't lose its working dir. The team record is still removed.
|
|
302
|
+
console.log(`[Groove:Teams] Keeping ${team.workingDir}: agents from other teams still use it`);
|
|
303
|
+
} else {
|
|
304
|
+
try {
|
|
305
|
+
rmSync(team.workingDir, { recursive: true, force: true });
|
|
306
|
+
} catch (err) {
|
|
307
|
+
console.log(`[Groove:Teams] Failed to delete directory: ${err.message}`);
|
|
308
|
+
}
|
|
288
309
|
}
|
|
289
310
|
}
|
|
290
311
|
|
|
@@ -292,6 +313,17 @@ export class Teams {
|
|
|
292
313
|
return true;
|
|
293
314
|
}
|
|
294
315
|
|
|
316
|
+
// True if an agent OUTSIDE this team still lives in the given directory —
|
|
317
|
+
// i.e. an agent that was moved to another team but kept its working dir here.
|
|
318
|
+
// Destroying the directory would break it, so callers skip the removal.
|
|
319
|
+
// Call after _killAndRemoveAgents so this team's own agents are already gone.
|
|
320
|
+
_dirHasExternalOccupants(dir) {
|
|
321
|
+
if (!dir) return false;
|
|
322
|
+
const prefix = `${dir}/`;
|
|
323
|
+
return this.daemon.registry.getAll().some((a) =>
|
|
324
|
+
a.workingDir && (a.workingDir === dir || a.workingDir.startsWith(prefix)));
|
|
325
|
+
}
|
|
326
|
+
|
|
295
327
|
_killAndRemoveAgents(teamId) {
|
|
296
328
|
const agents = this.daemon.registry.getAll().filter((a) => a.teamId === teamId);
|
|
297
329
|
for (const agent of agents) {
|