groove-dev 0.27.161 → 0.27.164

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 (55) 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/gateways/manager.js +1 -0
  4. package/node_modules/@groove-dev/daemon/src/index.js +3 -0
  5. package/node_modules/@groove-dev/daemon/src/model-lab.js +15 -0
  6. package/node_modules/@groove-dev/daemon/src/process.js +227 -105
  7. package/node_modules/@groove-dev/daemon/src/routes/teams.js +2 -0
  8. package/node_modules/@groove-dev/daemon/src/scheduler.js +1 -0
  9. package/node_modules/@groove-dev/daemon/src/tunnel-manager.js +4 -8
  10. package/node_modules/@groove-dev/gui/dist/assets/index-BJVNpGIp.css +1 -0
  11. package/node_modules/@groove-dev/gui/dist/assets/index-CkCFf4Fl.js +1025 -0
  12. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  13. package/node_modules/@groove-dev/gui/package.json +1 -1
  14. package/node_modules/@groove-dev/gui/src/App.jsx +2 -0
  15. package/node_modules/@groove-dev/gui/src/components/agents/diff-viewer.jsx +20 -12
  16. package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +5 -2
  17. package/node_modules/@groove-dev/gui/src/components/agents/workspace-mode.jsx +2 -19
  18. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-agent-row.jsx +176 -0
  19. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-content.jsx +135 -0
  20. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-pane.jsx +105 -0
  21. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-sidebar.jsx +216 -0
  22. package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +2 -1
  23. package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +4 -0
  24. package/node_modules/@groove-dev/gui/src/stores/slices/ui-slice.js +71 -1
  25. package/node_modules/@groove-dev/gui/src/views/fleet.jsx +15 -0
  26. package/package.json +1 -1
  27. package/packages/cli/package.json +1 -1
  28. package/packages/daemon/package.json +1 -1
  29. package/packages/daemon/src/gateways/manager.js +1 -0
  30. package/packages/daemon/src/index.js +3 -0
  31. package/packages/daemon/src/model-lab.js +15 -0
  32. package/packages/daemon/src/process.js +227 -105
  33. package/packages/daemon/src/routes/teams.js +2 -0
  34. package/packages/daemon/src/scheduler.js +1 -0
  35. package/packages/daemon/src/tunnel-manager.js +4 -8
  36. package/packages/gui/dist/assets/index-BJVNpGIp.css +1 -0
  37. package/packages/gui/dist/assets/index-CkCFf4Fl.js +1025 -0
  38. package/packages/gui/dist/index.html +2 -2
  39. package/packages/gui/package.json +1 -1
  40. package/packages/gui/src/App.jsx +2 -0
  41. package/packages/gui/src/components/agents/diff-viewer.jsx +20 -12
  42. package/packages/gui/src/components/agents/spawn-wizard.jsx +5 -2
  43. package/packages/gui/src/components/agents/workspace-mode.jsx +2 -19
  44. package/packages/gui/src/components/fleet/fleet-agent-row.jsx +176 -0
  45. package/packages/gui/src/components/fleet/fleet-content.jsx +135 -0
  46. package/packages/gui/src/components/fleet/fleet-pane.jsx +105 -0
  47. package/packages/gui/src/components/fleet/fleet-sidebar.jsx +216 -0
  48. package/packages/gui/src/components/layout/activity-bar.jsx +2 -1
  49. package/packages/gui/src/stores/slices/agents-slice.js +4 -0
  50. package/packages/gui/src/stores/slices/ui-slice.js +71 -1
  51. package/packages/gui/src/views/fleet.jsx +15 -0
  52. package/node_modules/@groove-dev/gui/dist/assets/index-DpRdb7o1.js +0 -1020
  53. package/node_modules/@groove-dev/gui/dist/assets/index-Dzofq3wS.css +0 -1
  54. package/packages/gui/dist/assets/index-DpRdb7o1.js +0 -1020
  55. package/packages/gui/dist/assets/index-Dzofq3wS.css +0 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.161",
