groove-dev 0.27.113 → 0.27.116

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 (70) hide show
  1. package/CENTRAL_COMMAND_REBUILD.md +689 -0
  2. package/EMBEDDING_DIAGNOSTIC.md +197 -0
  3. package/TRAINING_DATA_v4.md +6 -0
  4. package/node_modules/@groove-dev/cli/package.json +1 -1
  5. package/node_modules/@groove-dev/cli/src/commands/team.js +59 -2
  6. package/node_modules/@groove-dev/daemon/package.json +1 -1
  7. package/node_modules/@groove-dev/daemon/src/api.js +27 -2
  8. package/node_modules/@groove-dev/daemon/src/filewatcher.js +45 -0
  9. package/node_modules/@groove-dev/daemon/src/index.js +14 -2
  10. package/node_modules/@groove-dev/daemon/src/process.js +254 -208
  11. package/node_modules/@groove-dev/daemon/src/teams.js +143 -20
  12. package/node_modules/@groove-dev/daemon/src/tunnel-manager.js +78 -45
  13. package/node_modules/@groove-dev/gui/dist/assets/index-DdN9RVnC.css +1 -0
  14. package/node_modules/@groove-dev/gui/dist/assets/{index-BYh6iHqL.js → index-fq--PD7_.js} +1731 -1731
  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/agents/workspace-mode.jsx +0 -22
  18. package/node_modules/@groove-dev/gui/src/components/layout/status-bar.jsx +43 -45
  19. package/node_modules/@groove-dev/gui/src/components/settings/quick-connect.jsx +2 -1
  20. package/node_modules/@groove-dev/gui/src/components/teams/team-removal-dialog.jsx +156 -0
  21. package/node_modules/@groove-dev/gui/src/stores/groove.js +57 -12
  22. package/node_modules/@groove-dev/gui/src/views/agents.jsx +23 -4
  23. package/node_modules/@groove-dev/gui/src/views/editor.jsx +1 -20
  24. package/node_modules/@groove-dev/gui/src/views/teams.jsx +84 -5
  25. package/package.json +1 -1
  26. package/packages/cli/package.json +1 -1
  27. package/packages/cli/src/commands/team.js +59 -2
  28. package/packages/daemon/package.json +1 -1
  29. package/packages/daemon/src/api.js +27 -2
  30. package/packages/daemon/src/filewatcher.js +45 -0
  31. package/packages/daemon/src/index.js +14 -2
  32. package/packages/daemon/src/process.js +254 -208
  33. package/packages/daemon/src/teams.js +143 -20
  34. package/packages/daemon/src/tunnel-manager.js +78 -45
  35. package/packages/gui/dist/assets/index-DdN9RVnC.css +1 -0
  36. package/packages/gui/dist/assets/{index-BYh6iHqL.js → index-fq--PD7_.js} +1731 -1731
  37. package/packages/gui/dist/index.html +2 -2
  38. package/packages/gui/package.json +1 -1
  39. package/packages/gui/src/components/agents/workspace-mode.jsx +0 -22
  40. package/packages/gui/src/components/layout/status-bar.jsx +43 -45
  41. package/packages/gui/src/components/settings/quick-connect.jsx +2 -1
  42. package/packages/gui/src/components/teams/team-removal-dialog.jsx +156 -0
  43. package/packages/gui/src/stores/groove.js +57 -12
  44. package/packages/gui/src/views/agents.jsx +23 -4
  45. package/packages/gui/src/views/editor.jsx +1 -20
  46. package/packages/gui/src/views/teams.jsx +84 -5
  47. package/TRAINING_DATA_v3.md +0 -11
  48. package/codex-test/offroad-nitro-racer/dist/assets/index-CuvdKK6U.js +0 -44
  49. package/codex-test/offroad-nitro-racer/dist/assets/index-DvHn2Thu.css +0 -1
  50. package/codex-test/offroad-nitro-racer/dist/index.html +0 -23
  51. package/codex-test/offroad-nitro-racer/index.html +0 -21
  52. package/codex-test/offroad-nitro-racer/package-lock.json +0 -841
  53. package/codex-test/offroad-nitro-racer/package.json +0 -15
  54. package/codex-test/offroad-nitro-racer/src/game/AI.ts +0 -28
  55. package/codex-test/offroad-nitro-racer/src/game/Audio.ts +0 -63
  56. package/codex-test/offroad-nitro-racer/src/game/Car.ts +0 -247
  57. package/codex-test/offroad-nitro-racer/src/game/Effects.ts +0 -62
  58. package/codex-test/offroad-nitro-racer/src/game/Game.ts +0 -229
  59. package/codex-test/offroad-nitro-racer/src/game/Input.ts +0 -45
  60. package/codex-test/offroad-nitro-racer/src/game/Renderer.ts +0 -224
  61. package/codex-test/offroad-nitro-racer/src/game/Track.ts +0 -158
  62. package/codex-test/offroad-nitro-racer/src/game/UI.ts +0 -96
  63. package/codex-test/offroad-nitro-racer/src/game/math.ts +0 -42
  64. package/codex-test/offroad-nitro-racer/src/main.ts +0 -24
  65. package/codex-test/offroad-nitro-racer/src/style.css +0 -291
  66. package/codex-test/offroad-nitro-racer/src/vite-env.d.ts +0 -1
  67. package/codex-test/offroad-nitro-racer/tsconfig.json +0 -18
  68. package/codex-test/offroad-nitro-racer/vite.config.ts +0 -7
  69. package/node_modules/@groove-dev/gui/dist/assets/index-DAlSbVyK.css +0 -1
  70. package/packages/gui/dist/assets/index-DAlSbVyK.css +0 -1
