Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions autoforge_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
.claude_settings.json
.claude_assistant_settings.json
.claude_settings.expand.*.json
.system_prompt.*.md
.progress_cache
.migration_version
"""
Expand Down Expand Up @@ -178,6 +179,16 @@ def get_expand_settings_path(project_dir: Path, uuid_hex: str) -> Path:
return project_dir / ".autoforge" / f".claude_settings.expand.{uuid_hex}.json"


def get_system_prompt_path(project_dir: Path, session_kind: str, uuid_hex: str) -> Path:
"""Return the path for an ephemeral chat-session system prompt file.

The system prompt is passed to the Claude CLI via ``--system-prompt-file``
instead of on the command line, which on Windows is limited to ~8191
characters. These files are short-lived and always stored in ``.autoforge/``.
"""
return project_dir / ".autoforge" / f".system_prompt.{session_kind}.{uuid_hex}.md"


# ---------------------------------------------------------------------------
# Lock-file safety check
# ---------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion requirements-prod.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Production runtime dependencies only
# For development, use requirements.txt (includes ruff, mypy, pytest)
claude-agent-sdk>=0.1.39,<0.2.0
claude-agent-sdk>=0.1.51,<0.2.0
python-dotenv>=1.0.0
sqlalchemy>=2.0.0
fastapi>=0.115.0
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
claude-agent-sdk>=0.1.39,<0.2.0
claude-agent-sdk>=0.1.51,<0.2.0
python-dotenv>=1.0.0
sqlalchemy>=2.0.0
fastapi>=0.115.0
Expand Down
19 changes: 11 additions & 8 deletions server/services/assistant_chat_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@
ROOT_DIR,
check_rate_limit_error,
format_client_init_error,
remove_system_prompt_file,
safe_receive_response,
write_system_prompt_file,
)

# Load environment variables from .env file if present
Expand Down Expand Up @@ -208,6 +210,7 @@ def __init__(self, project_name: str, project_dir: Path, conversation_id: Option
self._client_entered: bool = False
self.created_at = datetime.now()
self._history_loaded: bool = False # Track if we've loaded history for resumed conversations
self._system_prompt_file: Optional[Path] = None

async def close(self) -> None:
"""Clean up resources and close the Claude client."""
Expand All @@ -219,6 +222,8 @@ async def close(self) -> None:
finally:
self._client_entered = False
self.client = None
remove_system_prompt_file(self._system_prompt_file)
self._system_prompt_file = None

async def start(self) -> AsyncGenerator[dict, None]:
"""
Expand Down Expand Up @@ -279,12 +284,10 @@ async def start(self) -> AsyncGenerator[dict, None]:
# Get system prompt with project context
system_prompt = get_system_prompt(self.project_name, self.project_dir)

# Write system prompt to CLAUDE.md file to avoid Windows command line length limit
# The SDK will read this via setting_sources=["project"]
claude_md_path = self.project_dir / "CLAUDE.md"
with open(claude_md_path, "w", encoding="utf-8") as f:
f.write(system_prompt)
logger.info(f"Wrote assistant system prompt to {claude_md_path}")
# Pass the system prompt via a file (--system-prompt-file) rather than on the
# command line, which on Windows is limited to ~8191 characters (issue #8)
remove_system_prompt_file(self._system_prompt_file)
self._system_prompt_file = write_system_prompt_file(self.project_dir, "assistant", system_prompt)

# Use system Claude CLI
system_cli = shutil.which("claude")
Expand All @@ -304,8 +307,8 @@ async def start(self) -> AsyncGenerator[dict, None]:
model=model,
effort=effort, # type: ignore[arg-type] # SDK 0.1.61 Literal omits "xhigh"
cli_path=system_cli,
# System prompt loaded from CLAUDE.md via setting_sources
# This avoids Windows command line length limit (~8191 chars)
system_prompt={"type": "file", "path": str(self._system_prompt_file)},
# Load the project's own CLAUDE.md as additional context
setting_sources=["project"],
allowed_tools=[*READONLY_BUILTIN_TOOLS, *ASSISTANT_FEATURE_TOOLS],
disallowed_tools=DISALLOWED_ASSISTANT_TOOLS,
Expand Down
29 changes: 29 additions & 0 deletions server/services/chat_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import logging
import sys
import uuid
from pathlib import Path
from typing import Any, AsyncGenerator

