Skip to content
Merged
37 changes: 36 additions & 1 deletion apps/backend/core/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,18 @@

logger = logging.getLogger(__name__)

# =============================================================================
# Windows System Prompt Limits
# =============================================================================
# Windows CreateProcessW has a 32,768 character limit for the entire command line.
# When CLAUDE.md is very large and passed as --system-prompt, the command can exceed
# this limit, causing ERROR_FILE_NOT_FOUND. We cap CLAUDE.md content to stay safe.
# 20,000 chars leaves ~12KB headroom for CLI overhead (model, tools, MCP config, etc.)
WINDOWS_MAX_SYSTEM_PROMPT_CHARS = 20000
WINDOWS_TRUNCATION_MESSAGE = (
"\n\n[... CLAUDE.md truncated due to Windows command-line length limit ...]"
)

# =============================================================================
# Project Index Cache
# =============================================================================
Expand Down Expand Up @@ -821,8 +833,31 @@ def create_client(
if should_use_claude_md():
claude_md_content = load_claude_md(project_dir)
if claude_md_content:
# On Windows, the SDK passes system_prompt as a --system-prompt CLI argument.
# Windows CreateProcessW has a 32,768 character limit for the entire command line.
# When CLAUDE.md is very large, the command can exceed this limit, causing Windows
# to return ERROR_FILE_NOT_FOUND which the SDK misreports as "Claude Code not found".
# Cap CLAUDE.md content to keep total command line under the limit. (#1661)
was_truncated = False
if is_windows():
max_claude_md_chars = (
WINDOWS_MAX_SYSTEM_PROMPT_CHARS
- len(base_prompt)
- len(WINDOWS_TRUNCATION_MESSAGE)
- len("\n\n# Project Instructions (from CLAUDE.md)\n\n")
)
if len(claude_md_content) > max_claude_md_chars > 0:
claude_md_content = (
claude_md_content[:max_claude_md_chars]
+ WINDOWS_TRUNCATION_MESSAGE
)
print(
" - CLAUDE.md: truncated (exceeded Windows command-line limit)"
)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The CLAUDE.md truncation logic can be bypassed on Windows if max_claude_md_chars becomes negative, causing the > 0 check in the if condition to fail.
Severity: CRITICAL

Suggested Fix

Modify the conditional logic to handle cases where max_claude_md_chars is not positive. For example, change the check to if max_claude_md_chars < len(claude_md_content): and ensure max_claude_md_chars is clamped at a minimum of 0 before being used for slicing, preventing negative budget issues.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: apps/backend/core/client.py#L856

Potential issue: On Windows, the calculation for `max_claude_md_chars` can result in a
negative value if the `base_prompt` is sufficiently large. The conditional check `if
len(claude_md_content) > max_claude_md_chars > 0:` requires `max_claude_md_chars` to be
positive. When it's negative, the check fails, and the truncation logic is skipped
entirely. This allows the total system prompt to exceed the Windows `CreateProcessW`
character limit, leading to a failure that the SDK misreports as "Claude Code not
found," which is the exact bug this change was intended to fix.

Did we get this right? 👍 / 👎 to inform future reviews.

was_truncated = True
base_prompt = f"{base_prompt}\n\n# Project Instructions (from CLAUDE.md)\n\n{claude_md_content}"
print(" - CLAUDE.md: included in system prompt")
if not was_truncated:
print(" - CLAUDE.md: included in system prompt")
Comment on lines 836 to 860
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Edge case: when max_claude_md_chars <= 0, full CLAUDE.md is included untruncated.

If base_prompt + WINDOWS_TRUNCATION_MESSAGE already exceeds WINDOWS_MAX_SYSTEM_PROMPT_CHARS, the > 0 guard skips truncation and the entire CLAUDE.md is appended — which will definitely exceed the Windows limit. While base_prompt is currently short (~400 chars), this is a latent bug if the prompt grows.

Proposed fix: omit CLAUDE.md when budget is exhausted
             if is_windows():
                 max_claude_md_chars = (
                     WINDOWS_MAX_SYSTEM_PROMPT_CHARS
                     - len(base_prompt)
                     - len(WINDOWS_TRUNCATION_MESSAGE)
                 )
-                if len(claude_md_content) > max_claude_md_chars > 0:
+                if max_claude_md_chars <= 0:
+                    claude_md_content = ""
+                    print(
+                        "   - CLAUDE.md: omitted (base prompt already near Windows limit)"
+                    )
+                    was_truncated = True
+                elif len(claude_md_content) > max_claude_md_chars:
                     claude_md_content = (
                         claude_md_content[:max_claude_md_chars]
                         + WINDOWS_TRUNCATION_MESSAGE
                     )
🤖 Prompt for AI Agents
In `@apps/backend/core/client.py` around lines 836 - 859, The current Windows
truncation logic fails when max_claude_md_chars <= 0 and ends up appending the
full claude_md_content; update the block around is_windows()/max_claude_md_chars
so that if max_claude_md_chars <= 0 you do not append CLAUDE.md at all (set
was_truncated = True), print a clear message (e.g., "CLAUDE.md: omitted (no
budget under Windows command-line limit)"), and only append claude_md_content
when max_claude_md_chars > 0 (truncating when necessary with
WINDOWS_TRUNCATION_MESSAGE); reference is_windows,
WINDOWS_MAX_SYSTEM_PROMPT_CHARS, base_prompt, WINDOWS_TRUNCATION_MESSAGE,
claude_md_content, and was_truncated to locate where to change the logic and
messages.

else:
print(" - CLAUDE.md: not found in project root")
else:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ describe('Subprocess Spawn Integration', () => {
'Test task description'
]),
expect.objectContaining({
cwd: AUTO_CLAUDE_SOURCE, // Process runs from auto-claude source directory
cwd: TEST_PROJECT_PATH, // Process runs from project directory to avoid cross-drive issues on Windows (#1661)
env: expect.objectContaining({
PYTHONUNBUFFERED: '1'
})
Expand Down Expand Up @@ -218,7 +218,7 @@ describe('Subprocess Spawn Integration', () => {
'spec-001'
]),
expect.objectContaining({
cwd: AUTO_CLAUDE_SOURCE // Process runs from auto-claude source directory
cwd: TEST_PROJECT_PATH // Process runs from project directory to avoid cross-drive issues on Windows (#1661)
})
);
}, 30000); // Increase timeout for Windows CI (dynamic imports are slow)
Expand Down Expand Up @@ -248,7 +248,7 @@ describe('Subprocess Spawn Integration', () => {
'--qa'
]),
expect.objectContaining({
cwd: AUTO_CLAUDE_SOURCE // Process runs from auto-claude source directory
cwd: TEST_PROJECT_PATH // Process runs from project directory to avoid cross-drive issues on Windows (#1661)
})
);
}, 30000); // Increase timeout for Windows CI (dynamic imports are slow)
Expand Down
12 changes: 9 additions & 3 deletions apps/frontend/src/main/agent/agent-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,9 @@ export class AgentManager extends EventEmitter {
this.registerTaskWithOperationRegistry(taskId, 'spec-creation', { projectPath, taskDescription, specDir });

// Note: This is spec-creation but it chains to task-execution via run.py
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution', projectId);
// Use projectPath as cwd instead of autoBuildSource to avoid cross-drive file access
// issues on Windows. The script path is absolute so Python finds its modules via sys.path[0]. (#1661)
await this.processManager.spawnProcess(taskId, projectPath, args, combinedEnv, 'task-execution', projectId);
}

