groove-dev 0.27.32 → 0.27.33

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/node_modules/@groove-dev/cli/package.json +1 -1
  2. package/node_modules/@groove-dev/cli/src/commands/start.js +1 -1
  3. package/node_modules/@groove-dev/daemon/package.json +1 -1
  4. package/node_modules/@groove-dev/daemon/src/api.js +28 -1
  5. package/node_modules/@groove-dev/daemon/src/firstrun.js +1 -0
  6. package/node_modules/@groove-dev/daemon/src/index.js +14 -0
  7. package/node_modules/@groove-dev/daemon/src/journalist.js +16 -4
  8. package/node_modules/@groove-dev/daemon/src/memory.js +6 -1
  9. package/node_modules/@groove-dev/daemon/src/process.js +44 -28
  10. package/node_modules/@groove-dev/daemon/src/rotator.js +1 -0
  11. package/node_modules/@groove-dev/daemon/src/tunnel-manager.js +19 -7
  12. package/node_modules/@groove-dev/daemon/test/rotator.test.js +1 -0
  13. package/node_modules/@groove-dev/gui/dist/assets/{index-BwNjgBny.css → index-BnLiWvrh.css} +1 -1
  14. package/node_modules/@groove-dev/gui/dist/assets/{index-PxWmJjcJ.js → index-BoU6IhQI.js} +1635 -1635
  15. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  16. package/node_modules/@groove-dev/gui/package.json +1 -1
  17. package/node_modules/@groove-dev/gui/src/components/layout/app-shell.jsx +2 -0
  18. package/node_modules/@groove-dev/gui/src/components/layout/breadcrumb-bar.jsx +16 -1
  19. package/node_modules/@groove-dev/gui/src/components/layout/project-picker.jsx +127 -0
  20. package/node_modules/@groove-dev/gui/src/components/settings/quick-connect.jsx +22 -15
  21. package/node_modules/@groove-dev/gui/src/stores/groove.js +39 -2
  22. package/package.json +1 -1
  23. package/packages/cli/package.json +1 -1
  24. package/packages/cli/src/commands/start.js +1 -1
  25. package/packages/daemon/package.json +1 -1
  26. package/packages/daemon/src/api.js +28 -1
  27. package/packages/daemon/src/firstrun.js +1 -0
  28. package/packages/daemon/src/index.js +14 -0
  29. package/packages/daemon/src/journalist.js +16 -4
  30. package/packages/daemon/src/memory.js +6 -1
  31. package/packages/daemon/src/process.js +44 -28
  32. package/packages/daemon/src/rotator.js +1 -0
  33. package/packages/daemon/src/tunnel-manager.js +19 -7
  34. package/packages/gui/dist/assets/{index-BwNjgBny.css → index-BnLiWvrh.css} +1 -1
  35. package/packages/gui/dist/assets/{index-PxWmJjcJ.js → index-BoU6IhQI.js} +1635 -1635
  36. package/packages/gui/dist/index.html +2 -2
  37. package/packages/gui/package.json +1 -1
  38. package/packages/gui/src/components/layout/app-shell.jsx +2 -0
  39. package/packages/gui/src/components/layout/breadcrumb-bar.jsx +16 -1
  40. package/packages/gui/src/components/layout/project-picker.jsx +127 -0
  41. package/packages/gui/src/components/settings/quick-connect.jsx +22 -15
  42. package/packages/gui/src/stores/groove.js +39 -2
@@ -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-PxWmJjcJ.js"></script>
9
+ <script type="module" crossorigin src="/assets/index-BoU6IhQI.js"></script>
10
10
  <link rel="modulepreload" crossorigin href="/assets/vendor-C0HXlhrU.js">
11
11
  <link rel="modulepreload" crossorigin href="/assets/reactflow-BQPfi37R.js">
12
12
  <link rel="modulepreload" crossorigin href="/assets/codemirror-BBL3i_JW.js">
13
13
  <link rel="modulepreload" crossorigin href="/assets/xterm--7_ns2zW.js">