Expand Down Expand Up @@ -96,6 +97,34 @@ def format_client_init_error(exc: Exception) -> str:
return f"Failed to initialize Claude: {exc}"


def write_system_prompt_file(project_dir: Path, session_kind: str, system_prompt: str) -> Path:
"""Write a chat session's system prompt to a unique file under ``.autoforge/``.

The SDK passes a plain-string ``system_prompt`` to the Claude CLI as a
command-line argument, which fails on Windows with "The command line is
too long." once the prompt exceeds ~8191 characters. Pass the returned
path as ``system_prompt={"type": "file", "path": ...}`` instead so the CLI
reads it via ``--system-prompt-file``. Callers should remove the file
with :func:`remove_system_prompt_file` when the session closes.
"""
from autoforge_paths import ensure_autoforge_dir, get_system_prompt_path

ensure_autoforge_dir(project_dir)
path = get_system_prompt_path(project_dir, session_kind, uuid.uuid4().hex)
path.write_text(system_prompt, encoding="utf-8")
return path.resolve()


def remove_system_prompt_file(path: Path | None) -> None:
"""Delete a system prompt file created by :func:`write_system_prompt_file`."""
if path is None:
return
try:
path.unlink(missing_ok=True)
except OSError as e:
logger.warning(f"Error removing system prompt file {path}: {e}")


async def safe_receive_response(client: Any, log: logging.Logger) -> AsyncGenerator:
"""Wrap ``client.receive_response()`` to skip ``MessageParseError``.

Expand Down
12 changes: 11 additions & 1 deletion server/services/expand_chat_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@
check_rate_limit_error,
format_client_init_error,
make_multimodal_message,
remove_system_prompt_file,
safe_receive_response,
write_system_prompt_file,
)

# Load environment variables from .env file if present
Expand Down Expand Up @@ -74,6 +76,7 @@ def __init__(self, project_name: str, project_dir: Path):
self.features_created: int = 0
self.created_feature_ids: list[int] = []
self._settings_file: Optional[Path] = None
self._system_prompt_file: Optional[Path] = None
self._query_lock = asyncio.Lock()

async def close(self) -> None:
Expand All @@ -93,6 +96,8 @@ async def close(self) -> None:
self._settings_file.unlink()
except Exception as e:
logger.warning(f"Error removing settings file: {e}")
remove_system_prompt_file(self._system_prompt_file)
self._system_prompt_file = None

async def start(self) -> AsyncGenerator[dict, None]:
"""
Expand Down Expand Up @@ -162,6 +167,11 @@ async def start(self) -> AsyncGenerator[dict, None]:
project_path = str(self.project_dir.resolve())
system_prompt = skill_content.replace("$ARGUMENTS", project_path)

# Pass the system prompt via a file (--system-prompt-file) rather than on the
# command line, which on Windows is limited to ~8191 characters (issue #8)
remove_system_prompt_file(self._system_prompt_file)
self._system_prompt_file = write_system_prompt_file(self.project_dir, "expand", system_prompt)

# Build environment overrides for API configuration
from registry import DEFAULT_MODEL, get_effective_sdk_env, get_effort_setting
sdk_env = get_effective_sdk_env()
Expand Down Expand Up @@ -189,7 +199,7 @@ async def start(self) -> AsyncGenerator[dict, None]:
model=model,
effort=effort, # type: ignore[arg-type] # SDK 0.1.61 Literal omits "xhigh"
cli_path=system_cli,
system_prompt=system_prompt,
system_prompt={"type": "file", "path": str(self._system_prompt_file)},
allowed_tools=[
"Read",
"Glob",
Expand Down
17 changes: 10 additions & 7 deletions server/services/spec_chat_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@
check_rate_limit_error,
format_client_init_error,
make_multimodal_message,
remove_system_prompt_file,
safe_receive_response,
write_system_prompt_file,
)

# Load environment variables from .env file if present
Expand Down Expand Up @@ -64,6 +66,7 @@ def __init__(self, project_name: str, project_dir: Path):
self.created_at = datetime.now()
self._conversation_id: Optional[str] = None
self._client_entered: bool = False # Track if context manager is active
self._system_prompt_file: Optional[Path] = None