/**
Expand Down Expand Up @@ -410,7 +412,10 @@ export class AgentManager extends EventEmitter {
// Register with unified OperationRegistry for proactive swap support
this.registerTaskWithOperationRegistry(taskId, 'task-execution', { projectPath, specId, options });

await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution', projectId);
// Use projectPath as cwd instead of autoBuildSource to avoid cross-drive file access
// issues on Windows. The script path (runPath) is absolute so Python finds its modules
// via sys.path[0] which is set to the script's directory. (#1661)
await this.processManager.spawnProcess(taskId, projectPath, args, combinedEnv, 'task-execution', projectId);
}

/**
Expand Down Expand Up @@ -448,7 +453,8 @@ export class AgentManager extends EventEmitter {

const args = [runPath, '--spec', specId, '--project-dir', projectPath, '--qa'];

await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'qa-process', projectId);
// Use projectPath as cwd instead of autoBuildSource to avoid cross-drive issues on Windows (#1661)
await this.processManager.spawnProcess(taskId, projectPath, args, combinedEnv, 'qa-process', projectId);
}

/**
Expand Down
46 changes: 43 additions & 3 deletions apps/frontend/src/main/agent/agent-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import type { AppSettings } from '../../shared/types/settings';
import { getOAuthModeClearVars } from './env-utils';
import { getAugmentedEnv } from '../env-utils';
import { getToolInfo, getClaudeCliPathForSdk } from '../cli-tool-manager';
import { killProcessGracefully, isWindows } from '../platform';
import { killProcessGracefully, isWindows, getPathDelimiter } from '../platform';
import { debugLog } from '../../shared/utils/debug-logger';

/**
Expand Down Expand Up @@ -679,15 +679,55 @@ export class AgentProcessManager {
},
});

// Parse Python commandto handle space-separated commands like "py -3"
// Merge PATH from pythonEnv with augmented PATH from env.
// pythonEnv may contain its own PATH (e.g., on Windows with pywin32_system32 prepended).
// Simply spreading pythonEnv after env would overwrite the augmented PATH (which includes
// npm globals, homebrew, etc.), causing "Claude code not found" on Windows (#1661).
// Instead, merge PATH entries: prepend pythonEnv-specific paths to the augmented PATH.
const mergedPythonEnv = { ...pythonEnv };
const pathSep = getPathDelimiter();

// Normalize env to a single uppercase 'PATH' key to avoid duplicate PATH keys on Windows.
// On Windows, process.env spread produces 'Path' (system) while getAugmentedEnv() writes 'PATH'.
// Without normalization, Object.keys().find() returns 'Path' first (insertion order), missing
// augmented entries, and the final spread produces both 'Path' and 'PATH' keys. (#1661)
const envPathKey = 'PATH' in env ? 'PATH' : (Object.keys(env).find(k => k.toUpperCase() === 'PATH') || 'PATH');
if (envPathKey !== 'PATH' && envPathKey in env) {
env['PATH'] = env[envPathKey] as string;
delete env[envPathKey];
}

// Also normalize pythonEnv PATH key to uppercase
const pythonPathKey = Object.keys(mergedPythonEnv).find(k => k.toUpperCase() === 'PATH');
if (pythonPathKey && pythonPathKey !== 'PATH') {
mergedPythonEnv['PATH'] = mergedPythonEnv[pythonPathKey] as string;
delete mergedPythonEnv[pythonPathKey];
}

if (mergedPythonEnv['PATH'] && env['PATH']) {
const augmentedPathEntries = new Set(
(env['PATH'] as string).split(pathSep).filter(Boolean)
);
// Extract only new entries from pythonEnv.PATH that aren't already in the augmented PATH
const pythonPathEntries = (mergedPythonEnv['PATH'] as string)
.split(pathSep)
.filter(entry => entry && !augmentedPathEntries.has(entry));

// Prepend python-specific paths (e.g., pywin32_system32) to the augmented PATH
mergedPythonEnv['PATH'] = pythonPathEntries.length > 0
? [...pythonPathEntries, env['PATH'] as string].join(pathSep)
: env['PATH'] as string;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider case-insensitive PATH entry deduplication on Windows.

Line 714 uses strict string equality via Set.has(). On Windows, path entries are case-insensitive (C:\Python39 vs c:\python39), so the same directory could appear twice. This won't break anything but produces a slightly bloated PATH.

♻️ Optional: normalize entries for comparison on Windows
      const augmentedPathEntries = new Set(
-       (env['PATH'] as string).split(pathSep).filter(Boolean)
+       (env['PATH'] as string).split(pathSep).filter(Boolean).map(e => isWindows() ? e.toLowerCase() : e)
      );
      // Extract only new entries from pythonEnv.PATH that aren't already in the augmented PATH
      const pythonPathEntries = (mergedPythonEnv['PATH'] as string)
        .split(pathSep)
-       .filter(entry => entry && !augmentedPathEntries.has(entry));
+       .filter(entry => entry && !augmentedPathEntries.has(isWindows() ? entry.toLowerCase() : entry));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/frontend/src/main/agent/agent-process.ts` around lines 707 - 720, The
current PATH dedup logic for mergedPythonEnv['PATH'] uses Set.has() which is
case-sensitive and can leave duplicate entries on Windows; update the comparison
to normalize path entries when building augmentedPathEntries and when filtering
pythonPathEntries (e.g., on process.platform === 'win32' use entry.toLowerCase()
and optionally path.normalize()) so comparisons are case-insensitive on Windows
while preserving original entry casing in the final mergedPythonEnv['PATH'];
apply this change around the augmentedPathEntries and pythonPathEntries
construction in the agent-process PATH merge block.


// Parse Python command to handle space-separated commands like "py -3"
const [pythonCommand, pythonBaseArgs] = parsePythonCommand(this.getPythonPath());
let childProcess;
try {
childProcess = spawn(pythonCommand, [...pythonBaseArgs, ...args], {
cwd,
env: {
...env, // Already includes process.env, extraEnv, profileEnv, PYTHONUNBUFFERED, PYTHONUTF8
...pythonEnv, // Include Python environment (PYTHONPATH for bundled packages)
...mergedPythonEnv, // Python env with merged PATH (preserves augmented PATH entries)
...oauthModeClearVars, // Clear stale ANTHROPIC_* vars when in OAuth mode
...apiProfileEnv // Include active API profile config (highest priority for ANTHROPIC_* vars)
}
Comment on lines 696 to 703
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find pythonEnvManager.getPythonEnv() calls and implementation
rg -n "getPythonEnv" --type=ts -A5 -B5 | head -100

Repository: AndyMik90/Auto-Claude

Length of output: 10363


🏁 Script executed:

#!/bin/bash
# Look for pythonEnvManager definition and initialization
rg -n "pythonEnvManager\s*=" --type=ts -A3 -B3

Repository: AndyMik90/Auto-Claude

Length of output: 615


🏁 Script executed:

#!/bin/bash
# Check the file where pythonEnvManager is defined
fd pythonEnvManager --type=ts

Repository: AndyMik90/Auto-Claude

Length of output: 234


🏁 Script executed:

#!/bin/bash
# Read the getPythonEnv() method implementation
sed -n '690,750p' apps/frontend/src/main/python-env-manager.ts

Repository: AndyMik90/Auto-Claude

Length of output: 3204


🏁 Script executed:

#!/bin/bash
# Read more of the getPythonEnv() implementation to see all returned keys
sed -n '690,800p' apps/frontend/src/main/python-env-manager.ts

Repository: AndyMik90/Auto-Claude

Length of output: 4692


🏁 Script executed:

#!/bin/bash
# Look at agent-process.ts to see mergedPythonEnv construction and context
sed -n '700,750p' apps/frontend/src/main/agent/agent-process.ts

Repository: AndyMik90/Auto-Claude

Length of output: 2573


🏁 Script executed:

#!/bin/bash
# Look at env construction before it's passed to spawn
sed -n '650,730p' apps/frontend/src/main/agent/agent-process.ts | head -100

Repository: AndyMik90/Auto-Claude

Length of output: 3992


🏁 Script executed:

#!/bin/bash
# Check what extraEnv, profileEnv, and other env components contain
rg -n "extraEnv|profileEnv|oauthModeClearVars" apps/frontend/src/main/agent/agent-process.ts -B2 -A2 | head -80

Repository: AndyMik90/Auto-Claude

Length of output: 3624


🏁 Script executed:

#!/bin/bash
# Check setupProcessEnvironment to see what's set in env
sed -n '176,260p' apps/frontend/src/main/agent/agent-process.ts

Repository: AndyMik90/Auto-Claude

Length of output: 4017


Clarify redundant Python environment variable overrides and document override semantics.

mergedPythonEnv spreads after env (line 730), causing all keys to override env values. PATH is explicitly merged (lines 706-721) to preserve augmented entries, but other keys override unconditionally: PYTHONUNBUFFERED, PYTHONIOENCODING, and PYTHONUTF8 are redundantly set in both env and mergedPythonEnv. While new keys like PYTHONDONTWRITEBYTECODE and PYTHONNOUSERSITE should override consistently, remove the redundant duplication from setupProcessEnvironment() (lines 242-244) since getPythonEnv() now provides the canonical Python configuration. Update the comment on line 730 to document this override behavior or consider an explicit merge strategy for all Python-related keys to match PATH handling.

🤖 Prompt for AI Agents
In `@apps/frontend/src/main/agent/agent-process.ts` around lines 726 - 733, The
spawn call currently spreads env then mergedPythonEnv so mergedPythonEnv
unconditionally overrides keys from env (including PATH which you purposely
merged earlier), and setupProcessEnvironment() still injects duplicate Python
vars; remove the redundant PYTHON-specific assignments (e.g., PYTHONUNBUFFERED,
PYTHONIOENCODING, PYTHONUTF8) from setupProcessEnvironment() so
getPythonEnv()/getMergedPythonEnv (the code producing mergedPythonEnv) is the
canonical source, or alternatively change the spawn merge to explicitly merge
Python keys the same way PATH is merged; also update the inline comment near the
spawn (where env, mergedPythonEnv, oauthModeClearVars, apiProfileEnv are
combined) to clearly state that mergedPythonEnv has final precedence for
Python-related vars and PATH merging is preserved.

Expand Down
Loading