groove-dev 0.27.157 → 0.27.159

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 (78) hide show
  1. package/node_modules/@groove-dev/cli/package.json +1 -1
  2. package/node_modules/@groove-dev/daemon/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/src/process.js +130 -2
  4. package/node_modules/@groove-dev/daemon/src/tunnel-manager.js +67 -61
  5. package/node_modules/@groove-dev/gui/dist/assets/{index-B6taUF7J.js → index-Bij9o_dc.js} +232 -227
  6. package/node_modules/@groove-dev/gui/dist/assets/index-Dzofq3wS.css +1 -0
  7. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  8. package/node_modules/@groove-dev/gui/package.json +1 -2
  9. package/node_modules/@groove-dev/gui/src/app.css +2 -2
  10. package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +8 -8
  11. package/node_modules/@groove-dev/gui/src/components/dashboard/cache-ring.jsx +2 -2
  12. package/node_modules/@groove-dev/gui/src/components/dashboard/token-chart.jsx +2 -2
  13. package/node_modules/@groove-dev/gui/src/components/editor/terminal.jsx +1 -1
  14. package/node_modules/@groove-dev/gui/src/components/layout/welcome-splash.jsx +8 -1
  15. package/node_modules/@groove-dev/gui/src/components/network/activity-chart.jsx +4 -4
  16. package/node_modules/@groove-dev/gui/src/components/network/performance-dashboard.jsx +1 -1
  17. package/node_modules/@groove-dev/gui/src/components/settings/quick-connect.jsx +18 -6
  18. package/node_modules/@groove-dev/gui/src/components/settings/ssh-wizard.jsx +122 -17
  19. package/node_modules/@groove-dev/gui/src/stores/groove.js +9 -1
  20. package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +69 -38
  21. package/node_modules/@groove-dev/gui/src/views/memory.jsx +75 -30
  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/process.js +130 -2
  26. package/packages/daemon/src/tunnel-manager.js +67 -61
  27. package/packages/gui/dist/assets/{index-B6taUF7J.js → index-Bij9o_dc.js} +232 -227
  28. package/packages/gui/dist/assets/index-Dzofq3wS.css +1 -0
  29. package/packages/gui/dist/index.html +2 -2
  30. package/packages/gui/package.json +1 -2
  31. package/packages/gui/src/app.css +2 -2
  32. package/packages/gui/src/components/agents/agent-feed.jsx +8 -8
  33. package/packages/gui/src/components/dashboard/cache-ring.jsx +2 -2
  34. package/packages/gui/src/components/dashboard/token-chart.jsx +2 -2
  35. package/packages/gui/src/components/editor/terminal.jsx +1 -1
  36. package/packages/gui/src/components/layout/welcome-splash.jsx +8 -1
  37. package/packages/gui/src/components/network/activity-chart.jsx +4 -4
  38. package/packages/gui/src/components/network/performance-dashboard.jsx +1 -1
  39. package/packages/gui/src/components/settings/quick-connect.jsx +18 -6
  40. package/packages/gui/src/components/settings/ssh-wizard.jsx +122 -17
  41. package/packages/gui/src/stores/groove.js +9 -1
  42. package/packages/gui/src/stores/slices/agents-slice.js +69 -38
  43. package/packages/gui/src/views/memory.jsx +75 -30
  44. package/node_modules/@fontsource-variable/jetbrains-mono/CHANGELOG.md +0 -2
  45. package/node_modules/@fontsource-variable/jetbrains-mono/LICENSE +0 -93
  46. package/node_modules/@fontsource-variable/jetbrains-mono/README.md +0 -48
  47. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-cyrillic-ext-wght-italic.woff2 +0 -0
  48. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-cyrillic-ext-wght-normal.woff2 +0 -0
  49. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-cyrillic-wght-italic.woff2 +0 -0
  50. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-cyrillic-wght-normal.woff2 +0 -0
  51. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-greek-wght-italic.woff2 +0 -0
  52. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-greek-wght-normal.woff2 +0 -0
  53. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-ext-wght-italic.woff2 +0 -0
  54. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-ext-wght-normal.woff2 +0 -0
  55. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-wght-italic.woff2 +0 -0
  56. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-wght-normal.woff2 +0 -0
  57. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-vietnamese-wght-italic.woff2 +0 -0
  58. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-vietnamese-wght-normal.woff2 +0 -0
  59. package/node_modules/@fontsource-variable/jetbrains-mono/index.css +0 -59
  60. package/node_modules/@fontsource-variable/jetbrains-mono/metadata.json +0 -29
  61. package/node_modules/@fontsource-variable/jetbrains-mono/package.json +0 -47
  62. package/node_modules/@fontsource-variable/jetbrains-mono/scss/metadata.scss +0 -46
  63. package/node_modules/@fontsource-variable/jetbrains-mono/scss/mixins.scss +0 -193
  64. package/node_modules/@fontsource-variable/jetbrains-mono/unicode.json +0 -8
  65. package/node_modules/@fontsource-variable/jetbrains-mono/wght-italic.css +0 -59
  66. package/node_modules/@fontsource-variable/jetbrains-mono/wght.css +0 -59
  67. package/node_modules/@groove-dev/gui/dist/assets/index-BAM0QzR0.css +0 -1
  68. package/node_modules/@groove-dev/gui/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  69. package/node_modules/@groove-dev/gui/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  70. package/node_modules/@groove-dev/gui/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  71. package/node_modules/@groove-dev/gui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  72. package/node_modules/@groove-dev/gui/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  73. package/packages/gui/dist/assets/index-BAM0QzR0.css +0 -1
  74. package/packages/gui/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  75. package/packages/gui/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  76. package/packages/gui/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  77. package/packages/gui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  78. package/packages/gui/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