async def close(self) -> None:
"""Clean up resources and close the Claude client."""
Expand All @@ -75,6 +78,8 @@ async def close(self) -> None:
finally:
self._client_entered = False
self.client = None
remove_system_prompt_file(self._system_prompt_file)
self._system_prompt_file = None

async def start(self) -> AsyncGenerator[dict, None]:
"""
Expand Down Expand Up @@ -135,12 +140,10 @@ async def start(self) -> AsyncGenerator[dict, None]:
project_path = str(self.project_dir.resolve())
system_prompt = skill_content.replace("$ARGUMENTS", project_path)

# Write system prompt to CLAUDE.md file to avoid Windows command line length limit
# The SDK will read this via setting_sources=["project"]
claude_md_path = self.project_dir / "CLAUDE.md"
with open(claude_md_path, "w", encoding="utf-8") as f:
f.write(system_prompt)
logger.info(f"Wrote system prompt to {claude_md_path}")
# Pass the system prompt via a file (--system-prompt-file) rather than on the
# command line, which on Windows is limited to ~8191 characters (issue #8)
remove_system_prompt_file(self._system_prompt_file)
self._system_prompt_file = write_system_prompt_file(self.project_dir, "spec", system_prompt)

# Create Claude SDK client with limited tools for spec creation
# Use Opus for best quality spec generation
Expand All @@ -161,7 +164,7 @@ async def start(self) -> AsyncGenerator[dict, None]:
model=model,
effort=effort, # type: ignore[arg-type] # SDK 0.1.61 Literal omits "xhigh"
cli_path=system_cli,
# System prompt loaded from CLAUDE.md via setting_sources
system_prompt={"type": "file", "path": str(self._system_prompt_file)},
# Include "user" for global skills and subagents from ~/.claude/
setting_sources=["project", "user"],
allowed_tools=[
Expand Down
54 changes: 54 additions & 0 deletions test_system_prompt_file.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""
Tests for passing chat-session system prompts via file (issue #8).

Long system prompts must not be passed on the command line, because Windows
limits command lines to ~8191 characters ("The command line is too long.").
"""

from claude_agent_sdk import ClaudeAgentOptions
from claude_agent_sdk._internal.transport.subprocess_cli import SubprocessCLITransport

from server.services.chat_constants import remove_system_prompt_file, write_system_prompt_file


def test_write_and_remove_system_prompt_file(tmp_path):
prompt = "x" * 20000 + " ünïcode"
path = write_system_prompt_file(tmp_path, "spec", prompt)

assert path.exists()
assert path.parent == (tmp_path / ".autoforge").resolve()
assert path.read_text(encoding="utf-8") == prompt
# Runtime file is gitignored
assert ".system_prompt.*.md" in (tmp_path / ".autoforge" / ".gitignore").read_text(encoding="utf-8")
# Does not touch the project's CLAUDE.md
assert not (tmp_path / "CLAUDE.md").exists()

remove_system_prompt_file(path)
assert not path.exists()
# Idempotent and tolerant of None
remove_system_prompt_file(path)
remove_system_prompt_file(None)


def test_unique_file_per_session(tmp_path):
a = write_system_prompt_file(tmp_path, "expand", "a")
b = write_system_prompt_file(tmp_path, "expand", "b")
assert a != b
assert a.read_text(encoding="utf-8") == "a"
assert b.read_text(encoding="utf-8") == "b"


def test_prompt_not_on_command_line(tmp_path):
prompt = "LONG_PROMPT_MARKER " + "y" * 20000
path = write_system_prompt_file(tmp_path, "spec", prompt)
options = ClaudeAgentOptions(
system_prompt={"type": "file", "path": str(path)},
cli_path="claude",
cwd=str(tmp_path),
)
cmd = SubprocessCLITransport(prompt="", options=options)._build_command()

assert "--system-prompt-file" in cmd
assert cmd[cmd.index("--system-prompt-file") + 1] == str(path)
assert not any("LONG_PROMPT_MARKER" in arg for arg in cmd)
assert len(" ".join(cmd)) < 8191