groove-dev 0.27.80 → 0.27.84

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 (39) hide show
  1. package/moe-training/package-lock.json +7 -4
  2. package/moe-training/package.json +1 -1
  3. package/node_modules/@groove-dev/cli/package.json +1 -1
  4. package/node_modules/@groove-dev/daemon/package.json +1 -1
  5. package/node_modules/@groove-dev/daemon/src/agent-loop.js +84 -36
  6. package/node_modules/@groove-dev/daemon/src/conversations.js +11 -2
  7. package/node_modules/@groove-dev/daemon/src/journalist.js +6 -4
  8. package/node_modules/@groove-dev/daemon/src/process.js +42 -8
  9. package/node_modules/@groove-dev/daemon/src/providers/index.js +33 -0
  10. package/node_modules/@groove-dev/daemon/src/tool-executor.js +22 -11
  11. package/node_modules/@groove-dev/gui/dist/assets/{index-BJgEJ9lZ.js → index-DoEeiBhY.js} +1732 -1732
  12. package/node_modules/@groove-dev/gui/dist/assets/index-fhMxiPGp.css +1 -0
  13. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  14. package/node_modules/@groove-dev/gui/package.json +1 -1
  15. package/node_modules/@groove-dev/gui/src/components/chat/chat-header.jsx +83 -2
  16. package/node_modules/@groove-dev/gui/src/components/chat/chat-input.jsx +4 -2
  17. package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +17 -6
  18. package/node_modules/@groove-dev/gui/src/components/chat/chat-view.jsx +12 -1
  19. package/node_modules/@groove-dev/gui/src/stores/groove.js +28 -1
  20. package/package.json +1 -1
  21. package/packages/cli/package.json +1 -1
  22. package/packages/daemon/package.json +1 -1
  23. package/packages/daemon/src/agent-loop.js +84 -36
  24. package/packages/daemon/src/conversations.js +11 -2
  25. package/packages/daemon/src/journalist.js +6 -4
  26. package/packages/daemon/src/process.js +42 -8
  27. package/packages/daemon/src/providers/index.js +33 -0
  28. package/packages/daemon/src/tool-executor.js +22 -11
  29. package/packages/gui/dist/assets/{index-BJgEJ9lZ.js → index-DoEeiBhY.js} +1732 -1732
  30. package/packages/gui/dist/assets/index-fhMxiPGp.css +1 -0
  31. package/packages/gui/dist/index.html +2 -2
  32. package/packages/gui/package.json +1 -1
  33. package/packages/gui/src/components/chat/chat-header.jsx +83 -2
  34. package/packages/gui/src/components/chat/chat-input.jsx +4 -2
  35. package/packages/gui/src/components/chat/chat-messages.jsx +17 -6
  36. package/packages/gui/src/components/chat/chat-view.jsx +12 -1
  37. package/packages/gui/src/stores/groove.js +28 -1
  38. package/node_modules/@groove-dev/gui/dist/assets/index-kbR5tOHu.css +0 -1
  39. package/packages/gui/dist/assets/index-kbR5tOHu.css +0 -1
@@ -9,7 +9,7 @@
9
9
  "version": "0.1.0",
10
10
  "license": "FSL-1.1-Apache-2.0",
11
11
  "dependencies": {
12
- "better-sqlite3": "^11.0.0",
12
+ "better-sqlite3": "^12.9.0",
13
13
  "express": "^4.18.0",
14
14
  "uuid": "^9.0.0"
15
15
  }
@@ -54,14 +54,17 @@
54
54
  "license": "MIT"
55
55
  },
56
56
  "node_modules/better-sqlite3": {
57
- "version": "11.10.0",
58
- "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
59
- "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
57
+ "version": "12.9.0",
58
+ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.9.0.tgz",
59
+ "integrity": "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==",
60
60
  "hasInstallScript": true,
61
61
  "license": "MIT",
62
62
  "dependencies": {
63
63
  "bindings": "^1.5.0",
64
64
  "prebuild-install": "^7.1.1"
65
+ },
66
+ "engines": {
67
+ "node": "20.x || 22.x || 23.x || 24.x || 25.x"
65
68
  }
66
69
  },
