groove-dev 0.27.32 → 0.27.34

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 +12 -9
  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 +65 -15
  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 +12 -9
  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 +65 -15
  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.34",
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",
@@ -14,15 +14,18 @@ export async function start(options) {
14
14
  // ── First-run interactive wizard ────────────────────────────
15
15
  let setupKeys = {};
16
16
  if (isFirstRun) {
17
- try {
18
- const result = await runSetupWizard();
19
- setupKeys = result.keys || {};
20
- } catch (err) {
21
- // If stdin is not interactive (piped), skip wizard
22
- if (err.code === 'ERR_USE_AFTER_CLOSE') {
23
- console.log(chalk.dim(' Non-interactive mode skipping setup wizard.'));
24
- } else {
25
- throw err;
17
+ if (!process.stdin.isTTY) {
18
+ console.log(chalk.dim(' Non-interactive mode skipping setup wizard.'));
19
+ } else {
20
+ try {
21
+ const result = await runSetupWizard();
22
+ setupKeys = result.keys || {};
23
+ } catch (err) {
24
+ if (err.code === 'ERR_USE_AFTER_CLOSE') {
25
+ console.log(chalk.dim(' Non-interactive mode — skipping setup wizard.'));
26
+ } else {
27
+ throw err;
28
+ }
26
29
  }
27
30
  }
28
31
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/daemon",
3
- "version": "0.27.32",
3
+ "version": "0.27.34",
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);
@@ -3,10 +3,19 @@
3
3
 
4
4
  import { execFileSync, spawn } from 'child_process';
5
5
  import { existsSync, writeFileSync, readFileSync, statSync } from 'fs';
6
- import { resolve } from 'path';
6
+ import { resolve, dirname, join } from 'path';
7
+ import { fileURLToPath } from 'url';
7
8
  import { createConnection } from 'net';
8
9
  import crypto from 'crypto';
9
10
 
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+ function getLocalVersion() {
13
+ try {
14
+ const pkg = JSON.parse(readFileSync(join(__dirname, '..', '..', '..', 'package.json'), 'utf8'));
15
+ return pkg.version || '0.0.0';
16
+ } catch { return '0.0.0'; }
17
+ }
18
+
10
19
  const REMOTE_PORT = 31415;
11
20
  const DEFAULT_LOCAL_PORT = 31416;
12
21
  const MAX_PORT_ATTEMPTS = 10;
@@ -185,7 +194,7 @@ export class TunnelManager {
185
194
  '-o', 'StrictHostKeyChecking=accept-new',
186
195
  '-o', 'BatchMode=yes',
187
196
  target,
188
- `curl -sf http://localhost:${REMOTE_PORT}/api/health 2>/dev/null || (which groove >/dev/null 2>&1 && echo __GROOVE_STOPPED__ || echo __GROOVE_NOT_INSTALLED__)`,
197
+ `bash -lc 'curl -sf http://localhost:${REMOTE_PORT}/api/health 2>/dev/null || (which groove >/dev/null 2>&1 && echo __GROOVE_VER__$(groove --version 2>/dev/null || echo unknown)__GROOVE_STOPPED__ || echo __GROOVE_NOT_INSTALLED__)'`,
189
198
  ], {
190
199
  encoding: 'utf8',
191
200
  timeout: 20000,
@@ -196,7 +205,9 @@ export class TunnelManager {
196
205
  return { reachable: true, daemonRunning: false, grooveInstalled: false };
197
206
  }
198
207
  if (result.includes('__GROOVE_STOPPED__')) {
199
- return { reachable: true, daemonRunning: false, grooveInstalled: true };
208
+ const verMatch = result.match(/__GROOVE_VER__(.+?)__GROOVE_STOPPED__/);
209
+ const remoteVersion = verMatch ? verMatch[1].trim() : null;
210
+ return { reachable: true, daemonRunning: false, grooveInstalled: true, remoteVersion };
200
211
  }
201
212
  return { reachable: true, daemonRunning: true, grooveInstalled: true };
202
213
  } catch (err) {
@@ -211,7 +222,7 @@ export class TunnelManager {
211
222
  }
212
223
  }
213
224
 
214
- async connect(id) {
225
+ async connect(id, opts = {}) {
215
226
  const config = this.saved.get(id);
216
227
  if (!config) throw new Error(`Remote ${id} not found`);
217
228
 
@@ -220,7 +231,14 @@ export class TunnelManager {
220
231
  return { localPort: existing.localPort, pid: existing.pid };
221
232
  }
222
233
 
223
- const testResult = await this.test(id);
234
+ this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'testing' } });
235
+
236
+ let testResult;
237
+ if (opts.skipTest && opts.testResult) {
238
+ testResult = opts.testResult;
239
+ } else {
240
+ testResult = await this.test(id);
241
+ }
224
242
  if (!testResult.reachable) {
225
243
  throw new Error(testResult.error || 'Host unreachable');
226
244
  }
@@ -229,10 +247,17 @@ export class TunnelManager {
229
247
  this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'installing' } });
230
248
  await this.remoteInstall(id);
231
249
  } else if (!testResult.daemonRunning && testResult.grooveInstalled) {
250
+ const localVer = getLocalVersion();
251
+ if (testResult.remoteVersion && testResult.remoteVersion !== localVer) {
252
+ this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'upgrading', from: testResult.remoteVersion, to: localVer } });
253
+ await this._remoteUpgrade(id, config);
254
+ }
232
255
  this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'starting' } });
233
256
  await this.autoStart(id);
234
257
  }
235
258
 
259
+ this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'connecting' } });
260
+
236
261
  const localPort = await this._findAvailablePort();
237
262
  const target = `${config.user}@${config.host}`;
238
263
  const keyArgs = config.sshKeyPath ? ['-i', config.sshKeyPath] : [];