14
- <link rel="stylesheet" crossorigin href="/assets/index-BwNjgBny.css">
14
+ <link rel="stylesheet" crossorigin href="/assets/index-BnLiWvrh.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.32",
3
+ "version": "0.27.33",
4
4
  "description": "GROOVE GUI — visual agent control plane",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -13,6 +13,7 @@ import { DetailPanel } from './detail-panel';
13
13
  import { CommandPalette } from './command-palette';
14
14
  import { ApprovalModal } from '../ui/approval-modal';
15
15
  import { QuickConnect } from '../settings/quick-connect';
16
+ import { ProjectPicker } from './project-picker';
16
17
  import { TeamTabBar } from '../../views/agents';
17
18
 
18
19
  export function AppShell({ children, detailContent, terminalContent }) {
@@ -117,6 +118,7 @@ export function AppShell({ children, detailContent, terminalContent }) {
117
118
 
118
119
  <CommandPalette />
119
120
  <QuickConnect />
121
+ <ProjectPicker />
120
122
  <ApprovalModal />
121
123
  <ToastContainer />
122
124
  </div>
@@ -1,6 +1,6 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
2
  import { useState, useEffect, useRef } from 'react';
3
- import { Search, ChevronRight, LogIn, LogOut, User, ExternalLink, BookOpen, ChevronDown } from 'lucide-react';
3
+ import { Search, ChevronRight, LogIn, LogOut, User, ExternalLink, BookOpen, ChevronDown, FolderOpen } from 'lucide-react';
4
4
  import { cn } from '../../lib/cn';
5
5
  import { useGrooveStore } from '../../stores/groove';
6
6
  import { isElectron, getPlatform } from '../../lib/electron';
@@ -130,10 +130,13 @@ const VIEW_LABELS = {
130
130
  export function BreadcrumbBar({
131
131
  activeView,
132
132
  connected,
133
+ tunneled,
133
134
  daemonHost,
134
135
  editorActiveFile,
135
136
  onOpenCommandPalette,
136
137
  }) {
138
+ const projectDir = useGrooveStore((s) => s.projectDir);
139
+ const toggleProjectPicker = useGrooveStore((s) => s.toggleProjectPicker);
137
140
  const crumbs = ['Groove', VIEW_LABELS[activeView] || activeView];
138
141
  if (activeView === 'editor' && editorActiveFile) {
139
142
  crumbs.push(editorActiveFile.split('/').pop());
@@ -182,6 +185,18 @@ export function BreadcrumbBar({
182
185
  </span>
183
186
  )}
184
187
 
188
+ {/* Project dir badge — remote sessions, clickable to change */}
189
+ {tunneled && projectDir && (
190
+ <button
191
+ onClick={toggleProjectPicker}
192
+ className="flex items-center gap-1 text-2xs font-mono font-medium text-text-2 bg-surface-5 px-1.5 py-0.5 rounded flex-shrink-0 hover:bg-surface-4 hover:text-text-0 transition-colors cursor-pointer"
193
+ title={projectDir}
194
+ >
195
+ <FolderOpen size={11} />
196
+ {projectDir.split('/').pop() || '/'}
197
+ </button>
198
+ )}
199
+
185
200
  <div className="flex-1 min-w-4" />
186
201
 
187
202
  {/* Breadcrumbs */}
@@ -0,0 +1,127 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+ import { useState } from 'react';
3
+ import { useGrooveStore } from '../../stores/groove';
4
+ import { FolderBrowser } from '../agents/folder-browser';
5
+ import { cn } from '../../lib/cn';
6
+ import {
7
+ FolderOpen, FolderClosed, Clock, ChevronRight, Plus, Monitor,
8
+ } from 'lucide-react';
9
+
10
+ function formatTimeAgo(iso) {
11
+ if (!iso) return '';
12
+ const diff = Date.now() - new Date(iso).getTime();
13
+ const mins = Math.floor(diff / 60000);
14
+ if (mins < 1) return 'just now';
15
+ if (mins < 60) return `${mins}m ago`;
16
+ const hours = Math.floor(mins / 60);
17
+ if (hours < 24) return `${hours}h ago`;
18
+ const days = Math.floor(hours / 24);
19
+ return `${days}d ago`;
20
+ }
21
+
22
+ export function ProjectPicker() {
23
+ const show = useGrooveStore((s) => s.showProjectPicker);
24
+ const recentProjects = useGrooveStore((s) => s.recentProjects);
25
+ const setProjectDir = useGrooveStore((s) => s.setProjectDir);
26
+ const [browserOpen, setBrowserOpen] = useState(false);
27
+ const [loading, setLoading] = useState(null);
28
+
29
+ if (!show) return null;
30
+
31
+ async function handleSelect(path) {
32
+ setLoading(path);
33
+ try {
34
+ await setProjectDir(path);
35
+ } catch {
36
+ setLoading(null);
37
+ }
38
+ }
39
+
40
+ async function handleBrowseSelect(path) {
41
+ setBrowserOpen(false);
42
+ await handleSelect(path);
43
+ }
44
+
45
+ return (
46
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-surface-0/90 backdrop-blur-sm">
47
+ <div className="w-full max-w-lg mx-4">
48
+ {/* Header */}
49
+ <div className="text-center mb-8">
50
+ <div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-accent/10 mb-4">
51
+ <Monitor size={28} className="text-accent" />
52
+ </div>
53
+ <h1 className="text-xl font-semibold text-text-0 mb-1">Open a project</h1>
54
+ <p className="text-sm text-text-3">Select a working directory for this session</p>
55
+ </div>
56
+
57
+ {/* Recent projects */}
58
+ {recentProjects.length > 0 && (
59
+ <div className="mb-4">
60
+ <div className="flex items-center gap-2 mb-2 px-1">
61
+ <Clock size={13} className="text-text-4" />
62
+ <span className="text-xs font-medium text-text-3 uppercase tracking-wider">Recent</span>
63
+ </div>
64
+ <div className="bg-surface-2 rounded-xl border border-border overflow-hidden">
65
+ {recentProjects.map((project, i) => (
66
+ <button
67
+ key={project.path}
68
+ onClick={() => handleSelect(project.path)}
69
+ disabled={loading !== null}
70
+ className={cn(
71
+ 'w-full flex items-center gap-3 px-4 py-3 text-left cursor-pointer',
72
+ 'hover:bg-surface-4 transition-colors',
73
+ 'disabled:opacity-50 disabled:cursor-wait',
74
+ i < recentProjects.length - 1 && 'border-b border-border-subtle',
75
+ )}
76
+ >
77
+ <FolderClosed size={18} className="text-warning flex-shrink-0" />
78
+ <div className="flex-1 min-w-0">
79
+ <div className="text-sm font-medium text-text-0 truncate">{project.name}</div>
80
+ <div className="text-xs text-text-3 font-mono truncate">{project.path}</div>
81
+ </div>
82
+ <div className="flex items-center gap-2 flex-shrink-0">
83
+ {project.openedAt && (
84
+ <span className="text-[11px] text-text-4">{formatTimeAgo(project.openedAt)}</span>
85
+ )}
86
+ {loading === project.path ? (
87
+ <div className="w-4 h-4 border-2 border-accent border-t-transparent rounded-full animate-spin" />
88
+ ) : (
89
+ <ChevronRight size={14} className="text-text-4" />
90
+ )}
91
+ </div>
92
+ </button>
93
+ ))}
94
+ </div>
95
+ </div>
96
+ )}
97
+
98
+ {/* Open folder button */}
99
+ <button
100
+ onClick={() => setBrowserOpen(true)}
101
+ disabled={loading !== null}
102
+ className={cn(
103
+ 'w-full flex items-center gap-3 px-4 py-3.5 rounded-xl cursor-pointer',
104
+ 'bg-surface-2 border border-border border-dashed',
105
+ 'hover:bg-surface-4 hover:border-accent/30 transition-colors',
106
+ 'disabled:opacity-50 disabled:cursor-wait',
107
+ )}
108
+ >
109
+ <div className="flex items-center justify-center w-9 h-9 rounded-lg bg-accent/10">
110
+ <Plus size={18} className="text-accent" />
111
+ </div>
112
+ <div className="text-left">
113
+ <div className="text-sm font-medium text-text-0">Open Folder</div>
114
+ <div className="text-xs text-text-3">Browse the filesystem</div>
115
+ </div>
116
+ </button>
117
+
118
+ <FolderBrowser
119
+ open={browserOpen}
120
+ onOpenChange={setBrowserOpen}
121
+ currentPath="/home"
122
+ onSelect={handleBrowseSelect}
123
+ />
124
+ </div>
125
+ </div>
126
+ );
127
+ }
@@ -1,5 +1,5 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
- import { useState } from 'react';
2
+ import { useState, useRef } from 'react';
3
3
  import { useGrooveStore } from '../../stores/groove';
4
4
  import { cn } from '../../lib/cn';
5
5
  import { AnimatePresence, motion } from 'framer-motion';
@@ -17,6 +17,7 @@ export function QuickConnect() {
17
17
  const addToast = useGrooveStore((s) => s.addToast);
18
18
  const [connectingId, setConnectingId] = useState(null);
19
19
  const [showWizard, setShowWizard] = useState(false);
20
+ const wizardTunnelId = useRef(null);
20
21
 
21
22
  if (!open) return null;
22
23
 
@@ -25,7 +26,9 @@ export function QuickConnect() {
25
26
  try {
26
27
  await useGrooveStore.getState().connectTunnel(id);
27
28
  toggle();
28
- } catch {}
29
+ } catch (err) {
30
+ addToast('error', 'Connection failed', err?.message || 'Unknown error');
31
+ }
29
32
  setConnectingId(null);
30
33
  }
31
34
 
@@ -81,24 +84,28 @@ export function QuickConnect() {
81
84
  <SSHWizard
82
85
  server={null}
83
86
  onSave={async (data) => {
84
- if (data.id) {
85
- await useGrooveStore.getState().updateTunnel(data.id, data);
87
+ const existingId = data.id || wizardTunnelId.current;
88
+ if (existingId) {
89
+ await useGrooveStore.getState().updateTunnel(existingId, data);
90
+ addToast('success', 'Server updated');
86
91
  } else {
87
- await useGrooveStore.getState().saveTunnel(data);
92
+ const result = await useGrooveStore.getState().saveTunnel(data);
93
+ if (result?.id) wizardTunnelId.current = result.id;
94
+ addToast('success', 'Server added');
88
95
  }
89
- addToast('success', data.id ? 'Server updated' : 'Server added');
90
96
  }}
91
97
  onTest={() => {
92
- const tunnels = useGrooveStore.getState().savedTunnels;
93
- const last = tunnels[tunnels.length - 1];
94
- if (last?.id) return useGrooveStore.getState().testTunnel(last.id);
98
+ const id = wizardTunnelId.current;
99
+ if (id) return useGrooveStore.getState().testTunnel(id);
95
100
  }}
96
101
  onConnect={() => {
97
- const tunnels = useGrooveStore.getState().savedTunnels;
98
- const last = tunnels[tunnels.length - 1];
99
- if (last?.id) return useGrooveStore.getState().connectTunnel(last.id);
102
+ const id = wizardTunnelId.current;
103
+ if (id) return useGrooveStore.getState().connectTunnel(id);
104
+ }}
105
+ onCancel={() => {
106
+ wizardTunnelId.current = null;
107
+ setShowWizard(false);
100
108
  }}
101
- onCancel={() => setShowWizard(false)}
102
109
  />
103
110
  ) : (
104
111
  <>
@@ -112,7 +119,7 @@ export function QuickConnect() {
112
119
  <Button
113
120
  variant="primary"
114
121
  size="sm"
115
- onClick={() => setShowWizard(true)}
122
+ onClick={() => { wizardTunnelId.current = null; setShowWizard(true); }}
116
123
  className="h-8 text-xs gap-1.5 mt-3"
117
124
  >
118
125
  <Plus size={12} /> Add Connection
@@ -157,7 +164,7 @@ export function QuickConnect() {
157
164
  {/* Footer with Add button */}
158
165
  <div className="px-4 py-2.5 border-t border-border-subtle">
159
166
  <button
160
- onClick={() => setShowWizard(true)}
167
+ onClick={() => { wizardTunnelId.current = null; setShowWizard(true); }}
161
168
  className="flex items-center gap-1.5 text-2xs text-accent hover:text-accent/80 font-sans font-medium cursor-pointer transition-colors"
162
169
  >
163
170
  <Plus size={10} /> Add new connection
@@ -107,6 +107,11 @@ export const useGrooveStore = create((set, get) => ({
107
107
  // ── Toasts ────────────────────────────────────────────────
108
108
  toasts: [],
109
109
 
110
+ // ── Project Directory ───────────────────────────────────────
111
+ projectDir: null,
112
+ recentProjects: [],
113
+ showProjectPicker: false,
114
+
110
115
  // ── Tunnels ────────────────────────────────────────────────
111
116
  savedTunnels: [],
112
117
  activeTunnelId: null,
@@ -140,9 +145,11 @@ export const useGrooveStore = create((set, get) => ({
140
145
  const updates = {};
141
146
  if (s.host && s.host !== '127.0.0.1') updates.daemonHost = s.host;
142
147
  const browserPort = window.location.port || '80';
143
- if (String(s.port) !== browserPort) updates.tunneled = true;
148
+ const isTunneled = String(s.port) !== browserPort;
149
+ if (isTunneled) updates.tunneled = true;
144
150
  if (s.version) updates.version = s.version;
145
151
  if (Object.keys(updates).length > 0) set(updates);
152
+ if (isTunneled) get().fetchProjectDir();
146
153
  }).catch(() => {});
147
154
  get().fetchTeams();
148
155
  get().fetchApprovals();
@@ -502,6 +509,10 @@ export const useGrooveStore = create((set, get) => ({
502
509
  }));
503
510
  break;
504
511
 
512
+ case 'project-dir:changed':
513
+ set({ projectDir: msg.data?.projectDir, showProjectPicker: false });
514
+ break;
515
+
505
516
  case 'tunnel.connected':
506
517
  set({ activeTunnelId: msg.data?.id || null });
507
518
  get().fetchTunnels();
@@ -1034,6 +1045,33 @@ export const useGrooveStore = create((set, get) => ({
1034
1045
  get().fetchImportedRepos();
1035
1046
  },
1036
1047
 
1048
+ // ── Project Directory ────────────────────────────────────
1049
+
1050
+ async fetchProjectDir() {
1051
+ try {
1052
+ const data = await api.get('/project-dir');
1053
+ const isHome = /^\/home\/[^/]+$/.test(data.projectDir) || data.projectDir === '/root';
1054
+ set({
1055
+ projectDir: data.projectDir,
1056
+ recentProjects: data.recentProjects || [],
1057
+ showProjectPicker: isHome || (data.recentProjects || []).length === 0,
1058
+ });
1059
+ } catch {}
1060
+ },
1061
+
1062
+ async setProjectDir(path) {
1063
+ const data = await api.post('/project-dir', { path });
1064
+ set({
1065
+ projectDir: data.projectDir,
1066
+ recentProjects: data.recentProjects || [],
1067
+ showProjectPicker: false,
1068
+ });
1069
+ },
1070
+
1071
+ toggleProjectPicker() {
1072
+ set((s) => ({ showProjectPicker: !s.showProjectPicker }));
1073
+ },
1074
+
1037
1075
  // ── Tunnels ──────────────────────────────────────────────
1038
1076
 
1039
1077
  async fetchTunnels() {
@@ -1068,7 +1106,6 @@ export const useGrooveStore = create((set, get) => ({
1068
1106
  const result = await api.post(`/tunnels/${encodeURIComponent(id)}/connect`);
1069
1107
  set({ activeTunnelId: id });
1070
1108
  get().fetchTunnels();
1071
- if (result.url) window.open(result.url, '_blank');
1072
1109
  return result;
1073
1110
  },
1074
1111
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "groove-dev",
3
- "version": "0.27.32",
3
+ "version": "0.27.33",
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.32",
3
+ "version": "0.27.33",
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",
@@ -19,7 +19,7 @@ export async function start(options) {
19
19
  setupKeys = result.keys || {};
20
20
  } catch (err) {
21
21
  // If stdin is not interactive (piped), skip wizard
22
- if (err.code === 'ERR_USE_AFTER_CLOSE') {
22
+ if (err.code === 'ERR_USE_AFTER_CLOSE' || !process.stdin.isTTY) {
23
23
  console.log(chalk.dim(' Non-interactive mode — skipping setup wizard.'));
24
24
  } else {
25
25
  throw err;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/daemon",
3
- "version": "0.27.32",
3
+ "version": "0.27.33",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -695,6 +695,28 @@ export function createApi(app, daemon) {
695
695
  });
696
696
  });
697
697
 
698
+ // --- Project Directory ---
699
+
700
+ app.get('/api/project-dir', (req, res) => {
701
+ res.json({
702
+ projectDir: daemon.projectDir,
703
+ recentProjects: daemon.config.recentProjects || [],
704
+ });
705
+ });
706
+
707
+ app.post('/api/project-dir', (req, res) => {
708
+ const { path: dirPath } = req.body || {};
709
+ if (!dirPath || typeof dirPath !== 'string') {
710
+ return res.status(400).json({ error: 'path is required' });
711
+ }
712
+ try {
713
+ daemon.setProjectDir(dirPath);
714
+ res.json({ projectDir: daemon.projectDir, recentProjects: daemon.config.recentProjects || [] });
715
+ } catch (err) {
716
+ res.status(400).json({ error: err.message });
717
+ }
718
+ });
719
+
698
720
  // --- Teams (live agent groups) ---
699
721
 
700
722
  app.get('/api/teams', (req, res) => {
@@ -3330,7 +3352,12 @@ Keep responses concise. Help them think, don't lecture them about the system the
3330
3352
 
3331
3353
  app.post('/api/tunnels/:id/connect', proOnly, async (req, res) => {
3332
3354
  try {
3333
- const result = await daemon.tunnelManager.connect(req.params.id);
3355
+ const opts = {};
3356
+ if (req.body?.skipTest && req.body?.testResult) {
3357
+ opts.skipTest = true;
3358
+ opts.testResult = req.body.testResult;
3359
+ }
3360
+ const result = await daemon.tunnelManager.connect(req.params.id, opts);
3334
3361
  res.json(result);
3335
3362
  } catch (err) {
3336
3363
  const body = { error: err.message };
@@ -25,6 +25,7 @@ const DEFAULT_CONFIG = {
25
25
  autoRotate: true,
26
26
  tokenCeilingPerAgent: 5_000_000,
27
27
  },
28
+ recentProjects: [],
28
29
  };
29
30
 
30
31
  export function isFirstRun(grooveDir) {
@@ -337,6 +337,20 @@ export class Daemon {
337
337
  }
338
338
  }
339
339
 
340
+ setProjectDir(dirPath) {
341
+ if (!dirPath || typeof dirPath !== 'string') throw new Error('Invalid path');
342
+ const resolved = resolve(dirPath);
343
+ if (!existsSync(resolved) || !statSync(resolved).isDirectory()) {
344
+ throw new Error('Directory does not exist');
345
+ }
346
+ this.projectDir = resolved;
347
+ const recents = (this.config.recentProjects || []).filter((r) => r.path !== resolved);
348
+ recents.unshift({ path: resolved, name: resolved.split('/').pop(), openedAt: new Date().toISOString() });
349
+ this.config.recentProjects = recents.slice(0, 10);
350
+ saveConfig(this.grooveDir, this.config);
351
+ this.broadcast({ type: 'project-dir:changed', data: { projectDir: resolved } });
352
+ }
353
+
340
354
  async _pollSubscription() {
341
355
  if (!this.authToken) return;
342
356
  const API_BASE = 'https://docs.groovedev.ai/api/v1';
@@ -22,6 +22,7 @@ export class Journalist {
22
22
  this.history = []; // recent synthesis summaries
23
23
  this._debounceTimer = null;
24
24
  this._debounceReason = null;
25
+ this._forceNextCycle = false;
25
26
  }
26
27
 
27
28
  start(intervalMs = DEFAULT_INTERVAL) {
@@ -61,6 +62,9 @@ export class Journalist {
61
62
  }
62
63
 
63
64
  requestSynthesis(reason = 'unknown') {
65
+ if (reason === 'completion' || reason === 'rotation_complete') {
66
+ this._forceNextCycle = true;
67
+ }
64
68
  if (this._debounceTimer) {
65
69
  this._debounceReason = reason;
66
70
  return;
@@ -84,7 +88,10 @@ export class Journalist {
84
88
  }
85
89
 
86
90
  async cycle() {
87
- if (this.synthesizing) return; // Don't overlap
91
+ if (this.synthesizing) {
92
+ console.log(' Journalist: skipping cycle (already synthesizing)');
93
+ return;
94
+ }
88
95
 
89
96
  const agents = this.daemon.registry.getAll();
90
97
  const running = agents.filter((a) => a.status === 'running');
@@ -99,14 +106,19 @@ export class Journalist {
99
106
  const activeAgents = [...running, ...recentlyCompleted];
100
107
 
101
108
  // Skip if no agents to synthesize
102
- if (activeAgents.length === 0) return;
109
+ if (activeAgents.length === 0) {
110
+ console.log(' Journalist: skipping cycle (no active agents)');
111
+ return;
112
+ }
103
113
 
104
- // Smart scheduling: skip if no new log output since last cycle
105
- if (this.lastCycleAt && !this.hasNewActivity(activeAgents)) {
114
+ // Smart scheduling: skip if no new log output since last cycle (unless forced by completion/rotation)
115
+ if (this.lastCycleAt && !this._forceNextCycle && !this.hasNewActivity(activeAgents)) {
116
+ console.log(' Journalist: skipping cycle (no new activity)');
106
117
  return;
107
118
  }
108
119
 
109
120
  this.synthesizing = true;
121
+ this._forceNextCycle = false;
110
122
  this.cycleCount++;
111
123
  this.lastCycleAt = Date.now();
112
124
 
@@ -165,12 +165,13 @@ export class MemoryStore {
165
165
  const entries = [];
166
166
  const blocks = content.split(/\n(?=## Rotation )/);
167
167
  for (const block of blocks) {
168
- const headerMatch = block.match(/^## Rotation (\d+) —/);
168
+ const headerMatch = block.match(/^## Rotation (\d+) — [^(]*\(([\w?-]+) →/);
169
169
  if (!headerMatch) continue;
170
170
  const body = block.replace(/\n---\s*$/, '').trim();
171
171
  entries.push({
172
172
  rotationN: parseInt(headerMatch[1], 10),
173
173
  body,
174
+ agentId: headerMatch[2] || null,
174
175
  });
175
176
  }
176
177
  return entries;
@@ -182,6 +183,10 @@ export class MemoryStore {
182
183
  appendHandoffBrief(role, entry, workingDir, teamId) {
183
184
  if (!role || !entry) return false;
184
185
  const chain = this.getHandoffChain(role, workingDir, teamId);
186
+
187
+ // Dedup: prevent the same agent from having multiple entries in the chain
188
+ if (entry.agentId && chain.some(c => c.agentId === entry.agentId)) return false;
189
+
185
190
  const nextN = (chain[0]?.rotationN || 0) + 1;
186
191
 
187
192
  const block = [