groove-dev 0.27.161 → 0.27.164

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/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/src/gateways/manager.js +1 -0
  4. package/node_modules/@groove-dev/daemon/src/index.js +3 -0
  5. package/node_modules/@groove-dev/daemon/src/model-lab.js +15 -0
  6. package/node_modules/@groove-dev/daemon/src/process.js +227 -105
  7. package/node_modules/@groove-dev/daemon/src/routes/teams.js +2 -0
  8. package/node_modules/@groove-dev/daemon/src/scheduler.js +1 -0
  9. package/node_modules/@groove-dev/daemon/src/tunnel-manager.js +4 -8
  10. package/node_modules/@groove-dev/gui/dist/assets/index-BJVNpGIp.css +1 -0
  11. package/node_modules/@groove-dev/gui/dist/assets/index-CkCFf4Fl.js +1025 -0
  12. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  13. package/node_modules/@groove-dev/gui/package.json +1 -1
  14. package/node_modules/@groove-dev/gui/src/App.jsx +2 -0
  15. package/node_modules/@groove-dev/gui/src/components/agents/diff-viewer.jsx +20 -12
  16. package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +5 -2
  17. package/node_modules/@groove-dev/gui/src/components/agents/workspace-mode.jsx +2 -19
  18. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-agent-row.jsx +176 -0
  19. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-content.jsx +135 -0
  20. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-pane.jsx +105 -0
  21. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-sidebar.jsx +216 -0
  22. package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +2 -1
  23. package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +4 -0
  24. package/node_modules/@groove-dev/gui/src/stores/slices/ui-slice.js +71 -1
  25. package/node_modules/@groove-dev/gui/src/views/fleet.jsx +15 -0
  26. package/package.json +1 -1
  27. package/packages/cli/package.json +1 -1
  28. package/packages/daemon/package.json +1 -1
  29. package/packages/daemon/src/gateways/manager.js +1 -0
  30. package/packages/daemon/src/index.js +3 -0
  31. package/packages/daemon/src/model-lab.js +15 -0
  32. package/packages/daemon/src/process.js +227 -105
  33. package/packages/daemon/src/routes/teams.js +2 -0
  34. package/packages/daemon/src/scheduler.js +1 -0
  35. package/packages/daemon/src/tunnel-manager.js +4 -8
  36. package/packages/gui/dist/assets/index-BJVNpGIp.css +1 -0
  37. package/packages/gui/dist/assets/index-CkCFf4Fl.js +1025 -0
  38. package/packages/gui/dist/index.html +2 -2
  39. package/packages/gui/package.json +1 -1
  40. package/packages/gui/src/App.jsx +2 -0
  41. package/packages/gui/src/components/agents/diff-viewer.jsx +20 -12
  42. package/packages/gui/src/components/agents/spawn-wizard.jsx +5 -2
  43. package/packages/gui/src/components/agents/workspace-mode.jsx +2 -19
  44. package/packages/gui/src/components/fleet/fleet-agent-row.jsx +176 -0
  45. package/packages/gui/src/components/fleet/fleet-content.jsx +135 -0
  46. package/packages/gui/src/components/fleet/fleet-pane.jsx +105 -0
  47. package/packages/gui/src/components/fleet/fleet-sidebar.jsx +216 -0
  48. package/packages/gui/src/components/layout/activity-bar.jsx +2 -1
  49. package/packages/gui/src/stores/slices/agents-slice.js +4 -0
  50. package/packages/gui/src/stores/slices/ui-slice.js +71 -1
  51. package/packages/gui/src/views/fleet.jsx +15 -0
  52. package/node_modules/@groove-dev/gui/dist/assets/index-DpRdb7o1.js +0 -1020
  53. package/node_modules/@groove-dev/gui/dist/assets/index-Dzofq3wS.css +0 -1
  54. package/packages/gui/dist/assets/index-DpRdb7o1.js +0 -1020
  55. package/packages/gui/dist/assets/index-Dzofq3wS.css +0 -1