3
+ "version": "0.27.164",
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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/daemon",
3
- "version": "0.27.161",
3
+ "version": "0.27.164",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -874,6 +874,7 @@ export class GatewayManager {
874
874
  teamId: defaultTeamId,
875
875
  })),
876
876
  });
877
+ this.daemon.processes._persistPendingPhase2();
877
878
  }
878
879
  }
879
880
 
@@ -588,6 +588,9 @@ export class Daemon {
588
588
  console.log(` ${resumableIds.size} agent-loop session(s) marked as resumable`);
589
589
  }
590
590
 
591
+ // Restore pending phase 2 groups from disk
592
+ this.processes.loadPendingPhase2();
593
+
591
594
  // Migrate old agents without teamId to default team
592
595
  this.teams.migrateAgents();
593
596
 
@@ -8,6 +8,7 @@ import { homedir } from 'os';
8
8
  import { spawn } from 'child_process';
9
9
  import { LlamaServerManager } from './llama-server.js';
10
10
  import { MLXServerManager } from './mlx-server.js';
11
+ import { OllamaProvider } from './providers/ollama.js';
11
12
  const RUNTIME_TYPES = ['ollama', 'vllm', 'llama-cpp', 'mlx', 'tgi', 'openai-compatible'];
12
13
  const DEFAULT_OLLAMA_ENDPOINT = 'http://localhost:11434';
13
14
  const GLOBAL_GROOVE_DIR = resolve(homedir(), '.groove');
@@ -698,11 +699,13 @@ export class ModelLab {
698
699
 
699
700
  listLocalModels() {
700
701
  const models = [];
702
+ const seen = new Set();
701
703
 
702
704
  // GGUF models from ModelManager
703
705
  const mm = this.daemon.modelManager;
704
706
  if (mm) {
705
707
  for (const m of mm.getInstalled().filter((m) => m.exists)) {
708
+ seen.add(m.id);
706
709
  models.push({ ...m, type: 'gguf', compatibleBackends: ['llama-cpp'] });
707
710
  }
708
711
  }
@@ -711,10 +714,22 @@ export class ModelLab {
711
714
  try {
712
715
  const hfModels = MLXServerManager.scanModels();
713
716
  for (const m of hfModels) {
717
+ seen.add(m.id);
714
718
  models.push(m);
715
719
  }
716
720
  } catch { /* scan may fail */ }
717
721
 
722
+ // Ollama installed models
723
+ try {
724
+ if (OllamaProvider.isInstalled()) {
725
+ for (const m of OllamaProvider.getInstalledModels()) {
726
+ if (seen.has(m.id)) continue;
727
+ seen.add(m.id);
728
+ models.push({ ...m, type: 'ollama', compatibleBackends: ['ollama'] });
729
+ }
730
+ }
731
+ } catch { /* ollama may not be available */ }
732
+
718
733
  return models;
719
734
  }
720
735
 
@@ -349,6 +349,7 @@ export class ProcessManager {
349
349
  this._reviewTriggered = new Set(); // teamIds that have had review triggered (one round only)
350
350
  this._pendingReviews = new Map(); // teamId -> { reviewAgentId }
351
351
  this._reviewPending = new Set(); // teamIds with review in progress (blocks preview launch)
352
+ this._phase2Debounce = new Map(); // groupIndex -> timer (300ms settle delay)
352
353
 
353
354
  this._stallWatchdog = setInterval(() => this._checkStalls(), STALL_CHECK_INTERVAL_MS);
354
355
  if (this._stallWatchdog.unref) this._stallWatchdog.unref();
@@ -1850,6 +1851,7 @@ For normal file edits within your scope, proceed without review.
1850
1851
  teamId: defaultTeamId,
1851
1852
  })),
1852
1853
  });
1854
+ this._persistPendingPhase2();
1853
1855
  }
1854
1856
 
1855
1857
  // Clean up the file
@@ -1867,133 +1869,253 @@ For normal file edits within your scope, proceed without review.
1867
1869
  /**
1868
1870
  * Check if a completed/crashed agent was the last phase 1 agent in a team.
1869
1871
  * If so, auto-spawn the phase 2 (QC/finisher) agents.
1872
+ *
1873
+ * Uses a 300ms debounce so rapid parallel completions settle before the
1874
+ * allDone check, and defers splicing the pending group until spawns succeed.
1870
1875
  */
1871
1876
  _checkPhase2(completedAgentId) {
1872
1877
  const pending = this.daemon._pendingPhase2;
1873
- if (!pending || pending.length === 0) return;
1874
-
1875
- const registry = this.daemon.registry;
1878
+ if (!pending || pending.length === 0) {
1879
+ console.log(`[Groove:Phase2] _checkPhase2(${completedAgentId}): no pending groups, skipping`);
1880
+ return;
1881
+ }
1876
1882
 
1877
1883
  for (let i = pending.length - 1; i >= 0; i--) {
1878
1884
  const group = pending[i];
1879
1885
  if (!group.waitFor.includes(completedAgentId)) continue;
1880
1886
 
1881
- // Check if ALL phase 1 agents in this group are done
1882
- const allDone = group.waitFor.every((id) => {
1887
+ // Debounce: cancel any existing timer for this group and restart.
1888
+ // This lets parallel agent completions (50-100ms apart) settle
1889
+ // before we evaluate the allDone condition.
1890
+ const debounceKey = group.waitFor.join(',');
1891
+ const existingTimer = this._phase2Debounce.get(debounceKey);
1892
+ if (existingTimer) clearTimeout(existingTimer);
1893
+
1894
+ const groupRef = group;
1895
+ const timer = setTimeout(() => {
1896
+ this._phase2Debounce.delete(debounceKey);
1897
+ this._executePhase2Check(completedAgentId, groupRef);
1898
+ }, 300);
1899
+ this._phase2Debounce.set(debounceKey, timer);
1900
+ console.log(`[Groove:Phase2] _checkPhase2(${completedAgentId}): debounce scheduled for group [${debounceKey}]`);
1901
+ }
1902
+ }
1903
+
1904
+ _executePhase2Check(completedAgentId, group) {
1905
+ const pending = this.daemon._pendingPhase2;
1906
+ if (!pending || !pending.includes(group)) {
1907
+ console.log(`[Groove:Phase2] _executePhase2Check: group no longer in pending queue, skipping`);
1908
+ return;
1909
+ }
1910
+
1911
+ if (group._spawning) {
1912
+ console.log(`[Groove:Phase2] _executePhase2Check: group already spawning, skipping`);
1913
+ return;
1914
+ }
1915
+
1916
+ const registry = this.daemon.registry;
1917
+ const allDone = group.waitFor.every((id) => {
1918
+ const a = registry.get(id);
1919
+ return !a || a.status === 'completed' || a.status === 'crashed' || a.status === 'stopped' || a.status === 'killed';
1920
+ });
1921
+
1922
+ if (!allDone) {
1923
+ const still = group.waitFor.filter((id) => {
1883
1924
  const a = registry.get(id);
1884
- return !a || a.status === 'completed' || a.status === 'crashed' || a.status === 'stopped' || a.status === 'killed';
1925
+ return a && a.status !== 'completed' && a.status !== 'crashed' && a.status !== 'stopped' && a.status !== 'killed';
1885
1926
  });
1927
+ console.log(`[Groove:Phase2] _executePhase2Check(${completedAgentId}): not all done, still waiting on [${still.join(', ')}]`);
1928
+ return;
1929
+ }
1930
+
1931
+ console.log(`[Groove:Phase2] All phase 1 agents done for group [${group.waitFor.join(', ')}], spawning phase 2`);
1932
+
1933
+ // Mark group as in-flight (don't splice yet — wait for spawns to succeed)
1934
+ group._spawning = true;
1886
1935
 
1887
- if (allDone) {
1888
- // Remove from pending
1889
- pending.splice(i, 1);
1890
-
1891
- // Check if phase 1 agents did any real work by looking at file modifications.
1892
- // If no agent modified any files, there's nothing to QC.
1893
- const journalist = this.daemon.journalist;
1894
- const phase1Idle = group.waitFor.every((id) => {
1895
- const a = registry.get(id);
1896
- if (!a) return true;
1897
- const files = journalist?.getAgentFiles(a) || [];
1898
- return files.length === 0;
1936
+ const journalist = this.daemon.journalist;
1937
+ const phase1Idle = group.waitFor.every((id) => {
1938
+ const a = registry.get(id);
1939
+ if (!a) return true;
1940
+ const files = journalist?.getAgentFiles(a) || [];
1941
+ return files.length === 0;
1942
+ });
1943
+
1944
+ const groupTeamId = group.agents[0]?.teamId || this.daemon.teams.getDefault()?.id || null;
1945
+ if (!this._phase2Spawning) this._phase2Spawning = new Map();
1946
+ const spawnPromises = [];
1947
+ let reusedCount = 0;
1948
+
1949
+ for (const config of group.agents) {
1950
+ if (phase1Idle) config.prompt = '';
1951
+
1952
+ const teamId = config.teamId || this.daemon.teams.getDefault()?.id || null;
1953
+ const existing = teamId ? registry.getAll().find((a) =>
1954
+ a.teamId === teamId && a.role === config.role && a.id !== undefined
1955
+ ) : null;
1956
+
1957
+ if (existing && (existing.status === 'running' || existing.status === 'starting')) {
1958
+ if (config.prompt) {
1959
+ this.sendMessage(existing.id, config.prompt, 'planner').catch((err) => {
1960
+ console.error(`[Groove:Phase2] Reuse message failed for ${existing.name}: ${err.message}`);
1961
+ });
1962
+ }
1963
+ this.daemon.audit.log('phase2.reuse', { id: existing.id, name: existing.name, role: existing.role });
1964
+ this.daemon.broadcast({
1965
+ type: 'phase2:spawned',
1966
+ agentId: existing.id,
1967
+ name: existing.name,
1968
+ role: existing.role,
1969
+ reused: true,
1899
1970
  });
1971
+ reusedCount++;
1972
+ console.log(`[Groove:Phase2] Reused existing agent ${existing.name} (${existing.id}) for role ${config.role}`);
1973
+ continue;
1974
+ }
1900
1975
 
1901
- // Track that phase 2 spawns are in-flight for this team so
1902
- // _checkPreviewReady doesn't race ahead of the async spawn calls.
1903
- const groupTeamId = group.agents[0]?.teamId || this.daemon.teams.getDefault()?.id || null;
1904
- if (!this._phase2Spawning) this._phase2Spawning = new Map();
1905
- const spawnPromises = [];
1906
-
1907
- // Auto-spawn phase 2 agents — if phase 1 was idle, clear the prompt
1908
- // so QC also waits for instructions instead of auditing nothing
1909
- for (const config of group.agents) {
1910
- if (phase1Idle) config.prompt = '';
1911
-
1912
- // Dedup: check if team already has an agent with this role
1913
- const teamId = config.teamId || this.daemon.teams.getDefault()?.id || null;
1914
- const existing = teamId ? registry.getAll().find((a) =>
1915
- a.teamId === teamId && a.role === config.role && a.id !== undefined
1916
- ) : null;
1917
-
1918
- if (existing && (existing.status === 'running' || existing.status === 'starting')) {
1919
- // Agent already active — reuse it instead of spawning a duplicate
1920
- if (config.prompt) {
1921
- this.sendMessage(existing.id, config.prompt, 'planner').catch((err) => {
1922
- console.error(`[Groove] Phase 2 reuse message failed for ${existing.name}: ${err.message}`);
1923
- });
1924
- }
1925
- this.daemon.audit.log('phase2.reuse', { id: existing.id, name: existing.name, role: existing.role });
1976
+ if (existing && (existing.status === 'completed' || existing.status === 'stopped' || existing.status === 'crashed' || existing.status === 'killed')) {
1977
+ config.name = existing.name;
1978
+ registry.remove(existing.id);
1979
+ this.daemon.locks.release(existing.id);
1980
+ }
1981
+
1982
+ try {
1983
+ const validated = validateAgentConfig(config);
1984
+ if (!validated.teamId) validated.teamId = this.daemon.teams.getDefault()?.id || null;
1985
+ validated.metadata = { ...(validated.metadata || {}), isQcPhase2: true };
1986
+ const existingId = existing?.id || null;
1987
+ const p = this.spawn(validated).then((agent) => {
1988
+ registry.update(agent.id, { metadata: { ...(agent.metadata || {}), isQcPhase2: true } });
1989
+ this.daemon.broadcast({
1990
+ type: 'phase2:spawned',
1991
+ agentId: agent.id,
1992
+ oldAgentId: existingId,
1993
+ name: agent.name,
1994
+ role: agent.role,
1995
+ });
1996
+ if (existingId) {
1926
1997
  this.daemon.broadcast({
1927
- type: 'phase2:spawned',
1928
- agentId: existing.id,
1929
- name: existing.name,
1930
- role: existing.role,
1931
- reused: true,
1998
+ type: 'rotation:complete',
1999
+ agentId: agent.id,
2000
+ oldAgentId: existingId,
2001
+ agentName: agent.name,
2002
+ reason: 'phase2_respawn',
1932
2003
  });
1933
- continue;
1934
2004
  }
2005
+ this.daemon.audit.log('phase2.autoSpawn', { id: agent.id, name: agent.name, role: agent.role });
2006
+ console.log(`[Groove:Phase2] Spawned ${agent.name} (${agent.id}) for role ${validated.role}`);
2007
+ return { status: 'spawned', agentId: agent.id };
2008
+ }).catch((err) => {
2009
+ console.error(`[Groove:Phase2] Spawn failed for ${config.role}: ${err.message}`);
2010
+ this.daemon.broadcast({
2011
+ type: 'phase2:failed',
2012
+ role: config.role,
2013
+ error: err.message,
2014
+ });
2015
+ throw err;
2016
+ });
2017
+ spawnPromises.push(p);
2018
+ } catch (err) {
2019
+ console.error(`[Groove:Phase2] Config invalid for ${config.role}: ${err.message}`);
2020
+ this.daemon.broadcast({
2021
+ type: 'phase2:failed',
2022
+ role: config.role,
2023
+ error: err.message,
2024
+ });
2025
+ }
2026
+ }
1935
2027
 
1936
- if (existing && (existing.status === 'completed' || existing.status === 'stopped' || existing.status === 'crashed' || existing.status === 'killed')) {
1937
- // Previous agent finished remove it and respawn with the same name
1938
- config.name = existing.name;
1939
- registry.remove(existing.id);
1940
- this.daemon.locks.release(existing.id);
1941
- }
2028
+ if (spawnPromises.length === 0 && reusedCount === 0) {
2029
+ console.error(`[Groove:Phase2] No spawns attempted and no reuses for group [${group.waitFor.join(', ')}]`);
2030
+ this.daemon.broadcast({
2031
+ type: 'phase2:failed',
2032
+ role: group.agents.map(a => a.role).join(', '),
2033
+ error: 'No phase 2 agents could be spawned or reused',
2034
+ });
2035
+ // Remove failed group from pending and persist
2036
+ const idx = pending.indexOf(group);
2037
+ if (idx !== -1) pending.splice(idx, 1);
2038
+ this._persistPendingPhase2();
2039
+ return;
2040
+ }
1942
2041
 
1943
- try {
1944
- const validated = validateAgentConfig(config);
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;
1948
- const p = this.spawn(validated).then((agent) => {
1949
- registry.update(agent.id, { metadata: { ...(agent.metadata || {}), isQcPhase2: true } });
1950
- this.daemon.broadcast({
1951
- type: 'phase2:spawned',
1952
- agentId: agent.id,
1953
- oldAgentId: existingId,
1954
- name: agent.name,
1955
- role: agent.role,
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
- }
1966
- this.daemon.audit.log('phase2.autoSpawn', { id: agent.id, name: agent.name, role: agent.role });
1967
- }).catch((err) => {
1968
- console.error(`[Groove] Phase 2 spawn failed for ${config.role}: ${err.message}`);
1969
- this.daemon.broadcast({
1970
- type: 'phase2:failed',
1971
- role: config.role,
1972
- error: err.message,
1973
- });
1974
- });
1975
- spawnPromises.push(p);
1976
- } catch (err) {
1977
- console.error(`[Groove] Phase 2 config invalid for ${config.role}: ${err.message}`);
1978
- this.daemon.broadcast({
1979
- type: 'phase2:failed',
1980
- role: config.role,
1981
- error: err.message,
1982
- });
1983
- }
1984
- }
2042
+ if (spawnPromises.length > 0 && groupTeamId) {
2043
+ this._phase2Spawning.set(groupTeamId, (this._phase2Spawning.get(groupTeamId) || 0) + spawnPromises.length);
2044
+ }
1985
2045
 
1986
- // Mark this team as having phase 2 spawns in-flight. Cleared once
1987
- // all spawn promises settle so _checkPreviewReady won't race ahead.
1988
- if (spawnPromises.length > 0 && groupTeamId) {
1989
- this._phase2Spawning.set(groupTeamId, (this._phase2Spawning.get(groupTeamId) || 0) + spawnPromises.length);
1990
- Promise.allSettled(spawnPromises).then(() => {
1991
- const remaining = (this._phase2Spawning.get(groupTeamId) || 0) - spawnPromises.length;
1992
- if (remaining <= 0) this._phase2Spawning.delete(groupTeamId);
1993
- else this._phase2Spawning.set(groupTeamId, remaining);
2046
+ if (spawnPromises.length > 0) {
2047
+ Promise.allSettled(spawnPromises).then((results) => {
2048
+ const succeeded = results.filter(r => r.status === 'fulfilled').length;
2049
+ const failed = results.filter(r => r.status === 'rejected').length;
2050
+
2051
+ console.log(`[Groove:Phase2] Spawn results: ${succeeded} succeeded, ${failed} failed, ${reusedCount} reused`);
2052
+
2053
+ if (succeeded > 0 || reusedCount > 0) {
2054
+ // At least one spawn succeeded — remove group from pending
2055
+ const idx = pending.indexOf(group);
2056
+ if (idx !== -1) pending.splice(idx, 1);
2057
+ this._persistPendingPhase2();
2058
+ console.log(`[Groove:Phase2] Group removed from pending queue`);
2059
+ } else {
2060
+ // All spawns failed — keep group in pending for retry, broadcast failure
2061
+ group._spawning = false;
2062
+ console.error(`[Groove:Phase2] All spawns failed for group [${group.waitFor.join(', ')}], keeping in queue for retry`);
2063
+ this.daemon.broadcast({
2064
+ type: 'phase2:failed',
2065
+ role: group.agents.map(a => a.role).join(', '),
2066
+ error: 'All phase 2 spawn attempts failed',
2067
+ retryable: true,
1994
2068
  });
1995
2069
  }
2070
+
2071
+ // Clear in-flight counter
2072
+ if (groupTeamId) {
2073
+ const remaining = (this._phase2Spawning.get(groupTeamId) || 0) - spawnPromises.length;
2074
+ if (remaining <= 0) this._phase2Spawning.delete(groupTeamId);
2075
+ else this._phase2Spawning.set(groupTeamId, remaining);
2076
+ }
2077
+ });
2078
+ } else {
2079
+ // All agents were reused, no async spawns — remove group now
2080
+ const idx = pending.indexOf(group);
2081
+ if (idx !== -1) pending.splice(idx, 1);
2082
+ this._persistPendingPhase2();
2083
+ console.log(`[Groove:Phase2] Group removed from pending queue (all reused)`);
2084
+ }
2085
+ }
2086
+
2087
+ _persistPendingPhase2() {
2088
+ try {
2089
+ const pending = this.daemon._pendingPhase2 || [];
2090
+ const saveable = pending.filter(g => !g._spawning);
2091
+ const filePath = resolve(this.daemon.grooveDir, 'pending-phase2.json');
2092
+ if (saveable.length === 0) {
2093
+ try { unlinkSync(filePath); } catch { /* already gone */ }
2094
+ } else {
2095
+ writeFileSync(filePath, JSON.stringify(saveable, null, 2));
2096
+ }
2097
+ } catch (err) {
2098
+ console.error(`[Groove:Phase2] Failed to persist pending-phase2.json: ${err.message}`);
2099
+ }
2100
+ }
2101
+
2102
+ loadPendingPhase2() {
2103
+ try {
2104
+ const filePath = resolve(this.daemon.grooveDir, 'pending-phase2.json');
2105
+ if (!existsSync(filePath)) return;
2106
+ const data = JSON.parse(readFileSync(filePath, 'utf8'));
2107
+ if (Array.isArray(data) && data.length > 0) {
2108
+ this.daemon._pendingPhase2 = this.daemon._pendingPhase2 || [];
2109
+ for (const group of data) {
2110
+ const isDupe = this.daemon._pendingPhase2.some(g =>
2111
+ g.waitFor.join(',') === group.waitFor.join(',')
2112
+ );
2113
+ if (!isDupe) this.daemon._pendingPhase2.push(group);
2114
+ }
2115
+ console.log(`[Groove:Phase2] Loaded ${data.length} pending phase 2 group(s) from disk`);
1996
2116
  }
2117
+ } catch (err) {
2118
+ console.error(`[Groove:Phase2] Failed to load pending-phase2.json: ${err.message}`);
1997
2119
  }
1998
2120
  }
1999
2121
 
@@ -418,6 +418,7 @@ export function registerTeamRoutes(app, daemon) {
418
418
  teamId: defaultTeamId,
419
419
  })),
420
420
  });
421
+ daemon.processes._persistPendingPhase2();
421
422
  }
422
423
  }
423
424
 
@@ -632,6 +633,7 @@ export function registerTeamRoutes(app, daemon) {
632
633
  teamId: defaultTeamId,
633
634
  })),
634
635
  });
