groove-dev 0.27.80 → 0.27.81
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/CLAUDE.md +11 -0
- 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/conversations.js +11 -2
- package/node_modules/@groove-dev/daemon/src/journalist.js +6 -4
- package/node_modules/@groove-dev/daemon/src/process.js +27 -7
- package/node_modules/@groove-dev/daemon/src/providers/index.js +33 -0
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/conversations.js +11 -2
- package/packages/daemon/src/journalist.js +6 -4
- package/packages/daemon/src/process.js +27 -7
- package/packages/daemon/src/providers/index.js +33 -0
- package/packages/gui/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -263,3 +263,14 @@ Audit-driven release. Multi-agent orchestration system with 7 coordination layer
|
|
|
263
263
|
- Dashboard: routing donut, cache panel, context health gauges
|
|
264
264
|
- Monitor/QC agent mode (stay active, loop)
|
|
265
265
|
- Distribution: demo video, HN launch, Twitter content
|
|
266
|
+
|
|
267
|
+
<!-- GROOVE:START -->
|
|
268
|
+
## GROOVE Orchestration (auto-injected)
|
|
269
|
+
Active agents: 2
|
|
270
|
+
| Name | Role | Scope |
|
|
271
|
+
|------|------|-------|
|
|
272
|
+
| planner-4 | planner | - |
|
|
273
|
+
| planner-7 | planner | - |
|
|
274
|
+
See AGENTS_REGISTRY.md for full agent state.
|
|
275
|
+
**Memory policy:** GROOVE manages project memory automatically. Do not read or write MEMORY.md or .groove/memory/ files directly.
|
|
276
|
+
<!-- GROOVE:END -->
|
|
@@ -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
|
|
|
@@ -780,7 +780,8 @@ For normal file edits within your scope, proceed without review.
|
|
|
780
780
|
}
|
|
781
781
|
|
|
782
782
|
const spawnCmd = provider.buildSpawnCommand(spawnConfig);
|
|
783
|
-
const { command, args, env, stdin: stdinData, cwd: providerCwd } = spawnCmd;
|
|
783
|
+
const { command: rawCommand, args, env, stdin: stdinData, cwd: providerCwd } = spawnCmd;
|
|
784
|
+
const command = resolveProviderCommand(agent.provider || config.provider) || rawCommand;
|
|
784
785
|
|
|
785
786
|
// Log the spawn command (mask anything that looks like an API key)
|
|
786
787
|
const maskArg = (a) => /^(sk-|AIza|key-|token-)/.test(a) ? '***' : a;
|
|
@@ -804,13 +805,22 @@ For normal file edits within your scope, proceed without review.
|
|
|
804
805
|
}
|
|
805
806
|
|
|
806
807
|
// Spawn the process (use pipe for stdin if provider needs to send prompt via stdin)
|
|
808
|
+
const spawnCwd = [providerCwd, agent.workingDir, this.daemon.projectDir].find(d => d && existsSync(d)) || this.daemon.projectDir;
|
|
807
809
|
const proc = cpSpawn(command, args, {
|
|
808
|
-
cwd:
|
|
810
|
+
cwd: spawnCwd,
|
|
809
811
|
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
812
|
stdio: [stdinData ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
|
811
813
|
detached: false,
|
|
812
814
|
});
|
|
813
815
|
|
|
816
|
+
proc.on('error', (err) => {
|
|
817
|
+
if (!logStream.destroyed) logStream.write(`[${new Date().toISOString()}] Spawn error: ${err.message}\n`);
|
|
818
|
+
if (!logStream.destroyed) logStream.end();
|
|
819
|
+
this.handles.delete(agent.id);
|
|
820
|
+
registry.update(agent.id, { status: 'crashed', pid: null });
|
|
821
|
+
this.daemon.broadcast({ type: 'agent:exit', agentId: agent.id, code: null, signal: null, status: 'crashed', error: err.message });
|
|
822
|
+
});
|
|
823
|
+
|
|
814
824
|
// Write prompt via stdin if provider requested it (e.g., Ollama avoids arg length limits)
|
|
815
825
|
if (stdinData && proc.stdin) {
|
|
816
826
|
proc.stdin.write(stdinData);
|
|
@@ -820,7 +830,7 @@ For normal file edits within your scope, proceed without review.
|
|
|
820
830
|
if (!proc.pid) {
|
|
821
831
|
registry.remove(agent.id);
|
|
822
832
|
locks.release(agent.id);
|
|
823
|
-
logStream.end();
|
|
833
|
+
if (!logStream.destroyed) logStream.end();
|
|
824
834
|
throw new Error(`Failed to spawn ${command} — process has no PID`);
|
|
825
835
|
}
|
|
826
836
|
|
|
@@ -1568,7 +1578,8 @@ For normal file edits within your scope, proceed without review.
|
|
|
1568
1578
|
locks.release(agentId);
|
|
1569
1579
|
|
|
1570
1580
|
// Build resume command
|
|
1571
|
-
const { command, args, env } = provider.buildResumeCommand(sessionId, message, config.model);
|
|
1581
|
+
const { command: rawCommand, args, env } = provider.buildResumeCommand(sessionId, message, config.model);
|
|
1582
|
+
const command = resolveProviderCommand(config.provider || 'claude-code') || rawCommand;
|
|
1572
1583
|
|
|
1573
1584
|
// Set up log capture
|
|
1574
1585
|
const logDir = resolve(this.daemon.grooveDir, 'logs');
|
|
@@ -1603,17 +1614,26 @@ For normal file edits within your scope, proceed without review.
|
|
|
1603
1614
|
}
|
|
1604
1615
|
|
|
1605
1616
|
// Spawn the resumed process
|
|
1617
|
+
const resumeCwd = [config.workingDir, this.daemon.projectDir].find(d => d && existsSync(d)) || this.daemon.projectDir;
|
|
1606
1618
|
const proc = cpSpawn(command, args, {
|
|
1607
|
-
cwd:
|
|
1619
|
+
cwd: resumeCwd,
|
|
1608
1620
|
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
1621
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1610
1622
|
detached: false,
|
|
1611
1623
|
});
|
|
1612
1624
|
|
|
1625
|
+
proc.on('error', (err) => {
|
|
1626
|
+
if (!logStream.destroyed) logStream.write(`[${new Date().toISOString()}] Resume spawn error: ${err.message}\n`);
|
|
1627
|
+
if (!logStream.destroyed) logStream.end();
|
|
1628
|
+
this.handles.delete(newAgent.id);
|
|
1629
|
+
registry.update(newAgent.id, { status: 'crashed', pid: null });
|
|
1630
|
+
this.daemon.broadcast({ type: 'agent:exit', agentId: newAgent.id, code: null, signal: null, status: 'crashed', error: err.message });
|
|
1631
|
+
});
|
|
1632
|
+
|
|
1613
1633
|
if (!proc.pid) {
|
|
1614
1634
|
registry.remove(newAgent.id);
|
|
1615
1635
|
locks.release(newAgent.id);
|
|
1616
|
-
logStream.end();
|
|
1636
|
+
if (!logStream.destroyed) logStream.end();
|
|
1617
1637
|
throw new Error(`Failed to resume — process has no PID`);
|
|
1618
1638
|
}
|
|
1619
1639
|
|
|
@@ -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);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "groove-dev",
|
|
3
|
-
"version": "0.27.
|
|
3
|
+
"version": "0.27.81",
|
|
4
4
|
"description": "Open-source agent orchestration layer — the AI company OS. Local model agent engine (GGUF/Ollama/llama-server), HuggingFace model browser, MCP integrations (Slack, Gmail, Stripe, 15+), agent scheduling (cron), business roles (CMO, CFO, EA). GUI dashboard, multi-agent coordination, zero cold-start, infinite sessions. Works with Claude Code, Codex, Gemini CLI, Ollama, any local model.",
|
|
5
5
|
"license": "FSL-1.1-Apache-2.0",
|
|
6
6
|
"author": "Groove Dev <hello@groovedev.ai> (https://groovedev.ai)",
|
|
@@ -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
|
|
|
@@ -780,7 +780,8 @@ For normal file edits within your scope, proceed without review.
|
|
|
780
780
|
}
|
|
781
781
|
|
|
782
782
|
const spawnCmd = provider.buildSpawnCommand(spawnConfig);
|
|
783
|
-
const { command, args, env, stdin: stdinData, cwd: providerCwd } = spawnCmd;
|
|
783
|
+
const { command: rawCommand, args, env, stdin: stdinData, cwd: providerCwd } = spawnCmd;
|
|
784
|
+
const command = resolveProviderCommand(agent.provider || config.provider) || rawCommand;
|
|
784
785
|
|
|
785
786
|
// Log the spawn command (mask anything that looks like an API key)
|
|
786
787
|
const maskArg = (a) => /^(sk-|AIza|key-|token-)/.test(a) ? '***' : a;
|
|
@@ -804,13 +805,22 @@ For normal file edits within your scope, proceed without review.
|
|
|
804
805
|
}
|
|
805
806
|
|
|
806
807
|
// Spawn the process (use pipe for stdin if provider needs to send prompt via stdin)
|
|
808
|
+
const spawnCwd = [providerCwd, agent.workingDir, this.daemon.projectDir].find(d => d && existsSync(d)) || this.daemon.projectDir;
|
|
807
809
|
const proc = cpSpawn(command, args, {
|
|
808
|
-
cwd:
|
|
810
|
+
cwd: spawnCwd,
|
|
809
811
|
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
812
|
stdio: [stdinData ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
|
811
813
|
detached: false,
|
|
812
814
|
});
|
|
813
815
|
|
|
816
|
+
proc.on('error', (err) => {
|
|
817
|
+
if (!logStream.destroyed) logStream.write(`[${new Date().toISOString()}] Spawn error: ${err.message}\n`);
|
|
818
|
+
if (!logStream.destroyed) logStream.end();
|
|
819
|
+
this.handles.delete(agent.id);
|
|
820
|
+
registry.update(agent.id, { status: 'crashed', pid: null });
|
|
821
|
+
this.daemon.broadcast({ type: 'agent:exit', agentId: agent.id, code: null, signal: null, status: 'crashed', error: err.message });
|
|
822
|
+
});
|
|
823
|
+
|
|
814
824
|
// Write prompt via stdin if provider requested it (e.g., Ollama avoids arg length limits)
|
|
815
825
|
if (stdinData && proc.stdin) {
|
|
816
826
|
proc.stdin.write(stdinData);
|
|
@@ -820,7 +830,7 @@ For normal file edits within your scope, proceed without review.
|
|
|
820
830
|
if (!proc.pid) {
|
|
821
831
|
registry.remove(agent.id);
|
|
822
832
|
locks.release(agent.id);
|
|
823
|
-
logStream.end();
|
|
833
|
+
if (!logStream.destroyed) logStream.end();
|
|
824
834
|
throw new Error(`Failed to spawn ${command} — process has no PID`);
|
|
825
835
|
}
|
|
826
836
|
|
|
@@ -1568,7 +1578,8 @@ For normal file edits within your scope, proceed without review.
|
|
|
1568
1578
|
locks.release(agentId);
|
|
1569
1579
|
|
|
1570
1580
|
// Build resume command
|
|
1571
|
-
const { command, args, env } = provider.buildResumeCommand(sessionId, message, config.model);
|
|
1581
|
+
const { command: rawCommand, args, env } = provider.buildResumeCommand(sessionId, message, config.model);
|
|
1582
|
+
const command = resolveProviderCommand(config.provider || 'claude-code') || rawCommand;
|
|
1572
1583
|
|
|
1573
1584
|
// Set up log capture
|
|
1574
1585
|
const logDir = resolve(this.daemon.grooveDir, 'logs');
|
|
@@ -1603,17 +1614,26 @@ For normal file edits within your scope, proceed without review.
|
|
|
1603
1614
|
}
|
|
1604
1615
|
|
|
1605
1616
|
// Spawn the resumed process
|
|
1617
|
+
const resumeCwd = [config.workingDir, this.daemon.projectDir].find(d => d && existsSync(d)) || this.daemon.projectDir;
|
|
1606
1618
|
const proc = cpSpawn(command, args, {
|
|
1607
|
-
cwd:
|
|
1619
|
+
cwd: resumeCwd,
|
|
1608
1620
|
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
1621
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1610
1622
|
detached: false,
|
|
1611
1623
|
});
|
|
1612
1624
|
|
|
1625
|
+
proc.on('error', (err) => {
|
|
1626
|
+
if (!logStream.destroyed) logStream.write(`[${new Date().toISOString()}] Resume spawn error: ${err.message}\n`);
|
|
1627
|
+
if (!logStream.destroyed) logStream.end();
|
|
1628
|
+
this.handles.delete(newAgent.id);
|
|
1629
|
+
registry.update(newAgent.id, { status: 'crashed', pid: null });
|
|
1630
|
+
this.daemon.broadcast({ type: 'agent:exit', agentId: newAgent.id, code: null, signal: null, status: 'crashed', error: err.message });
|
|
1631
|
+
});
|
|
1632
|
+
|
|
1613
1633
|
if (!proc.pid) {
|
|
1614
1634
|
registry.remove(newAgent.id);
|
|
1615
1635
|
locks.release(newAgent.id);
|
|
1616
|
-
logStream.end();
|
|
1636
|
+
if (!logStream.destroyed) logStream.end();
|
|
1617
1637
|
throw new Error(`Failed to resume — process has no PID`);
|
|
1618
1638
|
}
|
|
1619
1639
|
|
|
@@ -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);
|