@@ -507,6 +507,7 @@ export class ProcessManager {
507
507
 
508
508
  if (finalStatus === 'completed' && agent.role === 'planner') {
509
509
  this._extractRecommendedTeam(agent, logPath);
510
+ this._consumeRecommendedTeamAutonomous(agent);
510
511
  this._handleReviewComplete(agent);
511
512
  }
512
513
 
@@ -534,7 +535,7 @@ export class ProcessManager {
534
535
 
535
536
  this._checkPhase2(agent.id);
536
537
 
537
- if (finalStatus === 'completed' && agent.role === 'fullstack' && agent.teamId) {
538
+ if (finalStatus === 'completed' && agent.role === 'fullstack' && agent.teamId && agent.metadata?.isQcPhase2) {
538
539
  this._triggerReview(agent);
539
540
  }
540
541
 
@@ -1755,6 +1756,114 @@ For normal file edits within your scope, proceed without review.
1755
1756
  } catch { /* best effort */ }
1756
1757
  }
1757
1758
 
1759
+ /**
1760
+ * Daemon-autonomous consumption of recommended-team.json.
1761
+ * If the file exists and the GUI hasn't already consumed it (no _pendingPhase2
1762
+ * for this team), broadcast a notification so the GUI picks it up on next tick.
1763
+ * This closes the race where GUI polling stops before the file is written.
1764
+ */
1765
+ _consumeRecommendedTeamAutonomous(agent) {
1766
+ try {
1767
+ const workDir = agent.workingDir || this.daemon.projectDir;
1768
+ const targetPath = resolve(workDir, '.groove', 'recommended-team.json');
1769
+ if (!existsSync(targetPath)) return;
1770
+
1771
+ const teamId = agent.teamId || null;
1772
+
1773
+ // If phase 2 is already pending for this team, GUI already consumed it
1774
+ const pending = this.daemon._pendingPhase2 || [];
1775
+ if (teamId && pending.some(g => g.agents.some(a => a.teamId === teamId))) return;
1776
+
1777
+ // Broadcast so the GUI knows to fetch — even if its polling interval was cleared
1778
+ this.daemon.broadcast({
1779
+ type: 'recommended-team:ready',
1780
+ teamId,
1781
+ agentId: agent.id,
1782
+ agentName: agent.name,
1783
+ });
1784
+
1785
+ // Delayed self-consumption: if the GUI doesn't consume within 5s, daemon does it directly
1786
+ setTimeout(() => {
1787
+ if (!existsSync(targetPath)) return; // GUI consumed it
1788
+ try {
1789
+ const raw = JSON.parse(readFileSync(targetPath, 'utf8'));
1790
+ delete raw._meta;
1791
+ const agentConfigs = Array.isArray(raw) ? raw : (raw.agents || []);
1792
+ if (agentConfigs.length === 0) return;
1793
+
1794
+ const phase1 = agentConfigs.filter(a => !a.phase || a.phase === 1);
1795
+ const phase2 = agentConfigs.filter(a => a.phase === 2);
1796
+ if (phase1.length === 0) return;
1797
+
1798
+ // Check again — GUI may have consumed during the timeout
1799
+ const currentPending = this.daemon._pendingPhase2 || [];
1800
+ if (teamId && currentPending.some(g => g.agents.some(a => a.teamId === teamId))) return;
1801
+
1802
+ const baseDir = agent.workingDir || this.daemon.projectDir;
1803
+ const projectDir = raw.projectDir || null;
1804
+ let projectWorkingDir = baseDir;
1805
+ if (projectDir) {
1806
+ const safeName = String(projectDir).replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 64);
1807
+ projectWorkingDir = resolve(baseDir, safeName);
1808
+ mkdirSync(projectWorkingDir, { recursive: true });
1809
+ }
1810
+
1811
+ const defaultTeamId = teamId || this.daemon.teams.getDefault()?.id || null;
1812
+ const phase1Ids = [];
1813
+
1814
+ // Spawn phase 1 agents
1815
+ const spawnPromises = phase1.map(async (config) => {
1816
+ try {
1817
+ const validated = validateAgentConfig({
1818
+ role: config.role,
1819
+ scope: config.scope || [],
1820
+ prompt: config.prompt || '',
1821
+ provider: config.provider || agent.provider || this.daemon.config?.defaultProvider,
1822
+ model: config.model || agent.model || this.daemon.config?.defaultModel || 'auto',
1823
+ permission: config.permission || 'auto',
1824
+ workingDir: config.workingDir || projectWorkingDir,
1825
+ name: config.name || undefined,
1826
+ });
1827
+ validated.teamId = defaultTeamId;
1828
+ const spawned = await this.spawn(validated);
1829
+ phase1Ids.push(spawned.id);
1830
+ return spawned;
1831
+ } catch (err) {
1832
+ console.error(`[Groove] Autonomous team launch: failed to spawn ${config.role}: ${err.message}`);
1833
+ return null;
1834
+ }
1835
+ });
1836
+
1837
+ Promise.all(spawnPromises).then(() => {
1838
+ // Set up phase 2 pending
1839
+ if (phase2.length > 0 && phase1Ids.length > 0) {
1840
+ this.daemon._pendingPhase2 = this.daemon._pendingPhase2 || [];
1841
+ this.daemon._pendingPhase2.push({
1842
+ waitFor: phase1Ids,
1843
+ agents: phase2.map(c => ({
1844
+ role: c.role, scope: c.scope || [], prompt: c.prompt || '',
1845
+ provider: c.provider || agent.provider || this.daemon.config?.defaultProvider,
1846
+ model: c.model || agent.model || this.daemon.config?.defaultModel || 'auto',
1847
+ permission: c.permission || 'auto',
1848
+ workingDir: c.workingDir || projectWorkingDir,
1849
+ name: c.name || undefined,
1850
+ teamId: defaultTeamId,
1851
+ })),
1852
+ });
1853
+ }
1854
+
1855
+ // Clean up the file
1856
+ try { unlinkSync(targetPath); } catch { /* */ }
1857
+ this.daemon.audit?.log('team.autonomousLaunch', { teamId: defaultTeamId, phase1: phase1Ids.length, phase2: phase2.length });
1858
+ console.log(`[Groove] Autonomous team launch: ${phase1Ids.length} phase 1 agents spawned for team ${defaultTeamId}`);
1859
+ });
1860
+ } catch (err) {
1861
+ console.error(`[Groove] Autonomous team consumption failed: ${err.message}`);
1862
+ }
1863
+ }, 5000);
1864
+ } catch { /* best effort */ }
1865
+ }
1866
+
1758
1867
  /**
1759
1868
  * Check if a completed/crashed agent was the last phase 1 agent in a team.
1760
1869
  * If so, auto-spawn the phase 2 (QC/finisher) agents.
@@ -1834,13 +1943,26 @@ For normal file edits within your scope, proceed without review.
1834
1943
  try {
1835
1944
  const validated = validateAgentConfig(config);
1836
1945
  if (!validated.teamId) validated.teamId = this.daemon.teams.getDefault()?.id || null;
1946
+ validated.metadata = { ...(validated.metadata || {}), isQcPhase2: true };
1947
+ const existingId = existing?.id || null;
1837
1948
  const p = this.spawn(validated).then((agent) => {
1949
+ registry.update(agent.id, { metadata: { ...(agent.metadata || {}), isQcPhase2: true } });
1838
1950
  this.daemon.broadcast({
1839
1951
  type: 'phase2:spawned',
1840
1952
  agentId: agent.id,
1953
+ oldAgentId: existingId,
1841
1954
  name: agent.name,
1842
1955
  role: agent.role,
1843
1956
  });
1957
+ if (existingId) {
1958
+ this.daemon.broadcast({
1959
+ type: 'rotation:complete',
1960
+ agentId: agent.id,
1961
+ oldAgentId: existingId,
1962
+ agentName: agent.name,
1963
+ reason: 'phase2_respawn',
1964
+ });
1965
+ }
1844
1966
  this.daemon.audit.log('phase2.autoSpawn', { id: agent.id, name: agent.name, role: agent.role });
1845
1967
  }).catch((err) => {
1846
1968
  console.error(`[Groove] Phase 2 spawn failed for ${config.role}: ${err.message}`);
@@ -1884,6 +2006,7 @@ For normal file edits within your scope, proceed without review.
1884
2006
  const teamId = agent.teamId;
1885
2007
  if (!teamId) return;
1886
2008
  if (this._reviewTriggered.has(teamId)) return;
2009
+ this._reviewTriggered.add(teamId);
1887
2010
 
1888
2011
  const registry = this.daemon.registry;
1889
2012
  const teamAgents = registry.getAll().filter(a => a.teamId === teamId);
@@ -1898,7 +2021,6 @@ For normal file edits within your scope, proceed without review.
1898
2021
  a.role !== 'planner' && (a.status === 'running' || a.status === 'starting'));
1899
2022
  if (hasRunning) return;
1900
2023
 
1901
- this._reviewTriggered.add(teamId);
1902
2024
  this._reviewPending.add(teamId);
1903
2025
 
1904
2026
  const journalist = this.daemon.journalist;
@@ -1912,6 +2034,12 @@ For normal file edits within your scope, proceed without review.
1912
2034
  for (const f of (journalist?.getAgentFiles(a) || [])) allFiles.add(f);
1913
2035
  }
1914
2036
 
2037
+ if (!originalSpec.trim() && !plannerResult.trim() && allFiles.size === 0) {
2038
+ console.log(`[Groove] Review skipped for team ${teamId}: no spec, plan, or files to review`);
2039
+ this._reviewPending.delete(teamId);
2040
+ return;
2041
+ }
2042
+
1915
2043
  const reviewPrompt = `You are reviewing a completed team build against the original specification.
1916
2044
 
1917
2045
  ## Original Task
@@ -225,6 +225,14 @@ export class TunnelManager {
225
225
  const keyArgs = config.sshKeyPath ? ['-i', config.sshKeyPath] : [];
226
226
 
227
227
  try {
228
+ const probeCmd = [
229
+ `NV=$(node --version 2>/dev/null || echo "");`,
230
+ `echo "__NODE__${`$\{NV\}`}__NODE_END__";`,
231
+ `S=$(curl -sf http://localhost:${REMOTE_PORT}/api/status 2>/dev/null);`,
232
+ `if [ -n "$S" ]; then echo "__GROOVE_RUNNING__$S__GROOVE_END__";`,
233
+ `else which groove >/dev/null 2>&1 && echo __GROOVE_VER__$(groove --version 2>/dev/null || echo unknown)__GROOVE_STOPPED__ || echo __GROOVE_NOT_INSTALLED__; fi`,
234
+ ].join(' ');
235
+
228
236
  const result = execFileSync('ssh', [
229
237
  ...keyArgs,
230
238
  '-p', String(config.port || 22),
@@ -232,27 +240,32 @@ export class TunnelManager {
232
240
  '-o', 'StrictHostKeyChecking=accept-new',
233
241
  '-o', 'BatchMode=yes',
234
242
  target,
235
- sshCmd(`S=$(curl -sf http://localhost:${REMOTE_PORT}/api/status 2>/dev/null); if [ -n "$S" ]; then echo "__GROOVE_RUNNING__$S__GROOVE_END__"; else which groove >/dev/null 2>&1 && echo __GROOVE_VER__$(groove --version 2>/dev/null || echo unknown)__GROOVE_STOPPED__ || echo __GROOVE_NOT_INSTALLED__; fi`),
243
+ sshCmd(probeCmd),
236
244
  ], {
237
245
  encoding: 'utf8',
238
246
  timeout: 15000,
239
247
  stdio: ['pipe', 'pipe', 'pipe'],
240
248
  });
241
249
 
250
+ const nodeMatch = result.match(/__NODE__(.+?)__NODE_END__/);
251
+ const nodeVersionRaw = nodeMatch ? nodeMatch[1].trim() : '';
252
+ const nodeInstalled = nodeVersionRaw.startsWith('v');
253
+ const nodeVersion = nodeInstalled ? nodeVersionRaw : null;
254
+
242
255
  if (result.includes('__GROOVE_NOT_INSTALLED__')) {
243
- return { reachable: true, daemonRunning: false, grooveInstalled: false };
256
+ return { reachable: true, daemonRunning: false, grooveInstalled: false, nodeInstalled, nodeVersion };
244
257
  }
245
258
  if (result.includes('__GROOVE_STOPPED__')) {
246
259
  const verMatch = result.match(/__GROOVE_VER__(.+?)__GROOVE_STOPPED__/);
247
260
  const remoteVersion = verMatch ? verMatch[1].trim() : null;
248
- return { reachable: true, daemonRunning: false, grooveInstalled: true, remoteVersion };
261
+ return { reachable: true, daemonRunning: false, grooveInstalled: true, remoteVersion, nodeInstalled, nodeVersion };
249
262
  }
250
263
  const runMatch = result.match(/__GROOVE_RUNNING__(.+?)__GROOVE_END__/);
251
264
  let remoteVersion = null;
252
265
  if (runMatch) {
253
266
  try { remoteVersion = JSON.parse(runMatch[1]).version || null; } catch { /* ignore */ }
