fix(runtime): prefer shell node for GUI launches
This commit is contained in:
parent
c88a8836df
commit
cbf5356fdc
9 changed files with 78 additions and 22 deletions
|
|
@ -147,7 +147,7 @@ An orchestration layer for AI agent teams across Claude, Codex, and OpenCode.
|
||||||
|
|
||||||
- **Built-in code editor** — edit project files with Git support without leaving the app
|
- **Built-in code editor** — edit project files with Git support without leaving the app
|
||||||
|
|
||||||
- **Branch strategy** — choose via prompt: single branch or git worktree per agent
|
- **Branch strategy** - choose per teammate at launch: use the main checkout or run selected agents in their own git worktree. You can still spell out branch rules in the provisioning prompt.
|
||||||
|
|
||||||
- **Team member stats** — global performance statistics per member
|
- **Team member stats** — global performance statistics per member
|
||||||
|
|
||||||
|
|
@ -247,13 +247,13 @@ Yes. Every task shows a full diff view where you can accept, reject, or comment
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>What happens if an agent gets stuck?</strong></summary>
|
<summary><strong>What happens if an agent gets stuck?</strong></summary>
|
||||||
<br />
|
<br />
|
||||||
Send a direct message to course-correct, or stop and restart from the process dashboard. If an agent needs your input, you'll get a notification and the task will show a distinct badge on the board.
|
Send a direct message to course-correct, or stop and restart from the process dashboard. Agent Teams also has a nudge system: the app can send a short control message when there is a clear reason to wake an agent up, such as after a known rate-limit cooldown, when a teammate has not synced with its current task or review, or when progress appears stalled. Nudges are guarded and rate limited, so they are meant to help the agent continue, not spam it. If an agent needs your input, you'll get a notification and the task will show a distinct badge on the board.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>Does it support multiple projects and teams?</strong></summary>
|
<summary><strong>Does it support multiple projects and teams?</strong></summary>
|
||||||
<br />
|
<br />
|
||||||
Yes. Run multiple teams in one project or across different projects, even simultaneously. To avoid Git conflicts, ask agents to use git worktree in your provisioning prompt.
|
Yes. Run multiple teams in one project or across different projects, even simultaneously. To avoid Git conflicts, enable git worktree isolation for selected teammates when launching the team, and use the provisioning prompt for any extra branch or merge rules.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -442,6 +442,17 @@ async function resolveNodePath(options?: McpLaunchSpecResolveOptions): Promise<s
|
||||||
if (_resolvedNodePath) return _resolvedNodePath;
|
if (_resolvedNodePath) return _resolvedNodePath;
|
||||||
|
|
||||||
emitProgress(options, 'node-runtime', 'Resolving Node.js runtime for MCP server...');
|
emitProgress(options, 'node-runtime', 'Resolving Node.js runtime for MCP server...');
|
||||||
|
const preferShellNodeProbe = shouldPreferShellNodeProbe();
|
||||||
|
if (preferShellNodeProbe && !process.env.NODE_BINARY?.trim()) {
|
||||||
|
emitProgress(options, 'node-runtime-shell-fallback', 'Trying login shell Node.js runtime...');
|
||||||
|
const shellProbe = await probeShellNodeRuntimePath(options);
|
||||||
|
if (shellProbe.ok) {
|
||||||
|
_resolvedNodePath = shellProbe.path;
|
||||||
|
emitProgress(options, 'node-runtime-found', 'Using resolved Node.js runtime...');
|
||||||
|
return _resolvedNodePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const fastProbe = await probeNodeRuntimePath(buildNodeResolveEnv({}));
|
const fastProbe = await probeNodeRuntimePath(buildNodeResolveEnv({}));
|
||||||
if (fastProbe.ok) {
|
if (fastProbe.ok) {
|
||||||
_resolvedNodePath = fastProbe.path;
|
_resolvedNodePath = fastProbe.path;
|
||||||
|
|
@ -449,21 +460,28 @@ async function resolveNodePath(options?: McpLaunchSpecResolveOptions): Promise<s
|
||||||
return _resolvedNodePath;
|
return _resolvedNodePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldPreferShellNodeProbe()) {
|
if (!preferShellNodeProbe) {
|
||||||
emitProgress(options, 'node-runtime-shell-fallback', 'Trying login shell Node.js runtime...');
|
emitProgress(options, 'node-runtime-shell-fallback', 'Trying login shell Node.js runtime...');
|
||||||
}
|
const shellProbe = await probeShellNodeRuntimePath(options);
|
||||||
const shellProbe = await probeShellNodeRuntimePath(options);
|
if (shellProbe.ok) {
|
||||||
if (shellProbe.ok) {
|
_resolvedNodePath = shellProbe.path;
|
||||||
_resolvedNodePath = shellProbe.path;
|
emitProgress(options, 'node-runtime-found', 'Using resolved Node.js runtime...');
|
||||||
emitProgress(options, 'node-runtime-found', 'Using resolved Node.js runtime...');
|
return _resolvedNodePath;
|
||||||
return _resolvedNodePath;
|
}
|
||||||
|
|
||||||
|
emitProgress(options, 'node-runtime-missing', 'Node.js runtime for MCP server was not found.');
|
||||||
|
throw new Error(
|
||||||
|
`Node.js runtime for Agent Teams MCP was not found. Ensure Node.js is installed and available from the login shell PATH. Last error: ${
|
||||||
|
shellProbe.error ? stringifyError(shellProbe.error) : stringifyError(fastProbe.error)
|
||||||
|
}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
emitProgress(options, 'node-runtime-missing', 'Node.js runtime for MCP server was not found.');
|
emitProgress(options, 'node-runtime-missing', 'Node.js runtime for MCP server was not found.');
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Node.js runtime for Agent Teams MCP was not found. Ensure Node.js is installed and available from the login shell PATH. Last error: ${
|
`Node.js runtime for Agent Teams MCP was not found. Ensure Node.js is installed and available from the login shell PATH. Last error: ${stringifyError(
|
||||||
shellProbe.error ? stringifyError(shellProbe.error) : stringifyError(fastProbe.error)
|
fastProbe.error
|
||||||
}`
|
)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -142,7 +142,7 @@ describePosix('OpenCode packaged-runtime preflight integration', () => {
|
||||||
[
|
[
|
||||||
'#!/bin/sh',
|
'#!/bin/sh',
|
||||||
'if [ "$1" = "-e" ]; then',
|
'if [ "$1" = "-e" ]; then',
|
||||||
' printf "%s" "$FAKE_NODE_PATH"',
|
' printf "{\\"execPath\\":\\"%s\\",\\"version\\":\\"%s\\"}" "$FAKE_NODE_PATH" "22.0.0"',
|
||||||
' exit 0',
|
' exit 0',
|
||||||
'fi',
|
'fi',
|
||||||
'echo "unexpected node args: $*" >&2',
|
'echo "unexpected node args: $*" >&2',
|
||||||
|
|
|
||||||
|
|
@ -476,13 +476,21 @@ describe('TeamMcpConfigBuilder', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips strict shell env lookup when fast Node lookup succeeds from a minimal GUI PATH', async () => {
|
it('prefers strict shell env lookup over fast Node lookup from a minimal GUI PATH', async () => {
|
||||||
mockBuiltWorkspaceEntryAvailable();
|
mockBuiltWorkspaceEntryAvailable();
|
||||||
const previousPath = process.env.PATH;
|
const previousPath = process.env.PATH;
|
||||||
process.env.PATH = ['/usr/bin', '/bin', '/usr/sbin', '/sbin'].join(path.delimiter);
|
process.env.PATH = ['/usr/bin', '/bin', '/usr/sbin', '/sbin'].join(path.delimiter);
|
||||||
hoisted.execCliMock.mockResolvedValue({
|
hoisted.resolveInteractiveShellEnvMock.mockResolvedValue({
|
||||||
stdout: nodeRuntimeProbeStdout('/fast/node'),
|
PATH: ['/strict-shell-node-bin', '/usr/bin'].join(path.delimiter),
|
||||||
stderr: '',
|
HOME: '/Users/tester',
|
||||||
|
});
|
||||||
|
hoisted.execCliMock.mockImplementation(async (command, _args, options) => {
|
||||||
|
const env = options?.env as NodeJS.ProcessEnv | undefined;
|
||||||
|
if (env?.PATH?.split(path.delimiter)[0] === '/strict-shell-node-bin') {
|
||||||
|
expect(command).toBe('node');
|
||||||
|
return { stdout: nodeRuntimeProbeStdout('/strict-shell-node-bin/node'), stderr: '' };
|
||||||
|
}
|
||||||
|
return { stdout: nodeRuntimeProbeStdout('/fast/node'), stderr: '' };
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -490,8 +498,10 @@ describe('TeamMcpConfigBuilder', () => {
|
||||||
const configPath = await builder.writeConfigFile();
|
const configPath = await builder.writeConfigFile();
|
||||||
createdPaths.push(configPath);
|
createdPaths.push(configPath);
|
||||||
|
|
||||||
expect(readGeneratedServer(configPath)?.command).toBe('/fast/node');
|
expect(readGeneratedServer(configPath)?.command).toBe('/strict-shell-node-bin/node');
|
||||||
expect(hoisted.resolveInteractiveShellEnvMock).not.toHaveBeenCalled();
|
expect(hoisted.resolveInteractiveShellEnvMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ source: 'mcp-node-runtime' })
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (previousPath === undefined) {
|
if (previousPath === undefined) {
|
||||||
delete process.env.PATH;
|
delete process.env.PATH;
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,10 @@ vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({
|
||||||
vi.mock('@main/utils/childProcess', () => ({
|
vi.mock('@main/utils/childProcess', () => ({
|
||||||
execCli: vi.fn(async (_binaryPath: string | null, args: string[]) => {
|
execCli: vi.fn(async (_binaryPath: string | null, args: string[]) => {
|
||||||
if (args[0] === '-e' && args[1]?.includes('process.execPath')) {
|
if (args[0] === '-e' && args[1]?.includes('process.execPath')) {
|
||||||
return { stdout: process.execPath, stderr: '' };
|
return {
|
||||||
|
stdout: JSON.stringify({ execPath: process.execPath, version: process.versions.node }),
|
||||||
|
stderr: '',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (args.includes('model') && args.includes('list')) {
|
if (args.includes('model') && args.includes('list')) {
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,13 @@ vi.mock('@main/services/team/TeamTaskReader', () => ({
|
||||||
|
|
||||||
vi.mock('@main/utils/childProcess', () => ({
|
vi.mock('@main/utils/childProcess', () => ({
|
||||||
execCli: vi.fn(async (_binaryPath: string | null, args: string[]) => {
|
execCli: vi.fn(async (_binaryPath: string | null, args: string[]) => {
|
||||||
|
if (args[0] === '-e' && args[1]?.includes('process.execPath')) {
|
||||||
|
return {
|
||||||
|
stdout: JSON.stringify({ execPath: process.execPath, version: process.versions.node }),
|
||||||
|
stderr: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (args[0] === 'model') {
|
if (args[0] === 'model') {
|
||||||
return {
|
return {
|
||||||
stdout: JSON.stringify({
|
stdout: JSON.stringify({
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,13 @@ async function setupRunningTeam(teamName: string) {
|
||||||
const { child, writeSpy } = createFakeChild();
|
const { child, writeSpy } = createFakeChild();
|
||||||
vi.mocked(spawnCli).mockReturnValue(child as any);
|
vi.mocked(spawnCli).mockReturnValue(child as any);
|
||||||
vi.mocked(execCli).mockImplementation(async (_binaryPath, args) => {
|
vi.mocked(execCli).mockImplementation(async (_binaryPath, args) => {
|
||||||
|
if (args[0] === '-e' && args[1]?.includes('process.execPath')) {
|
||||||
|
return {
|
||||||
|
stdout: JSON.stringify({ execPath: process.execPath, version: process.versions.node }),
|
||||||
|
stderr: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const providerIndex = args.indexOf('--provider');
|
const providerIndex = args.indexOf('--provider');
|
||||||
const providerId = providerIndex >= 0 ? args[providerIndex + 1] : 'anthropic';
|
const providerId = providerIndex >= 0 ? args[providerIndex + 1] : 'anthropic';
|
||||||
if (args[0] === 'model' && args[1] === 'list') {
|
if (args[0] === 'model' && args[1] === 'list') {
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,14 @@ vi.mock('@main/services/infrastructure/NotificationManager', () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const defaultExecCliMockImplementation = async (_binaryPath: string | null, args: string[]) => {
|
const defaultExecCliMockImplementation = async (_binaryPath: string | null, args: string[]) => {
|
||||||
|
if (args[0] === '-e' && args[1]?.includes('process.execPath')) {
|
||||||
|
return {
|
||||||
|
stdout: JSON.stringify({ execPath: process.execPath, version: process.versions.node }),
|
||||||
|
stderr: '',
|
||||||
|
exitCode: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (args[0] === 'model') {
|
if (args[0] === 'model') {
|
||||||
return {
|
return {
|
||||||
stdout: JSON.stringify({
|
stdout: JSON.stringify({
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,10 @@ vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({
|
||||||
vi.mock('@main/utils/childProcess', () => ({
|
vi.mock('@main/utils/childProcess', () => ({
|
||||||
execCli: vi.fn(async (_binaryPath: string | null, args: string[]) => {
|
execCli: vi.fn(async (_binaryPath: string | null, args: string[]) => {
|
||||||
if (args[0] === '-e' && args[1]?.includes('process.execPath')) {
|
if (args[0] === '-e' && args[1]?.includes('process.execPath')) {
|
||||||
return { stdout: process.execPath, stderr: '' };
|
return {
|
||||||
|
stdout: JSON.stringify({ execPath: process.execPath, version: process.versions.node }),
|
||||||
|
stderr: '',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (args.includes('model') && args.includes('list')) {
|
if (args.includes('model') && args.includes('list')) {
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue