groove-dev 0.27.50 → 0.27.52

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.50",
3
+ "version": "0.27.52",
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.50",
3
+ "version": "0.27.52",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -3866,7 +3866,14 @@ Keep responses concise. Help them think, don't lecture them about the system the
3866
3866
  // so revoked or expired codes lock the feature automatically. Non-blocking.
3867
3867
  daemon.revalidateBetaCode = async function revalidateBetaCode() {
3868
3868
  const cfg = daemon.config?.networkBeta;
3869
- if (!cfg?.unlocked || !cfg?.code) return;
3869
+ if (!cfg?.unlocked) return;
3870
+ if (!cfg?.code) {
3871
+ daemon.config.networkBeta = { ...cfg, unlocked: false, expiresAt: null, features: [] };
3872
+ await persistConfig();
3873
+ daemon.audit.log('beta.revoked', { reason: 'missing code' });
3874
+ daemon.broadcast({ type: 'config:updated' });
3875
+ return;
3876
+ }
3870
3877
  const remote = await validateCodeWithServer(cfg.code);
3871
3878
  // If we couldn't reach the server, keep the current unlocked state —
3872
3879
  // network failures must not lock out beta users.
@@ -3999,7 +4006,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
3999
4006
  }
4000
4007
 
4001
4008
  const cfg = daemon.config.networkBeta || {};
4002
- const signal = cfg.signalUrl || 'signal.groovedev.ai';
4009
+ const signal = stripScheme(cfg.signalUrl);
4003
4010
  if (!isAllowedSignalHost(signal)) {
4004
4011
  return res.status(400).json({ error: 'Invalid signal host' });
4005
4012
  }
@@ -4017,18 +4024,22 @@ Keep responses concise. Help them think, don't lecture them about the system the
4017
4024
  if (!existsSync(deployPath)) {
4018
4025
  return res.status(400).json({ error: `Deploy path not found: ${deployPath}` });
4019
4026
  }
4027
+ if (!isInsideGrooveHome(deployPath) && !deployPath.startsWith(resolve(process.env.HOME || '', 'Desktop'))) {
4028
+ return res.status(400).json({ error: 'Deploy path outside allowed directories' });
4029
+ }
4020
4030
 
4021
4031
  const signalFlag = supportsSignalFlag(cfg.version) ? '--signal' : '--relay';
4022
4032
  const args = [
4023
4033
  '-m', 'src.node.server',
4024
4034
  signalFlag, signal,
4035
+ '--tls',
4025
4036
  '--device', device,
4026
4037
  '--max-context', String(maxContext),
4027
4038
  ];
4028
4039
 
4029
4040
  let proc;
