groove-dev 0.27.187 → 0.27.189

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 (42) hide show
  1. package/innerchat/Screenshot_2026-07-23_at_1.30.16_PM.png +0 -0
  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/index.js +6 -0
  5. package/node_modules/@groove-dev/daemon/src/innerchat-docs.js +18 -13
  6. package/node_modules/@groove-dev/daemon/src/process.js +10 -0
  7. package/node_modules/@groove-dev/daemon/src/teams.js +36 -4
  8. package/node_modules/@groove-dev/daemon/src/watcher.js +230 -104
  9. package/node_modules/@groove-dev/daemon/test/teams.test.js +48 -1
  10. package/node_modules/@groove-dev/daemon/test/watcher.test.js +68 -10
  11. package/node_modules/@groove-dev/gui/dist/assets/index-Cat5pJUx.css +1 -0
  12. package/node_modules/@groove-dev/gui/dist/assets/{index-DyI84i9Y.js → index-DTY0KsH9.js} +222 -222
  13. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  14. package/node_modules/@groove-dev/gui/package.json +1 -1
  15. package/node_modules/@groove-dev/gui/src/App.jsx +51 -2
  16. package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +11 -2
  17. package/node_modules/@groove-dev/gui/src/components/agents/agent-panel.jsx +106 -10
  18. package/node_modules/@groove-dev/gui/src/lib/logpaths.js +1 -1
  19. package/node_modules/@groove-dev/gui/src/stores/groove.js +19 -6
  20. package/node_modules/@groove-dev/gui/src/stores/helpers.js +28 -0
  21. package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +20 -9
  22. package/package.json +1 -1
  23. package/packages/cli/package.json +1 -1
  24. package/packages/daemon/package.json +1 -1
  25. package/packages/daemon/src/index.js +6 -0
  26. package/packages/daemon/src/innerchat-docs.js +18 -13
  27. package/packages/daemon/src/process.js +10 -0
  28. package/packages/daemon/src/teams.js +36 -4
  29. package/packages/daemon/src/watcher.js +230 -104
  30. package/packages/gui/dist/assets/index-Cat5pJUx.css +1 -0
  31. package/packages/gui/dist/assets/{index-DyI84i9Y.js → index-DTY0KsH9.js} +222 -222
  32. package/packages/gui/dist/index.html +2 -2
  33. package/packages/gui/package.json +1 -1
  34. package/packages/gui/src/App.jsx +51 -2
  35. package/packages/gui/src/components/agents/agent-feed.jsx +11 -2
  36. package/packages/gui/src/components/agents/agent-panel.jsx +106 -10
  37. package/packages/gui/src/lib/logpaths.js +1 -1
  38. package/packages/gui/src/stores/groove.js +19 -6
  39. package/packages/gui/src/stores/helpers.js +28 -0
  40. package/packages/gui/src/stores/slices/agents-slice.js +20 -9
  41. package/node_modules/@groove-dev/gui/dist/assets/index-CU8L_r5f.css +0 -1
  42. 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-DyI84i9Y.js"></script>
9
+ <script type="module" crossorigin src="/assets/index-DTY0KsH9.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-CU8L_r5f.css">
14
+ <link rel="stylesheet" crossorigin href="/assets/index-Cat5pJUx.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.187",
3
+ "version": "0.27.189",
4
4
  "description": "GROOVE GUI — visual agent control plane",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -1,7 +1,7 @@
1
1
  // GROOVE GUI v2 — App Root
2
2
  // FSL-1.1-Apache-2.0 — see LICENSE
3
3
 
4
- import React, { useEffect, useMemo } from 'react';
4
+ import React, { useEffect, useMemo, useState, useRef } from 'react';
5
5
  import { useGrooveStore } from './stores/groove';
6
6
  import { AppShell } from './components/layout/app-shell';
7
7
  import { SetupWizard } from './components/onboarding/setup-wizard';
@@ -133,10 +133,59 @@ function TunneledFolderPicker() {
133
133
  }
134
134
 
