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
|
||||
|
||||
- **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
|
||||
|
||||
|
|
@ -247,13 +247,13 @@ Yes. Every task shows a full diff view where you can accept, reject, or comment
|
|||
<details>
|
||||
<summary><strong>What happens if an agent gets stuck?</strong></summary>
|
||||
<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>
|
||||
<summary><strong>Does it support multiple projects and teams?</strong></summary>
|
||||
<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>
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -442,6 +442,17 @@ async function resolveNodePath(options?: McpLaunchSpecResolveOptions): Promise<s
|
|||
if (_resolvedNodePath) return _resolvedNodePath;
|
||||
|
||||
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({}));
|
||||
if (fastProbe.ok) {
|
||||
_resolvedNodePath = fastProbe.path;
|
||||
|
|
@ -449,9 +460,8 @@ async function resolveNodePath(options?: McpLaunchSpecResolveOptions): Promise<s
|
|||
return _resolvedNodePath;
|
||||
}
|
||||
|
||||
if (shouldPreferShellNodeProbe()) {
|
||||
if (!preferShellNodeProbe) {
|
||||
emitProgress(options, 'node-runtime-shell-fallback', 'Trying login shell Node.js runtime...');
|
||||
}
|
||||
const shellProbe = await probeShellNodeRuntimePath(options);
|
||||
if (shellProbe.ok) {
|
||||
_resolvedNodePath = shellProbe.path;
|
||||
|
|
@ -465,6 +475,14 @@ async function resolveNodePath(options?: McpLaunchSpecResolveOptions): Promise<s
|
|||
shellProbe.error ? stringifyError(shellProbe.error) : stringifyError(fastProbe.error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
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: ${stringifyError(
|
||||
fastProbe.error
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ describePosix('OpenCode packaged-runtime preflight integration', () => {
|
|||
[
|
||||
'#!/bin/sh',
|
||||
'if [ "$1" = "-e" ]; then',
|
||||
' printf "%s" "$FAKE_NODE_PATH"',
|
||||
' printf "{\\"execPath\\":\\"%s\\",\\"version\\":\\"%s\\"}" "$FAKE_NODE_PATH" "22.0.0"',
|
||||
' exit 0',
|
||||
'fi',
|
||||
'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();
|
||||
const previousPath = process.env.PATH;
|
||||
process.env.PATH = ['/usr/bin', '/bin', '/usr/sbin', '/sbin'].join(path.delimiter);
|
||||
hoisted.execCliMock.mockResolvedValue({
|
||||
stdout: nodeRuntimeProbeStdout('/fast/node'),
|
||||
stderr: '',
|
||||
hoisted.resolveInteractiveShellEnvMock.mockResolvedValue({
|
||||
PATH: ['/strict-shell-node-bin', '/usr/bin'].join(path.delimiter),
|
||||
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 {
|
||||
|
|
@ -490,8 +498,10 @@ describe('TeamMcpConfigBuilder', () => {
|
|||
const configPath = await builder.writeConfigFile();
|
||||
createdPaths.push(configPath);
|
||||
|
||||
expect(readGeneratedServer(configPath)?.command).toBe('/fast/node');
|
||||
expect(hoisted.resolveInteractiveShellEnvMock).not.toHaveBeenCalled();
|
||||
expect(readGeneratedServer(configPath)?.command).toBe('/strict-shell-node-bin/node');
|
||||
expect(hoisted.resolveInteractiveShellEnvMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ source: 'mcp-node-runtime' })
|
||||
);
|
||||
} finally {
|
||||
if (previousPath === undefined) {
|
||||
delete process.env.PATH;
|
||||
|
|
|
|||
|
|
@ -27,7 +27,10 @@ vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({
|
|||
vi.mock('@main/utils/childProcess', () => ({
|
||||
execCli: vi.fn(async (_binaryPath: string | null, args: string[]) => {
|
||||
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')) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -52,6 +52,13 @@ vi.mock('@main/services/team/TeamTaskReader', () => ({
|
|||
|
||||
vi.mock('@main/utils/childProcess', () => ({
|
||||
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') {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
|
|
|
|||
|
|
@ -80,6 +80,13 @@ async function setupRunningTeam(teamName: string) {
|
|||
const { child, writeSpy } = createFakeChild();
|
||||
vi.mocked(spawnCli).mockReturnValue(child as any);
|
||||
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 providerId = providerIndex >= 0 ? args[providerIndex + 1] : 'anthropic';
|
||||
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[]) => {
|
||||
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') {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
|
|
|
|||
|
|
@ -26,7 +26,10 @@ vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({
|
|||
vi.mock('@main/utils/childProcess', () => ({
|
||||
execCli: vi.fn(async (_binaryPath: string | null, args: string[]) => {
|
||||
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')) {
|
||||
return {
|
||||
|
|
|
|||
Loading…
Reference in a new issue