636
+ daemon.processes._persistPendingPhase2();
635
637
  }
636
638
 
637
639
  daemon.audit.log('team-builder.launch', {
@@ -574,6 +574,7 @@ export class Scheduler {
574
574
  teamId: team.id,
575
575
  })),
576
576
  });
577
+ this.daemon.processes._persistPendingPhase2();
577
578
  }
578
579
 
579
580
  this.runningAgents.set(schedule.id, {
@@ -453,7 +453,7 @@ export class TunnelManager {
453
453
  try {
454
454
  this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'checking' } });
455
455
 
456
- // Get remote daemon version
456
+ // Get remote daemon version through the already-open tunnel
457
457
  const resp = await fetch(`http://localhost:${localPort}/api/status`, {
458
458
  signal: AbortSignal.timeout(5000),
459
459
  });
@@ -462,15 +462,11 @@ export class TunnelManager {
462
462
  const remoteVer = status.version;
463
463
  if (!remoteVer) return;
464
464
 
465
- // Check latest version on npm (from the remote server)
466
- const target = `${config.user}@${config.host}`;
467
- const keyArgs = config.sshKeyPath ? ['-i', config.sshKeyPath] : [];
468
- const sshBase = [...keyArgs, '-p', String(config.port || 22), '-o', 'ConnectTimeout=10', '-o', 'BatchMode=yes', target];
469
-
465
+ // Check latest version on npm locally (same registry everywhere, no extra SSH)
470
466
  let npmVer;
471
467
  try {
472
- npmVer = execFileSync('ssh', [...sshBase, sshCmd('npm view groove-dev version 2>/dev/null')], {
473
- encoding: 'utf8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'],
468
+ npmVer = execFileSync('npm', ['view', 'groove-dev', 'version'], {
469
+ encoding: 'utf8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'],
474
470
  }).trim();
475
471
  } catch { return; }
476
472