135
135
  function LoadingScreen() {
136
+ const connected = useGrooveStore((s) => s.connected);
137
+ const [slow, setSlow] = useState(false);
138
+ const [reconnecting, setReconnecting] = useState(false);
139
+ const triedRef = useRef(false);
140
+
141
+ // If we haven't come up within a few seconds, the connection (or the SSH
142
+ // tunnel behind it) is likely stalled — surface a reload rather than an
143
+ // endless pulse the user can't act on.
144
+ useEffect(() => {
145
+ const t = setTimeout(() => setSlow(true), 6000);
146
+ return () => clearTimeout(t);
147
+ }, []);
148
+
149
+ // If the socket can't even open (not just un-hydrated), the tunnel behind it
150
+ // is probably dead — ask the desktop shell to re-establish it. Once only, and
151
+ // only when disconnected, so a merely-slow load isn't disturbed.
152
+ useEffect(() => {
153
+ if (!slow || connected || triedRef.current) return;
154
+ if (!window.groove?.reconnect) return;
155
+ triedRef.current = true;
156
+ setReconnecting(true);
157
+ window.groove.reconnect().catch(() => {}).finally(() => setReconnecting(false));
158
+ }, [slow, connected]);
159
+
160
+ async function manualReconnect() {
161
+ if (window.groove?.reconnect) {
162
+ setReconnecting(true);
163
+ try { await window.groove.reconnect(); } catch { /* falls through to reload */ }
164
+ setReconnecting(false);
165
+ }
166
+ window.location.reload();
167
+ }
168
+
136
169
  return (
137
170
  <div className="h-screen bg-surface-0 flex flex-col items-center justify-center gap-4">
138
171
  <img src="/favicon.png" alt="" className="w-10 h-10 opacity-60 animate-pulse" />
139
- <p className="text-sm text-text-3 font-sans">Connecting...</p>
172
+ <p className="text-sm text-text-3 font-sans">
173
+ {reconnecting ? 'Re-establishing connection…' : connected ? 'Loading…' : 'Connecting to daemon…'}
174
+ </p>
175
+ {slow && (
176
+ <div className="flex flex-col items-center gap-2 mt-1">
177
+ <p className="text-xs text-text-4 font-sans max-w-xs text-center">
178
+ Still trying to reach the daemon. If you just woke your machine, the connection is re-establishing.
179
+ </p>
180
+ <button
181
+ onClick={manualReconnect}
182
+ disabled={reconnecting}
183
+ className="px-3 py-1.5 rounded-md text-xs font-medium font-sans border border-accent/40 bg-accent/10 text-accent hover:bg-accent/20 disabled:opacity-50 transition-colors cursor-pointer"
184
+ >
185
+ {reconnecting ? 'Reconnecting…' : 'Reconnect'}
186
+ </button>
187
+ </div>
188
+ )}
140
189
  </div>
141
190
  );
142
191
  }
@@ -390,6 +390,15 @@ function shq(s) {
390
390
  return `'${String(s).replace(/'/g, `'\\''`)}'`;
391
391
  }
392
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
+
393
402
  // Build a `tail -f` that actually resolves. Agents usually write a log path
394
403
  // relative to wherever they ran it — often a bare filename several directories
395
404
  // deep — but the terminal opens in a different cwd, so a naive `tail -f name`
@@ -397,9 +406,9 @@ function shq(s) {
397
406
  // agent's working directory, falling back to a `find` by basename when the file
398
407
  // sits below that directory.
399
408
  function tailCommand(path, workdir) {
400
- if (/^[/~]/.test(path) || !workdir) return `tail -f ${shq(path)}`;
409
+ if (/^[/~]/.test(path) || !workdir) return `tail -f ${shqPath(path)}`;
401
410
  const base = path.split('/').pop();
402
- return `cd ${shq(workdir)} 2>/dev/null; `
411
+ return `cd ${shqPath(workdir)} 2>/dev/null; `
403
412
  + `if [ -f ${shq(path)} ]; then tail -f ${shq(path)}; `
404
413
  + `else tail -f "$(find . -name ${shq(base)} -type f 2>/dev/null | head -1)"; fi`;
405
414
  }
@@ -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
- function InlineName({ agent }) {
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 inputRef = useRef(null);
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
- <h2 className="text-sm font-bold text-text-0 font-sans truncate">{agent.name}</h2>
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
- if (activeTeamId && agent.teamId && agent.teamId !== activeTeamId) return null;
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
- <InlineName agent={agent} />
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'"`(=])([~/]?(?:[\w.-]+\/)*[\w.-]+\.log)\b/g;
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
- // Clear stale persisted data on version change
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
- persistJSON('groove:chatHistory', history);
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
- persistJSON('groove:chatHistory', history);
405
+ persistChatHistory(history);
406
406
 
