groove-dev 0.27.79 → 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 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 -->
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.79",
3
+ "version": "0.27.81",
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.79",
3
+ "version": "0.27.81",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -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: providerCwd || agent.workingDir || this.daemon.projectDir,
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: config.workingDir || this.daemon.projectDir,
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);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/gui",
3
- "version": "0.27.79",
3
+ "version": "0.27.81",
4
4
  "description": "GROOVE GUI — visual agent control plane",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "groove-dev",
3
- "version": "0.27.79",
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)",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.79",
3
+ "version": "0.27.81",
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.79",
3
+ "version": "0.27.81",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -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: providerCwd || agent.workingDir || this.daemon.projectDir,
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: config.workingDir || this.daemon.projectDir,
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);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/gui",
3
- "version": "0.27.79",
3
+ "version": "0.27.81",
4
4
  "description": "GROOVE GUI — visual agent control plane",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",