67
70
  "node_modules/bindings": {
@@ -13,7 +13,7 @@
13
13
  "start:server": "node server/index.js"
14
14
  },
15
15
  "dependencies": {
16
- "better-sqlite3": "^11.0.0",
16
+ "better-sqlite3": "^12.9.0",
17
17
  "uuid": "^9.0.0",
18
18
  "express": "^4.18.0"
19
19
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.80",
3
+ "version": "0.27.84",
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.80",
3
+ "version": "0.27.84",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -36,6 +36,7 @@ export class AgentLoop extends EventEmitter {
36
36
  agent.workingDir || daemon.projectDir,
37
37
  daemon,
38
38
  agent.id,
39
+ daemon.projectDir,
39
40
  );
40
41
 
41
42
  // Session persistence
@@ -57,6 +58,7 @@ export class AgentLoop extends EventEmitter {
57
58
 
58
59
  async start(initialPrompt) {
59
60
  this.running = true;
61
+ this.isInitialPrompt = true;
60
62
  this._writeLog({ type: 'system', event: 'start', model: this.config.model });
61
63
 
62
64
  if (initialPrompt) {
@@ -78,6 +80,19 @@ export class AgentLoop extends EventEmitter {
78
80
  this.emit('error', { message: err.message });
79
81
  }
80
82
 
83
+ if (this.running && this.isInitialPrompt && this._shouldAutoComplete()) {
84
+ this.running = false;
85
+ const duration = Date.now() - this.startedAt;
86
+ this.daemon.tokens.recordResult(this.agent.id, { durationMs: duration, turns: this.turns });
87
+ this.emit('exit', { code: 0, signal: null, status: 'completed' });
88
+ }
89
+
90
+ if (this.running && this.isInitialPrompt && this.turns <= 1 && this.totalTokensIn === 0 && this.totalTokensOut === 0) {
91
+ this.running = false;
92
+ this.emit('exit', { code: 1, signal: null, status: 'crashed' });
93
+ }
94
+
95
+ this.isInitialPrompt = false;
81
96
  this._saveSession();
82
97
  this.idle = true;
83
98
  }
@@ -142,14 +157,14 @@ export class AgentLoop extends EventEmitter {
142
157
  if (content) {
143
158
  this._writeLog({ type: 'assistant', content: content.slice(0, 2000) });
144
159
  }
145
- this.emit('output', { type: 'result', data: content || 'Turn complete', turns: this.turns });
160
+ this.emit('output', { type: 'result', subtype: 'assistant', data: content || 'Turn complete', turns: this.turns });
146
161
  break;
147
162
  }
148
163
 
149
164
  // Has tool calls — broadcast text before executing tools (if model sent text + tools)
150
165
  if (content) {
151
166
  this._writeLog({ type: 'assistant', content: content.slice(0, 2000) });
152
- this.emit('output', { type: 'activity', subtype: 'text', data: content });
167
+ this.emit('output', { type: 'activity', subtype: 'assistant', data: content });
153
168
  }
154
169
 
155
170
  // Execute each tool call
@@ -168,7 +183,7 @@ export class AgentLoop extends EventEmitter {
168
183
 
169
184
  // Log + broadcast tool invocation
170
185
  this._writeLog({ type: 'tool_use', tool: toolName, input: inputSummary });
171
- this.emit('output', { type: 'activity', subtype: 'tool_use', data: `${toolName}: ${inputSummary}` });
186
+ this.emit('output', { type: 'activity', subtype: 'tool_use', data: [{ type: 'tool_use', name: toolName, input: args }] });
172
187
 
173
188
  // Feed classifier for adaptive routing
174
189
  this.daemon.classifier.addEvent(this.agent.id, {
@@ -188,7 +203,7 @@ export class AgentLoop extends EventEmitter {
188
203
  });
189
204
  this.emit('output', {
190
205
  type: 'activity', subtype: 'tool_result',
191
- data: result.success ? `${toolName}: done` : `${toolName}: error ${result.error}`,
206
+ data: [{ type: 'tool_result', name: toolName, success: result.success, output: resultPreview }],
192
207
  });
193
208
 
194
209
  if (!result.success) {
@@ -209,11 +224,15 @@ export class AgentLoop extends EventEmitter {
209
224
  }
210
225
  }
211
226
 
227
+ _shouldAutoComplete() {
228
+ const lastAssistant = [...this.messages].reverse().find(m => m.role === 'assistant');
229
+ if (!lastAssistant) return false;
230
+ return lastAssistant.content && (!lastAssistant.tool_calls || lastAssistant.tool_calls.length === 0) && this.turns >= 1;
231
+ }
232
+
212
233
  // --- API Communication ---
213
234
 
214
235
  async _callApi() {
215
- this.abortController = new AbortController();
216
-
217
236
  const body = {
218
237
  model: this.config.model,
219
238
  messages: this.messages,
@@ -223,46 +242,75 @@ export class AgentLoop extends EventEmitter {
223
242
  max_tokens: this.config.maxResponseTokens || 4096,
224
243
  };
225
244
 
226
- // Streaming for real-time output
227
245
  if (this.config.stream !== false) {
228
246
  body.stream = true;
229
247
  body.stream_options = { include_usage: true };
230
248
  }
231
249
 
232
250
  const url = `${this.config.apiBase}/chat/completions`;
251
+ const maxRetries = 3;
252
+ let lastError = null;
253
+
254
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
255
+ this.abortController = new AbortController();
256
+
257
+ let response;
258
+ try {
259
+ response = await fetch(url, {
260
+ method: 'POST',
261
+ headers: {
262
+ 'Content-Type': 'application/json',
263
+ ...(this.config.apiKey ? { Authorization: `Bearer ${this.config.apiKey}` } : {}),
264
+ ...this.config.headers,
265
+ },
266
+ body: JSON.stringify(body),
267
+ signal: this.abortController.signal,
268
+ });
269
+ } catch (err) {
270
+ if (err.name === 'AbortError') return null;
271
+ lastError = `Inference API unreachable: ${err.message}`;
272
+ if (attempt < 2) {
273
+ this._writeLog({ type: 'retry', attempt: attempt + 1, reason: lastError, delayMs: 2000 });
274
+ await new Promise(r => setTimeout(r, 2000));
275
+ continue;
276
+ }
277
+ this._writeLog({ type: 'error', text: `API request failed: ${err.message}` });
278
+ this.emit('error', { message: lastError });
279
+ return null;
280
+ }
233
281
 
234
- let response;
235
- try {
236
- response = await fetch(url, {
237
- method: 'POST',
238
- headers: {
239
- 'Content-Type': 'application/json',
240
- ...(this.config.apiKey ? { Authorization: `Bearer ${this.config.apiKey}` } : {}),
241
- ...this.config.headers,
242
- },
243
- body: JSON.stringify(body),
244
- signal: this.abortController.signal,
245
- });
246
- } catch (err) {
247
- if (err.name === 'AbortError') return null;
248
- this._writeLog({ type: 'error', text: `API request failed: ${err.message}` });
249
- this.emit('error', { message: `Inference API unreachable: ${err.message}` });
250
- return null;
251
- }
282
+ if (!response.ok) {
283
+ const text = await response.text().catch(() => '');
284
+ const errMsg = `API error ${response.status}: ${text.slice(0, 500)}`;
252
285
 
253
- if (!response.ok) {
254
- const text = await response.text().catch(() => '');
255
- const errMsg = `API error ${response.status}: ${text.slice(0, 500)}`;
256
- this._writeLog({ type: 'error', text: errMsg });
257
- this.emit('error', { message: errMsg });
258
- return null;
259
- }
286
+ if (response.status === 401 || response.status === 403) {
287
+ this._writeLog({ type: 'error', text: errMsg });
288
+ this.emit('error', { message: errMsg });
289
+ return null;
290
+ }
260
291
 
261
- // Parse streaming or non-streaming response
262
- if (body.stream) {
263
- return this._parseSSE(response);
292
+ if ((response.status === 429 || response.status === 503) && attempt < maxRetries) {
293
+ const delay = Math.pow(2, attempt + 1) * 1000;
294
+ this._writeLog({ type: 'retry', attempt: attempt + 1, reason: errMsg, delayMs: delay });
295
+ await new Promise(r => setTimeout(r, delay));
296
+ lastError = errMsg;
297
+ continue;
298
+ }
299
+
300
+ this._writeLog({ type: 'error', text: errMsg });
301
+ this.emit('error', { message: errMsg });
302
+ return null;
303
+ }
304
+
305
+ if (body.stream) {
306
+ return this._parseSSE(response);
307
+ }
308
+ return this._parseJSON(response);
264
309
  }
265
- return this._parseJSON(response);
310
+
311
+ this._writeLog({ type: 'error', text: `API failed after retries: ${lastError}` });
312
+ this.emit('error', { message: lastError });
313
+ return null;
266
314
  }
267
315
 
268
316
  async _parseSSE(response) {
@@ -5,7 +5,7 @@ import { readFileSync, writeFileSync, existsSync } from 'fs';
5
5
  import { resolve } from 'path';
6
6
  import { randomUUID } from 'crypto';
7
7
  import { spawn as cpSpawn } from 'child_process';
8
- import { getProvider, getInstalledProviders, isProviderInstalled } from './providers/index.js';
8
+ import { getProvider, getInstalledProviders, isProviderInstalled, resolveProviderCommand } from './providers/index.js';
9
9
 
10
10
  export class ConversationManager {
11
11
  constructor(daemon) {
@@ -371,7 +371,8 @@ export class ConversationManager {
371
371
  });
372
372
  return;
373
373
  }
374
- const { command, args, env, stdin: stdinData, cwd } = headlessCmd;
374
+ const { command: rawCommand, args, env, stdin: stdinData, cwd } = headlessCmd;
375
+ const command = resolveProviderCommand(providerName) || rawCommand;
375
376
 
376
377
  const spawnOpts = {
377
378
  env: { ...process.env, ...env },
@@ -382,6 +383,14 @@ export class ConversationManager {
382
383
  const proc = cpSpawn(command, args, spawnOpts);
383
384
  this._getStreamingProcesses().set(id, proc);
384
385
 
386
+ proc.on('error', (err) => {
387
+ this._getStreamingProcesses().delete(id);
388
+ this.daemon.broadcast({
389
+ type: 'conversation:error',
390
+ data: { conversationId: id, error: err.message },
391
+ });
392
+ });
393
+
385
394
  if (stdinData) {
386
395
  proc.stdin.write(stdinData);
387
396
  proc.stdin.end();
@@ -4,7 +4,7 @@
4
4
  import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from 'fs';
5
5
  import { resolve } from 'path';
6
6
  import { execFile, spawn as cpSpawn } from 'child_process';
7
- import { getProvider, getInstalledProviders } from './providers/index.js';
7
+ import { getProvider, getInstalledProviders, resolveProviderCommand } from './providers/index.js';
8
8
 
9
9
  const DEFAULT_INTERVAL = 300_000; // 5 minutes (safety-net fallback; event-driven triggers handle the normal case)
10
10
  const MAX_LOG_CHARS = 100_000; // ~25k tokens budget for synthesis input (captures 80-90% of recent activity)
@@ -558,7 +558,7 @@ export class Journalist {
558
558
  const headlessCmd = provider.buildHeadlessCommand(prompt, modelId);
559
559
  if (headlessCmd) {
560
560
  try {
561
- return await this._execHeadlessCmd(headlessCmd, trackAs, modelId);
561
+ return await this._execHeadlessCmd(headlessCmd, trackAs, modelId, providerId);
562
562
  } catch {
563
563
  continue;
564
564
  }
@@ -606,8 +606,9 @@ export class Journalist {
606
606
  throw new Error('No provider available for synthesis');
607
607
  }
608
608
 
609
- _execHeadlessCmd(headlessCmd, trackAs, modelId) {
610
- const { command, args, env, stdin: stdinData } = headlessCmd;
609
+ _execHeadlessCmd(headlessCmd, trackAs, modelId, providerId) {
610
+ const { command: rawCommand, args, env, stdin: stdinData } = headlessCmd;
611
+ const command = (providerId && resolveProviderCommand(providerId)) || rawCommand;
611
612
 
612
613
  return new Promise((resolve, reject) => {
613
614
  if (stdinData) {
@@ -617,6 +618,7 @@ export class Journalist {
617
618
  cwd: this.daemon.projectDir,
618
619
  stdio: ['pipe', 'pipe', 'pipe'],
619
620
  });
621
+ proc.on('error', (err) => reject(err));
620
622
  proc.stdin.write(stdinData);
621
623
  proc.stdin.end();
622
624
  proc.stdout.on('data', (d) => { stdout += d.toString(); });
@@ -5,7 +5,7 @@ import { spawn as cpSpawn } from 'child_process';
5
5
  import { createWriteStream, mkdirSync, chmodSync, existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync, copyFileSync } from 'fs';
6
6
  import { resolve, dirname, isAbsolute } from 'path';
7
7
  import { fileURLToPath } from 'url';
8
- import { getProvider, getInstalledProviders } from './providers/index.js';
8
+ import { getProvider, getInstalledProviders, resolveProviderCommand } from './providers/index.js';
9
9
  import { AgentLoop } from './agent-loop.js';
10
10
  import { validateAgentConfig } from './validate.js';
11
11
 
@@ -454,6 +454,14 @@ export class ProcessManager {
454
454
  );
455
455
  }
456
456
 
457
+ // Validate explicit model against provider's supported models
458
+ if (config.model && config.model !== 'auto' && provider.constructor.models) {
459
+ const valid = provider.constructor.models.some(m => m.id === config.model);
460
+ if (!valid) {
461
+ throw new Error(`Model '${config.model}' is not available for ${provider.constructor.displayName}. Available: ${provider.constructor.models.map(m => m.id).join(', ')}`);
462
+ }
463
+ }
464
+
457
465
  // Resolve auto model routing before registering
458
466
  // Treat missing/null/empty model as 'auto' — GUI sends empty string for "Auto" option
459
467
  let resolvedModel = config.model;
@@ -753,12 +761,15 @@ For normal file edits within your scope, proceed without review.
753
761
  }
754
762
  });
755
763
 
756
- // Wire errors — broadcast to GUI for display
764
+ // Wire errors — broadcast to GUI for display, crash on fatal API errors
757
765
  loop.on('error', ({ message }) => {
758
766
  this.daemon.broadcast({
759
767
  type: 'agent:output', agentId: agent.id,
760
768
  data: { type: 'activity', subtype: 'error', data: message },
761
769
  });
770
+ if ((message.includes('API error 4') && !message.includes('API error 429')) || message.includes('Inference API unreachable')) {
771
+ loop.stop();
772
+ }
762
773
  });
763
774
 
764
775
  // Start the agent loop with the fully assembled prompt
@@ -780,7 +791,8 @@ For normal file edits within your scope, proceed without review.
780
791
  }
781
792
 
782
793
  const spawnCmd = provider.buildSpawnCommand(spawnConfig);
783
- const { command, args, env, stdin: stdinData, cwd: providerCwd } = spawnCmd;
794
+ const { command: rawCommand, args, env, stdin: stdinData, cwd: providerCwd } = spawnCmd;
795
+ const command = resolveProviderCommand(agent.provider || config.provider) || rawCommand;
784
796
 
785
797
  // Log the spawn command (mask anything that looks like an API key)
786
798
  const maskArg = (a) => /^(sk-|AIza|key-|token-)/.test(a) ? '***' : a;
@@ -804,13 +816,22 @@ For normal file edits within your scope, proceed without review.
804
816
  }
805
817
 
806
818
  // Spawn the process (use pipe for stdin if provider needs to send prompt via stdin)
819
+ const spawnCwd = [providerCwd, agent.workingDir, this.daemon.projectDir].find(d => d && existsSync(d)) || this.daemon.projectDir;
807
820
  const proc = cpSpawn(command, args, {
808
- cwd: providerCwd || agent.workingDir || this.daemon.projectDir,
821
+ cwd: spawnCwd,
809
822
  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) },
810
823
  stdio: [stdinData ? 'pipe' : 'ignore', 'pipe', 'pipe'],
811
824
  detached: false,
812
825
  });
813
826
 
827
+ proc.on('error', (err) => {
828
+ if (!logStream.destroyed) logStream.write(`[${new Date().toISOString()}] Spawn error: ${err.message}\n`);
829
+ if (!logStream.destroyed) logStream.end();
830
+ this.handles.delete(agent.id);
831
+ registry.update(agent.id, { status: 'crashed', pid: null });
832
+ this.daemon.broadcast({ type: 'agent:exit', agentId: agent.id, code: null, signal: null, status: 'crashed', error: err.message });
833
+ });
834
+
814
835
  // Write prompt via stdin if provider requested it (e.g., Ollama avoids arg length limits)
815
836
  if (stdinData && proc.stdin) {
816
837
  proc.stdin.write(stdinData);
@@ -820,7 +841,7 @@ For normal file edits within your scope, proceed without review.
820
841
  if (!proc.pid) {
821
842
  registry.remove(agent.id);
822
843
  locks.release(agent.id);
823
- logStream.end();
844
+ if (!logStream.destroyed) logStream.end();
824
845
  throw new Error(`Failed to spawn ${command} — process has no PID`);
825
846
  }
826
847
 
@@ -1568,7 +1589,8 @@ For normal file edits within your scope, proceed without review.
1568
1589
  locks.release(agentId);
1569
1590
 
1570
1591
  // Build resume command
1571
- const { command, args, env } = provider.buildResumeCommand(sessionId, message, config.model);
1592
+ const { command: rawCommand, args, env } = provider.buildResumeCommand(sessionId, message, config.model);
1593
+ const command = resolveProviderCommand(config.provider || 'claude-code') || rawCommand;
1572
1594
 
1573
1595
  // Set up log capture
1574
1596
  const logDir = resolve(this.daemon.grooveDir, 'logs');
@@ -1603,17 +1625,26 @@ For normal file edits within your scope, proceed without review.
1603
1625
  }
1604
1626
 
1605
1627
  // Spawn the resumed process
1628
+ const resumeCwd = [config.workingDir, this.daemon.projectDir].find(d => d && existsSync(d)) || this.daemon.projectDir;
1606
1629
  const proc = cpSpawn(command, args, {
1607
- cwd: config.workingDir || this.daemon.projectDir,
1630
+ cwd: resumeCwd,
1608
1631
  env: { ...process.env, ...env, GROOVE_AGENT_ID: newAgent.id, GROOVE_AGENT_NAME: newAgent.name, GROOVE_DAEMON_HOST: this.daemon.host || '127.0.0.1', GROOVE_DAEMON_PORT: String(this.daemon.port || 31415) },
1609
1632
  stdio: ['ignore', 'pipe', 'pipe'],
1610
1633
  detached: false,
1611
1634
  });
1612
1635
 
1636
+ proc.on('error', (err) => {
1637
+ if (!logStream.destroyed) logStream.write(`[${new Date().toISOString()}] Resume spawn error: ${err.message}\n`);
1638
+ if (!logStream.destroyed) logStream.end();
1639
+ this.handles.delete(newAgent.id);
1640
+ registry.update(newAgent.id, { status: 'crashed', pid: null });
1641
+ this.daemon.broadcast({ type: 'agent:exit', agentId: newAgent.id, code: null, signal: null, status: 'crashed', error: err.message });
1642
+ });
1643
+
1613
1644
  if (!proc.pid) {
1614
1645
  registry.remove(newAgent.id);
1615
1646
  locks.release(newAgent.id);
1616
- logStream.end();
1647
+ if (!logStream.destroyed) logStream.end();
1617
1648
  throw new Error(`Failed to resume — process has no PID`);
1618
1649
  }
1619
1650
 
@@ -1813,6 +1844,9 @@ For normal file edits within your scope, proceed without review.
1813
1844
  type: 'agent:output', agentId: newAgent.id,
1814
1845
  data: { type: 'activity', subtype: 'error', data: errMsg },
1815
1846
  });
1847
+ if ((errMsg.includes('API error 4') && !errMsg.includes('API error 429')) || errMsg.includes('Inference API unreachable')) {
1848
+ loop.stop();
1849
+ }
1816
1850
  });
1817
1851
 
1818
1852
  loop.start();
@@ -41,6 +41,22 @@ export function getProviderPath(id) {
41
41
  (function augmentPath() {
42
42
  const isWin = process.platform === 'win32';
43
43
  const extra = isWin ? [] : ['/usr/local/bin', '/opt/homebrew/bin'];
44
+
45
+ // Electron forked processes may not inherit the user's full shell PATH.
46
+ // Try to resolve it from a login shell (non-interactive to avoid compinit noise).
47
+ if (!isWin) {
48
+ try {
49
+ const shell = process.env.SHELL || '/bin/zsh';
50
+ const shellPath = execSync(`${shell} -lc 'echo $PATH'`, { encoding: 'utf8', timeout: 5000 }).trim();
51
+ if (shellPath && shellPath.includes('/')) {
52
+ const last = shellPath.split('\n').pop();
53
+ for (const dir of last.split(pathDelimiter)) {
54
+ if (dir && !extra.includes(dir)) extra.push(dir);
55
+ }
56
+ }
57
+ } catch { /* login shell unavailable — fall through to static paths */ }
58
+ }
59
+
44
60
  try {
45
61
  const suppressErr = isWin ? '2>NUL' : '2>/dev/null';
46
62
  const npmPrefix = execSync(`npm config get prefix ${suppressErr}`, { encoding: 'utf8', timeout: 5000 }).trim();
@@ -77,6 +93,23 @@ const providers = {
77
93
  };
78
94
 
79
95
  const installCache = new Map();
96
+ const _resolvedCommands = new Map();
97
+
98
+ export function resolveProviderCommand(providerId) {
99
+ if (_resolvedCommands.has(providerId)) return _resolvedCommands.get(providerId);
100
+ const custom = getProviderPath(providerId);
101
+ if (custom) { _resolvedCommands.set(providerId, custom); return custom; }
102
+ const p = providers[providerId];
103
+ if (!p) return null;
104
+ const command = p.constructor.command;
105
+ if (!command) return null;
106
+ try {
107
+ const cmd = process.platform === 'win32' ? `where ${command}` : `which ${command}`;
108
+ const resolved = execSync(cmd, { encoding: 'utf8', timeout: 5000 }).trim().split('\n')[0];
109
+ if (resolved) { _resolvedCommands.set(providerId, resolved); return resolved; }
110
+ } catch { /* not found — fall through */ }
111
+ return command;
112
+ }
80
113
 
81
114
  export function isProviderInstalled(providerId) {
82
115
  if (installCache.has(providerId)) return installCache.get(providerId);
@@ -2,7 +2,7 @@
2
2
  // FSL-1.1-Apache-2.0 — see LICENSE
3
3
 
4
4
  import { readFileSync, writeFileSync, readdirSync, statSync, mkdirSync, existsSync } from 'fs';
5
- import { execSync, execFileSync } from 'child_process';
5
+ import { execSync } from 'child_process';
6
6
  import { resolve, relative, dirname, sep } from 'path';
7
7
  import { minimatch } from 'minimatch';
8
8
 
@@ -120,10 +120,11 @@ export const TOOL_DEFINITIONS = [
120
120
  ];
121
121
 
122
122
  export class ToolExecutor {
123
- constructor(workingDir, daemon, agentId) {
123
+ constructor(workingDir, daemon, agentId, projectDir) {
124
124
  this.workingDir = resolve(workingDir);
125
125
  this.daemon = daemon;
126
126
  this.agentId = agentId;
127
+ this.projectDir = resolve(projectDir || workingDir);
127
128
  }
128
129
 
129
130
  async execute(name, args) {
@@ -155,6 +156,15 @@ export class ToolExecutor {
155
156
  return resolved;
156
157
  }
157
158
 
159
+ _resolveReadPath(filePath) {
160
+ if (!filePath) throw new Error('Path is required');
161
+ const resolved = resolve(this.workingDir, filePath);
162
+ if (!resolved.startsWith(this.projectDir + sep) && resolved !== this.projectDir) {
163
+ throw new Error(`Access denied: path outside project directory`);
164
+ }
165
+ return resolved;
166
+ }
167
+
158
168
  _checkWriteScope(resolvedPath) {
159
169
  if (!this.daemon?.locks) return;
160
170
  const rel = relative(this.workingDir, resolvedPath);
@@ -171,7 +181,7 @@ export class ToolExecutor {
171
181
  // --- Tool Implementations ---
172
182
 
173
183
  readFile({ path: filePath, offset, limit }) {
174
- const resolved = this._resolvePath(filePath);
184
+ const resolved = this._resolveReadPath(filePath);
175
185
  if (!existsSync(resolved)) {
176
186
  return { success: false, error: `File not found: ${filePath}` };
177
187
  }
@@ -241,16 +251,17 @@ export class ToolExecutor {
241
251
  const execCwd = cwd ? this._resolvePath(cwd) : this.workingDir;
242
252
  const timeoutMs = Math.min(timeout || 30000, 120000);
243
253
 
254
+ if (/\brm\s+(-\w+\s+)*-rf\s+(\/|~)/.test(command) || /\bsudo\b/.test(command)) {
255
+ return { success: false, error: 'Command blocked: dangerous shell pattern detected' };
256
+ }
257
+
244
258
  try {
245
- // Split command safely for shell: false — avoids shell injection
246
- const parts = command.split(/\s+/);
247
- const cmd = parts[0];
248
- const cmdArgs = parts.slice(1);
249
- const output = execFileSync(cmd, cmdArgs, {
259
+ const output = execSync(command, {
250
260
  cwd: execCwd,
251
261
  timeout: timeoutMs,
252
262
  maxBuffer: 1024 * 1024,
253
263
  encoding: 'utf8',
264
+ shell: true,
254
265
  env: { ...process.env, GROOVE_AGENT_ID: this.agentId },
255
266
  });
256
267
  // Cap output to prevent context window blowup
@@ -265,7 +276,7 @@ export class ToolExecutor {
265
276
  }
266
277
 
267
278
  searchFiles({ pattern, cwd }) {
268
- const searchDir = cwd ? this._resolvePath(cwd) : this.workingDir;
279
+ const searchDir = cwd ? this._resolveReadPath(cwd) : this.workingDir;
269
280
  const results = [];
270
281
 
271
282
  const walk = (dir, depth) => {
@@ -298,7 +309,7 @@ export class ToolExecutor {
298
309
  if (!/^[a-zA-Z0-9_.\-\/\\*?[\]{}()|^$+\s]+$/.test(pattern)) {
299
310
  throw new Error('Invalid search pattern');
300
311
  }
301
- const searchDir = searchPath ? this._resolvePath(searchPath) : this.workingDir;
312
+ const searchDir = searchPath ? this._resolveReadPath(searchPath) : this.workingDir;
302
313
 
303
314
  // Prefer ripgrep (faster, respects .gitignore), fall back to grep
304
315
  const escapedPattern = pattern.replace(/'/g, "'\\''");
@@ -342,7 +353,7 @@ export class ToolExecutor {
342
353
  }
343
354
 
344
355
  listDirectory({ path: dirPath } = {}) {
345
- const resolved = dirPath ? this._resolvePath(dirPath) : this.workingDir;
356
+ const resolved = dirPath ? this._resolveReadPath(dirPath) : this.workingDir;
346
357
 
347
358
  if (!existsSync(resolved)) {
348
359
  return { success: false, error: `Directory not found: ${dirPath || '.'}` };