@@ -0,0 +1,216 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+ import { useRef, useCallback, useMemo, useState } from 'react';
3
+ import { Search, X, ChevronRight, Plus, Trash2 } from 'lucide-react';
4
+ import { useGrooveStore } from '../../stores/groove';
5
+ import { cn } from '../../lib/cn';
6
+ import { FleetAgentRow } from './fleet-agent-row';
7
+
8
+ function teamStatusDot(agents) {
9
+ if (agents.some((a) => a.status === 'crashed')) return 'bg-danger';
10
+ if (agents.some((a) => a.status === 'running' || a.status === 'starting')) return 'bg-accent';
11
+ if (agents.some((a) => a.status === 'completed')) return 'bg-info';
12
+ return 'bg-text-4';
13
+ }
14
+
15
+ export function FleetSidebar({ width }) {
16
+ const teams = useGrooveStore((s) => s.teams);
17
+ const agents = useGrooveStore((s) => s.agents);
18
+ const search = useGrooveStore((s) => s.fleetSearch);
19
+ const setSearch = useGrooveStore((s) => s.fleetSetSearch);
20
+ const collapsed = useGrooveStore((s) => s.fleetSidebarCollapsed);
21
+ const toggleCollapsed = useGrooveStore((s) => s.fleetToggleTeamCollapsed);
22
+ const setSidebarWidth = useGrooveStore((s) => s.fleetSetSidebarWidth);
23
+ const deleteTeam = useGrooveStore((s) => s.deleteTeam);
24
+ const openDetail = useGrooveStore((s) => s.openDetail);
25
+
26
+ const [confirmDeleteTeam, setConfirmDeleteTeam] = useState(null);
27
+
28
+ const dragging = useRef(false);
29
+ const startX = useRef(0);
30
+ const startW = useRef(0);
31
+
32
+ const onMouseDown = useCallback((e) => {
33
+ e.preventDefault();
34
+ dragging.current = true;
35
+ startX.current = e.clientX;
36
+ startW.current = width;
37
+
38
+ function onMouseMove(ev) {
39
+ if (!dragging.current) return;
40
+ const delta = ev.clientX - startX.current;
41
+ setSidebarWidth(startW.current + delta);
42
+ }
43
+
44
+ function onMouseUp() {
45
+ dragging.current = false;
46
+ document.removeEventListener('mousemove', onMouseMove);
47
+ document.removeEventListener('mouseup', onMouseUp);
48
+ }
49
+
50
+ document.addEventListener('mousemove', onMouseMove);
51
+ document.addEventListener('mouseup', onMouseUp);
52
+ }, [width, setSidebarWidth]);
53
+
54
+ const agentsByTeam = useMemo(() => {
55
+ const map = {};
56
+ for (const a of agents) {
57
+ if (!a.teamId) continue;
58
+ if (!map[a.teamId]) map[a.teamId] = [];
59
+ map[a.teamId].push(a);
60
+ }
61
+ return map;
62
+ }, [agents]);
63
+
64
+ const lowerSearch = search.toLowerCase();
65
+ const filteredTeams = useMemo(() => {
66
+ if (!lowerSearch) return teams;
67
+ return teams.filter((t) => {
68
+ if (t.name?.toLowerCase().includes(lowerSearch)) return true;
69
+ const ta = agentsByTeam[t.id] || [];
70
+ return ta.some((a) =>
71
+ a.name?.toLowerCase().includes(lowerSearch) ||
72
+ a.role?.toLowerCase().includes(lowerSearch)
73
+ );
74
+ });
75
+ }, [teams, agentsByTeam, lowerSearch]);
76
+
77
+ const filteredAgentsForTeam = useCallback((teamId) => {
78
+ const ta = agentsByTeam[teamId] || [];
79
+ if (!lowerSearch) return ta;
80
+ return ta.filter((a) =>
81
+ a.name?.toLowerCase().includes(lowerSearch) ||
82
+ a.role?.toLowerCase().includes(lowerSearch)
83
+ );
84
+ }, [agentsByTeam, lowerSearch]);
85
+
86
+ function handleDeleteTeam(e, teamId) {
87
+ e.stopPropagation();
88
+ if (confirmDeleteTeam === teamId) {
89
+ deleteTeam(teamId);
90
+ setConfirmDeleteTeam(null);
91
+ } else {
92
+ setConfirmDeleteTeam(teamId);
93
+ setTimeout(() => setConfirmDeleteTeam(null), 3000);
94
+ }
95
+ }
96
+
97
+ function handleSpawnToTeam(e, teamId) {
98
+ e.stopPropagation();
99
+ openDetail({ type: 'spawn', presetTeamId: teamId });
100
+ }
101
+
102
+ return (
103
+ <div
104
+ className="flex-shrink-0 flex flex-col bg-surface-1 border-r border-border relative h-full"
105
+ style={{ width }}
106
+ >
107
+ {/* Search */}
108
+ <div className="px-2.5 pt-2.5 pb-2 flex-shrink-0">
109
+ <div className="relative">
110
+ <Search size={14} className="absolute left-2 top-1/2 -translate-y-1/2 text-text-4" />
111
+ <input
112
+ type="text"
113
+ value={search}
114
+ onChange={(e) => setSearch(e.target.value)}
115
+ placeholder="Search agents..."
116
+ className="w-full h-7 pl-7 pr-7 text-xs bg-surface-3 rounded border border-border-subtle text-text-0 placeholder:text-text-4 focus:outline-none focus:border-text-4/40 font-sans"
117
+ />
118
+ {search && (
119
+ <button
120
+ onClick={() => setSearch('')}
121
+ className="absolute right-1.5 top-1/2 -translate-y-1/2 p-0.5 rounded text-text-4 hover:text-text-1 cursor-pointer"
122
+ >
123
+ <X size={14} />
124
+ </button>
125
+ )}
126
+ </div>
127
+ </div>
128
+
129
+ {/* Team list */}
130
+ <div className="flex-1 overflow-y-auto min-h-0 px-1">
131
+ {filteredTeams.map((team) => {
132
+ const teamAgents = filteredAgentsForTeam(team.id);
133
+ const allTeamAgents = agentsByTeam[team.id] || [];
134
+ const isCollapsed = collapsed[team.id];
135
+ const isConfirming = confirmDeleteTeam === team.id;
136
+
137
+ return (
138
+ <div key={team.id} className="mb-0.5">
139
+ {/* Team header */}
140
+ <div className={cn(
141
+ 'w-full flex items-center gap-1 px-2 py-1.5 rounded-md hover:bg-surface-2 transition-colors group',
142
+ isConfirming && 'bg-danger/10 hover:bg-danger/20',
143
+ )}>
144
+ <button
145
+ onClick={() => toggleCollapsed(team.id)}
146
+ className="flex items-center gap-1.5 flex-1 min-w-0 cursor-pointer"
147
+ >
148
+ <ChevronRight
149
+ size={14}
150
+ className={cn(
151
+ 'text-text-4 transition-transform flex-shrink-0',
152
+ !isCollapsed && 'rotate-90',
153
+ )}
154
+ />
155
+ <span className={cn(
156
+ 'text-xs font-medium font-sans truncate text-left',
157
+ isConfirming ? 'text-danger' : 'text-text-1',
158
+ )}>
159
+ {isConfirming ? 'Click again to delete' : team.name}
160
+ </span>
161
+ </button>
162
+
163
+ {/* Hover actions + meta — stacked in same space */}
164
+ <div className="flex items-center gap-0.5 flex-shrink-0">
165
+ <button
166
+ onClick={(e) => handleSpawnToTeam(e, team.id)}
167
+ className="opacity-0 group-hover:opacity-100 p-0.5 rounded text-text-4 hover:text-accent transition-opacity cursor-pointer"
168
+ title="Spawn agent to team"
169
+ >
170
+ <Plus size={14} />
171
+ </button>
172
+ <button
173
+ onClick={(e) => handleDeleteTeam(e, team.id)}
174
+ className={cn(
175
+ 'opacity-0 group-hover:opacity-100 p-0.5 rounded transition-opacity cursor-pointer',
176
+ isConfirming ? 'text-danger' : 'text-text-4 hover:text-danger',
177
+ )}
178
+ title="Delete team"
179
+ >
180
+ <Trash2 size={12} />
181
+ </button>
182
+ <span className="group-hover:opacity-0 text-2xs text-text-4 font-mono transition-opacity">
183
+ {allTeamAgents.length}
184
+ </span>
185
+ <span className={cn('w-1.5 h-1.5 rounded-full flex-shrink-0', teamStatusDot(allTeamAgents))} />
186
+ </div>
187
+ </div>
188
+
189
+ {/* Agent rows */}
190
+ {!isCollapsed && teamAgents.length > 0 && (
191
+ <div className="ml-3 pl-1">
192
+ {teamAgents.map((agent) => (
193
+ <FleetAgentRow key={agent.id} agent={agent} />
194
+ ))}
195
+ </div>
196
+ )}
197
+ </div>
198
+ );
199
+ })}
200
+
201
+ {filteredTeams.length === 0 && (
202
+ <div className="flex flex-col items-center justify-center py-8 text-center px-4">
203
+ <Search size={16} className="text-text-4 mb-2" />
204
+ <p className="text-xs text-text-3 font-sans">No matching agents</p>
205
+ </div>
206
+ )}
207
+ </div>
208
+
209
+ {/* Resize handle */}
210
+ <div
211
+ className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-accent/30 transition-colors z-10"
212
+ onMouseDown={onMouseDown}
213
+ />
214
+ </div>
215
+ );
216
+ }
@@ -1,5 +1,5 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
- import { Network, Code2, ChartSpline, Puzzle, Users, Box, FlaskConical, Newspaper, Settings, Globe, MessageCircle, BookOpen } from 'lucide-react';
2
+ import { Network, Code2, ChartSpline, Puzzle, Users, Box, FlaskConical, Newspaper, Settings, Globe, MessageCircle, BookOpen, LayoutList } from 'lucide-react';
3
3
  import { cn } from '../../lib/cn';