254
267
  }
255
- return { reachable: true, daemonRunning: true, grooveInstalled: true, remoteVersion };
268
+ return { reachable: true, daemonRunning: true, grooveInstalled: true, remoteVersion, nodeInstalled, nodeVersion };
256
269
  } catch (err) {
257
270
  const stderr = err.stderr?.toString() || '';
258
271
  if (stderr.includes('Permission denied')) {
@@ -271,14 +284,17 @@ export class TunnelManager {
271
284
 
272
285
  if (this.active.has(id)) {
273
286
  const existing = this.active.get(id);
274
- return { localPort: existing.localPort, pid: existing.pid };
287
+ return { localPort: existing.localPort, pid: existing.pid, name: config.name };
275
288
  }
276
289
 
277
290
  this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'testing' } });
278
291
 
292
+ // For known servers, skip the full test — tunnel first, check version after
279
293
  let testResult;
280
294
  if (opts.skipTest && opts.testResult) {
281
295
  testResult = opts.testResult;
296
+ } else if (config.lastConnected && opts.skipTest !== false) {
297
+ testResult = { reachable: true, daemonRunning: true, grooveInstalled: true, remoteVersion: null };
282
298
  } else {
283
299
  testResult = await this.test(id);
284
300
  }
@@ -286,22 +302,19 @@ export class TunnelManager {
286
302
  throw new Error(testResult.error || 'Host unreachable');
287
303
  }
