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
@@ -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 = [
@@ -296,6 +296,7 @@ export class ProcessManager {
296
296
  this.peakContextUsage = new Map(); // agentId -> highest contextUsage seen
297
297
  this.pendingMessages = new Map(); // agentId -> { message, timestamp }
298
298
  this._streamThrottle = new Map(); // agentId -> { timer, pending }
299
+ this._rotatingAgents = new Set(); // agentIds currently being rotated (rotator wrote handoff)
299
300
 
300
301
  }
301
302
 
@@ -623,7 +624,11 @@ For normal file edits within your scope, proceed without review.
623
624
  const files = this.daemon.journalist?.getAgentFiles(agent) || [];
624
625
  if (files.length > 0) this._triggerIdleQC(agent);
625
626
  this._processHandoffs(agent);
626
- this._writeCompletionHandoff(agent);
627
+ if (this._rotatingAgents.has(agent.id)) {
628
+ this._rotatingAgents.delete(agent.id);
629
+ } else {
630
+ this._writeCompletionHandoff(agent).catch(err => console.error(`[Groove] Completion handoff failed for ${agent.name}:`, err.message));
631
+ }
627
632
  }
628
633
  });
629
634
 
@@ -815,7 +820,11 @@ For normal file edits within your scope, proceed without review.
815
820
  const files = this.daemon.journalist?.getAgentFiles(agent) || [];
816
821
  if (files.length > 0) this._triggerIdleQC(agent);
817
822
  this._processHandoffs(agent);
818
- this._writeCompletionHandoff(agent);
823
+ if (this._rotatingAgents.has(agent.id)) {
824
+ this._rotatingAgents.delete(agent.id);
825
+ } else {
826
+ this._writeCompletionHandoff(agent).catch(err => console.error(`[Groove] Completion handoff failed for ${agent.name}:`, err.message));
827
+ }
819
828
  }
820
829
 
821
830
  // Update Layer 7 specialization profile for this agent's session
@@ -1149,34 +1158,41 @@ For normal file edits within your scope, proceed without review.
1149
1158
  });
1150
1159
  }
1151
1160
 
1152
- _writeCompletionHandoff(agent) {
1161
+ async _writeCompletionHandoff(agent) {
1153
1162
  if (!this.daemon.memory || !this.daemon.journalist) return;
1154
1163
  try {
1155
1164
  const agentData = this.daemon.registry.get(agent.id);
1156
- const filteredLogs = this.daemon.journalist.collectFilteredLogs([agent]);
1157
- const agentLog = filteredLogs[agent.id];
1158
- const entries = agentLog?.entries || [];
1159
- const files = this.daemon.journalist.getAgentFiles(agent) || [];
1160
-
1161
- const toolSummary = entries
1162
- .filter(e => e.type === 'tool')
1163
- .map(e => `- ${e.tool}: ${e.input}`)
1164
- .slice(-15)
1165
- .join('\n');
1166
-
1167
- const errorSummary = entries
1168
- .filter(e => e.type === 'error')
1169
- .map(e => `- ${e.text}`)
1170
- .slice(-5)
1171
- .join('\n');
1172
-
1173
- const brief = [
1174
- `Agent ${agent.name} (${agent.role}) completed.`,
1175
- agent.prompt ? `Task: ${agent.prompt.slice(0, 300)}` : '',
1176
- files.length > 0 ? `\nFiles modified:\n${files.slice(0, 15).map(f => '- ' + f).join('\n')}` : '',
1177
- toolSummary ? `\nRecent actions:\n${toolSummary}` : '',
1178
- errorSummary ? `\nErrors encountered:\n${errorSummary}` : '',
1179
- ].filter(Boolean).join('\n');
1165
+
1166
+ let brief;
1167
+ try {
1168
+ brief = await this.daemon.journalist.generateHandoffBrief(agent, { reason: 'completed' });
1169
+ } catch {
1170
+ // Fallback to structural brief if AI synthesis fails
1171
+ const filteredLogs = this.daemon.journalist.collectFilteredLogs([agent]);
1172
+ const agentLog = filteredLogs[agent.id];
1173
+ const entries = agentLog?.entries || [];
1174
+ const files = this.daemon.journalist.getAgentFiles(agent) || [];
1175
+
1176
+ const toolSummary = entries
1177
+ .filter(e => e.type === 'tool')
1178
+ .map(e => `- ${e.tool}: ${e.input}`)
1179
+ .slice(-15)
1180
+ .join('\n');
1181
+
1182
+ const errorSummary = entries
1183
+ .filter(e => e.type === 'error')
1184
+ .map(e => `- ${e.text}`)
1185
+ .slice(-5)
1186
+ .join('\n');
1187
+
1188
+ brief = [
1189
+ `Agent ${agent.name} (${agent.role}) completed.`,
1190
+ agent.prompt ? `Task: ${agent.prompt.slice(0, 300)}` : '',
1191
+ files.length > 0 ? `\nFiles modified:\n${files.slice(0, 15).map(f => '- ' + f).join('\n')}` : '',
1192
+ toolSummary ? `\nRecent actions:\n${toolSummary}` : '',
1193
+ errorSummary ? `\nErrors encountered:\n${errorSummary}` : '',
1194
+ ].filter(Boolean).join('\n');
1195
+ }
1180
1196
 
1181
1197
  this.daemon.memory.appendHandoffBrief(agent.role, {
1182
1198
  timestamp: new Date().toISOString(),
@@ -1184,7 +1200,7 @@ For normal file edits within your scope, proceed without review.
1184
1200
  reason: 'completed',
1185
1201
  oldTokens: agentData?.tokensUsed || 0,
1186
1202
  contextUsage: agentData?.contextUsage || 0,
1187
- brief: brief.slice(0, 4000),
1203
+ brief: (typeof brief === 'string' ? brief : '').slice(0, 4000),
1188
1204
  }, agent.workingDir, agent.teamId);
1189
1205
  } catch { /* best-effort */ }
1190
1206
  }
@@ -309,6 +309,7 @@ export class Rotator extends EventEmitter {
309
309
  timestamp: new Date().toISOString(),
310
310
  };
311
311
 
312
+ processes._rotatingAgents.add(agentId);
312
313
  await processes.kill(agentId);
313
314
 
314
315
  const routingMode = this.daemon.router.getMode(agentId);
@@ -211,7 +211,7 @@ export class TunnelManager {
211
211
  }
212
212
  }