@@ -257,13 +282,16 @@ export class TunnelManager {
257
282
  let stderrBuf = '';
258
283
  tunnel.stderr.on('data', (chunk) => { stderrBuf += chunk.toString(); });
259
284
 
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'}`);
285
+ let tunnelUp = false;
286
+ for (let elapsed = 0; elapsed < 8000; elapsed += 500) {
287
+ await new Promise((r) => setTimeout(r, 500));
288
+ if (tunnel.exitCode !== null) {
289
+ throw new Error(`Tunnel failed to start: ${stderrBuf.trim() || 'unknown error'}`);
290
+ }
291
+ tunnelUp = await this._isPortInUse(localPort);
292
+ if (tunnelUp) break;
264
293
  }
265
294
 
266
- const tunnelUp = await this._isPortInUse(localPort);
267
295
  if (!tunnelUp) {
268
296
  try { process.kill(tunnel.pid); } catch { /* ignore */ }
269
297
  throw new Error('Tunnel started but port forward not active');
@@ -320,6 +348,24 @@ export class TunnelManager {
320
348
  }
321
349
  }
322
350
 
351
+ async _remoteUpgrade(id, config) {
352
+ const target = `${config.user}@${config.host}`;
353
+ const keyArgs = config.sshKeyPath ? ['-i', config.sshKeyPath] : [];
354
+ const sshBase = [...keyArgs, '-p', String(config.port || 22), '-o', 'ConnectTimeout=10', '-o', 'BatchMode=yes', target];
355
+ const installCmd = config.user === 'root' ? 'npm i -g groove-dev' : 'sudo npm i -g groove-dev';
356
+
357
+ try {
358
+ execFileSync('ssh', [...sshBase, `bash -lc '${installCmd}'`], {
359
+ encoding: 'utf8',
360
+ timeout: 120000,
361
+ stdio: ['pipe', 'pipe', 'pipe'],
362
+ });
363
+ } catch (err) {
364
+ const output = err.stdout?.toString() || err.stderr?.toString() || err.message;
365
+ throw new Error(`Remote upgrade failed: ${output.slice(-400)}`);
366
+ }
367
+ }
368
+
323
369
  async autoStart(id) {
324
370
  const config = this.saved.get(id);
325
371
  if (!config) throw new Error(`Remote ${id} not found`);
@@ -334,7 +380,7 @@ export class TunnelManager {
334
380
  '-o', 'ConnectTimeout=10',
335
381
  '-o', 'BatchMode=yes',
336
382
  target,
337
- `bash -lc 'nohup groove start > /tmp/groove-daemon.log 2>&1 < /dev/null & disown; sleep 4; curl -sf http://localhost:${REMOTE_PORT}/api/health > /dev/null && echo __DAEMON_OK__ || echo __DAEMON_FAIL__'`,
383
+ `bash -lc 'nohup groove start > /tmp/groove-daemon.log 2>&1 < /dev/null & disown; sleep 5; curl -sf http://localhost:${REMOTE_PORT}/api/health > /dev/null && echo __DAEMON_OK__ || (echo __DAEMON_FAIL__; tail -20 /tmp/groove-daemon.log 2>/dev/null)'`,
338
384
  ], {
339
385
  encoding: 'utf8',
340
386
  timeout: 45000,
@@ -342,10 +388,12 @@ export class TunnelManager {
342
388
  });
343
389
 
344
390
  if (result.includes('__DAEMON_FAIL__')) {
345
- throw new Error('Daemon process started but health check failed — check /tmp/groove-daemon.log on remote');
391
+ const logLines = result.split('__DAEMON_FAIL__')[1]?.trim() || '';
392
+ const detail = logLines ? `: ${logLines.slice(-300)}` : '';
393
+ throw new Error(`Remote daemon failed to start${detail}`);
346
394
  }
347
395
  } catch (err) {
348
- if (err.message.includes('Daemon process started')) throw err;
396
+ if (err.message.includes('Remote daemon failed')) throw err;
349
397
  const output = err.stdout?.toString() || err.stderr?.toString() || err.message;
350
398
  throw new Error(`Failed to start remote daemon: ${output.slice(-300)}`);
351
399
  }
@@ -411,7 +459,7 @@ export class TunnelManager {
411
459
  try {
412
460
  const result = execFileSync('ssh', [
413
461
  ...sshBase,
414
- remoteCmd(`nohup groove start > /tmp/groove-daemon.log 2>&1 < /dev/null & disown; sleep 4; curl -sf http://localhost:${REMOTE_PORT}/api/health > /dev/null && echo __DAEMON_OK__ || echo __DAEMON_FAIL__`),
462
+ remoteCmd(`nohup groove start > /tmp/groove-daemon.log 2>&1 < /dev/null & disown; sleep 5; curl -sf http://localhost:${REMOTE_PORT}/api/health > /dev/null && echo __DAEMON_OK__ || (echo __DAEMON_FAIL__; tail -20 /tmp/groove-daemon.log 2>/dev/null)`),
415
463
  ], {
416
464
  encoding: 'utf8',
417
465
  timeout: 45000,
@@ -419,7 +467,9 @@ export class TunnelManager {
419
467
  });
420
468
 
421
469
  if (result.includes('__DAEMON_FAIL__')) {
422
- throw new Error('Groove installed but daemon failed to start — check /tmp/groove-daemon.log on remote');
470
+ const logLines = result.split('__DAEMON_FAIL__')[1]?.trim() || '';
471
+ const detail = logLines ? `: ${logLines.slice(-300)}` : '';
472
+ throw new Error(`Groove installed but daemon failed to start${detail}`);
423
473
  }
424
474
  } catch (err) {
425
475
  if (err.message.includes('Groove installed')) throw err;
@@ -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
  },