288
304
 
305
+ // First-time only: install groove if missing, start daemon if not running
289
306
  let preConnectHandled = false;
290
307
  if (!testResult.daemonRunning && !testResult.grooveInstalled) {
291
308
  this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'installing' } });
292
309
  await this.remoteInstall(id);
293
310
  preConnectHandled = true;
294
311
  } else if (!testResult.daemonRunning && testResult.grooveInstalled) {
295
- const localVer = getLocalVersion();
296
- if (testResult.remoteVersion && testResult.remoteVersion !== localVer) {
297
- this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'upgrading', from: testResult.remoteVersion, to: localVer } });
298
- await this._remoteUpgrade(id, config);
299
- }
300
312
  this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'starting' } });
301
313
  await this.autoStart(id);
302
314
  preConnectHandled = true;
303
315
  }
304
316
 
317
+ // Establish SSH tunnel
305
318
  this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'connecting' } });
306
319
 
307
320
  const localPort = await this._findAvailablePort();
@@ -357,9 +370,7 @@ export class TunnelManager {
357
370
  failCount: 0,
358
371
  });
359
372
 
360
- // Verify the remote daemon is actually reachable through the tunnel.
361
- // The cached test result (line 270) assumes daemonRunning=true based on
362
- // lastConnected, but the daemon may have stopped since then.
373
+ // Verify daemon is reachable through tunnel, start if needed
363
374
  let remoteAlive = false;