213
213
 
214
- async connect(id) {
214
+ async connect(id, opts = {}) {
215
215
  const config = this.saved.get(id);
216
216
  if (!config) throw new Error(`Remote ${id} not found`);
217
217
 
@@ -220,7 +220,14 @@ export class TunnelManager {
220
220
  return { localPort: existing.localPort, pid: existing.pid };
221
221
  }
222
222
 
223
- const testResult = await this.test(id);
223
+ this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'testing' } });
224
+
225
+ let testResult;
226
+ if (opts.skipTest && opts.testResult) {
227
+ testResult = opts.testResult;
228
+ } else {
229
+ testResult = await this.test(id);
230
+ }
224
231
  if (!testResult.reachable) {
225
232
  throw new Error(testResult.error || 'Host unreachable');
226
233
  }
@@ -233,6 +240,8 @@ export class TunnelManager {
233
240
  await this.autoStart(id);
234
241
  }
235
242
 
243
+ this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'connecting' } });
244
+
236
245
  const localPort = await this._findAvailablePort();
237
246
  const target = `${config.user}@${config.host}`;
238
247
  const keyArgs = config.sshKeyPath ? ['-i', config.sshKeyPath] : [];
@@ -257,13 +266,16 @@ export class TunnelManager {
257
266
  let stderrBuf = '';
258
267
  tunnel.stderr.on('data', (chunk) => { stderrBuf += chunk.toString(); });
259
268
 
260
- await new Promise((r) => setTimeout(r, 2000));
261
-
262
- if (tunnel.exitCode !== null) {
263
- throw new Error(`Tunnel failed to start: ${stderrBuf.trim() || 'unknown error'}`);
269
+ let tunnelUp = false;
270
+ for (let elapsed = 0; elapsed < 8000; elapsed += 500) {
271
+ await new Promise((r) => setTimeout(r, 500));
272
+ if (tunnel.exitCode !== null) {
273
+ throw new Error(`Tunnel failed to start: ${stderrBuf.trim() || 'unknown error'}`);
274
+ }
275
+ tunnelUp = await this._isPortInUse(localPort);
276
+ if (tunnelUp) break;
264
277
  }
265
278
 
266
- const tunnelUp = await this._isPortInUse(localPort);
267
279
  if (!tunnelUp) {
268
280
  try { process.kill(tunnel.pid); } catch { /* ignore */ }
269
281
  throw new Error('Tunnel started but port forward not active');
@@ -23,6 +23,7 @@ describe('Rotator', () => {
23
23
  mockDaemon = {
24
24
  registry: mockRegistry,
25
25
  processes: {
26
+ _rotatingAgents: new Set(),
26
27
  async kill(id) { /* mock */ },
27
28
  async spawn(config) { return { id: 'new-' + config.role, name: config.name, ...config }; },
28
29
  },