4030
4041
  try {
4031
- proc = spawn(join(deployPath, 'venv', 'bin', 'python3.12'), args, {
4042
+ proc = spawn(join(deployPath, 'venv', 'bin', 'python3'), args, {
4032
4043
  cwd: deployPath,
4033
4044
  env: { ...process.env, PYTHONUNBUFFERED: '1' },
4034
4045
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -4063,7 +4074,33 @@ Keep responses concise. Help them think, don't lecture them about the system the
4063
4074
  stderrBuf = stderrBuf.slice(idx + 1);
4064
4075
  if (!line) continue;
4065
4076
  if (line[0] !== '{') {
4077
+ // Python node emits plain-text logs like "Node identity: abc123",
4078
+ // "shard loaded: layers 0-12", "registered with signal". Parse those
4079
+ // here so the GUI reflects reality even without structured logging.
4080
+ let changed = false;
4081
+ const idMatch = line.match(/Node identity:\s*([A-Za-z0-9_\-:.]+)/i);
4082
+ if (idMatch && idMatch[1] !== daemon.networkNode.nodeId) {
4083
+ daemon.networkNode.nodeId = idMatch[1]; changed = true;
4084
+ }
4085
+ const layerMatch = line.match(/layers?\s*(\d+)\s*[-–to]+\s*(\d+)/i);
4086
+ if (layerMatch) {
4087
+ const start = parseInt(layerMatch[1], 10);
4088
+ const end = parseInt(layerMatch[2], 10);
4089
+ if (Number.isFinite(start) && Number.isFinite(end)) {
4090
+ daemon.networkNode.layers = [start, end]; changed = true;
4091
+ }
4092
+ }
4093
+ const modelMatch = line.match(/model[:\s]+([A-Za-z0-9_\-./]+\/[A-Za-z0-9_\-.]+)/i);
4094
+ if (modelMatch && modelMatch[1] !== daemon.networkNode.model) {
4095
+ daemon.networkNode.model = modelMatch[1]; changed = true;
4096
+ }
4097
+ if (/\bregistered\b/i.test(line) || /\bconnected\b/i.test(line)) {
4098
+ if (daemon.networkNode.status !== 'connected') {
4099
+ daemon.networkNode.status = 'connected'; changed = true;
4100
+ }
4101
+ }
4066
4102
  pushNodeEvent('log', { line });
4103
+ if (changed) broadcastNodeStatus();
4067
4104
  continue;
4068
4105
  }
4069
4106
  let entry;
@@ -4138,10 +4175,18 @@ Keep responses concise. Help them think, don't lecture them about the system the
4138
4175
  });
4139
4176
 
4140
4177
  function isAllowedSignalHost(host) {
4141
- const h = (host || '').replace(/^https?:\/\//i, '').replace(/\/.*$/, '').toLowerCase();
4178
+ const h = (host || '').replace(/^(wss?|https?):\/\//i, '').replace(/\/.*$/, '').toLowerCase();
4142
4179
  return h === 'signal.groovedev.ai' || h.endsWith('.groovedev.ai');
4143
4180
  }
4144
4181
 
4182
+ // The Python node/client code prepends the scheme itself from `--tls`.
4183
+ // Daemon must pass a BARE host to --relay/--signal; otherwise the Python
4184
+ // side ends up with a double-scheme URI like wss://wss://host.
4185
+ function stripScheme(url) {
4186
+ if (!url) return 'signal.groovedev.ai';
4187
+ return url.replace(/^wss?:\/\//i, '').replace(/\/.*$/, '');
4188
+ }
4189
+
4145
4190
  app.get('/api/network/status', networkGate, async (req, res) => {
4146
4191
  const cfg = daemon.config.networkBeta || {};
4147
4192
  const signalHost = cfg.signalUrl || 'signal.groovedev.ai';
@@ -4150,9 +4195,8 @@ Keep responses concise. Help them think, don't lecture them about the system the
4150
4195
  return res.status(400).json({ error: 'Invalid signal host' });
4151
4196
  }
4152
4197
 
4153
- const statusUrl = /^https?:\/\//i.test(signalHost)
4154
- ? `${signalHost.replace(/\/$/, '')}/status`
4155
- : `https://${signalHost}/status`;
4198
+ const bareHost = signalHost.replace(/^(wss?|https?):\/\//i, '').replace(/\/.*$/, '');
4199
+ const statusUrl = `https://${bareHost}/status`;
4156
4200
 
4157
4201
  try {
4158
4202
  const controller = new AbortController();
@@ -4161,7 +4205,70 @@ Keep responses concise. Help them think, don't lecture them about the system the
4161
4205
  clearTimeout(timer);
4162
4206
  if (r.ok) {
4163
4207
  const data = await r.json();
4164
- return res.json(data);
4208
+ // Signal service returns snake_case; GUI expects camelCase.
4209
+ const models = Array.isArray(data.models) ? data.models.map((m) => {
4210
+ if (!m || typeof m !== 'object') return m;
4211
+ const { covered_layers, total_layers, ...rest } = m;
4212
+ return {
4213
+ ...rest,
4214
+ ...(covered_layers !== undefined ? { coveredLayers: covered_layers } : {}),
4215
+ ...(total_layers !== undefined ? { totalLayers: total_layers } : {}),
4216
+ };
4217
+ }) : [];
4218
+ const primaryModel = Array.isArray(data.models) && data.models[0] ? data.models[0] : {};
4219
+
4220
+ // Enrich local node state from signal's authoritative topology.
4221
+ // Signal truncates IDs (e.g. "0xf608fd..."), so match by prefix.
4222
+ if (daemon.networkNode?.active && daemon.networkNode.nodeId) {
4223
+ const selfId = daemon.networkNode.nodeId;
4224
+ const signalNodes = Array.isArray(data.nodes) ? data.nodes : [];
4225
+ const self = signalNodes.find((n) => {
4226
+ const nid = n.node_id || n.nodeId || '';
4227
+ const prefix = nid.replace(/\.{2,}$/, '');
4228
+ return selfId === nid || (prefix.length >= 6 && selfId.startsWith(prefix));
4229
+ });
4230
+ let changed = false;
4231
+ if (self) {
4232
+ if (Array.isArray(self.layers) && self.layers.length === 2) {
4233
+ daemon.networkNode.layers = self.layers;
4234
+ changed = true;
4235
+ }
4236
+ if (self.device) {
4237
+ daemon.networkNode.hardware = {
4238
+ device: self.device,
4239
+ memory: daemon.networkNode.hardware?.memory || null,
4240
+ gpu: daemon.networkNode.hardware?.gpu || null,
4241
+ };
4242
+ changed = true;
4243
+ }
4244
+ }
4245
+ const availModel = Array.isArray(data.models)
4246
+ ? data.models.find((m) => m && m.available !== false)
4247
+ : null;
4248
+ if (availModel && !daemon.networkNode.model) {
4249
+ daemon.networkNode.model = availModel.name || null;
4250
+ changed = true;
4251
+ }
4252
+ if (changed) broadcastNodeStatus();
4253
+ }
4254
+
4255
+ const capStr = (s, max = 200) => (typeof s === 'string' ? s.slice(0, max) : s);
4256
+ const safeNodes = (Array.isArray(data.nodes) ? data.nodes : []).map((n) => ({
4257
+ node_id: capStr(n.node_id || n.nodeId),
4258
+ device: capStr(n.device),
4259
+ layers: Array.isArray(n.layers) ? n.layers.slice(0, 2) : n.layers,
4260
+ status: capStr(n.status, 50),
4261
+ active_sessions: n.active_sessions ?? 0,
4262
+ }));
4263
+
4264
+ return res.json({
4265
+ nodes: safeNodes,
4266
+ models,
4267
+ coverage: data.covered_layers ?? primaryModel.covered_layers ?? data.coverage ?? 0,
4268
+ totalLayers: data.total_layers ?? primaryModel.total_layers ?? data.totalLayers ?? 24,
4269
+ activeSessions: data.active_sessions ?? data.activeSessions ?? 0,
4270
+ totalNodes: data.total_nodes ?? data.totalNodes ?? (Array.isArray(data.nodes) ? data.nodes.length : 0),
4271
+ });
4165
4272
  }
4166
4273
  } catch { /* fall through to local snapshot */ }
4167
4274
 
@@ -4180,13 +4287,14 @@ Keep responses concise. Help them think, don't lecture them about the system the
4180
4287
  coverage,
4181
4288
  totalLayers: 24,
4182
4289
  activeSessions: node.sessions || 0,
4290
+ totalNodes: selfNode.length,
4183
4291
  });
4184
4292
  });
4185
4293
 
4186
4294
  // --- Network package install/uninstall ---
4187
4295
 
4188
4296
  const NETWORK_REPO_URL = 'https://github.com/grooveai-dev/groove-network.git';
4189
- const NETWORK_VERSION = 'v0.1.0';
4297
+ const NETWORK_VERSION = 'v0.2.0';
4190
4298
 
4191
4299
  function networkRoot() {
4192
4300
  return resolve(homedir(), '.groove', 'network');
@@ -4260,18 +4368,27 @@ Keep responses concise. Help them think, don't lecture them about the system the
4260
4368
  };
4261
4369
 
4262
4370
  try {
4263
- // Build clone URL with optional PAT
4264
4371
  const pat = daemon.credentials?.getKey?.('github-pat') || null;
4265
- const cloneUrl = pat
4266
- ? NETWORK_REPO_URL.replace('https://', `https://${pat}@`)
4267
- : NETWORK_REPO_URL;
4268
4372
 
4269
- broadcastInstallProgress('cloning', 'Cloning network package...', 0);
4373
+ let installVersion;
4374
+ try {
4375
+ installVersion = (await getLatestNetworkTag()) || NETWORK_VERSION;
4376
+ } catch {
4377
+ installVersion = NETWORK_VERSION;
4378
+ }
4379
+
4380
+ broadcastInstallProgress('cloning', `Cloning network package ${installVersion}...`, 0);
4270
4381
 
4271
- const cloneArgs = ['clone', '--branch', NETWORK_VERSION, '--depth', '1', cloneUrl, installPath];
4382
+ const cloneArgs = ['clone', '--branch', installVersion, '--depth', '1', NETWORK_REPO_URL, installPath];
4383
+ const cloneEnv = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
4384
+ if (pat) {
4385
+ cloneEnv.GIT_CONFIG_COUNT = '1';
4386
+ cloneEnv.GIT_CONFIG_KEY_0 = 'http.extraHeader';
4387
+ cloneEnv.GIT_CONFIG_VALUE_0 = `Authorization: token ${pat}`;
4388
+ }
4272
4389
  const clone = spawn('git', cloneArgs, {
4273
4390
  stdio: ['ignore', 'pipe', 'pipe'],
4274
- env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
4391
+ env: cloneEnv,
4275
4392
  });
4276
4393
 
4277
4394
  const stripCredentials = (s) => s.replace(/https:\/\/[^@]+@/g, 'https://***@');
@@ -4344,12 +4461,12 @@ Keep responses concise. Help them think, don't lecture them about the system the
4344
4461
  ...(daemon.config.networkBeta || {}),
4345
4462
  installed: true,
4346
4463
  deployPath: installPath,
4347
- version: NETWORK_VERSION,
4464
+ version: installVersion,
4348
4465
  };
4349
4466
  await persistConfig();
4350
4467
  daemon.broadcast({ type: 'config:updated' });
4351
- broadcastInstallProgress('done', 'Network package installed', 100);
4352
- daemon.audit.log('network.install', { path: installPath, version: NETWORK_VERSION });
4468
+ broadcastInstallProgress('done', `Network package ${installVersion} installed`, 100);
4469
+ daemon.audit.log('network.install', { path: installPath, version: installVersion });
4353
4470
  daemon.networkInstall = { running: false };
4354
4471
  } catch (err) {
4355
4472
  fail(err?.message || 'Install failed');
@@ -155,5 +155,5 @@ export function loadConfig(grooveDir) {
155
155
  }
156
156
 
157
157
  export function saveConfig(grooveDir, config) {
158
- writeFileSync(resolve(grooveDir, 'config.json'), JSON.stringify(config, null, 2));
158
+ writeFileSync(resolve(grooveDir, 'config.json'), JSON.stringify(config, null, 2), { mode: 0o600 });
159
159
  }
@@ -740,7 +740,7 @@ For normal file edits within your scope, proceed without review.
740
740
  }
741
741
 
742
742
  const spawnCmd = provider.buildSpawnCommand(spawnConfig);
743
- const { command, args, env, stdin: stdinData } = spawnCmd;
743
+ const { command, args, env, stdin: stdinData, cwd: providerCwd } = spawnCmd;
744
744
 
745
745
  // Log the spawn command (mask anything that looks like an API key)
746
746
  const maskArg = (a) => /^(sk-|AIza|key-|token-)/.test(a) ? '***' : a;
@@ -765,7 +765,7 @@ For normal file edits within your scope, proceed without review.
765
765
 
766
766
  // Spawn the process (use pipe for stdin if provider needs to send prompt via stdin)
767
767
  const proc = cpSpawn(command, args, {
768
- cwd: agent.workingDir || this.daemon.projectDir,
768
+ cwd: providerCwd || agent.workingDir || this.daemon.projectDir,
769
769
  env: { ...process.env, ...env, ...integrationEnv, GROOVE_AGENT_ID: agent.id, GROOVE_AGENT_NAME: agent.name, GROOVE_DAEMON_HOST: this.daemon.host || '127.0.0.1', GROOVE_DAEMON_PORT: String(this.daemon.port || 31415) },
770
770
  stdio: [stdinData ? 'pipe' : 'ignore', 'pipe', 'pipe'],
771
771
  detached: false,
@@ -58,10 +58,23 @@ function signalFlagName() {
58
58
  return supportsSignalFlag(cfg.version) ? '--signal' : '--relay';
59
59
  }
60
60
 
61
+ // The Python client prepends the scheme itself — daemon passes a bare host
62
+ // and adds `--tls` to request wss://. Strip any ws:// or wss:// a user may
63
+ // have left in the stored signalUrl (e.g. from an older daemon default).
64
+ function stripScheme(url) {
65
+ if (!url) return 'signal.groovedev.ai';
66
+ return url.replace(/^wss?:\/\//i, '').replace(/\/.*$/, '');
67
+ }
68
+
69
+ function isAllowedSignalHost(host) {
70
+ const h = (host || '').replace(/^(wss?|https?):\/\//i, '').replace(/\/.*$/, '').toLowerCase();
71
+ return h === 'signal.groovedev.ai' || h.endsWith('.groovedev.ai');
72
+ }
73
+
61
74
  export class GrooveNetworkProvider extends Provider {
62
75
  static name = 'groove-network';
63
76
  static displayName = 'Groove Network';
64
- static command = 'python3.12';
77
+ static command = 'python3';
65
78
  static authType = 'none';
66
79
 
67
80
  static models = [
@@ -79,7 +92,8 @@ export class GrooveNetworkProvider extends Provider {
79
92
 
80
93
  buildSpawnCommand(agent) {
81
94
  const cfg = getConfig() || {};
82
- const signal = cfg.signalUrl || 'signal.groovedev.ai';
95
+ const signal = stripScheme(cfg.signalUrl);
96
+ if (!isAllowedSignalHost(signal)) throw new Error('Invalid signal host');
83
97
  const model = agent.model || GrooveNetworkProvider.models[0].id;
84
98
  const maxTokens = agent.maxTokens || 500;
85
99
  const prompt = agent.prompt || '';
@@ -89,6 +103,7 @@ export class GrooveNetworkProvider extends Provider {
89
103
  const args = [
90
104
  '-m', 'src.consumer.client',
91
105
  signalFlagName(), signal,
106
+ '--tls',
92
107
  '--model', model,
93
108
  '--prompt', prompt,
94
109
  '--max-tokens', String(maxTokens),
@@ -96,7 +111,7 @@ export class GrooveNetworkProvider extends Provider {
96
111
  ];
97
112
 
98
113
  return {
99
- command: join(deployPath, 'venv', 'bin', 'python3.12'),
114
+ command: join(deployPath, 'venv', 'bin', 'python3'),
100
115
  args,
101
116
  env: { PYTHONUNBUFFERED: '1' },
102
117
  cwd: deployPath,
@@ -105,14 +120,16 @@ export class GrooveNetworkProvider extends Provider {
105
120
 
106
121
  buildHeadlessCommand(prompt, model) {
107
122
  const cfg = getConfig() || {};
108
- const signal = cfg.signalUrl || 'signal.groovedev.ai';
123
+ const signal = stripScheme(cfg.signalUrl);
124
+ if (!isAllowedSignalHost(signal)) throw new Error('Invalid signal host');
109
125
  const m = model || GrooveNetworkProvider.models[0].id;
110
126
  const deployPath = expandHome(cfg.deployPath) || resolve(homedir(), 'Desktop/groove-deploy');
111
127
  return {
112
- command: join(deployPath, 'venv', 'bin', 'python3.12'),
128
+ command: join(deployPath, 'venv', 'bin', 'python3'),
113
129
  args: [
114
130
  '-m', 'src.consumer.client',
115
131
  signalFlagName(), signal,
132
+ '--tls',
116
133
  '--model', m,
117
134
  '--prompt', prompt,
118
135
  '--max-tokens', '500',