364
375
  try {
365
376
  const probe = await fetch(`http://localhost:${localPort}/api/health`, {
@@ -371,7 +382,6 @@ export class TunnelManager {
371
382
  if (!remoteAlive && config.autoStart) {
372
383
  this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'starting' } });
373
384
  await this.autoStart(id);
374
- // Give the daemon a moment to accept connections through the tunnel
375
385
  for (let i = 0; i < 5; i++) {
376
386
  await new Promise(r => setTimeout(r, 1000));
377
387
  try {
@@ -385,9 +395,9 @@ export class TunnelManager {
385
395
  this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'waiting', message: 'Remote daemon not running. Start it manually or enable auto-start.' } });
386
396
  }
387
397
 
388
- const skipUpgrade = remoteAlive && testResult.remoteVersion && testResult.remoteVersion === getLocalVersion();
389
- if (remoteAlive && !preConnectHandled && !skipUpgrade) {
390
- await this._checkAndUpgradeRunning(id, config, localPort);
398
+ // Auto-upgrade: check version through tunnel, upgrade if behind (non-blocking)
399
+ if (remoteAlive && !preConnectHandled) {
400
+ this._checkAndUpgradeRunning(id, config, localPort).catch(() => {});
391
401
  }
392
402
 
393
403
  const remoteVer = testResult?.remoteVersion || null;
@@ -440,97 +450,93 @@ export class TunnelManager {
440
450
  }
441
451
 
442
452
  async _checkAndUpgradeRunning(id, config, localPort) {
443
- const localVer = getLocalVersion();
444
- if (localVer === '0.0.0') return;
445
-
446
453
  try {
454
+ // Get remote daemon version
447
455
  const resp = await fetch(`http://localhost:${localPort}/api/status`, {
448
456
  signal: AbortSignal.timeout(5000),
449
457
  });
450
458
  if (!resp.ok) return;
451
459
  const status = await resp.json();
452
- const oldVersion = status.version;
453
- if (!oldVersion || oldVersion === localVer) return;
454
-
455
- this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'upgrading', from: oldVersion, to: localVer } });
460
+ const remoteVer = status.version;
461
+ if (!remoteVer) return;
456
462
 
463
+ // Check latest version on npm (from the remote server)
457
464
  const target = `${config.user}@${config.host}`;
458
465
  const keyArgs = config.sshKeyPath ? ['-i', config.sshKeyPath] : [];
459
466
  const sshBase = [...keyArgs, '-p', String(config.port || 22), '-o', 'ConnectTimeout=10', '-o', 'BatchMode=yes', target];
460
- const pinnedPkg = `groove-dev@${localVer}`;
461
- const installCmd = npmGlobalInstall(pinnedPkg, config.user);
462
467
 
468
+ let npmVer;
463
469
  try {
464
- execFileSync('ssh', [...sshBase, sshCmd(installCmd)], {
465
- encoding: 'utf8',
466
- timeout: 120000,
467
- stdio: ['pipe', 'pipe', 'pipe'],
468
- });
469
- } catch {
470
- const fallbackCmd = npmGlobalInstall('groove-dev', config.user);
471
- execFileSync('ssh', [...sshBase, sshCmd(fallbackCmd)], {
472
- encoding: 'utf8',
473
- timeout: 120000,
474
- stdio: ['pipe', 'pipe', 'pipe'],
475
- });
470
+ npmVer = execFileSync('ssh', [...sshBase, sshCmd('npm view groove-dev version 2>/dev/null')], {
471
+ encoding: 'utf8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'],
472
+ }).trim();
473
+ } catch { return; }
474
+
475
+ if (!npmVer || npmVer === remoteVer) {
476
+ const localVer = getLocalVersion();
477
+ this.daemon.broadcast({ type: 'tunnel.version-info', data: { id, localVersion: localVer, remoteVersion: remoteVer, match: remoteVer === localVer } });
478
+ return;
476
479
  }