@@ -1,8 +1,8 @@
1
1
  // GROOVE — Teams (Live Agent Groups)
2
2
  // FSL-1.1-Apache-2.0 — see LICENSE
3
3
 
4
- import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, rmSync } from 'fs';
5
- import { resolve } from 'path';
4
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, rmSync, readdirSync, cpSync } from 'fs';
5
+ import { resolve, basename } from 'path';
6
6
  import { randomUUID } from 'crypto';
7
7
  import { validateTeamName } from './validate.js';
8
8
 
@@ -141,29 +141,67 @@ export class Teams {
141
141
  }
142
142
 
143
143
  /**
144
- * Delete a team — kills its agents, removes its directory, drops it from the
145
- * registry. Deleting the default team regenerates a fresh empty one so users
146
- * can wipe accumulated state and keep working without restarting the daemon.
144
+ * Archive a team — kills its agents, moves its directory to archived-teams/,
145
+ * stores metadata.json for later restore.
147
146
  */
148
- delete(id) {
147
+ archive(id) {
149
148
  const team = this.teams.get(id);
150
149
  if (!team) throw new Error('Team not found');
151
150
 
152
- // Kill any running agents in this team
153
- const agents = this.daemon.registry.getAll().filter((a) => a.teamId === id);
154
- for (const agent of agents) {
155
- if (agent.status === 'running' || agent.status === 'starting') {
156
- try { this.daemon.processes.kill(agent.id); } catch { /* ignore */ }
151
+ const agents = this._killAndRemoveAgents(id);
152
+
153
+ if (
154
+ team.workingDir &&
155
+ team.workingDir !== this.daemon.projectDir &&
156
+ existsSync(team.workingDir)
157
+ ) {
158
+ try {
159
+ const archiveDir = resolve(this.daemon.grooveDir, 'archived-teams');
160
+ mkdirSync(archiveDir, { recursive: true });
161
+ const slug = basename(team.workingDir);
162
+ const archiveName = `${slug}-${Date.now()}`;
163
+ const archivePath = resolve(archiveDir, archiveName);
164
+
165
+ try {
166
+ renameSync(team.workingDir, archivePath);
167
+ } catch (err) {
168
+ if (err.code === 'EXDEV') {
169
+ cpSync(team.workingDir, archivePath, { recursive: true });
170
+ rmSync(team.workingDir, { recursive: true, force: true });
171
+ } else {
172
+ throw err;
173
+ }
174
+ }
175
+
176
+ const metadata = {
177
+ originalName: team.name,
178
+ originalId: team.id,
179
+ deletedAt: new Date().toISOString(),
180
+ agentCount: agents.length,
181
+ originalWorkingDir: team.workingDir,
182
+ };
183
+ writeFileSync(resolve(archivePath, 'metadata.json'), JSON.stringify(metadata, null, 2));
184
+ } catch (err) {
185
+ console.log(`[Groove:Teams] Failed to archive directory: ${err.message}`);
157
186
  }
158
187
  }
159
188
 
160
- // Remove agents from registry
161
- for (const agent of agents) {
162
- this.daemon.registry.remove(agent.id);
163
- }
189
+ this._removeTeamAndCleanup(team, id);
190
+ return true;
191
+ }
192
+
193
+ /**
194
+ * Delete a team — kills its agents, removes its directory permanently.
195
+ * If permanent is false (default), delegates to archive() instead.
196
+ */
197
+ delete(id, { permanent = false } = {}) {
198
+ if (!permanent) return this.archive(id);
199
+
200
+ const team = this.teams.get(id);
201
+ if (!team) throw new Error('Team not found');
202
+
203
+ this._killAndRemoveAgents(id);
164
204
 
165
- // Remove the team's working directory — refuse to nuke the project root
166
- // (legacy default teams that were never migrated point there).
167
205
  if (
168
206
  team.workingDir &&
169
207
  team.workingDir !== this.daemon.projectDir &&
@@ -172,24 +210,109 @@ export class Teams {
172
210
  try {
173
211
  rmSync(team.workingDir, { recursive: true, force: true });
174
212
  } catch (err) {
175
- console.log(`[Groove:Teams] Failed to remove directory: ${err.message}`);
213
+ console.log(`[Groove:Teams] Failed to delete directory: ${err.message}`);
214
+ }
215
+ }
216
+
217
+ this._removeTeamAndCleanup(team, id);
218
+ return true;
219
+ }
220
+
221
+ _killAndRemoveAgents(teamId) {
222
+ const agents = this.daemon.registry.getAll().filter((a) => a.teamId === teamId);
223
+ for (const agent of agents) {
224
+ if (agent.status === 'running' || agent.status === 'starting') {
225
+ try { this.daemon.processes.kill(agent.id); } catch { /* ignore */ }
176
226
  }
177
227
  }
228
+ for (const agent of agents) {
229
+ this.daemon.registry.remove(agent.id);
230
+ }
231
+ return agents;
232
+ }
178
233
 
234
+ _removeTeamAndCleanup(team, id) {
179
235
  this.teams.delete(id);
180
236
  this._save();
181
237
  this.daemon.broadcast({ type: 'team:deleted', teamId: id });
182
238
 
183
- // Always keep a default team available — regenerate one with a clean folder
184
239
  if (team.isDefault) {
185
240
  this._ensureDefault();
186
241
  const fresh = this.getDefault();
187
242
  if (fresh) this.daemon.broadcast({ type: 'team:created', team: fresh });
188
243
  }
189
244
 
190
- // Clean up orphaned logs immediately — don't wait for the 24h GC cycle
191
245
  try { this.daemon._gc(); } catch { /* gc should never block deletion */ }
246
+ }
247
+
248
+ listArchived() {
249
+ const archiveDir = resolve(this.daemon.grooveDir, 'archived-teams');
250
+ if (!existsSync(archiveDir)) return [];
251
+ const entries = readdirSync(archiveDir, { withFileTypes: true });
252
+ const result = [];
253
+ for (const entry of entries) {
254
+ if (!entry.isDirectory()) continue;
255
+ const metaPath = resolve(archiveDir, entry.name, 'metadata.json');
256
+ try {
257
+ const meta = JSON.parse(readFileSync(metaPath, 'utf8'));
258
+ result.push({ id: entry.name, ...meta });
259
+ } catch {
260
+ result.push({ id: entry.name, originalName: entry.name, deletedAt: null, agentCount: 0 });
261
+ }
262
+ }
263
+ return result;
264
+ }
265
+
266
+ restore(archivedId) {
267
+ const archiveDir = resolve(this.daemon.grooveDir, 'archived-teams');
268
+ const archivePath = resolve(archiveDir, archivedId);
269
+ if (!existsSync(archivePath)) throw new Error('Archived team not found');
270
+
271
+ let meta = {};
272
+ const metaPath = resolve(archivePath, 'metadata.json');
273
+ try { meta = JSON.parse(readFileSync(metaPath, 'utf8')); } catch { /* use defaults */ }
274
+
275
+ const name = meta.originalName || archivedId;
276
+ let workingDir = meta.originalWorkingDir || resolve(this.daemon.projectDir, slugify(name));
277
+
278
+ if (existsSync(workingDir)) {
279
+ workingDir = resolve(this.daemon.projectDir, `${slugify(name)}-${Date.now()}`);
280
+ }
281
+
282
+ try {
283
+ renameSync(archivePath, workingDir);
284
+ } catch (err) {
285
+ if (err.code === 'EXDEV') {
286
+ cpSync(archivePath, workingDir, { recursive: true });
287
+ rmSync(archivePath, { recursive: true, force: true });
288
+ } else {
289
+ throw err;
290
+ }
291
+ }
292
+
293
+ // Remove the metadata file from the restored directory
294
+ const restoredMetaPath = resolve(workingDir, 'metadata.json');
295
+ try { rmSync(restoredMetaPath); } catch { /* may not exist */ }
296
+
297
+ const id = randomUUID().slice(0, 8);
298
+ const team = {
299
+ id,
300
+ name,
301
+ isDefault: false,
302
+ workingDir,
303
+ createdAt: new Date().toISOString(),
304
+ };
305
+ this.teams.set(id, team);
306
+ this._save();
307
+ this.daemon.broadcast({ type: 'team:created', team });
308
+ return team;
309
+ }
192
310
 
311
+ purge(archivedId) {
312
+ const archiveDir = resolve(this.daemon.grooveDir, 'archived-teams');
313
+ const archivePath = resolve(archiveDir, archivedId);
314
+ if (!existsSync(archivePath)) throw new Error('Archived team not found');
315
+ rmSync(archivePath, { recursive: true, force: true });
193
316
  return true;
194
317
  }
195
318
 
@@ -3,15 +3,13 @@
3
3
 
4
4
  import { execFileSync, spawn } from 'child_process';
5
5
  import { existsSync, writeFileSync, readFileSync, statSync } from 'fs';
6
- import { resolve, dirname, join } from 'path';
7
- import { fileURLToPath } from 'url';
6
+ import { resolve } from 'path';
8
7
  import { createConnection } from 'net';
9
8
  import crypto from 'crypto';
10
9
 
11
- const __dirname = dirname(fileURLToPath(import.meta.url));
12
10
  function getLocalVersion() {
13
11
  try {
14
- const pkg = JSON.parse(readFileSync(join(__dirname, '..', '..', '..', 'package.json'), 'utf8'));
12
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
15
13
  return pkg.version || '0.0.0';
16
14
  } catch { return '0.0.0'; }
17
15
  }
@@ -34,6 +32,11 @@ function validateField(value, name) {
34
32
  }
35
33
  }
36
34
 
35
+ function sshCmd(cmd) {
36
+ const nvmProbe = 'export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"; [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"; ';
37
+ return `bash -lc '${nvmProbe}${cmd}'`;
38
+ }
39
+
37
40
  export class TunnelManager {
38
41
  constructor(daemon) {
39
42
  this.daemon = daemon;
@@ -217,7 +220,7 @@ export class TunnelManager {
217
220
  '-o', 'StrictHostKeyChecking=accept-new',
218
221
  '-o', 'BatchMode=yes',
219
222
  target,
220
- `bash -lc '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'`,
223
+ 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`),
221
224
  ], {
222
225
  encoding: 'utf8',
223
226
  timeout: 20000,
@@ -412,7 +415,7 @@ export class TunnelManager {
412
415
  const installCmd = config.user === 'root' ? `npm i -g --prefer-online ${pinnedPkg}` : `sudo npm i -g --prefer-online ${pinnedPkg}`;
413
416
 
414
417
  try {
415
- execFileSync('ssh', [...sshBase, `bash -lc '${installCmd}'`], {
418
+ execFileSync('ssh', [...sshBase, sshCmd(installCmd)], {
416
419
  encoding: 'utf8',
417
420
  timeout: 120000,
418
421
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -420,14 +423,14 @@ export class TunnelManager {
420
423
  } catch {
421
424
  const fallbackPkg = 'groove-dev';
422
425
  const fallbackCmd = config.user === 'root' ? `npm i -g --prefer-online ${fallbackPkg}` : `sudo npm i -g --prefer-online ${fallbackPkg}`;
423
- execFileSync('ssh', [...sshBase, `bash -lc '${fallbackCmd}'`], {
426
+ execFileSync('ssh', [...sshBase, sshCmd(fallbackCmd)], {
424
427
  encoding: 'utf8',
425
428
  timeout: 120000,
426
429
  stdio: ['pipe', 'pipe', 'pipe'],
427
430
  });
428
431
  }
429
432
 
430
- const verOutput = execFileSync('ssh', [...sshBase, `bash -lc 'groove --version'`], {
433
+ const verOutput = execFileSync('ssh', [...sshBase, sshCmd('groove --version')], {
431
434
  encoding: 'utf8',
432
435
  timeout: 10000,
433
436
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -438,8 +441,8 @@ export class TunnelManager {
438
441
  this.daemon.broadcast({ type: 'tunnel.version-mismatch', data: { id, localVersion: localVer, remoteVersion: installedVer, message: 'Pinned version not available on npm, installed latest' } });
439
442
  }
440
443
 
441
- const restartCmd = `kill $(lsof -t -i:${REMOTE_PORT}) 2>/dev/null || true; sleep 2; nohup groove start > /tmp/groove-daemon.log 2>&1 < /dev/null & disown; sleep 4; curl -sf http://localhost:${REMOTE_PORT}/api/status`;
442
- const restartResult = execFileSync('ssh', [...sshBase, `bash -lc '${restartCmd}'`], {
444
+ const restartCmd = `kill $(lsof -t -i:${REMOTE_PORT}) 2>/dev/null || true; sleep 2; 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`;
445
+ const restartResult = execFileSync('ssh', [...sshBase, sshCmd(restartCmd)], {
443
446
  encoding: 'utf8',
444
447
  timeout: 60000,
445
448
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -484,30 +487,40 @@ export class TunnelManager {
484
487
 
485
488
  let usedFallback = false;
486
489
  try {
487
- execFileSync('ssh', [...sshBase, `bash -lc '${installCmd}'`], {
490
+ execFileSync('ssh', [...sshBase, sshCmd(installCmd)], {
488
491
  encoding: 'utf8',
489
492
  timeout: 120000,
490
493
  stdio: ['pipe', 'pipe', 'pipe'],
491
494
  });
492
495
  } catch (err) {
493
- if (localVer !== '0.0.0' && pkg.includes('@')) {
494
- const fallbackCmd = config.user === 'root' ? 'npm i -g --prefer-online groove-dev' : 'sudo npm i -g --prefer-online groove-dev';
496
+ const errOutput = err.stdout?.toString() || err.stderr?.toString() || err.message;
497
+ if (errOutput.includes('ENOTEMPTY')) {
495
498
  try {
496
- execFileSync('ssh', [...sshBase, `bash -lc '${fallbackCmd}'`], {
497
- encoding: 'utf8',
498
- timeout: 120000,
499
- stdio: ['pipe', 'pipe', 'pipe'],
500
- });
501
- usedFallback = true;
502
- } catch { /* fall through to original error */ }
503
- }
504
- if (!usedFallback) {
505
- const output = err.stdout?.toString() || err.stderr?.toString() || err.message;
506
- throw new Error(`Remote upgrade failed: ${output.slice(-400)}`);
499
+ execFileSync('ssh', [...sshBase, sshCmd('rm -rf $(npm root -g)/.groove-dev-* $(npm root -g)/groove-dev 2>/dev/null || true')], { encoding: 'utf8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'] });
500
+ execFileSync('ssh', [...sshBase, sshCmd(installCmd)], { encoding: 'utf8', timeout: 120000, stdio: ['pipe', 'pipe', 'pipe'] });
501
+ } catch (retryErr) {
502
+ const retryOutput = retryErr.stdout?.toString() || retryErr.stderr?.toString() || retryErr.message;
503
+ throw new Error(`Remote upgrade failed after cleanup: ${retryOutput.slice(-400)}`);
504
+ }
505
+ } else {
506
+ if (localVer !== '0.0.0' && pkg.includes('@')) {
507
+ const fallbackCmd = config.user === 'root' ? 'npm i -g --prefer-online groove-dev' : 'sudo npm i -g --prefer-online groove-dev';
508
+ try {
509
+ execFileSync('ssh', [...sshBase, sshCmd(fallbackCmd)], {
510
+ encoding: 'utf8',
511
+ timeout: 120000,
512
+ stdio: ['pipe', 'pipe', 'pipe'],
513
+ });
514
+ usedFallback = true;
515
+ } catch { /* fall through to original error */ }
516
+ }
517
+ if (!usedFallback) {
518
+ throw new Error(`Remote upgrade failed: ${errOutput.slice(-400)}`);
519
+ }
507
520
  }
508
521
  }
509
522
 
510
- const verOutput = execFileSync('ssh', [...sshBase, `bash -lc 'groove --version'`], {
523
+ const verOutput = execFileSync('ssh', [...sshBase, sshCmd('groove --version')], {
511
524
  encoding: 'utf8',
512
525
  timeout: 10000,
513
526
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -537,7 +550,7 @@ export class TunnelManager {
537
550
  ? `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; `
538
551
  : '';
539
552
  const remoteCmd =
540
- `${cdPrefix}nohup groove start > /tmp/groove-daemon.log 2>&1 < /dev/null & disown; ` +
553
+ `${cdPrefix}GROOVE_BIN=$(which groove) && nohup "$GROOVE_BIN" start > /tmp/groove-daemon.log 2>&1 < /dev/null & disown; ` +
541
554
  `sleep 5; ` +
542
555
  `curl -sf http://localhost:${REMOTE_PORT}/api/health > /dev/null ` +
543
556
  `&& (${setProjectDir}echo __DAEMON_OK__) ` +
@@ -550,7 +563,7 @@ export class TunnelManager {
550
563
  '-o', 'ConnectTimeout=10',
551
564
  '-o', 'BatchMode=yes',
552
565
  target,
553
- `bash -lc '${remoteCmd}'`,
566
+ sshCmd(remoteCmd),
554
567
  ], {
555
568
  encoding: 'utf8',
556
569
  timeout: 45000,
@@ -564,8 +577,9 @@ export class TunnelManager {
564
577
  }
565
578
  } catch (err) {
566
579
  if (err.message.includes('Remote daemon failed')) throw err;
567
- const output = err.stdout?.toString() || err.stderr?.toString() || err.message;
568
- throw new Error(`Failed to start remote daemon: ${output.slice(-300)}`);
580
+ const output = err.stdout?.toString() || err.stderr?.toString() || '';
581
+ if (output.includes('__DAEMON_OK__')) return;
582
+ throw new Error(`Failed to start remote daemon: ${(output || err.message).slice(-300)}`);
569
583
  }
570
584
  }
571
585
 
@@ -585,7 +599,7 @@ export class TunnelManager {
585
599
 
586
600
  // Non-interactive SSH doesn't source shell profiles, so npm/node may not be on PATH.
587
601
  // Use a login shell (-l) to get the user's full environment.
588
- const remoteCmd = (cmd) => `bash -lc '${cmd}'`;
602
+ const remoteCmd = (cmd) => sshCmd(cmd);
589
603
 
590
604
  // Step 1: Check if node/npm are available
591
605
  try {
@@ -623,7 +637,16 @@ export class TunnelManager {
623
637
  stdio: ['pipe', 'pipe', 'pipe'],
624
638
  });
625
639
  } catch (err) {
626
- if (localVer !== '0.0.0' && pkg.includes('@')) {
640
+ const errOutput = err.stdout?.toString() || err.stderr?.toString() || err.message;
641
+ if (errOutput.includes('ENOTEMPTY')) {
642
+ try {
643
+ execFileSync('ssh', [...sshBase, remoteCmd('rm -rf $(npm root -g)/.groove-dev-* $(npm root -g)/groove-dev 2>/dev/null || true')], { encoding: 'utf8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'] });
644
+ execFileSync('ssh', [...sshBase, remoteCmd(installCmd)], { encoding: 'utf8', timeout: 120000, stdio: ['pipe', 'pipe', 'pipe'] });
645
+ } catch (retryErr) {
646
+ const retryOutput = retryErr.stdout?.toString() || retryErr.stderr?.toString() || retryErr.message;
647
+ throw new Error(`npm install failed after cleanup: ${retryOutput.slice(-400)}`);
648
+ }
649
+ } else if (localVer !== '0.0.0' && pkg.includes('@')) {
627
650
  const fallbackCmd = config.user === 'root' ? 'npm i -g --prefer-online groove-dev' : 'sudo npm i -g --prefer-online groove-dev';
628
651
  try {
629
652
  execFileSync('ssh', [...sshBase, remoteCmd(fallbackCmd)], {
@@ -636,8 +659,7 @@ export class TunnelManager {
636
659
  throw new Error(`npm install failed: ${output.slice(-400)}`);
637
660
  }
638
661
  } else {
639
- const output = err.stdout?.toString() || err.stderr?.toString() || err.message;
640
- throw new Error(`npm install failed: ${output.slice(-400)}`);
662
+ throw new Error(`npm install failed: ${errOutput.slice(-400)}`);
641
663
  }
642
664
  }
643
665
 
@@ -645,7 +667,7 @@ export class TunnelManager {
645
667
  try {
646
668
  const result = execFileSync('ssh', [
647
669
  ...sshBase,
648
- 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)`),
670
+ remoteCmd(`GROOVE_BIN=$(which groove) && nohup "$GROOVE_BIN" 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)`),
649
671
  ], {
650
672
  encoding: 'utf8',
651
673
  timeout: 45000,
@@ -683,29 +705,40 @@ export class TunnelManager {
683
705
  const installCmd = config.user === 'root' ? `npm i -g --prefer-online ${pinnedPkg}` : `sudo npm i -g --prefer-online ${pinnedPkg}`;
684
706
 
685
707
  try {
686
- execFileSync('ssh', [...sshBase, `bash -lc '${installCmd}'`], {
687
- encoding: 'utf8',
688
- timeout: 120000,
689
- stdio: ['pipe', 'pipe', 'pipe'],
690
- });
691
- } catch {
692
- const fallbackCmd = config.user === 'root' ? 'npm i -g --prefer-online groove-dev' : 'sudo npm i -g --prefer-online groove-dev';
693
- execFileSync('ssh', [...sshBase, `bash -lc '${fallbackCmd}'`], {
708
+ execFileSync('ssh', [...sshBase, sshCmd(installCmd)], {
694
709
  encoding: 'utf8',
695
710
  timeout: 120000,
696
711
  stdio: ['pipe', 'pipe', 'pipe'],
697
712
  });
713
+ } catch (err) {
714
+ const errOutput = err.stdout?.toString() || err.stderr?.toString() || err.message;
715
+ if (errOutput.includes('ENOTEMPTY')) {
716
+ try {
717
+ execFileSync('ssh', [...sshBase, sshCmd('rm -rf $(npm root -g)/.groove-dev-* $(npm root -g)/groove-dev 2>/dev/null || true')], { encoding: 'utf8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'] });
718
+ execFileSync('ssh', [...sshBase, sshCmd(installCmd)], { encoding: 'utf8', timeout: 120000, stdio: ['pipe', 'pipe', 'pipe'] });
719
+ } catch (retryErr) {
720
+ const retryOutput = retryErr.stdout?.toString() || retryErr.stderr?.toString() || retryErr.message;
721
+ throw new Error(`npm install failed after cleanup: ${retryOutput.slice(-400)}`);
722
+ }
723
+ } else {
724
+ const fallbackCmd = config.user === 'root' ? 'npm i -g --prefer-online groove-dev' : 'sudo npm i -g --prefer-online groove-dev';
725
+ execFileSync('ssh', [...sshBase, sshCmd(fallbackCmd)], {
726
+ encoding: 'utf8',
727
+ timeout: 120000,
728
+ stdio: ['pipe', 'pipe', 'pipe'],
729
+ });
730
+ }
698
731
  }
699
732
 
700
- const verOutput = execFileSync('ssh', [...sshBase, `bash -lc 'groove --version'`], {
733
+ const verOutput = execFileSync('ssh', [...sshBase, sshCmd('groove --version')], {
701
734
  encoding: 'utf8',
702
735
  timeout: 10000,
703
736
  stdio: ['pipe', 'pipe', 'pipe'],
704
737
  }).trim();
705
738
  const installedVer = verOutput.replace(/[^0-9.]/g, '') || verOutput.trim();
706
739
 
707
- const restartCmd = `kill $(lsof -t -i:${REMOTE_PORT}) 2>/dev/null || true; sleep 2; nohup groove start > /tmp/groove-daemon.log 2>&1 < /dev/null & disown; sleep 4; curl -sf http://localhost:${REMOTE_PORT}/api/status`;
708
- const restartResult = execFileSync('ssh', [...sshBase, `bash -lc '${restartCmd}'`], {
740
+ const restartCmd = `kill $(lsof -t -i:${REMOTE_PORT}) 2>/dev/null || true; sleep 2; 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`;
741
+ const restartResult = execFileSync('ssh', [...sshBase, sshCmd(restartCmd)], {
709
742
  encoding: 'utf8',
710
743
  timeout: 60000,
711
744
  stdio: ['pipe', 'pipe', 'pipe'],