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.
- package/moe-training/package-lock.json +7 -4
- package/moe-training/package.json +1 -1
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/agent-loop.js +84 -36
- package/node_modules/@groove-dev/daemon/src/conversations.js +11 -2
- package/node_modules/@groove-dev/daemon/src/journalist.js +6 -4
- package/node_modules/@groove-dev/daemon/src/process.js +42 -8
- package/node_modules/@groove-dev/daemon/src/providers/index.js +33 -0
- package/node_modules/@groove-dev/daemon/src/tool-executor.js +22 -11
- package/node_modules/@groove-dev/gui/dist/assets/{index-BJgEJ9lZ.js → index-DoEeiBhY.js} +1732 -1732
- package/node_modules/@groove-dev/gui/dist/assets/index-fhMxiPGp.css +1 -0
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/node_modules/@groove-dev/gui/src/components/chat/chat-header.jsx +83 -2
- package/node_modules/@groove-dev/gui/src/components/chat/chat-input.jsx +4 -2
- package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +17 -6
- package/node_modules/@groove-dev/gui/src/components/chat/chat-view.jsx +12 -1
- package/node_modules/@groove-dev/gui/src/stores/groove.js +28 -1
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/agent-loop.js +84 -36
- package/packages/daemon/src/conversations.js +11 -2
- package/packages/daemon/src/journalist.js +6 -4
- package/packages/daemon/src/process.js +42 -8
- package/packages/daemon/src/providers/index.js +33 -0
- package/packages/daemon/src/tool-executor.js +22 -11
- package/packages/gui/dist/assets/{index-BJgEJ9lZ.js → index-DoEeiBhY.js} +1732 -1732
- package/packages/gui/dist/assets/index-fhMxiPGp.css +1 -0
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/components/chat/chat-header.jsx +83 -2
- package/packages/gui/src/components/chat/chat-input.jsx +4 -2
- package/packages/gui/src/components/chat/chat-messages.jsx +17 -6
- package/packages/gui/src/components/chat/chat-view.jsx +12 -1
- package/packages/gui/src/stores/groove.js +28 -1
- package/node_modules/@groove-dev/gui/dist/assets/index-kbR5tOHu.css +0 -1
- 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": "^
|
|
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": "
|
|
58
|
-
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-
|
|
59
|
-
"integrity": "sha512-
|
|
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": {
|
|
@@ -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: '
|
|
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:
|
|
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:
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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 || '.'}` };
|