477
480
 
478
- const verOutput = execFileSync('ssh', [...sshBase, sshCmd('groove --version')], {
479
- encoding: 'utf8',
480
- timeout: 10000,
481
- stdio: ['pipe', 'pipe', 'pipe'],
482
- }).trim();
483
- const installedVer = verOutput.replace(/[^0-9.]/g, '') || verOutput.trim();
481
+ // Remote is behind npm upgrade
482
+ this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'upgrading', from: remoteVer, to: npmVer } });
484
483
 
485
- if (installedVer !== localVer) {
486
- this.daemon.broadcast({ type: 'tunnel.version-mismatch', data: { id, localVersion: localVer, remoteVersion: installedVer, message: 'Pinned version not available on npm, installed latest' } });
484
+ const installCmd = npmGlobalInstall(`groove-dev@${npmVer}`, config.user);
485
+ const cleanupCmd = 'rm -rf $(npm root -g)/.groove-dev-* $(npm root -g)/groove-dev 2>/dev/null || true';
486
+
487
+ try {
488
+ execFileSync('ssh', [...sshBase, sshCmd(installCmd)], {
489
+ encoding: 'utf8', timeout: 120000, stdio: ['pipe', 'pipe', 'pipe'],
490
+ });
491
+ } catch (err) {
492
+ const errOutput = err.stdout?.toString() || err.stderr?.toString() || err.message;
493
+ if (errOutput.includes('ENOTEMPTY')) {
494
+ execFileSync('ssh', [...sshBase, sshCmd(cleanupCmd)], { encoding: 'utf8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'] });
495
+ execFileSync('ssh', [...sshBase, sshCmd(installCmd)], { encoding: 'utf8', timeout: 120000, stdio: ['pipe', 'pipe', 'pipe'] });
496
+ } else {
497
+ throw err;
498
+ }
487
499
  }