4
4
  import { Tooltip } from '../ui/tooltip';
5
5
  import { useGrooveStore } from '../../stores/groove';
@@ -7,6 +7,7 @@ import { isElectron, getPlatform } from '../../lib/electron';
7
7
 
8
8
  const BASE_NAV_ITEMS = [
9
9
  { id: 'agents', icon: Network, label: 'Agents' },
10
+ { id: 'fleet', icon: LayoutList, label: 'Fleet' },
10
11
  { id: 'chat', icon: MessageCircle, label: 'Chat' },
11
12
  { id: 'editor', icon: Code2, label: 'Editor' },
12
13
  { id: 'dashboard', icon: ChartSpline, label: 'Dashboard' },
@@ -188,6 +188,10 @@ export const createAgentsSlice = (set, get) => ({
188
188
  if (get().labAssistantAgentId === id) {
189
189
  localStorage.setItem('groove:labAssistantAgentId', newAgent.id);
190
190
  set({ labAssistantAgentId: newAgent.id });
191
+ } else if (get().activeView === 'fleet') {
192
+ const sel = get().fleetSelectedAgents;
193
+ const pane = sel[1] === id ? 1 : 0;
194
+ get().fleetSelectAgent(newAgent.id, pane);
191
195
  } else {
192
196
  get().selectAgent(newAgent.id);
193
197
  }
@@ -25,6 +25,14 @@ export const createUiSlice = (set, get) => ({
25
25
  // ── Toasts ────────────────────────────────────────────────
26
26
  toasts: [],
27
27
 
28
+ // ── Fleet View ─────────────────────────────────────────────
29
+ fleetSelectedAgents: [null, null],
30
+ fleetSplitMode: false,
31
+ fleetSidebarWidth: Number(localStorage.getItem('groove:fleetSidebarWidth')) || 240,
32
+ fleetSidebarCollapsed: {},
33
+ fleetSearch: '',
34
+ fleetUnreadMap: {},
35
+
28
36
  // ── Version / Auto-Update ──────────────────────────────────
29
37
  version: null,
30
38
  updateReady: null,
@@ -33,7 +41,34 @@ export const createUiSlice = (set, get) => ({
33
41
 
34
42
  // ── Navigation ────────────────────────────────────────────
35
43
 
36
- setActiveView(view) { set({ activeView: view }); },
44
+ setActiveView(view) {
45
+ const prev = get().activeView;
46
+ const updates = { activeView: view };
47
+ if (prev === 'fleet' && view !== 'fleet') {
48
+ const sel = get().fleetSelectedAgents;
49
+ const primaryId = sel[0] || sel[1];
50
+ if (primaryId) {
51
+ const tid = get().activeTeamId;
52
+ const panel = { type: 'agent', agentId: primaryId };
53
+ updates.detailPanel = panel;
54
+ updates.teamDetailPanels = { ...get().teamDetailPanels, [tid]: panel };
55
+ }
56
+ }
57
+ if (view === 'fleet' && prev !== 'fleet') {
58
+ const dp = get().detailPanel;
59
+ const sel = get().fleetSelectedAgents;
60
+ if (!sel[0] && !sel[1] && dp?.type === 'agent' && dp.agentId) {
61
+ updates.fleetSelectedAgents = [dp.agentId, null];
62
+ }
63
+ const tid = get().activeTeamId;
64
+ updates.detailPanel = null;
65
+ updates.teamDetailPanels = { ...get().teamDetailPanels, [tid]: null };
66
+ const allCollapsed = {};
67
+ for (const t of get().teams) allCollapsed[t.id] = true;
68
+ updates.fleetSidebarCollapsed = allCollapsed;
69
+ }
70
+ set(updates);
71
+ },
37
72
 
38
73
  openDetail(descriptor) {
39
74
  const tid = get().activeTeamId;
@@ -73,6 +108,41 @@ export const createUiSlice = (set, get) => ({
73
108
  persistJSON('groove:expandedNodes', expanded);
74
109
  },
75
110
 
111
+ // ── Fleet View ────────────────────────────────────────────
112
+
113
+ fleetSelectAgent(agentId, pane = 0) {
114
+ const selected = [...get().fleetSelectedAgents];
115
+ selected[pane] = agentId;
116
+ const updates = { fleetSelectedAgents: selected };
117
+ if (pane === 1 && agentId !== null && !get().fleetSplitMode) {
118
+ updates.fleetSplitMode = true;
119
+ }
120
+ if (pane === 1 && agentId === null) {
121
+ updates.fleetSplitMode = false;
122
+ }
123
+ set(updates);
124
+ },
125
+ fleetToggleSplit() {
126
+ const next = !get().fleetSplitMode;
127
+ const selected = [...get().fleetSelectedAgents];
128
+ if (!next) selected[1] = null;
129
+ set({ fleetSplitMode: next, fleetSelectedAgents: selected });
130
+ },
131
+ fleetSetSidebarWidth(width) {
132
+ const w = Math.max(180, Math.min(400, width));
133
+ set({ fleetSidebarWidth: w });
134
+ localStorage.setItem('groove:fleetSidebarWidth', String(w));
135
+ },
136
+ fleetToggleTeamCollapsed(teamId) {
137
+ const collapsed = { ...get().fleetSidebarCollapsed };
138
+ collapsed[teamId] = !collapsed[teamId];
139
+ set({ fleetSidebarCollapsed: collapsed });
140
+ },
141
+ fleetSetSearch(text) { set({ fleetSearch: text }); },
142
+ fleetMarkRead(agentId) {
143
+ set((s) => ({ fleetUnreadMap: { ...s.fleetUnreadMap, [agentId]: Date.now() } }));
144
+ },
145
+
76
146
  // ── Toasts ────────────────────────────────────────────────
77
147
 
78
148
  addToast(type, message, detail, action, options = {}) {
@@ -0,0 +1,15 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+ import { FleetSidebar } from '../components/fleet/fleet-sidebar';
3
+ import { FleetContent } from '../components/fleet/fleet-content';
4
+ import { useGrooveStore } from '../stores/groove';
5
+
6
+ export default function FleetView() {
7
+ const sidebarWidth = useGrooveStore((s) => s.fleetSidebarWidth);
8
+
9
+ return (
10
+ <div className="flex h-full min-h-0">
11
+ <FleetSidebar width={sidebarWidth} />
12
+ <FleetContent />
13
+ </div>
14
+ );
15
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "groove-dev",
3
- "version": "0.27.161",
3
+ "version": "0.27.164",
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.161",
3
+ "version": "0.27.164",
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.161",
3
+ "version": "0.27.164",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -874,6 +874,7 @@ export class GatewayManager {
874
874
  teamId: defaultTeamId,
875
875
  })),
876
876
  });
877
+ this.daemon.processes._persistPendingPhase2();
877
878
  }
878
879
  }
879
880
 
@@ -588,6 +588,9 @@ export class Daemon {
588
588
  console.log(` ${resumableIds.size} agent-loop session(s) marked as resumable`);
589
589
  }
590
590
 
591
+ // Restore pending phase 2 groups from disk
592
+ this.processes.loadPendingPhase2();
593
+
591
594
  // Migrate old agents without teamId to default team
592
595
  this.teams.migrateAgents();
593
596
 
@@ -8,6 +8,7 @@ import { homedir } from 'os';
8
8
  import { spawn } from 'child_process';
9
9
  import { LlamaServerManager } from './llama-server.js';
10
10
  import { MLXServerManager } from './mlx-server.js';
11
+ import { OllamaProvider } from './providers/ollama.js';
11
12
  const RUNTIME_TYPES = ['ollama', 'vllm', 'llama-cpp', 'mlx', 'tgi', 'openai-compatible'];
12
13
  const DEFAULT_OLLAMA_ENDPOINT = 'http://localhost:11434';
13
14
  const GLOBAL_GROOVE_DIR = resolve(homedir(), '.groove');
@@ -698,11 +699,13 @@ export class ModelLab {
698
699
 
699
700
  listLocalModels() {
700
701
  const models = [];
702
+ const seen = new Set();
701
703
 
702
704
  // GGUF models from ModelManager
703
705
  const mm = this.daemon.modelManager;
704
706
  if (mm) {
705
707
  for (const m of mm.getInstalled().filter((m) => m.exists)) {
708
+ seen.add(m.id);
706
709
  models.push({ ...m, type: 'gguf', compatibleBackends: ['llama-cpp'] });
707
710
  }
708
711
  }
@@ -711,10 +714,22 @@ export class ModelLab {
711
714
  try {
712
715
  const hfModels = MLXServerManager.scanModels();
713
716
  for (const m of hfModels) {
717
+ seen.add(m.id);
714
718
  models.push(m);
715
719
  }
716
720
  } catch { /* scan may fail */ }
717
721
 
722
+ // Ollama installed models
723
+ try {
724
+ if (OllamaProvider.isInstalled()) {
725
+ for (const m of OllamaProvider.getInstalledModels()) {
726
+ if (seen.has(m.id)) continue;
727
+ seen.add(m.id);
728
+ models.push({ ...m, type: 'ollama', compatibleBackends: ['ollama'] });
729
+ }
730
+ }
731
+ } catch { /* ollama may not be available */ }
732
+
718
733
  return models;
719
734
  }
720
735