407
407
  const answers = { ...s.innerchatAnswers };
408
408
  if (turn.kind === 'answer') {
@@ -1146,3 +1146,16 @@ export const useGrooveStore = create((set, get) => ({
1146
1146
  ws.onerror = () => ws.close();
1147
1147
  },
1148
1148
  }));
1149
+
1150
+ // Reconnect promptly when connectivity returns — laptop wake, network back, or
1151
+ // the window regaining focus — instead of waiting out the periodic 2s retry.
1152
+ // connect() no-ops if a socket already exists, so these are safe to fire often.
1153
+ if (typeof window !== 'undefined') {
1154
+ const kick = () => {
1155
+ const s = useGrooveStore.getState();
1156
+ if (!s.ws) s.connect();
1157
+ };
1158
+ window.addEventListener('online', kick);
1159
+ window.addEventListener('focus', kick);
1160
+ document.addEventListener('visibilitychange', () => { if (!document.hidden) kick(); });
1161
+ }
@@ -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
- persistJSON('groove:chatHistory', chatHistory);
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
- const forStorage = { ...history };
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) persistJSON('groove:chatHistory', get().chatHistory);
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.187",
3
+ "version": "0.27.189",
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.187",
3
+ "version": "0.27.189",
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.187",
3
+ "version": "0.27.189",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -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,32 @@ 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
- '## Getting Notified When Something Finishes (Watch)',
73
+ '## Running Long Jobs & Getting Notified (Watch)',
74
74
  '',
75
- 'Your process ends when your turn ends, so a test suite or long job you kicked',
76
- 'off has nothing to notify you telling the user "I\'ll report back when it lands"',
77
- 'does not work on its own; the session is gone. Instead, set a Watch. The daemon',
78
- 'outlives your turn and RESUMES you with the result when it finishes:',
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":"test suite","command":"npm test"}'`,
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** — you will be woken',
86
- 'with the exit code and output tail when the command finishes.',
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
- '- Prefer `command` (let the daemon run it) over launching it yourself that way',
89
- ' it captures the real exit code and output.',
90
- '- For something ALREADY running, poll instead with `until` (fires when the check',
91
- ` exits 0): \`-d '{"agent":"${agentName}","label":"server up","until":"curl -sf localhost:3000/health"}'\`.`,
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). One clear task per watch; do not watch trivial commands you could just run.',
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
  }
@@ -2628,6 +2628,9 @@ After fixing all issues, run tests (npm test) and build (npm run build) to verif
2628
2628
  // Let the aborted process finish tearing down before re-spawning; resume()
2629
2629
  // kills the current handle and re-registers under a new agent id.
2630
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;
2631
2634
  this.resume(agentId, pending.message).catch((err) => {
2632
2635
  this.daemon.broadcast({
2633
2636
  type: 'agent:output',
@@ -3060,6 +3063,13 @@ After fixing all issues, run tests (npm test) and build (npm run build) to verif
3060
3063
  const handle = this.handles.get(agentId);
3061
3064
  if (!handle) return;
3062
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
+
3063
3073
  const { proc, loop } = handle;
3064
3074
 
3065
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
- try {
285
- rmSync(team.workingDir, { recursive: true, force: true });
286
- } catch (err) {
287
- console.log(`[Groove:Teams] Failed to delete directory: ${err.message}`);
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) {