488
500
 
501
+ // Restart remote daemon
489
502
  const cdPrefix = config.projectDir ? `cd "${config.projectDir}" && ` : '';
490
503
  const setProjectDir = config.projectDir
491
504
  ? `curl -sf -X POST -H 'Content-Type: application/json' --data '{"path":"${config.projectDir}"}' http://localhost:${REMOTE_PORT}/api/project-dir > /dev/null 2>&1 || true; `
492
505
  : '';
493
506
  const restartCmd = `kill $(lsof -t -i:${REMOTE_PORT}) 2>/dev/null || true; sleep 2; ${cdPrefix}GROOVE_BIN=$(which groove) && nohup "$GROOVE_BIN" start > /tmp/groove-daemon.log 2>&1 < /dev/null & disown; sleep 4; curl -sf http://localhost:${REMOTE_PORT}/api/status && (${setProjectDir}true) || true`;
494
- const restartResult = execFileSync('ssh', [...sshBase, sshCmd(restartCmd)], {
495
- encoding: 'utf8',
496
- timeout: 60000,
497
- stdio: ['pipe', 'pipe', 'pipe'],
507
+ execFileSync('ssh', [...sshBase, sshCmd(restartCmd)], {
508
+ encoding: 'utf8', timeout: 60000, stdio: ['pipe', 'pipe', 'pipe'],
498
509
  });
499
510
 
511
+ // Verify through tunnel
500
512
  let daemonVer = null;
501
- try { daemonVer = JSON.parse(restartResult.trim()).version || null; } catch { /* parse failed */ }
502
-
503
513
  for (let i = 0; i < 3; i++) {
504
514
  try {
505
515
  const check = await fetch(`http://localhost:${localPort}/api/status`, {
506
516
  signal: AbortSignal.timeout(3000),
507
517
  });
508
518
  if (check.ok) {
509
- const checkData = await check.json();
510
- daemonVer = checkData.version || daemonVer;
519
+ daemonVer = (await check.json()).version || null;
511
520
  break;
512
521
  }
513
522
  } catch { /* retry */ }
514
523
  await new Promise(r => setTimeout(r, 2000));
515
524
  }
516
525
 
526
+ const localVer = getLocalVersion();
517
527
  if (daemonVer) {
518
528
  this.daemon.broadcast({ type: 'tunnel.version-info', data: { id, localVersion: localVer, remoteVersion: daemonVer, match: daemonVer === localVer } });
519
529
  } else {
520
- this.daemon.broadcast({ type: 'tunnel.upgrade-failed', data: { id, error: 'Daemon did not respond after restart', from: oldVersion, attempted: localVer } });
530
+ this.daemon.broadcast({ type: 'tunnel.upgrade-failed', data: { id, error: 'Daemon did not respond after restart', from: remoteVer, attempted: npmVer } });
521
531
  }
522
532
 
523
- this.daemon.audit.log('tunnel.upgrade', { id, from: oldVersion, to: daemonVer || installedVer });
533
+ this.daemon.audit.log('tunnel.upgrade', { id, from: remoteVer, to: daemonVer || npmVer });
524
534
  } catch (err) {
525
535
  try {
526
536
  const verify = await fetch(`http://localhost:${localPort}/api/status`, { signal: AbortSignal.timeout(5000) });
527
537
  if (verify.ok) {
528
538
  const verifyData = await verify.json();
529
- if (verifyData.version === localVer) {
530
- this.daemon.broadcast({ type: 'tunnel.version-info', data: { id, localVersion: localVer, remoteVersion: verifyData.version, match: true } });
531
- return;
532
- }
533
- this.daemon.broadcast({ type: 'tunnel.version-mismatch', data: { id, localVersion: localVer, remoteVersion: verifyData.version, message: 'Upgrade timed out but remote is reachable' } });
539
+ this.daemon.broadcast({ type: 'tunnel.version-info', data: { id, localVersion: getLocalVersion(), remoteVersion: verifyData.version, match: false } });
534
540
  return;
535
541
  }
536
542
  } catch { /* tunnel verification failed */ }