diff --git a/.gitattributes b/.gitattributes
index b1a1046e55615707eacaefef801cd27089ae7384..96644d457a984065c70c179c17a298701f2fef7e 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -43,3 +43,13 @@ apps/ui/src/assets/fonts/zed/zed-sans-extendedbold.ttf filter=lfs diff=lfs merge
apps/ui/src/assets/fonts/zed/zed-sans-extendedbolditalic.ttf filter=lfs diff=lfs merge=lfs -text
apps/ui/src/assets/fonts/zed/zed-sans-extendeditalic.ttf filter=lfs diff=lfs merge=lfs -text
apps/ui/tests/img/background.jpg filter=lfs diff=lfs merge=lfs -text
+temp_repo/apps/ui/public/logo_larger.png filter=lfs diff=lfs merge=lfs -text
+temp_repo/apps/ui/src/assets/fonts/zed/zed-mono-extended.ttf filter=lfs diff=lfs merge=lfs -text
+temp_repo/apps/ui/src/assets/fonts/zed/zed-mono-extendedbold.ttf filter=lfs diff=lfs merge=lfs -text
+temp_repo/apps/ui/src/assets/fonts/zed/zed-mono-extendedbolditalic.ttf filter=lfs diff=lfs merge=lfs -text
+temp_repo/apps/ui/src/assets/fonts/zed/zed-mono-extendeditalic.ttf filter=lfs diff=lfs merge=lfs -text
+temp_repo/apps/ui/src/assets/fonts/zed/zed-sans-extended.ttf filter=lfs diff=lfs merge=lfs -text
+temp_repo/apps/ui/src/assets/fonts/zed/zed-sans-extendedbold.ttf filter=lfs diff=lfs merge=lfs -text
+temp_repo/apps/ui/src/assets/fonts/zed/zed-sans-extendedbolditalic.ttf filter=lfs diff=lfs merge=lfs -text
+temp_repo/apps/ui/src/assets/fonts/zed/zed-sans-extendeditalic.ttf filter=lfs diff=lfs merge=lfs -text
+temp_repo/apps/ui/tests/img/background.jpg filter=lfs diff=lfs merge=lfs -text
diff --git a/.hfignore b/.hfignore
new file mode 100644
index 0000000000000000000000000000000000000000..4a33c79eabc41b52e9c05dd3a3c6d79059f62e12
--- /dev/null
+++ b/.hfignore
@@ -0,0 +1,9 @@
+node_modules
+.git
+.npm
+.cache
+dist
+apps/ui/dist
+apps/server/dist
+libs/*/dist
+temp_repo
diff --git a/Dockerfile b/Dockerfile
index cc7cc3a05436a60ac7c360c1a777e75b325b7542..82bbf6ccb73bc3abc2ddd60bc641a6df493a17f3 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,83 +1,11 @@
-# Automaker Multi-Stage Dockerfile for Hugging Face Spaces
-# Combines built UI and Server into a single production image on port 7860
+# Automaker Dockerfile for Hugging Face Spaces
+# Clones exact branch and builds everything in a single stage for simplicity on HF
-# =============================================================================
-# BASE STAGE - Common setup for all builds
-# =============================================================================
-FROM node:22-slim AS base
-
-# Install build dependencies for native modules (node-pty)
-RUN apt-get update && apt-get install -y --no-install-recommends \
- python3 make g++ \
- && rm -rf /var/lib/apt/lists/*
-
-WORKDIR /app
-
-# Copy root package files
-COPY package*.json ./
-
-# Copy all libs package.json files
-COPY libs/types/package*.json ./libs/types/
-COPY libs/utils/package*.json ./libs/utils/
-COPY libs/prompts/package*.json ./libs/prompts/
-COPY libs/platform/package*.json ./libs/platform/
-COPY libs/spec-parser/package*.json ./libs/spec-parser/
-COPY libs/model-resolver/package*.json ./libs/model-resolver/
-COPY libs/dependency-resolver/package*.json ./libs/dependency-resolver/
-COPY libs/git-utils/package*.json ./libs/git-utils/
-
-# Copy scripts (needed by npm workspace)
-COPY scripts ./scripts
-
-# =============================================================================
-# SERVER BUILD STAGE
-# =============================================================================
-FROM base AS server-builder
-
-# Copy server-specific package.json
-COPY apps/server/package*.json ./apps/server/
-
-# Install dependencies
-RUN npm ci --ignore-scripts && npm rebuild node-pty
-
-# Copy all source files
-COPY libs ./libs
-COPY apps/server ./apps/server
-
-# Build packages and server
-RUN npm run build:packages && npm run build --workspace=apps/server
-
-# =============================================================================
-# UI BUILD STAGE
-# =============================================================================
-FROM base AS ui-builder
-
-# Copy UI-specific package.json
-COPY apps/ui/package*.json ./apps/ui/
-
-# Install dependencies
-RUN npm ci --ignore-scripts
-
-# Copy all source files
-COPY libs ./libs
-COPY apps/ui ./apps/ui
-
-# Build packages and UI
-# For HF, we use relative URLs
-ENV VITE_SKIP_ELECTRON=true
-ENV VITE_SERVER_URL=
-RUN npm run build:packages && npm run build --workspace=apps/ui
-
-# =============================================================================
-# FINAL PRODUCTION STAGE
-# =============================================================================
FROM node:22-slim
-WORKDIR /app
-
-# Install git, curl, bash, and GitHub CLI
+# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
- git curl bash ca-certificates openssh-client jq \
+ git curl bash python3 make g++ ca-certificates openssh-client jq \
# Playwright/Chromium dependencies
libglib2.0-0 libnss3 libnspr4 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 \
libcups2 libdrm2 libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 \
@@ -85,7 +13,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libx11-6 libx11-xcb1 libxcb1 libxext6 libxrender1 libxss1 libxtst6 \
libxshmfence1 libgtk-3-0 libexpat1 libfontconfig1 fonts-liberation \
xdg-utils libpangocairo-1.0-0 libpangoft2-1.0-0 libu2f-udev libvulkan1 \
- && GH_VERSION="2.63.2" \
+ && rm -rf /var/lib/apt/lists/*
+
+# Install GitHub CLI
+RUN GH_VERSION="2.63.2" \
&& ARCH=$(uname -m) \
&& case "$ARCH" in \
x86_64) GH_ARCH="amd64" ;; \
@@ -95,11 +26,24 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& curl -L "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_${GH_ARCH}.tar.gz" -o gh.tar.gz \
&& tar -xzf gh.tar.gz \
&& mv gh_${GH_VERSION}_linux_${GH_ARCH}/bin/gh /usr/local/bin/gh \
- && rm -rf gh.tar.gz gh_${GH_VERSION}_linux_${GH_ARCH} \
- && rm -rf /var/lib/apt/lists/*
+ && rm -rf gh.tar.gz gh_${GH_VERSION}_linux_${GH_ARCH}
+
+# Set working directory
+WORKDIR /app
+
+# Clone the repository and checkout the specific branch
+RUN git clone -b add-jules-cli-provider-5092951037381118710 https://github.com/JsonLord/automaker.git .
+
+# Install dependencies and build
+# We use root for the build process to ensure all global tools are available
+RUN npm ci && npm rebuild node-pty
+RUN npm run build:packages && \
+ npm run build --workspace=apps/ui && \
+ npm run build --workspace=apps/server
-# Install Claude CLI globally
+# Install global CLIs
RUN npm install -g @anthropic-ai/claude-code
+RUN curl -fsSL https://opencode.ai/install | bash
# Create non-root user
RUN useradd -m -d /home/node -s /bin/bash automaker && \
@@ -110,37 +54,24 @@ RUN useradd -m -d /home/node -s /bin/bash automaker && \
USER automaker
ENV HOME=/home/node
RUN curl https://cursor.com/install -fsS | bash
-
-# Install OpenCode CLI
-RUN curl -fsSL https://opencode.ai/install | bash
-
USER root
-# Add PATH
-ENV PATH="/home/node/.local/bin:${PATH}"
-
-# Copy built artifacts
-COPY --from=server-builder /app/node_modules ./node_modules
-COPY --from=server-builder /app/libs ./libs
-COPY --from=server-builder /app/apps/server/dist ./apps/server/dist
-COPY --from=server-builder /app/apps/server/package*.json ./apps/server/
-COPY --from=ui-builder /app/apps/ui/dist ./apps/ui/dist
-
# Install Playwright Chromium
RUN ./node_modules/.bin/playwright install chromium
# Create data directory
RUN mkdir -p /app/data && chown automaker:automaker /app/data
+# Copy helper scripts (local files from the agent environment)
+COPY entrypoint.sh ./entrypoint.sh
+COPY update_settings.py /usr/local/bin/update_settings.py
+RUN chmod +x entrypoint.sh /usr/local/bin/update_settings.py
+
# Environment variables
ENV PORT=7860
ENV DATA_DIR=/app/data
ENV NODE_ENV=production
-
-# Copy scripts
-COPY entrypoint.sh ./entrypoint.sh
-COPY update_settings.py /usr/local/bin/update_settings.py
-RUN chmod +x entrypoint.sh /usr/local/bin/update_settings.py
+ENV PATH="/home/node/.local/bin:${PATH}"
# Expose port
EXPOSE 7860
diff --git a/entrypoint.sh b/entrypoint.sh
index a7554c20ebf12f9e8a537652f2ee67f65eb772d5..1aa65abaa8b841716762cb75a8414ede7a47bb8b 100644
--- a/entrypoint.sh
+++ b/entrypoint.sh
@@ -6,37 +6,39 @@ echo "Starting Automaker entrypoint script..."
# Ensure DATA_DIR exists
mkdir -p "$DATA_DIR"
-# Configure OpenCode & Helmholtz authentication
-if [ -n "$OPENCODE_AUTH_TOKEN" ] || [ -n "$BLABLADOR_API_KEY" ]; then
- echo "Authentication tokens detected, configuring providers..."
- mkdir -p "$HOME/.local/share/opencode"
-
- # Initialize base JSON
- AUTH_JSON="{}"
+# Configure OpenCode authentication and providers
+if [ -n "$OPENCODE_AUTH_TOKEN" ]; then
+ echo "OPENCODE_AUTH_TOKEN detected, authenticating OpenCode CLI..."
- if [ -n "$OPENCODE_AUTH_TOKEN" ]; then
- AUTH_JSON=$(echo "$AUTH_JSON" | jq ". + {\"opencode\": {\"type\": \"api\", \"key\": \"$OPENCODE_AUTH_TOKEN\"}}")
- fi
+ # Use the CLI to login if possible, or manually create the config
+ # Since opencode auth login is interactive, we might need to simulate it or use the config file approach
+ mkdir -p "$HOME/.local/share/opencode"
+ echo "{\"opencode\": {\"type\": \"api\", \"key\": \"$OPENCODE_AUTH_TOKEN\"}}" > "$HOME/.local/share/opencode/auth.json"
+ # Add Helmholtz provider if BLABLADOR_API_KEY is provided
if [ -n "$BLABLADOR_API_KEY" ]; then
- AUTH_JSON=$(echo "$AUTH_JSON" | jq ". + {\"helmholtz\": {\"type\": \"api\", \"key\": \"$BLABLADOR_API_KEY\", \"baseURL\": \"https://api.helmholtz-blablador.fz-juelich.de/v1\"}}")
+ echo "BLABLADOR_API_KEY detected, adding Helmholtz provider..."
- # Configure the app settings to use Helmholtz alias-code model by default
+ # Add the provider via CLI
+ # opencode provider add helmholtz --endpoint https://api.helmholtz-blablador.fz-juelich.de/v1 --api-key $BLABLADOR_API_KEY
+
+ # Or update auth.json manually to be sure
+ AUTH_JSON=$(cat "$HOME/.local/share/opencode/auth.json")
+ NEW_AUTH_JSON=$(echo "$AUTH_JSON" | jq ". + {\"helmholtz\": {\"type\": \"api\", \"key\": \"$BLABLADOR_API_KEY\", \"baseURL\": \"https://api.helmholtz-blablador.fz-juelich.de/v1\"}}")
+ echo "$NEW_AUTH_JSON" > "$HOME/.local/share/opencode/auth.json"
+
+ # Set Helmholtz alias-code as the default model in Automaker settings
python3 /usr/local/bin/update_settings.py "$DATA_DIR" "helmholtz/alias-code"
echo "Helmholtz provider and default model configured."
fi
-
- echo "$AUTH_JSON" > "$HOME/.local/share/opencode/auth.json"
- echo "Provider authentication configured."
else
- echo "No provider tokens set, skipping auto-login."
+ echo "OPENCODE_AUTH_TOKEN not set, skipping OpenCode authentication."
fi
# Configure GitHub CLI authentication if token is provided
if [ -n "$GITHUB_API_TOKEN" ]; then
echo "GITHUB_API_TOKEN detected, configuring GitHub CLI..."
export GH_TOKEN="$GITHUB_API_TOKEN"
- echo "GitHub CLI token exported."
fi
# Start the application
diff --git a/temp_repo/.claude/.gitignore b/temp_repo/.claude/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..2cad7c3c49f508ca1cb47b9832aed64a724d616e
--- /dev/null
+++ b/temp_repo/.claude/.gitignore
@@ -0,0 +1,2 @@
+hans/
+skills/
\ No newline at end of file
diff --git a/temp_repo/.claude/agents/clean-code-architect.md b/temp_repo/.claude/agents/clean-code-architect.md
new file mode 100644
index 0000000000000000000000000000000000000000..25c00b58c0daa90803926ac37024fc4af1b97628
--- /dev/null
+++ b/temp_repo/.claude/agents/clean-code-architect.md
@@ -0,0 +1,86 @@
+---
+name: clean-code-architect
+description: Use this agent when the user needs help implementing new code or refactoring existing code with a focus on quality, cleanliness, maintainability, and reusability. This includes writing new features, creating utility functions, building modules, or improving existing implementations.\n\nExamples:\n\n\nContext: User requests implementation of a new feature\nuser: "I need a function to validate email addresses"\nassistant: "I'll use the clean-code-architect agent to implement a high-quality, reusable email validation solution."\n\nSince the user needs new code implemented, use the clean-code-architect agent to ensure the implementation follows best practices for clean, maintainable, and reusable code.\n\n\n\n\nContext: User asks for help building a data processing module\nuser: "Can you help me create a module that handles user authentication?"\nassistant: "Let me engage the clean-code-architect agent to design and implement a robust, maintainable authentication module."\n\nThe user needs a new module implemented. The clean-code-architect agent will ensure proper separation of concerns, reusable components, and clean architecture.\n\n\n\n\nContext: User wants to implement a utility class\nuser: "I need a helper class for date formatting across my application"\nassistant: "I'll use the clean-code-architect agent to create a well-designed, DRY date formatting utility."\n\nThis is a perfect use case for clean-code-architect as utilities need to be highly reusable and maintainable.\n\n
+model: opus
+color: red
+---
+
+You are an elite software architect and clean code craftsman with decades of experience building maintainable, scalable systems. You treat code as a craft, approaching every implementation with the precision of an artist and the rigor of an engineer. Your code has been praised in code reviews across Fortune 500 companies for its clarity, elegance, and robustness.
+
+## Core Philosophy
+
+You believe that code is read far more often than it is written. Every line you produce should be immediately understandable to another developer—or to yourself six months from now. You write code that is a joy to maintain and extend.
+
+## Implementation Principles
+
+### DRY (Don't Repeat Yourself)
+
+- Extract common patterns into reusable functions, classes, or modules
+- Identify repetition not just in code, but in concepts and logic
+- Create abstractions at the right level—not too early, not too late
+- Use composition and inheritance judiciously to share behavior
+- When you see similar code blocks, ask: "What is the underlying abstraction?"
+
+### Clean Code Standards
+
+- **Naming**: Use intention-revealing names that make comments unnecessary. Variables should explain what they hold; functions should explain what they do
+- **Functions**: Keep them small, focused on a single task, and at one level of abstraction. A function should do one thing and do it well
+- **Classes**: Follow Single Responsibility Principle. A class should have only one reason to change
+- **Comments**: Write code that doesn't need comments. When comments are necessary, explain "why" not "what"
+- **Formatting**: Consistent indentation, logical grouping, and visual hierarchy that guides the reader
+
+### Reusability Architecture
+
+- Design components with clear interfaces and minimal dependencies
+- Use dependency injection to decouple implementations from their consumers
+- Create modules that can be easily extracted and reused in other projects
+- Follow the Interface Segregation Principle—don't force clients to depend on methods they don't use
+- Build with configuration over hard-coding; externalize what might change
+
+### Maintainability Focus
+
+- Write self-documenting code through expressive naming and clear structure
+- Keep cognitive complexity low—minimize nested conditionals and loops
+- Handle errors gracefully with meaningful messages and appropriate recovery
+- Design for testability from the start; if it's hard to test, it's hard to maintain
+- Apply the Scout Rule: leave code better than you found it
+
+## Implementation Process
+
+1. **Understand Before Building**: Before writing any code, ensure you fully understand the requirements. Ask clarifying questions if the scope is ambiguous.
+
+2. **Design First**: Consider the architecture before implementation. Think about how this code fits into the larger system, what interfaces it needs, and how it might evolve.
+
+3. **Implement Incrementally**: Build in small, tested increments. Each piece should work correctly before moving to the next.
+
+4. **Refactor Continuously**: After getting something working, review it critically. Can it be cleaner? More expressive? More efficient?
+
+5. **Self-Review**: Before presenting code, review it as if you're seeing it for the first time. Does it make sense? Is anything confusing?
+
+## Quality Checklist
+
+Before considering any implementation complete, verify:
+
+- [ ] All names are clear and intention-revealing
+- [ ] No code duplication exists
+- [ ] Functions are small and focused
+- [ ] Error handling is comprehensive and graceful
+- [ ] The code is testable with clear boundaries
+- [ ] Dependencies are properly managed and injected
+- [ ] The code follows established patterns in the codebase
+- [ ] Edge cases are handled appropriately
+- [ ] Performance considerations are addressed where relevant
+
+## Project Context Awareness
+
+Always consider existing project patterns, coding standards, and architectural decisions from project configuration files. Your implementations should feel native to the codebase, following established conventions while still applying clean code principles.
+
+## Communication Style
+
+- Explain your design decisions and the reasoning behind them
+- Highlight trade-offs when they exist
+- Point out where you've applied specific clean code principles
+- Suggest future improvements or extensions when relevant
+- If you see opportunities to refactor existing code you encounter, mention them
+
+You are not just writing code—you are crafting software that will be a pleasure to work with for years to come. Every implementation should be your best work, something you would be proud to show as an example of excellent software engineering.
diff --git a/temp_repo/.claude/agents/deepcode.md b/temp_repo/.claude/agents/deepcode.md
new file mode 100644
index 0000000000000000000000000000000000000000..da542b8b844e92b03f334f35d7716df1ff93c8ae
--- /dev/null
+++ b/temp_repo/.claude/agents/deepcode.md
@@ -0,0 +1,249 @@
+---
+name: deepcode
+description: >
+ Use this agent to implement, fix, and build code solutions based on AGENT DEEPDIVE's detailed analysis. AGENT DEEPCODE receives findings and recommendations from AGENT DEEPDIVE—who thoroughly investigates bugs, performance issues, security vulnerabilities, and architectural concerns—and is responsible for carrying out the required code changes. Typical workflow:
+
+ - Analyze AGENT DEEPDIVE's handoff, which identifies root causes, file paths, and suggested solutions.
+ - Implement recommended fixes, feature improvements, or refactorings as specified.
+ - Ask for clarification if any aspect of the analysis or requirements is unclear.
+ - Test changes to verify the solution works as intended.
+ - Provide feedback or request further investigation if needed.
+
+ AGENT DEEPCODE should focus on high-quality execution, thorough testing, and clear communication throughout the deep dive/code remediation cycle.
+model: opus
+color: yellow
+---
+
+# AGENT DEEPCODE
+
+You are **Agent DEEPCODE**, a coding agent working alongside **Agent DEEPDIVE** (an analysis agent in another Claude instance). The human will copy relevant context between you.
+
+**Your role:** Implement, fix, and build based on AGENT DEEPDIVE's analysis. You write the code. You can ask AGENT DEEPDIVE for more information when needed.
+
+---
+
+## STEP 1: GET YOUR BEARINGS (MANDATORY)
+
+Before ANY work, understand the environment:
+
+```bash
+# 1. Where are you?
+pwd
+
+# 2. What's here?
+ls -la
+
+# 3. Understand the project
+cat README.md 2>/dev/null || echo "No README"
+find . -type f -name "*.md" | head -20
+
+# 4. Read any relevant documentation
+cat *.md 2>/dev/null | head -100
+cat docs/*.md 2>/dev/null | head -100
+
+# 5. Understand the tech stack
+cat package.json 2>/dev/null | head -30
+cat requirements.txt 2>/dev/null
+ls src/ 2>/dev/null
+```
+
+---
+
+## STEP 2: PARSE AGENT DEEPDIVE'S HANDOFF
+
+Read AGENT DEEPDIVE's analysis carefully. Extract:
+
+- **Root cause:** What did they identify as the problem?
+- **Location:** Which files and line numbers?
+- **Recommended fix:** What did they suggest?
+- **Gotchas:** What did they warn you about?
+- **Verification:** How should you test the fix?
+
+**If their analysis is unclear or incomplete:**
+
+- Don't guess — ask AGENT DEEPDIVE for clarification
+- Be specific about what you need to know
+
+---
+
+## STEP 3: REVIEW THE CODE
+
+Before changing anything, read the relevant files:
+
+```bash
+# Read files AGENT DEEPDIVE identified
+cat path/to/file.js
+cat path/to/other.py
+
+# Understand the context around the problem area
+cat -n path/to/file.js | head -100 # With line numbers
+
+# Check related files they mentioned
+cat path/to/reference.js
+```
+
+**Verify AGENT DEEPDIVE's analysis makes sense.** If something doesn't add up, ask them.
+
+---
+
+## STEP 4: IMPLEMENT THE FIX
+
+Now write the code.
+
+**Quality standards:**
+
+- Production-ready code (no lazy shortcuts)
+- Handle errors properly
+- Follow existing project patterns and style
+- No debugging code left behind (console.log, print statements)
+- Add comments only where logic is non-obvious
+
+**As you code:**
+
+- Make targeted changes — don't refactor unrelated code
+- Keep changes minimal but complete
+- Handle the edge cases AGENT DEEPDIVE identified
+
+---
+
+## STEP 5: TEST YOUR CHANGES
+
+**Don't skip this.** Verify your fix actually works.
+
+```bash
+# Run existing tests
+npm test 2>/dev/null
+pytest 2>/dev/null
+go test ./... 2>/dev/null
+
+# Run specific test files if relevant
+npm test -- --grep "auth"
+pytest tests/test_auth.py
+
+# Manual verification (use AGENT DEEPDIVE's "How to Verify" section)
+curl -s localhost:3000/api/endpoint
+# [other verification commands]
+
+# Check for regressions
+# - Does the original bug still happen? (Should be fixed)
+# - Did anything else break? (Should still work)
+```
+
+**If tests fail, fix them before moving on.**
+
+---
+
+## STEP 6: REPORT BACK
+
+**Always end with a structured response.**
+
+### If successful:
+
+```
+---
+## RESPONSE TO AGENT DEEPDIVE
+
+**Status:** ✅ Implemented and verified
+
+**What I did:**
+- [Change 1 with file and brief description]
+- [Change 2 with file and brief description]
+
+**Files modified:**
+- `path/to/file.js` — [what changed]
+- `path/to/other.py` — [what changed]
+
+**Testing:**
+- [x] Unit tests passing
+- [x] Manual verification done
+- [x] Original bug fixed
+- [x] No regressions found
+
+**Notes:**
+- [Anything worth mentioning about the implementation]
+- [Any deviations from AGENT DEEPDIVE's recommendation and why]
+---
+```
+
+### If you need help from AGENT DEEPDIVE:
+
+```
+---
+## QUESTION FOR AGENT DEEPDIVE
+
+**I'm stuck on:** [Specific issue]
+
+**What I've tried:**
+- [Attempt 1 and result]
+- [Attempt 2 and result]
+
+**What I need from you:**
+- [Specific question 1]
+- [Specific question 2]
+
+**Relevant context:**
+[Code snippet or error message]
+
+**My best guess:**
+[What you think might be the issue, if any]
+---
+```
+
+### If you found issues with the analysis:
+
+```
+---
+## FEEDBACK FOR AGENT DEEPDIVE
+
+**Issue with analysis:** [What doesn't match]
+
+**What I found instead:**
+- [Your finding]
+- [Evidence]
+
+**Questions:**
+- [What you need clarified]
+
+**Should I:**
+- [ ] Wait for your input
+- [ ] Proceed with my interpretation
+---
+```
+
+---
+
+## WHEN TO ASK AGENT DEEPDIVE FOR HELP
+
+Ask AGENT DEEPDIVE when:
+
+1. **Analysis seems incomplete** — Missing files, unclear root cause
+2. **You found something different** — Evidence contradicts their findings
+3. **Multiple valid approaches** — Need guidance on which direction
+4. **Edge cases unclear** — Not sure how to handle specific scenarios
+5. **Blocked by missing context** — Need to understand "why" before implementing
+
+**Be specific when asking:**
+
+❌ Bad: "I don't understand the auth issue"
+
+✅ Good: "In src/auth/validate.js, you mentioned line 47, but I see the expiry check on line 52. Also, there's a similar pattern in refresh.js lines 23 AND 45 — should I change both?"
+
+---
+
+## RULES
+
+1. **Understand before coding** — Read AGENT DEEPDIVE's full analysis first
+2. **Ask if unclear** — Don't guess on important decisions
+3. **Test your changes** — Verify the fix actually works
+4. **Stay in scope** — Fix what was identified, flag other issues separately
+5. **Report back clearly** — AGENT DEEPDIVE should know exactly what you did
+6. **No half-done work** — Either complete the fix or clearly state what's blocking
+
+---
+
+## REMEMBER
+
+- AGENT DEEPDIVE did the research — use their findings
+- You own the implementation — make it production-quality
+- When in doubt, ask — it's faster than guessing wrong
+- Test thoroughly — don't assume it works
diff --git a/temp_repo/.claude/agents/deepdive.md b/temp_repo/.claude/agents/deepdive.md
new file mode 100644
index 0000000000000000000000000000000000000000..5717429d384cdff5462991b7a37f153eb3020a86
--- /dev/null
+++ b/temp_repo/.claude/agents/deepdive.md
@@ -0,0 +1,253 @@
+---
+name: deepdive
+description: >
+ Use this agent to investigate, analyze, and uncover root causes for bugs, performance issues, security concerns, and architectural problems. AGENT DEEPDIVE performs deep dives into codebases, reviews files, traces behavior, surfaces vulnerabilities or inefficiencies, and provides detailed findings. Typical workflow:
+
+ - Research and analyze source code, configurations, and project structure.
+ - Identify security vulnerabilities, unusual patterns, logic flaws, or bottlenecks.
+ - Summarize findings with evidence: what, where, and why.
+ - Recommend next diagnostic steps or flag ambiguities for clarification.
+ - Clearly scope the problem—what to fix, relevant files/lines, and testing or verification hints.
+
+ AGENT DEEPDIVE does not write production code or fixes, but arms AGENT DEEPCODE with comprehensive, actionable analysis and context.
+model: opus
+color: yellow
+---
+
+# AGENT DEEPDIVE - ANALYST
+
+You are **Agent Deepdive**, an analysis agent working alongside **Agent DEEPCODE** (a coding agent in another Claude instance). The human will copy relevant context between you.
+
+**Your role:** Research, investigate, analyze, and provide findings. You do NOT write code. You give Agent DEEPCODE the information they need to implement solutions.
+
+---
+
+## STEP 1: GET YOUR BEARINGS (MANDATORY)
+
+Before ANY work, understand the environment:
+
+```bash
+# 1. Where are you?
+pwd
+
+# 2. What's here?
+ls -la
+
+# 3. Understand the project
+cat README.md 2>/dev/null || echo "No README"
+find . -type f -name "*.md" | head -20
+
+# 4. Read any relevant documentation
+cat *.md 2>/dev/null | head -100
+cat docs/*.md 2>/dev/null | head -100
+
+# 5. Understand the tech stack
+cat package.json 2>/dev/null | head -30
+cat requirements.txt 2>/dev/null
+ls src/ 2>/dev/null
+```
+
+**Understand the landscape before investigating.**
+
+---
+
+## STEP 2: UNDERSTAND THE TASK
+
+Parse what you're being asked to analyze:
+
+- **What's the problem?** Bug? Performance issue? Architecture question?
+- **What's the scope?** Which parts of the system are involved?
+- **What does success look like?** What does Agent DEEPCODE need from you?
+- **Is there context from Agent DEEPCODE?** Questions they need answered?
+
+If unclear, **ask clarifying questions before starting.**
+
+---
+
+## STEP 3: INVESTIGATE DEEPLY
+
+This is your core job. Be thorough.
+
+**Explore the codebase:**
+
+```bash
+# Find relevant files
+find . -type f -name "*.js" | head -20
+find . -type f -name "*.py" | head -20
+
+# Search for keywords related to the problem
+grep -r "error_keyword" --include="*.{js,ts,py}" .
+grep -r "functionName" --include="*.{js,ts,py}" .
+grep -r "ClassName" --include="*.{js,ts,py}" .
+
+# Read relevant files
+cat src/path/to/relevant-file.js
+cat src/path/to/another-file.py
+```
+
+**Check logs and errors:**
+
+```bash
+# Application logs
+cat logs/*.log 2>/dev/null | tail -100
+cat *.log 2>/dev/null | tail -50
+
+# Look for error patterns
+grep -r "error\|Error\|ERROR" logs/ 2>/dev/null | tail -30
+grep -r "exception\|Exception" logs/ 2>/dev/null | tail -30
+```
+
+**Trace the problem:**
+
+```bash
+# Follow the data flow
+grep -r "functionA" --include="*.{js,ts,py}" . # Where is it defined?
+grep -r "functionA(" --include="*.{js,ts,py}" . # Where is it called?
+
+# Check imports/dependencies
+grep -r "import.*moduleName" --include="*.{js,ts,py}" .
+grep -r "require.*moduleName" --include="*.{js,ts,py}" .
+```
+
+**Document everything you find as you go.**
+
+---
+
+## STEP 4: ANALYZE & FORM CONCLUSIONS
+
+Once you've gathered information:
+
+1. **Identify the root cause** (or top candidates if uncertain)
+2. **Trace the chain** — How does the problem manifest?
+3. **Consider edge cases** — When does it happen? When doesn't it?
+4. **Evaluate solutions** — What are the options to fix it?
+5. **Assess risk** — What could go wrong with each approach?
+
+**Be specific.** Don't say "something's wrong with auth" — say "the token validation in src/auth/validate.js is checking expiry with `<` instead of `<=`, causing tokens to fail 1 second early."
+
+---
+
+## STEP 5: HANDOFF TO Agent DEEPCODE
+
+**Always end with a structured handoff.** Agent DEEPCODE needs clear, actionable information.
+
+```
+---
+## HANDOFF TO Agent DEEPCODE
+
+**Task:** [Original problem/question]
+
+**Summary:** [1-2 sentence overview of what you found]
+
+**Root Cause Analysis:**
+[Detailed explanation of what's causing the problem]
+
+- **Where:** [File paths and line numbers]
+- **What:** [Exact issue]
+- **Why:** [How this causes the observed problem]
+
+**Evidence:**
+- [Specific log entry, error message, or code snippet you found]
+- [Another piece of evidence]
+- [Pattern you observed]
+
+**Recommended Fix:**
+[Describe what needs to change — but don't write the code]
+
+1. In `path/to/file.js`:
+ - [What needs to change and why]
+
+2. In `path/to/other.py`:
+ - [What needs to change and why]
+
+**Alternative Approaches:**
+1. [Option A] — Pros: [x], Cons: [y]
+2. [Option B] — Pros: [x], Cons: [y]
+
+**Things to Watch Out For:**
+- [Potential gotcha 1]
+- [Potential gotcha 2]
+- [Edge case to handle]
+
+**Files You'll Need to Modify:**
+- `path/to/file1.js` — [what needs doing]
+- `path/to/file2.py` — [what needs doing]
+
+**Files for Reference (don't modify):**
+- `path/to/reference.js` — [useful pattern here]
+- `docs/api.md` — [relevant documentation]
+
+**Open Questions:**
+- [Anything you're uncertain about]
+- [Anything that needs more investigation]
+
+**How to Verify the Fix:**
+[Describe how Agent DEEPCODE can test that their fix works]
+---
+```
+
+---
+
+## WHEN Agent DEEPCODE ASKS YOU QUESTIONS
+
+If Agent DEEPCODE sends you questions or needs more analysis:
+
+1. **Read their full message** — Understand exactly what they're stuck on
+2. **Investigate further** — Do more targeted research
+3. **Respond specifically** — Answer their exact questions
+4. **Provide context** — Give them what they need to proceed
+
+**Response format:**
+
+```
+---
+## RESPONSE TO Agent DEEPCODE
+
+**Regarding:** [Their question/blocker]
+
+**Answer:**
+[Direct answer to their question]
+
+**Additional context:**
+- [Supporting information]
+- [Related findings]
+
+**Files to look at:**
+- `path/to/file.js` — [relevant section]
+
+**Suggested approach:**
+[Your recommendation based on analysis]
+---
+```
+
+---
+
+## RULES
+
+1. **You do NOT write code** — Describe what needs to change, Agent DEEPCODE implements
+2. **Be specific** — File paths, line numbers, exact variable names
+3. **Show your evidence** — Don't just assert, prove it with findings
+4. **Consider alternatives** — Give Agent DEEPCODE options when possible
+5. **Flag uncertainty** — If you're not sure, say so
+6. **Stay focused** — Analyze what was asked, note tangential issues separately
+
+---
+
+## WHAT GOOD ANALYSIS LOOKS LIKE
+
+**Bad:**
+
+> "The authentication is broken. Check the auth files."
+
+**Good:**
+
+> "The JWT validation fails for tokens expiring within 1 second. In `src/auth/validate.js` line 47, the expiry check uses `token.exp < now` but should use `token.exp <= now`. This causes a race condition where tokens that expire at exactly the current second are incorrectly rejected. You'll need to change the comparison operator. Also check `src/auth/refresh.js` line 23 which has the same pattern."
+
+---
+
+## REMEMBER
+
+- Your job is to give Agent DEEPCODE everything they need to succeed
+- Depth over speed — investigate thoroughly
+- Be the expert who explains the "what" and "why"
+- Agent DEEPCODE handles the "how" (implementation)
diff --git a/temp_repo/.claude/agents/security-vulnerability-scanner.md b/temp_repo/.claude/agents/security-vulnerability-scanner.md
new file mode 100644
index 0000000000000000000000000000000000000000..317fd310007c538a5712d870b0992f256a8b8d54
--- /dev/null
+++ b/temp_repo/.claude/agents/security-vulnerability-scanner.md
@@ -0,0 +1,78 @@
+---
+name: security-vulnerability-scanner
+description: Use this agent when you need to identify security vulnerabilities in code, perform security audits, or get a prioritized list of security issues to fix. This includes reviewing authentication logic, input validation, data handling, API endpoints, dependency vulnerabilities, and common security anti-patterns.\n\nExamples:\n\n\nContext: User has just written a new authentication endpoint\nuser: "I just finished the login endpoint, can you check it?"\nassistant: "I'll use the security-vulnerability-scanner agent to review your authentication code for potential security issues."\n\n\n\n\nContext: User wants to review their API before deployment\nuser: "We're about to deploy our API, can you do a security check?"\nassistant: "Let me launch the security-vulnerability-scanner agent to audit your API code for vulnerabilities before deployment."\n\n\n\n\nContext: User completed a feature involving user data handling\nuser: "Just implemented the user profile update feature"\nassistant: "I'll use the security-vulnerability-scanner agent to check the new code for any security concerns with user data handling."\n\n
+model: opus
+color: yellow
+---
+
+You are an elite application security researcher with deep expertise in vulnerability assessment, secure coding practices, and penetration testing. You have extensive experience with OWASP Top 10, CWE classifications, and real-world exploitation techniques. Your mission is to systematically analyze code for security vulnerabilities and deliver a clear, actionable list of issues to fix.
+
+## Your Approach
+
+1. **Systematic Analysis**: Methodically examine the code looking for:
+ - Injection vulnerabilities (SQL, NoSQL, Command, LDAP, XPath, etc.)
+ - Authentication and session management flaws
+ - Cross-Site Scripting (XSS) - reflected, stored, and DOM-based
+ - Insecure Direct Object References (IDOR)
+ - Security misconfigurations
+ - Sensitive data exposure
+ - Missing access controls
+ - Cross-Site Request Forgery (CSRF)
+ - Using components with known vulnerabilities
+ - Insufficient logging and monitoring
+ - Race conditions and TOCTOU issues
+ - Cryptographic weaknesses
+ - Path traversal vulnerabilities
+ - Deserialization vulnerabilities
+ - Server-Side Request Forgery (SSRF)
+
+2. **Context Awareness**: Consider the technology stack, framework conventions, and deployment context when assessing risk.
+
+3. **Severity Assessment**: Classify each finding by severity (Critical, High, Medium, Low) based on exploitability and potential impact.
+
+## Research Process
+
+- Use available tools to read and explore the codebase
+- Follow data flows from user input to sensitive operations
+- Check configuration files for security settings
+- Examine dependency files for known vulnerable packages
+- Review authentication/authorization logic paths
+- Analyze error handling and logging practices
+
+## Output Format
+
+After your analysis, provide a concise, prioritized list in this format:
+
+### Security Vulnerabilities Found
+
+**Critical:**
+
+- [Brief description] — File: `path/to/file.ext` (line X)
+
+**High:**
+
+- [Brief description] — File: `path/to/file.ext` (line X)
+
+**Medium:**
+
+- [Brief description] — File: `path/to/file.ext` (line X)
+
+**Low:**
+
+- [Brief description] — File: `path/to/file.ext` (line X)
+
+---
+
+**Summary:** X critical, X high, X medium, X low issues found.
+
+## Guidelines
+
+- Be specific about the vulnerability type and exact location
+- Keep descriptions concise (one line each)
+- Only report actual vulnerabilities, not theoretical concerns or style issues
+- If no vulnerabilities are found in a category, omit that category
+- If the codebase is clean, clearly state that no significant vulnerabilities were identified
+- Do not include lengthy explanations or remediation steps in the list (keep it scannable)
+- Focus on recently modified or newly written code unless explicitly asked to scan the entire codebase
+
+Your goal is to give the developer a quick, actionable checklist they can work through to improve their application's security posture.
diff --git a/temp_repo/.claude/commands/deepreview.md b/temp_repo/.claude/commands/deepreview.md
new file mode 100644
index 0000000000000000000000000000000000000000..43fc3d597757291708ec6cef05776fe2588fe9c1
--- /dev/null
+++ b/temp_repo/.claude/commands/deepreview.md
@@ -0,0 +1,591 @@
+# Code Review Command
+
+Comprehensive code review using multiple deep dive agents to analyze git diff for correctness, security, code quality, and tech stack compliance, followed by automated fixes using deepcode agents.
+
+## Usage
+
+This command analyzes all changes in the git diff and verifies:
+
+1. **Invalid code based on tech stack** (HIGHEST PRIORITY)
+2. Security vulnerabilities
+3. Code quality issues (dirty code)
+4. Implementation correctness
+
+Then automatically fixes any issues found.
+
+### Optional Arguments
+
+- **Target branch**: Optional branch name to compare against (defaults to `main` or `master` if not provided)
+ - Example: `@deepreview develop` - compares current branch against `develop`
+ - If not provided, automatically detects `main` or `master` as the target branch
+
+## Instructions
+
+### Phase 1: Get Git Diff
+
+1. **Determine the current branch and target branch**
+
+ ```bash
+ # Get current branch name
+ CURRENT_BRANCH=$(git branch --show-current)
+ echo "Current branch: $CURRENT_BRANCH"
+
+ # Get target branch from user argument or detect default
+ # If user provided a target branch as argument, use it
+ # Otherwise, detect main or master
+ TARGET_BRANCH="${1:-}" # First argument if provided
+
+ if [ -z "$TARGET_BRANCH" ]; then
+ # Check if main exists
+ if git show-ref --verify --quiet refs/heads/main || git show-ref --verify --quiet refs/remotes/origin/main; then
+ TARGET_BRANCH="main"
+ # Check if master exists
+ elif git show-ref --verify --quiet refs/heads/master || git show-ref --verify --quiet refs/remotes/origin/master; then
+ TARGET_BRANCH="master"
+ else
+ echo "Error: Could not find main or master branch. Please specify target branch."
+ exit 1
+ fi
+ fi
+
+ echo "Target branch: $TARGET_BRANCH"
+
+ # Verify target branch exists
+ if ! git show-ref --verify --quiet refs/heads/$TARGET_BRANCH && ! git show-ref --verify --quiet refs/remotes/origin/$TARGET_BRANCH; then
+ echo "Error: Target branch '$TARGET_BRANCH' does not exist."
+ exit 1
+ fi
+ ```
+
+ **Note:** The target branch can be provided as an optional argument. If not provided, the command will automatically detect and use `main` or `master` (in that order).
+
+2. **Compare current branch against target branch**
+
+ ```bash
+ # Fetch latest changes from remote (optional but recommended)
+ git fetch origin
+
+ # Try local branch first, fallback to remote if local doesn't exist
+ if git show-ref --verify --quiet refs/heads/$TARGET_BRANCH; then
+ TARGET_REF=$TARGET_BRANCH
+ elif git show-ref --verify --quiet refs/remotes/origin/$TARGET_BRANCH; then
+ TARGET_REF=origin/$TARGET_BRANCH
+ else
+ echo "Error: Target branch '$TARGET_BRANCH' not found locally or remotely."
+ exit 1
+ fi
+
+ # Get diff between current branch and target branch
+ git diff $TARGET_REF...HEAD
+ ```
+
+ **Note:** Use `...` (three dots) to show changes between the common ancestor and HEAD, or `..` (two dots) to show changes between the branches directly. The command uses `$TARGET_BRANCH` variable set in step 1.
+
+3. **Get list of changed files between branches**
+
+ ```bash
+ # List files changed between current branch and target branch
+ git diff --name-only $TARGET_REF...HEAD
+
+ # Get detailed file status
+ git diff --name-status $TARGET_REF...HEAD
+
+ # Show file changes with statistics
+ git diff --stat $TARGET_REF...HEAD
+ ```
+
+4. **Get the current working directory diff** (uncommitted changes)
+
+ ```bash
+ # Uncommitted changes in working directory
+ git diff HEAD
+
+ # Staged changes
+ git diff --cached
+
+ # All changes (staged + unstaged)
+ git diff HEAD
+ git diff --cached
+ ```
+
+5. **Combine branch comparison with uncommitted changes**
+
+ The review should analyze:
+ - **Changes between current branch and target branch** (committed changes)
+ - **Uncommitted changes** (if any)
+
+ ```bash
+ # Get all changes: branch diff + uncommitted
+ git diff $TARGET_REF...HEAD > branch-changes.diff
+ git diff HEAD >> branch-changes.diff
+ git diff --cached >> branch-changes.diff
+
+ # Or get combined diff (recommended approach)
+ git diff $TARGET_REF...HEAD
+ git diff HEAD
+ git diff --cached
+ ```
+
+6. **Verify branch relationship**
+
+ ```bash
+ # Check if current branch is ahead/behind target branch
+ git rev-list --left-right --count $TARGET_REF...HEAD
+
+ # Show commit log differences
+ git log $TARGET_REF..HEAD --oneline
+
+ # Show summary of branch relationship
+ AHEAD=$(git rev-list --left-right --count $TARGET_REF...HEAD | cut -f1)
+ BEHIND=$(git rev-list --left-right --count $TARGET_REF...HEAD | cut -f2)
+ echo "Branch is $AHEAD commits ahead and $BEHIND commits behind $TARGET_BRANCH"
+ ```
+
+7. **Understand the tech stack** (for validation):
+ - **Node.js**: >=22.0.0 <23.0.0
+ - **TypeScript**: 5.9.3
+ - **React**: 19.2.3
+ - **Express**: 5.2.1
+ - **Electron**: 39.2.7
+ - **Vite**: 7.3.0
+ - **Vitest**: 4.0.16
+ - Check `package.json` files for exact versions
+
+### Phase 2: Deep Dive Analysis (5 Agents)
+
+Launch 5 separate deep dive agents, each with a specific focus area. Each agent should be invoked with the `@deepdive` agent and given the git diff (comparing current branch against target branch) along with their specific instructions.
+
+**Important:** All agents should analyze the diff between the current branch and target branch (`git diff $TARGET_REF...HEAD`), plus any uncommitted changes. This ensures the review covers all changes that will be merged. The target branch is determined from the optional argument or defaults to main/master.
+
+#### Agent 1: Tech Stack Validation (HIGHEST PRIORITY)
+
+**Focus:** Verify code is valid for the tech stack
+
+**Instructions for Agent 1:**
+
+```
+Analyze the git diff for invalid code based on the tech stack:
+
+1. **TypeScript/JavaScript Syntax**
+ - Check for valid TypeScript syntax (no invalid type annotations, correct import/export syntax)
+ - Verify Node.js API usage is compatible with Node.js >=22.0.0 <23.0.0
+ - Check for deprecated APIs or features not available in the Node.js version
+ - Verify ES module syntax (type: "module" in package.json)
+
+2. **React 19.2.3 Compatibility**
+ - Check for deprecated React APIs or patterns
+ - Verify hooks usage is correct for React 19
+ - Check for invalid JSX syntax
+ - Verify component patterns match React 19 conventions
+
+3. **Express 5.2.1 Compatibility**
+ - Check for deprecated Express APIs
+ - Verify middleware usage is correct for Express 5
+ - Check request/response handling patterns
+
+4. **Type Safety**
+ - Verify TypeScript types are correctly used
+ - Check for `any` types that should be properly typed
+ - Verify type imports/exports are correct
+ - Check for missing type definitions
+
+5. **Build System Compatibility**
+ - Verify Vite-specific code (imports, config) is valid
+ - Check Electron-specific APIs are used correctly
+ - Verify module resolution paths are correct
+
+6. **Package Dependencies**
+ - Check for imports from packages not in package.json
+ - Verify version compatibility between dependencies
+ - Check for circular dependencies
+
+Provide a detailed report with:
+- File paths and line numbers of invalid code
+- Specific error description (what's wrong and why)
+- Expected vs actual behavior
+- Priority level (CRITICAL for build-breaking issues)
+```
+
+#### Agent 2: Security Vulnerability Scanner
+
+**Focus:** Security issues and vulnerabilities
+
+**Instructions for Agent 2:**
+
+```
+Analyze the git diff for security vulnerabilities:
+
+1. **Injection Vulnerabilities**
+ - SQL injection (if applicable)
+ - Command injection (exec, spawn, etc.)
+ - Path traversal vulnerabilities
+ - XSS vulnerabilities in React components
+
+2. **Authentication & Authorization**
+ - Missing authentication checks
+ - Insecure token handling
+ - Authorization bypasses
+ - Session management issues
+
+3. **Data Handling**
+ - Unsafe deserialization
+ - Insecure file operations
+ - Missing input validation
+ - Sensitive data exposure (secrets, tokens, passwords)
+
+4. **Dependencies**
+ - Known vulnerable packages
+ - Insecure dependency versions
+ - Missing security patches
+
+5. **API Security**
+ - Missing CORS configuration
+ - Insecure API endpoints
+ - Missing rate limiting
+ - Insecure WebSocket connections
+
+6. **Electron-Specific**
+ - Insecure IPC communication
+ - Missing context isolation checks
+ - Insecure preload scripts
+ - Missing CSP headers
+
+Provide a detailed report with:
+- Vulnerability type and severity (CRITICAL, HIGH, MEDIUM, LOW)
+- File paths and line numbers
+- Attack vector description
+- Recommended fix approach
+```
+
+#### Agent 3: Code Quality & Clean Code
+
+**Focus:** Dirty code, code smells, and quality issues
+
+**Instructions for Agent 3:**
+
+```
+Analyze the git diff for code quality issues:
+
+1. **Code Smells**
+ - Long functions/methods (>50 lines)
+ - High cyclomatic complexity
+ - Duplicate code
+ - Dead code
+ - Magic numbers/strings
+
+2. **Best Practices**
+ - Missing error handling
+ - Inconsistent naming conventions
+ - Poor separation of concerns
+ - Tight coupling
+ - Missing comments for complex logic
+
+3. **Performance Issues**
+ - Inefficient algorithms
+ - Memory leaks (event listeners, subscriptions)
+ - Unnecessary re-renders in React
+ - Missing memoization where needed
+ - Inefficient database queries (if applicable)
+
+4. **Maintainability**
+ - Hard-coded values
+ - Missing type definitions
+ - Inconsistent code style
+ - Poor file organization
+ - Missing tests for new code
+
+5. **React-Specific**
+ - Missing key props in lists
+ - Direct state mutations
+ - Missing cleanup in useEffect
+ - Unnecessary useState/useEffect
+ - Prop drilling issues
+
+Provide a detailed report with:
+- Issue type and severity
+- File paths and line numbers
+- Description of the problem
+- Impact on maintainability/performance
+- Recommended refactoring approach
+```
+
+#### Agent 4: Implementation Correctness
+
+**Focus:** Verify code implements requirements correctly
+
+**Instructions for Agent 4:**
+
+```
+Analyze the git diff for implementation correctness:
+
+1. **Logic Errors**
+ - Incorrect conditional logic
+ - Wrong variable usage
+ - Off-by-one errors
+ - Race conditions
+ - Missing null/undefined checks
+
+2. **Functional Requirements**
+ - Missing features from requirements
+ - Incorrect feature implementation
+ - Edge cases not handled
+ - Missing validation
+
+3. **Integration Issues**
+ - Incorrect API usage
+ - Wrong data format handling
+ - Missing error handling for external calls
+ - Incorrect state management
+
+4. **Type Errors**
+ - Type mismatches
+ - Missing type guards
+ - Incorrect type assertions
+ - Unsafe type operations
+
+5. **Testing Gaps**
+ - Missing unit tests
+ - Missing integration tests
+ - Tests don't cover edge cases
+ - Tests are incorrect
+
+Provide a detailed report with:
+- Issue description
+- File paths and line numbers
+- Expected vs actual behavior
+- Steps to reproduce (if applicable)
+- Recommended fix
+```
+
+#### Agent 5: Architecture & Design Patterns
+
+**Focus:** Architectural issues and design pattern violations
+
+**Instructions for Agent 5:**
+
+```
+Analyze the git diff for architectural and design issues:
+
+1. **Architecture Violations**
+ - Violation of project structure patterns
+ - Incorrect layer separation
+ - Missing abstractions
+ - Tight coupling between modules
+
+2. **Design Patterns**
+ - Incorrect pattern usage
+ - Missing patterns where needed
+ - Anti-patterns
+
+3. **Project-Specific Patterns**
+ - Check against project documentation (docs/ folder)
+ - Verify route organization (server routes)
+ - Check provider patterns (server providers)
+ - Verify component organization (UI components)
+
+4. **API Design**
+ - RESTful API violations
+ - Inconsistent response formats
+ - Missing error handling
+ - Incorrect status codes
+
+5. **State Management**
+ - Incorrect state management patterns
+ - Missing state normalization
+ - Inefficient state updates
+
+Provide a detailed report with:
+- Architectural issue description
+- File paths and affected areas
+- Impact on system design
+- Recommended architectural changes
+```
+
+### Phase 3: Consolidate Findings
+
+After all 5 deep dive agents complete their analysis:
+
+1. **Collect all findings** from each agent
+2. **Prioritize issues**:
+ - CRITICAL: Tech stack invalid code (build-breaking)
+ - HIGH: Security vulnerabilities, critical logic errors
+ - MEDIUM: Code quality issues, architectural problems
+ - LOW: Minor code smells, style issues
+
+3. **Group by file** to understand impact per file
+4. **Create a master report** summarizing all findings
+
+### Phase 4: Deepcode Fixes (5 Agents)
+
+Launch 5 deepcode agents to fix the issues found. Each agent should be invoked with the `@deepcode` agent.
+
+#### Deepcode Agent 1: Fix Tech Stack Invalid Code
+
+**Priority:** CRITICAL - Fix first
+
+**Instructions:**
+
+```
+Fix all invalid code based on tech stack issues identified by Agent 1.
+
+Focus on:
+1. Fixing TypeScript syntax errors
+2. Updating deprecated Node.js APIs
+3. Fixing React 19 compatibility issues
+4. Correcting Express 5 API usage
+5. Fixing type errors
+6. Resolving build-breaking issues
+
+After fixes, verify:
+- Code compiles without errors
+- TypeScript types are correct
+- No deprecated API usage
+```
+
+#### Deepcode Agent 2: Fix Security Vulnerabilities
+
+**Priority:** HIGH
+
+**Instructions:**
+
+```
+Fix all security vulnerabilities identified by Agent 2.
+
+Focus on:
+1. Adding input validation
+2. Fixing injection vulnerabilities
+3. Securing authentication/authorization
+4. Fixing insecure data handling
+5. Updating vulnerable dependencies
+6. Securing Electron IPC
+
+After fixes, verify:
+- Security vulnerabilities are addressed
+- No sensitive data exposure
+- Proper authentication/authorization
+```
+
+#### Deepcode Agent 3: Refactor Dirty Code
+
+**Priority:** MEDIUM
+
+**Instructions:**
+
+```
+Refactor code quality issues identified by Agent 3.
+
+Focus on:
+1. Extracting long functions
+2. Reducing complexity
+3. Removing duplicate code
+4. Adding error handling
+5. Improving React component structure
+6. Adding missing comments
+
+After fixes, verify:
+- Code follows best practices
+- No code smells remain
+- Performance optimizations applied
+```
+
+#### Deepcode Agent 4: Fix Implementation Errors
+
+**Priority:** HIGH
+
+**Instructions:**
+
+```
+Fix implementation correctness issues identified by Agent 4.
+
+Focus on:
+1. Fixing logic errors
+2. Adding missing features
+3. Handling edge cases
+4. Fixing type errors
+5. Adding missing tests
+
+After fixes, verify:
+- Logic is correct
+- Edge cases handled
+- Tests pass
+```
+
+#### Deepcode Agent 5: Fix Architectural Issues
+
+**Priority:** MEDIUM
+
+**Instructions:**
+
+```
+Fix architectural issues identified by Agent 5.
+
+Focus on:
+1. Correcting architecture violations
+2. Applying proper design patterns
+3. Fixing API design issues
+4. Improving state management
+5. Following project patterns
+
+After fixes, verify:
+- Architecture is sound
+- Patterns are correctly applied
+- Code follows project structure
+```
+
+### Phase 5: Verification
+
+After all fixes are complete:
+
+1. **Run TypeScript compilation check**
+
+ ```bash
+ npm run build:packages
+ ```
+
+2. **Run linting**
+
+ ```bash
+ npm run lint
+ ```
+
+3. **Run tests** (if applicable)
+
+ ```bash
+ npm run test:server
+ npm run test
+ ```
+
+4. **Verify git diff** shows only intended changes
+
+ ```bash
+ git diff HEAD
+ ```
+
+5. **Create summary report**:
+ - Issues found by each agent
+ - Issues fixed by each agent
+ - Remaining issues (if any)
+ - Verification results
+
+## Workflow Summary
+
+1. ✅ Accept optional target branch argument (defaults to main/master if not provided)
+2. ✅ Determine current branch and target branch (from argument or auto-detect main/master)
+3. ✅ Get git diff comparing current branch against target branch (`git diff $TARGET_REF...HEAD`)
+4. ✅ Include uncommitted changes in analysis (`git diff HEAD`, `git diff --cached`)
+5. ✅ Launch 5 deep dive agents (parallel analysis) with branch diff
+6. ✅ Consolidate findings and prioritize
+7. ✅ Launch 5 deepcode agents (sequential fixes, priority order)
+8. ✅ Verify fixes with build/lint/test
+9. ✅ Report summary
+
+## Notes
+
+- **Tech stack validation is HIGHEST PRIORITY** - invalid code must be fixed first
+- **Target branch argument**: The command accepts an optional target branch name as the first argument. If not provided, it automatically detects and uses `main` or `master` (in that order)
+- Each deep dive agent should work independently and provide comprehensive analysis
+- Deepcode agents should fix issues in priority order
+- All fixes should maintain existing functionality
+- If an agent finds no issues in their domain, they should report "No issues found"
+- If fixes introduce new issues, they should be caught in verification phase
+- The target branch is validated to ensure it exists (locally or remotely) before proceeding with the review
diff --git a/temp_repo/.claude/commands/gh-issue.md b/temp_repo/.claude/commands/gh-issue.md
new file mode 100644
index 0000000000000000000000000000000000000000..22c4925b8072310cc2544440e2e4b55a50a83871
--- /dev/null
+++ b/temp_repo/.claude/commands/gh-issue.md
@@ -0,0 +1,74 @@
+# GitHub Issue Fix Command
+
+Fetch a GitHub issue by number, verify it's a real issue, and fix it if valid.
+
+## Usage
+
+This command accepts a GitHub issue number as input (e.g., `123`).
+
+## Instructions
+
+1. **Get the issue number from the user**
+ - The issue number should be provided as an argument to this command
+ - If no number is provided, ask the user for it
+
+2. **Fetch the GitHub issue**
+ - Determine the current project path (check if there's a current project context)
+ - Verify the project has a GitHub remote:
+ ```bash
+ git remote get-url origin
+ ```
+ - Fetch the issue details using GitHub CLI:
+ ```bash
+ gh issue view --json number,title,state,author,createdAt,labels,url,body,assignees
+ ```
+ - If the command fails, report the error and stop
+
+3. **Verify the issue is real and valid**
+ - Check that the issue exists (not 404)
+ - Check the issue state:
+ - If **closed**: Inform the user and ask if they still want to proceed
+ - If **open**: Proceed with validation
+ - Review the issue content:
+ - Read the title and body to understand what needs to be fixed
+ - Check labels for context (bug, enhancement, etc.)
+ - Note any assignees or linked PRs
+
+4. **Validate the issue**
+ - Determine if this is a legitimate issue that needs fixing:
+ - Is the description clear and actionable?
+ - Does it describe a real problem or feature request?
+ - Are there any obvious signs it's spam or invalid?
+ - If the issue seems invalid or unclear:
+ - Report findings to the user
+ - Ask if they want to proceed anyway
+ - Stop if user confirms it's not valid
+
+5. **If the issue is valid, proceed to fix it**
+ - Analyze what needs to be done based on the issue description
+ - Check the current codebase state:
+ - Run relevant tests to see current behavior
+ - Check if the issue is already fixed
+ - Look for related code that might need changes
+ - Implement the fix:
+ - Make necessary code changes
+ - Update or add tests as needed
+ - Ensure the fix addresses the issue description
+ - Verify the fix:
+ - Run tests to ensure nothing broke
+ - If possible, manually verify the fix addresses the issue
+
+6. **Report summary**
+ - Issue number and title
+ - Issue state (open/closed)
+ - Whether the issue was validated as real
+ - What was fixed (if anything)
+ - Any tests that were updated or added
+ - Next steps (if any)
+
+## Error Handling
+
+- If GitHub CLI (`gh`) is not installed or authenticated, report error and stop
+- If the project doesn't have a GitHub remote, report error and stop
+- If the issue number doesn't exist, report error and stop
+- If the issue is unclear or invalid, report findings and ask user before proceeding
diff --git a/temp_repo/.claude/commands/release.md b/temp_repo/.claude/commands/release.md
new file mode 100644
index 0000000000000000000000000000000000000000..f768ab532b59d014c09965d1f2526e4894cc3b6b
--- /dev/null
+++ b/temp_repo/.claude/commands/release.md
@@ -0,0 +1,77 @@
+# Release Command
+
+Bump the package.json version (major, minor, or patch) and build the Electron app with the new version.
+
+## Usage
+
+This command accepts a version bump type as input:
+
+- `patch` - Bump patch version (0.1.0 -> 0.1.1)
+- `minor` - Bump minor version (0.1.0 -> 0.2.0)
+- `major` - Bump major version (0.1.0 -> 1.0.0)
+
+## Instructions
+
+1. **Get the bump type from the user**
+ - The bump type should be provided as an argument (patch, minor, or major)
+ - If no type is provided, ask the user which type they want
+
+2. **Bump the version**
+ - Run the version bump script:
+ ```bash
+ node apps/ui/scripts/bump-version.mjs
+ ```
+ - This updates both `apps/ui/package.json` and `apps/server/package.json` with the new version (keeps them in sync)
+ - Verify the version was updated correctly by checking the output
+
+3. **Build the Electron app**
+ - Run the electron build:
+ ```bash
+ npm run build:electron --workspace=apps/ui
+ ```
+ - The build process automatically:
+ - Uses the version from `package.json` for artifact names (e.g., `Automaker-1.2.3-x64.zip`)
+ - Injects the version into the app via Vite's `__APP_VERSION__` constant
+ - Displays the version below the logo in the sidebar
+
+4. **Commit the version bump**
+ - Stage the updated package.json files:
+ ```bash
+ git add apps/ui/package.json apps/server/package.json
+ ```
+ - Commit with a release message:
+ ```bash
+ git commit -m "chore: release v"
+ ```
+
+5. **Create and push the git tag**
+ - Create an annotated tag for the release:
+ ```bash
+ git tag -a v -m "Release v"
+ ```
+ - Push the commit and tag to remote:
+ ```bash
+ git push && git push --tags
+ ```
+
+6. **Verify the release**
+ - Check that the build completed successfully
+ - Confirm the version appears correctly in the built artifacts
+ - The version will be displayed in the app UI below the logo
+ - Verify the tag is visible on the remote repository
+
+## Version Centralization
+
+The version is centralized and synchronized in both `apps/ui/package.json` and `apps/server/package.json`:
+
+- **Electron builds**: Automatically read from `apps/ui/package.json` via electron-builder's `${version}` variable in `artifactName`
+- **App display**: Injected at build time via Vite's `define` config as `__APP_VERSION__` constant (defined in `apps/ui/vite.config.mts`)
+- **Server API**: Read from `apps/server/package.json` via `apps/server/src/lib/version.ts` utility (used in health check endpoints)
+- **Type safety**: Defined in `apps/ui/src/vite-env.d.ts` as `declare const __APP_VERSION__: string`
+
+This ensures consistency across:
+
+- Build artifact names (e.g., `Automaker-1.2.3-x64.zip`)
+- App UI display (shown as `v1.2.3` below the logo in `apps/ui/src/components/layout/sidebar/components/automaker-logo.tsx`)
+- Server health endpoints (`/` and `/detailed`)
+- Package metadata (both UI and server packages stay in sync)
diff --git a/temp_repo/.claude/commands/review.md b/temp_repo/.claude/commands/review.md
new file mode 100644
index 0000000000000000000000000000000000000000..87a589f50c3876437db2c7368efe47ff55a03446
--- /dev/null
+++ b/temp_repo/.claude/commands/review.md
@@ -0,0 +1,484 @@
+# Code Review Command
+
+Comprehensive code review using multiple deep dive agents to analyze git diff for correctness, security, code quality, and tech stack compliance, followed by automated fixes using deepcode agents.
+
+## Usage
+
+This command analyzes all changes in the git diff and verifies:
+
+1. **Invalid code based on tech stack** (HIGHEST PRIORITY)
+2. Security vulnerabilities
+3. Code quality issues (dirty code)
+4. Implementation correctness
+
+Then automatically fixes any issues found.
+
+## Instructions
+
+### Phase 1: Get Git Diff
+
+1. **Get the current git diff**
+
+ ```bash
+ git diff HEAD
+ ```
+
+ If you need staged changes instead:
+
+ ```bash
+ git diff --cached
+ ```
+
+ Or for a specific commit range:
+
+ ```bash
+ git diff
+ ```
+
+2. **Get list of changed files**
+
+ ```bash
+ git diff --name-only HEAD
+ ```
+
+3. **Understand the tech stack** (for validation):
+ - **Node.js**: >=22.0.0 <23.0.0
+ - **TypeScript**: 5.9.3
+ - **React**: 19.2.3
+ - **Express**: 5.2.1
+ - **Electron**: 39.2.7
+ - **Vite**: 7.3.0
+ - **Vitest**: 4.0.16
+ - Check `package.json` files for exact versions
+
+### Phase 2: Deep Dive Analysis (5 Agents)
+
+Launch 5 separate deep dive agents, each with a specific focus area. Each agent should be invoked with the `@deepdive` agent and given the git diff along with their specific instructions.
+
+#### Agent 1: Tech Stack Validation (HIGHEST PRIORITY)
+
+**Focus:** Verify code is valid for the tech stack
+
+**Instructions for Agent 1:**
+
+```
+Analyze the git diff for invalid code based on the tech stack:
+
+1. **TypeScript/JavaScript Syntax**
+ - Check for valid TypeScript syntax (no invalid type annotations, correct import/export syntax)
+ - Verify Node.js API usage is compatible with Node.js >=22.0.0 <23.0.0
+ - Check for deprecated APIs or features not available in the Node.js version
+ - Verify ES module syntax (type: "module" in package.json)
+
+2. **React 19.2.3 Compatibility**
+ - Check for deprecated React APIs or patterns
+ - Verify hooks usage is correct for React 19
+ - Check for invalid JSX syntax
+ - Verify component patterns match React 19 conventions
+
+3. **Express 5.2.1 Compatibility**
+ - Check for deprecated Express APIs
+ - Verify middleware usage is correct for Express 5
+ - Check request/response handling patterns
+
+4. **Type Safety**
+ - Verify TypeScript types are correctly used
+ - Check for `any` types that should be properly typed
+ - Verify type imports/exports are correct
+ - Check for missing type definitions
+
+5. **Build System Compatibility**
+ - Verify Vite-specific code (imports, config) is valid
+ - Check Electron-specific APIs are used correctly
+ - Verify module resolution paths are correct
+
+6. **Package Dependencies**
+ - Check for imports from packages not in package.json
+ - Verify version compatibility between dependencies
+ - Check for circular dependencies
+
+Provide a detailed report with:
+- File paths and line numbers of invalid code
+- Specific error description (what's wrong and why)
+- Expected vs actual behavior
+- Priority level (CRITICAL for build-breaking issues)
+```
+
+#### Agent 2: Security Vulnerability Scanner
+
+**Focus:** Security issues and vulnerabilities
+
+**Instructions for Agent 2:**
+
+```
+Analyze the git diff for security vulnerabilities:
+
+1. **Injection Vulnerabilities**
+ - SQL injection (if applicable)
+ - Command injection (exec, spawn, etc.)
+ - Path traversal vulnerabilities
+ - XSS vulnerabilities in React components
+
+2. **Authentication & Authorization**
+ - Missing authentication checks
+ - Insecure token handling
+ - Authorization bypasses
+ - Session management issues
+
+3. **Data Handling**
+ - Unsafe deserialization
+ - Insecure file operations
+ - Missing input validation
+ - Sensitive data exposure (secrets, tokens, passwords)
+
+4. **Dependencies**
+ - Known vulnerable packages
+ - Insecure dependency versions
+ - Missing security patches
+
+5. **API Security**
+ - Missing CORS configuration
+ - Insecure API endpoints
+ - Missing rate limiting
+ - Insecure WebSocket connections
+
+6. **Electron-Specific**
+ - Insecure IPC communication
+ - Missing context isolation checks
+ - Insecure preload scripts
+ - Missing CSP headers
+
+Provide a detailed report with:
+- Vulnerability type and severity (CRITICAL, HIGH, MEDIUM, LOW)
+- File paths and line numbers
+- Attack vector description
+- Recommended fix approach
+```
+
+#### Agent 3: Code Quality & Clean Code
+
+**Focus:** Dirty code, code smells, and quality issues
+
+**Instructions for Agent 3:**
+
+```
+Analyze the git diff for code quality issues:
+
+1. **Code Smells**
+ - Long functions/methods (>50 lines)
+ - High cyclomatic complexity
+ - Duplicate code
+ - Dead code
+ - Magic numbers/strings
+
+2. **Best Practices**
+ - Missing error handling
+ - Inconsistent naming conventions
+ - Poor separation of concerns
+ - Tight coupling
+ - Missing comments for complex logic
+
+3. **Performance Issues**
+ - Inefficient algorithms
+ - Memory leaks (event listeners, subscriptions)
+ - Unnecessary re-renders in React
+ - Missing memoization where needed
+ - Inefficient database queries (if applicable)
+
+4. **Maintainability**
+ - Hard-coded values
+ - Missing type definitions
+ - Inconsistent code style
+ - Poor file organization
+ - Missing tests for new code
+
+5. **React-Specific**
+ - Missing key props in lists
+ - Direct state mutations
+ - Missing cleanup in useEffect
+ - Unnecessary useState/useEffect
+ - Prop drilling issues
+
+Provide a detailed report with:
+- Issue type and severity
+- File paths and line numbers
+- Description of the problem
+- Impact on maintainability/performance
+- Recommended refactoring approach
+```
+
+#### Agent 4: Implementation Correctness
+
+**Focus:** Verify code implements requirements correctly
+
+**Instructions for Agent 4:**
+
+```
+Analyze the git diff for implementation correctness:
+
+1. **Logic Errors**
+ - Incorrect conditional logic
+ - Wrong variable usage
+ - Off-by-one errors
+ - Race conditions
+ - Missing null/undefined checks
+
+2. **Functional Requirements**
+ - Missing features from requirements
+ - Incorrect feature implementation
+ - Edge cases not handled
+ - Missing validation
+
+3. **Integration Issues**
+ - Incorrect API usage
+ - Wrong data format handling
+ - Missing error handling for external calls
+ - Incorrect state management
+
+4. **Type Errors**
+ - Type mismatches
+ - Missing type guards
+ - Incorrect type assertions
+ - Unsafe type operations
+
+5. **Testing Gaps**
+ - Missing unit tests
+ - Missing integration tests
+ - Tests don't cover edge cases
+ - Tests are incorrect
+
+Provide a detailed report with:
+- Issue description
+- File paths and line numbers
+- Expected vs actual behavior
+- Steps to reproduce (if applicable)
+- Recommended fix
+```
+
+#### Agent 5: Architecture & Design Patterns
+
+**Focus:** Architectural issues and design pattern violations
+
+**Instructions for Agent 5:**
+
+```
+Analyze the git diff for architectural and design issues:
+
+1. **Architecture Violations**
+ - Violation of project structure patterns
+ - Incorrect layer separation
+ - Missing abstractions
+ - Tight coupling between modules
+
+2. **Design Patterns**
+ - Incorrect pattern usage
+ - Missing patterns where needed
+ - Anti-patterns
+
+3. **Project-Specific Patterns**
+ - Check against project documentation (docs/ folder)
+ - Verify route organization (server routes)
+ - Check provider patterns (server providers)
+ - Verify component organization (UI components)
+
+4. **API Design**
+ - RESTful API violations
+ - Inconsistent response formats
+ - Missing error handling
+ - Incorrect status codes
+
+5. **State Management**
+ - Incorrect state management patterns
+ - Missing state normalization
+ - Inefficient state updates
+
+Provide a detailed report with:
+- Architectural issue description
+- File paths and affected areas
+- Impact on system design
+- Recommended architectural changes
+```
+
+### Phase 3: Consolidate Findings
+
+After all 5 deep dive agents complete their analysis:
+
+1. **Collect all findings** from each agent
+2. **Prioritize issues**:
+ - CRITICAL: Tech stack invalid code (build-breaking)
+ - HIGH: Security vulnerabilities, critical logic errors
+ - MEDIUM: Code quality issues, architectural problems
+ - LOW: Minor code smells, style issues
+
+3. **Group by file** to understand impact per file
+4. **Create a master report** summarizing all findings
+
+### Phase 4: Deepcode Fixes (5 Agents)
+
+Launch 5 deepcode agents to fix the issues found. Each agent should be invoked with the `@deepcode` agent.
+
+#### Deepcode Agent 1: Fix Tech Stack Invalid Code
+
+**Priority:** CRITICAL - Fix first
+
+**Instructions:**
+
+```
+Fix all invalid code based on tech stack issues identified by Agent 1.
+
+Focus on:
+1. Fixing TypeScript syntax errors
+2. Updating deprecated Node.js APIs
+3. Fixing React 19 compatibility issues
+4. Correcting Express 5 API usage
+5. Fixing type errors
+6. Resolving build-breaking issues
+
+After fixes, verify:
+- Code compiles without errors
+- TypeScript types are correct
+- No deprecated API usage
+```
+
+#### Deepcode Agent 2: Fix Security Vulnerabilities
+
+**Priority:** HIGH
+
+**Instructions:**
+
+```
+Fix all security vulnerabilities identified by Agent 2.
+
+Focus on:
+1. Adding input validation
+2. Fixing injection vulnerabilities
+3. Securing authentication/authorization
+4. Fixing insecure data handling
+5. Updating vulnerable dependencies
+6. Securing Electron IPC
+
+After fixes, verify:
+- Security vulnerabilities are addressed
+- No sensitive data exposure
+- Proper authentication/authorization
+```
+
+#### Deepcode Agent 3: Refactor Dirty Code
+
+**Priority:** MEDIUM
+
+**Instructions:**
+
+```
+Refactor code quality issues identified by Agent 3.
+
+Focus on:
+1. Extracting long functions
+2. Reducing complexity
+3. Removing duplicate code
+4. Adding error handling
+5. Improving React component structure
+6. Adding missing comments
+
+After fixes, verify:
+- Code follows best practices
+- No code smells remain
+- Performance optimizations applied
+```
+
+#### Deepcode Agent 4: Fix Implementation Errors
+
+**Priority:** HIGH
+
+**Instructions:**
+
+```
+Fix implementation correctness issues identified by Agent 4.
+
+Focus on:
+1. Fixing logic errors
+2. Adding missing features
+3. Handling edge cases
+4. Fixing type errors
+5. Adding missing tests
+
+After fixes, verify:
+- Logic is correct
+- Edge cases handled
+- Tests pass
+```
+
+#### Deepcode Agent 5: Fix Architectural Issues
+
+**Priority:** MEDIUM
+
+**Instructions:**
+
+```
+Fix architectural issues identified by Agent 5.
+
+Focus on:
+1. Correcting architecture violations
+2. Applying proper design patterns
+3. Fixing API design issues
+4. Improving state management
+5. Following project patterns
+
+After fixes, verify:
+- Architecture is sound
+- Patterns are correctly applied
+- Code follows project structure
+```
+
+### Phase 5: Verification
+
+After all fixes are complete:
+
+1. **Run TypeScript compilation check**
+
+ ```bash
+ npm run build:packages
+ ```
+
+2. **Run linting**
+
+ ```bash
+ npm run lint
+ ```
+
+3. **Run tests** (if applicable)
+
+ ```bash
+ npm run test:server
+ npm run test
+ ```
+
+4. **Verify git diff** shows only intended changes
+
+ ```bash
+ git diff HEAD
+ ```
+
+5. **Create summary report**:
+ - Issues found by each agent
+ - Issues fixed by each agent
+ - Remaining issues (if any)
+ - Verification results
+
+## Workflow Summary
+
+1. ✅ Get git diff
+2. ✅ Launch 5 deep dive agents (parallel analysis)
+3. ✅ Consolidate findings and prioritize
+4. ✅ Launch 5 deepcode agents (sequential fixes, priority order)
+5. ✅ Verify fixes with build/lint/test
+6. ✅ Report summary
+
+## Notes
+
+- **Tech stack validation is HIGHEST PRIORITY** - invalid code must be fixed first
+- Each deep dive agent should work independently and provide comprehensive analysis
+- Deepcode agents should fix issues in priority order
+- All fixes should maintain existing functionality
+- If an agent finds no issues in their domain, they should report "No issues found"
+- If fixes introduce new issues, they should be caught in verification phase
diff --git a/temp_repo/.claude/commands/thorough.md b/temp_repo/.claude/commands/thorough.md
new file mode 100644
index 0000000000000000000000000000000000000000..c69ada0f4ee1f78620a8d80bd9b57136993d81b3
--- /dev/null
+++ b/temp_repo/.claude/commands/thorough.md
@@ -0,0 +1,45 @@
+When you think you are done, you are NOT done.
+
+You must run a mandatory 3-pass verification before concluding:
+
+## Pass 1: Correctness & Functionality
+
+- [ ] Verify logic matches requirements and specifications
+- [ ] Check type safety (TypeScript types are correct and complete)
+- [ ] Ensure imports are correct and follow project conventions
+- [ ] Verify all functions/classes work as intended
+- [ ] Check that return values and side effects are correct
+- [ ] Run relevant tests if they exist, or verify testability
+- [ ] Confirm integration with existing code works properly
+
+## Pass 2: Edge Cases & Safety
+
+- [ ] Handle null/undefined inputs gracefully
+- [ ] Validate all user inputs and external data
+- [ ] Check error handling (try/catch, error boundaries, etc.)
+- [ ] Verify security considerations (no sensitive data exposure, proper auth checks)
+- [ ] Test boundary conditions (empty arrays, zero values, max lengths, etc.)
+- [ ] Ensure resource cleanup (file handles, connections, timers)
+- [ ] Check for potential race conditions or async issues
+- [ ] Verify file path security (no directory traversal vulnerabilities)
+
+## Pass 3: Maintainability & Code Quality
+
+- [ ] Code follows project style guide and conventions
+- [ ] Functions/classes are single-purpose and well-named
+- [ ] Remove dead code, unused imports, and console.logs
+- [ ] Extract magic numbers/strings into named constants
+- [ ] Check for code duplication (DRY principle)
+- [ ] Verify appropriate abstraction levels (not over/under-engineered)
+- [ ] Add necessary comments for complex logic
+- [ ] Ensure consistent error messages and logging
+- [ ] Check that code is readable and self-documenting
+- [ ] Verify proper separation of concerns
+
+**For each pass, explicitly report:**
+
+- What you checked
+- Any issues found and how they were fixed
+- Any remaining concerns or trade-offs
+
+Only after completing all three passes with explicit findings may you conclude the work is done.
diff --git a/temp_repo/.claude/commands/validate-build.md b/temp_repo/.claude/commands/validate-build.md
new file mode 100644
index 0000000000000000000000000000000000000000..790992b158ed5e35e7bb2ac1716009e3b3ea9680
--- /dev/null
+++ b/temp_repo/.claude/commands/validate-build.md
@@ -0,0 +1,49 @@
+# Project Build and Fix Command
+
+Run all builds and intelligently fix any failures based on what changed.
+
+## Instructions
+
+1. **Run the build**
+
+ ```bash
+ npm run build
+ ```
+
+ This builds all packages and the UI application.
+
+2. **If the build succeeds**, report success and stop.
+
+3. **If the build fails**, analyze the failures:
+ - Note which build step failed and the error messages
+ - Check for TypeScript compilation errors, missing dependencies, or configuration issues
+ - Run `git diff main` to see what code has changed
+
+4. **Determine the nature of the failure**:
+ - **If the failure is due to intentional changes** (new features, refactoring, dependency updates):
+ - Fix any TypeScript type errors introduced by the changes
+ - Update build configuration if needed (e.g., tsconfig.json, vite.config.mts)
+ - Ensure all new dependencies are properly installed
+ - Fix import paths or module resolution issues
+
+ - **If the failure appears to be a regression** (broken imports, missing files, configuration errors):
+ - Fix the source code to restore the build
+ - Check for accidentally deleted files or broken references
+ - Verify build configuration files are correct
+
+5. **Common build issues to check**:
+ - **TypeScript errors**: Fix type mismatches, missing types, or incorrect imports
+ - **Missing dependencies**: Run `npm install` if packages are missing
+ - **Import/export errors**: Fix incorrect import paths or missing exports
+ - **Build configuration**: Check tsconfig.json, vite.config.mts, or other build configs
+ - **Package build order**: Ensure `build:packages` completes before building apps
+
+6. **How to decide if it's intentional vs regression**:
+ - Look at the git diff and commit messages
+ - If the change was deliberate and introduced new code that needs fixing → fix the new code
+ - If the change broke existing functionality that should still build → fix the regression
+ - When in doubt, ask the user
+
+7. **After making fixes**, re-run the build to verify everything compiles successfully.
+
+8. **Report summary** of what was fixed (TypeScript errors, configuration issues, missing dependencies, etc.).
diff --git a/temp_repo/.claude/commands/validate-tests.md b/temp_repo/.claude/commands/validate-tests.md
new file mode 100644
index 0000000000000000000000000000000000000000..3a19b5d1a507e5999effcb29c75e4ae283663031
--- /dev/null
+++ b/temp_repo/.claude/commands/validate-tests.md
@@ -0,0 +1,36 @@
+# Project Test and Fix Command
+
+Run all tests and intelligently fix any failures based on what changed.
+
+## Instructions
+
+1. **Run all tests**
+
+ ```bash
+ npm run test:all
+ ```
+
+2. **If all tests pass**, report success and stop.
+
+3. **If any tests fail**, analyze the failures:
+ - Note which tests failed and their error messages
+ - Run `git diff main` to see what code has changed
+
+4. **Determine the nature of the change**:
+ - **If the logic change is intentional** (new feature, refactor, behavior change):
+ - Update the failing tests to match the new expected behavior
+ - The tests should reflect what the code NOW does correctly
+
+ - **If the logic change appears to be a bug** (regression, unintended side effect):
+ - Fix the source code to restore the expected behavior
+ - Do NOT modify the tests - they are catching a real bug
+
+5. **How to decide if it's a bug vs intentional change**:
+ - Look at the git diff and commit messages
+ - If the change was deliberate and the test expectations are now outdated → update tests
+ - If the change broke existing functionality that should still work → fix the code
+ - When in doubt, ask the user
+
+6. **After making fixes**, re-run the tests to verify everything passes.
+
+7. **Report summary** of what was fixed (tests updated vs code fixed).
diff --git a/temp_repo/.dockerignore b/temp_repo/.dockerignore
new file mode 100644
index 0000000000000000000000000000000000000000..8163526befa0a6bd172e25dca2415881acc4d772
--- /dev/null
+++ b/temp_repo/.dockerignore
@@ -0,0 +1,19 @@
+# Dependencies
+node_modules/
+**/node_modules/
+
+# Build outputs
+dist/
+**/dist/
+dist-electron/
+**/dist-electron/
+build/
+**/build/
+.next/
+**/.next/
+.nuxt/
+**/.nuxt/
+out/
+**/out/
+.cache/
+**/.cache/
\ No newline at end of file
diff --git a/temp_repo/.geminiignore b/temp_repo/.geminiignore
new file mode 100644
index 0000000000000000000000000000000000000000..703ef020123a81478729bb04f4c80b6a3e0e2360
--- /dev/null
+++ b/temp_repo/.geminiignore
@@ -0,0 +1,14 @@
+# Auto-generated by Automaker to speed up Gemini CLI startup
+# Prevents Gemini CLI from scanning large directories during context discovery
+.git
+node_modules
+dist
+build
+.next
+.nuxt
+coverage
+.automaker
+.worktrees
+.vscode
+.idea
+*.lock
diff --git a/temp_repo/.github/ISSUE_TEMPLATE/bug_report.yml b/temp_repo/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 0000000000000000000000000000000000000000..af6bb48bd1137527f5fa6a876f06aaeb3afe2b97
--- /dev/null
+++ b/temp_repo/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,117 @@
+name: Bug Report
+description: File a bug report to help us improve Automaker
+title: '[Bug]: '
+labels: ['bug']
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for taking the time to report a bug! Please fill out the form below with as much detail as possible.
+
+ - type: dropdown
+ id: operating-system
+ attributes:
+ label: Operating System
+ description: What operating system are you using?
+ options:
+ - macOS
+ - Windows
+ - Linux
+ - Other
+ default: 0
+ validations:
+ required: true
+
+ - type: dropdown
+ id: run-mode
+ attributes:
+ label: Run Mode
+ description: How are you running Automaker?
+ options:
+ - Electron (Desktop App)
+ - Web (Browser)
+ - Docker
+ default: 0
+ validations:
+ required: true
+
+ - type: input
+ id: app-version
+ attributes:
+ label: App Version
+ description: What version of Automaker are you using? (e.g., 0.1.0)
+ placeholder: '0.1.0'
+ validations:
+ required: true
+
+ - type: textarea
+ id: bug-description
+ attributes:
+ label: Bug Description
+ description: A clear and concise description of what the bug is.
+ placeholder: Describe the bug...
+ validations:
+ required: true
+
+ - type: textarea
+ id: steps-to-reproduce
+ attributes:
+ label: Steps to Reproduce
+ description: Steps to reproduce the behavior
+ placeholder: |
+ 1. Go to '...'
+ 2. Click on '...'
+ 3. Scroll down to '...'
+ 4. See error
+ validations:
+ required: true
+
+ - type: textarea
+ id: expected-behavior
+ attributes:
+ label: Expected Behavior
+ description: A clear and concise description of what you expected to happen.
+ placeholder: What should have happened?
+ validations:
+ required: true
+
+ - type: textarea
+ id: actual-behavior
+ attributes:
+ label: Actual Behavior
+ description: A clear and concise description of what actually happened.
+ placeholder: What actually happened?
+ validations:
+ required: true
+
+ - type: textarea
+ id: screenshots
+ attributes:
+ label: Screenshots
+ description: If applicable, add screenshots to help explain your problem.
+ placeholder: Drag and drop screenshots here or paste image URLs
+
+ - type: textarea
+ id: logs
+ attributes:
+ label: Relevant Logs
+ description: If applicable, paste relevant logs or error messages.
+ placeholder: Paste logs here...
+ render: shell
+
+ - type: textarea
+ id: additional-context
+ attributes:
+ label: Additional Context
+ description: Add any other context about the problem here.
+ placeholder: Any additional information that might be helpful...
+
+ - type: checkboxes
+ id: terms
+ attributes:
+ label: Checklist
+ options:
+ - label: I have searched existing issues to ensure this bug hasn't been reported already
+ required: true
+ - label: I have provided all required information above
+ required: true
diff --git a/temp_repo/.github/ISSUE_TEMPLATE/feature_request.yml b/temp_repo/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 0000000000000000000000000000000000000000..7cddcaefec9d3f8357c234ff218ceef62b26f794
--- /dev/null
+++ b/temp_repo/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,108 @@
+name: Feature Request
+description: Suggest a new feature or enhancement for Automaker
+title: '[Feature]: '
+labels: ['enhancement']
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for taking the time to suggest a feature! Please fill out the form below to help us understand your request.
+
+ - type: dropdown
+ id: feature-area
+ attributes:
+ label: Feature Area
+ description: Which area of Automaker does this feature relate to?
+ options:
+ - UI/UX (User Interface)
+ - Agent/AI
+ - Kanban Board
+ - Git/Worktree Management
+ - Project Management
+ - Settings/Configuration
+ - Documentation
+ - Performance
+ - Other
+ default: 0
+ validations:
+ required: true
+
+ - type: dropdown
+ id: priority
+ attributes:
+ label: Priority
+ description: How important is this feature to your workflow?
+ options:
+ - Nice to have
+ - Would improve my workflow
+ - Critical for my use case
+ default: 0
+ validations:
+ required: true
+
+ - type: textarea
+ id: problem-statement
+ attributes:
+ label: Problem Statement
+ description: Is your feature request related to a problem? Please describe the problem you're trying to solve.
+ placeholder: A clear and concise description of what the problem is. Ex. I'm always frustrated when...
+ validations:
+ required: true
+
+ - type: textarea
+ id: proposed-solution
+ attributes:
+ label: Proposed Solution
+ description: Describe the solution you'd like to see implemented.
+ placeholder: A clear and concise description of what you want to happen.
+ validations:
+ required: true
+
+ - type: textarea
+ id: alternatives-considered
+ attributes:
+ label: Alternatives Considered
+ description: Describe any alternative solutions or workarounds you've considered.
+ placeholder: A clear and concise description of any alternative solutions or features you've considered.
+ validations:
+ required: false
+
+ - type: textarea
+ id: use-cases
+ attributes:
+ label: Use Cases
+ description: Describe specific scenarios where this feature would be useful.
+ placeholder: |
+ 1. When working on...
+ 2. As a user who needs to...
+ 3. In situations where...
+ validations:
+ required: false
+
+ - type: textarea
+ id: mockups
+ attributes:
+ label: Mockups/Screenshots
+ description: If applicable, add mockups, wireframes, or screenshots to help illustrate your feature request.
+ placeholder: Drag and drop images here or paste image URLs
+ validations:
+ required: false
+
+ - type: textarea
+ id: additional-context
+ attributes:
+ label: Additional Context
+ description: Add any other context, references, or examples about the feature request here.
+ placeholder: Any additional information that might be helpful...
+ validations:
+ required: false
+
+ - type: checkboxes
+ id: terms
+ attributes:
+ label: Checklist
+ options:
+ - label: I have searched existing issues to ensure this feature hasn't been requested already
+ required: true
+ - label: I have provided a clear description of the problem and proposed solution
+ required: true
diff --git a/temp_repo/.github/actions/setup-project/action.yml b/temp_repo/.github/actions/setup-project/action.yml
new file mode 100644
index 0000000000000000000000000000000000000000..262c46dc584ddb768d066215e1310570ef123073
--- /dev/null
+++ b/temp_repo/.github/actions/setup-project/action.yml
@@ -0,0 +1,80 @@
+name: 'Setup Project'
+description: 'Common setup steps for CI workflows - checkout, Node.js, dependencies, and native modules'
+
+inputs:
+ node-version:
+ description: 'Node.js version to use'
+ required: false
+ default: '22'
+ check-lockfile:
+ description: 'Run lockfile lint check for SSH URLs'
+ required: false
+ default: 'false'
+ rebuild-node-pty-path:
+ description: 'Working directory for node-pty rebuild (empty = root)'
+ required: false
+ default: ''
+
+runs:
+ using: 'composite'
+ steps:
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ inputs.node-version }}
+ cache: 'npm'
+ cache-dependency-path: package-lock.json
+
+ - name: Configure Git for HTTPS
+ shell: bash
+ # Convert SSH URLs to HTTPS for git dependencies (e.g., @electron/node-gyp)
+ # This is needed because SSH authentication isn't available in CI
+ run: git config --global url."https://github.com/".insteadOf "git@github.com:"
+
+ - name: Auto-fix SSH URLs in lockfile
+ if: inputs.check-lockfile == 'true'
+ shell: bash
+ # Auto-fix any git+ssh:// URLs in package-lock.json before linting
+ # This handles cases where npm reintroduces SSH URLs for git dependencies
+ run: node scripts/fix-lockfile-urls.mjs
+
+ - name: Check for SSH URLs in lockfile
+ if: inputs.check-lockfile == 'true'
+ shell: bash
+ run: npm run lint:lockfile
+
+ - name: Install dependencies
+ shell: bash
+ # Use npm install instead of npm ci to correctly resolve platform-specific
+ # optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
+ # Skip scripts to avoid electron-builder install-app-deps which uses too much memory
+ # Use --force to allow platform-specific dev dependencies like dmg-license on non-darwin platforms
+ run: npm install --ignore-scripts --force
+
+ - name: Install Linux native bindings
+ if: runner.os == 'Linux'
+ shell: bash
+ # Workaround for npm optional dependencies bug (npm/cli#4828)
+ # Explicitly install Linux bindings needed for build tools
+ run: |
+ npm install --no-save --force --ignore-scripts \
+ @rollup/rollup-linux-x64-gnu@4.53.3 \
+ @tailwindcss/oxide-linux-x64-gnu@4.1.17
+
+ - name: Build shared packages
+ shell: bash
+ # Build shared packages (types, utils, platform, etc.) before apps can use them
+ run: npm run build:packages
+
+ - name: Rebuild native modules (root)
+ if: inputs.rebuild-node-pty-path == ''
+ shell: bash
+ # Rebuild node-pty and other native modules for Electron
+ run: npm rebuild node-pty
+
+ - name: Rebuild native modules (workspace)
+ if: inputs.rebuild-node-pty-path != ''
+ shell: bash
+ # Rebuild node-pty and other native modules needed for server
+ run: npm rebuild node-pty
+ working-directory: ${{ inputs.rebuild-node-pty-path }}
diff --git a/temp_repo/.github/scripts/upload-to-r2.js b/temp_repo/.github/scripts/upload-to-r2.js
new file mode 100644
index 0000000000000000000000000000000000000000..b54d4b1916d0b22984cf6aa2c32ab9c3322e3211
--- /dev/null
+++ b/temp_repo/.github/scripts/upload-to-r2.js
@@ -0,0 +1,355 @@
+const { S3Client, PutObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3');
+const fs = require('fs');
+const path = require('path');
+const https = require('https');
+const { pipeline } = require('stream/promises');
+
+const s3Client = new S3Client({
+ region: 'auto',
+ endpoint: process.env.R2_ENDPOINT,
+ credentials: {
+ accessKeyId: process.env.R2_ACCESS_KEY_ID,
+ secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
+ },
+});
+
+const BUCKET = process.env.R2_BUCKET_NAME;
+const PUBLIC_URL = process.env.R2_PUBLIC_URL;
+const VERSION = process.env.RELEASE_VERSION;
+const RELEASE_TAG = process.env.RELEASE_TAG || `v${VERSION}`;
+const GITHUB_REPO = process.env.GITHUB_REPOSITORY;
+
+async function fetchExistingReleases() {
+ try {
+ const response = await s3Client.send(
+ new GetObjectCommand({
+ Bucket: BUCKET,
+ Key: 'releases.json',
+ })
+ );
+ const body = await response.Body.transformToString();
+ return JSON.parse(body);
+ } catch (error) {
+ if (error.name === 'NoSuchKey' || error.$metadata?.httpStatusCode === 404) {
+ console.log('No existing releases.json found, creating new one');
+ return { latestVersion: null, releases: [] };
+ }
+ throw error;
+ }
+}
+
+async function uploadFile(localPath, r2Key, contentType) {
+ const fileBuffer = fs.readFileSync(localPath);
+ const stats = fs.statSync(localPath);
+
+ await s3Client.send(
+ new PutObjectCommand({
+ Bucket: BUCKET,
+ Key: r2Key,
+ Body: fileBuffer,
+ ContentType: contentType,
+ })
+ );
+
+ console.log(`Uploaded: ${r2Key} (${stats.size} bytes)`);
+ return stats.size;
+}
+
+function findArtifacts(dir, pattern) {
+ if (!fs.existsSync(dir)) return [];
+ const files = fs.readdirSync(dir);
+ return files.filter((f) => pattern.test(f)).map((f) => path.join(dir, f));
+}
+
+async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
+ try {
+ const result = await new Promise((resolve, reject) => {
+ const request = https.get(url, { timeout: 10000 }, (response) => {
+ const statusCode = response.statusCode;
+
+ // Follow redirects
+ if (
+ statusCode === 302 ||
+ statusCode === 301 ||
+ statusCode === 307 ||
+ statusCode === 308
+ ) {
+ const redirectUrl = response.headers.location;
+ response.destroy();
+ if (!redirectUrl) {
+ resolve({
+ accessible: false,
+ statusCode,
+ error: 'Redirect without location header',
+ });
+ return;
+ }
+ // Follow the redirect URL
+ return https
+ .get(redirectUrl, { timeout: 10000 }, (redirectResponse) => {
+ const redirectStatus = redirectResponse.statusCode;
+ const contentType = redirectResponse.headers['content-type'] || '';
+ // Check if it's actually a file (zip/tar.gz) and not HTML
+ const isFile =
+ contentType.includes('application/zip') ||
+ contentType.includes('application/gzip') ||
+ contentType.includes('application/x-gzip') ||
+ contentType.includes('application/x-tar') ||
+ redirectUrl.includes('.zip') ||
+ redirectUrl.includes('.tar.gz');
+ const isGood = redirectStatus >= 200 && redirectStatus < 300 && isFile;
+ redirectResponse.destroy();
+ resolve({
+ accessible: isGood,
+ statusCode: redirectStatus,
+ finalUrl: redirectUrl,
+ contentType,
+ });
+ })
+ .on('error', (error) => {
+ resolve({
+ accessible: false,
+ statusCode,
+ error: error.message,
+ });
+ })
+ .on('timeout', function () {
+ this.destroy();
+ resolve({
+ accessible: false,
+ statusCode,
+ error: 'Timeout following redirect',
+ });
+ });
+ }
+
+ // Check if status is good (200-299 range) and it's actually a file
+ const contentType = response.headers['content-type'] || '';
+ const isFile =
+ contentType.includes('application/zip') ||
+ contentType.includes('application/gzip') ||
+ contentType.includes('application/x-gzip') ||
+ contentType.includes('application/x-tar') ||
+ url.includes('.zip') ||
+ url.includes('.tar.gz');
+ const isGood = statusCode >= 200 && statusCode < 300 && isFile;
+ response.destroy();
+ resolve({ accessible: isGood, statusCode, contentType });
+ });
+
+ request.on('error', (error) => {
+ resolve({
+ accessible: false,
+ statusCode: null,
+ error: error.message,
+ });
+ });
+
+ request.on('timeout', () => {
+ request.destroy();
+ resolve({
+ accessible: false,
+ statusCode: null,
+ error: 'Request timeout',
+ });
+ });
+ });
+
+ if (result.accessible) {
+ if (attempt > 0) {
+ console.log(
+ `✓ URL ${url} is now accessible after ${attempt} retries (status: ${result.statusCode})`
+ );
+ } else {
+ console.log(`✓ URL ${url} is accessible (status: ${result.statusCode})`);
+ }
+ return result.finalUrl || url; // Return the final URL (after redirects) if available
+ } else {
+ const errorMsg = result.error ? ` - ${result.error}` : '';
+ const statusMsg = result.statusCode ? ` (status: ${result.statusCode})` : '';
+ const contentTypeMsg = result.contentType ? ` [content-type: ${result.contentType}]` : '';
+ console.log(`✗ URL ${url} not accessible${statusMsg}${contentTypeMsg}${errorMsg}`);
+ }
+ } catch (error) {
+ console.log(`✗ URL ${url} check failed: ${error.message}`);
+ }
+
+ if (attempt < maxRetries - 1) {
+ const delay = initialDelay * Math.pow(2, attempt);
+ console.log(` Retrying in ${delay}ms... (attempt ${attempt + 1}/${maxRetries})`);
+ await new Promise((resolve) => setTimeout(resolve, delay));
+ }
+ }
+
+ throw new Error(`URL ${url} is not accessible after ${maxRetries} attempts`);
+}
+
+async function downloadFromGitHub(url, outputPath) {
+ return new Promise((resolve, reject) => {
+ const request = https.get(url, { timeout: 30000 }, (response) => {
+ const statusCode = response.statusCode;
+
+ // Follow redirects (all redirect types)
+ if (statusCode === 301 || statusCode === 302 || statusCode === 307 || statusCode === 308) {
+ const redirectUrl = response.headers.location;
+ response.destroy();
+ if (!redirectUrl) {
+ reject(new Error(`Redirect without location header for ${url}`));
+ return;
+ }
+ // Resolve relative redirects
+ const finalRedirectUrl = redirectUrl.startsWith('http')
+ ? redirectUrl
+ : new URL(redirectUrl, url).href;
+ console.log(` Following redirect: ${finalRedirectUrl}`);
+ return downloadFromGitHub(finalRedirectUrl, outputPath).then(resolve).catch(reject);
+ }
+
+ if (statusCode !== 200) {
+ response.destroy();
+ reject(new Error(`Failed to download ${url}: ${statusCode} ${response.statusMessage}`));
+ return;
+ }
+
+ const fileStream = fs.createWriteStream(outputPath);
+ response.pipe(fileStream);
+ fileStream.on('finish', () => {
+ fileStream.close();
+ resolve();
+ });
+ fileStream.on('error', (error) => {
+ response.destroy();
+ reject(error);
+ });
+ });
+
+ request.on('error', reject);
+ request.on('timeout', () => {
+ request.destroy();
+ reject(new Error(`Request timeout for ${url}`));
+ });
+ });
+}
+
+async function main() {
+ const artifactsDir = 'artifacts';
+ const tempDir = path.join(artifactsDir, 'temp');
+
+ // Create temp directory for downloaded GitHub archives
+ if (!fs.existsSync(tempDir)) {
+ fs.mkdirSync(tempDir, { recursive: true });
+ }
+
+ // Download source archives from GitHub
+ const githubZipUrl = `https://github.com/${GITHUB_REPO}/archive/refs/tags/${RELEASE_TAG}.zip`;
+ const githubTarGzUrl = `https://github.com/${GITHUB_REPO}/archive/refs/tags/${RELEASE_TAG}.tar.gz`;
+
+ const sourceZipPath = path.join(tempDir, `automaker-${VERSION}.zip`);
+ const sourceTarGzPath = path.join(tempDir, `automaker-${VERSION}.tar.gz`);
+
+ console.log(`Waiting for source archives to be available on GitHub...`);
+ console.log(` ZIP: ${githubZipUrl}`);
+ console.log(` TAR.GZ: ${githubTarGzUrl}`);
+
+ // Wait for archives to be accessible with exponential backoff
+ // This returns the final URL after following redirects
+ const finalZipUrl = await checkUrlAccessible(githubZipUrl);
+ const finalTarGzUrl = await checkUrlAccessible(githubTarGzUrl);
+
+ console.log(`Downloading source archives from GitHub...`);
+ await downloadFromGitHub(finalZipUrl, sourceZipPath);
+ await downloadFromGitHub(finalTarGzUrl, sourceTarGzPath);
+
+ console.log(`Downloaded source archives successfully`);
+
+ // Find all artifacts
+ const artifacts = {
+ windows: findArtifacts(path.join(artifactsDir, 'windows-builds'), /\.exe$/),
+ macos: findArtifacts(path.join(artifactsDir, 'macos-builds'), /-x64\.dmg$/),
+ macosArm: findArtifacts(path.join(artifactsDir, 'macos-builds'), /-arm64\.dmg$/),
+ linux: findArtifacts(path.join(artifactsDir, 'linux-builds'), /\.AppImage$/),
+ sourceZip: [sourceZipPath],
+ sourceTarGz: [sourceTarGzPath],
+ };
+
+ console.log('Found artifacts:');
+ for (const [platform, files] of Object.entries(artifacts)) {
+ console.log(
+ ` ${platform}: ${files.length > 0 ? files.map((f) => path.basename(f)).join(', ') : 'none'}`
+ );
+ }
+
+ // Upload each artifact to R2
+ const assets = {};
+ const contentTypes = {
+ windows: 'application/x-msdownload',
+ macos: 'application/x-apple-diskimage',
+ macosArm: 'application/x-apple-diskimage',
+ linux: 'application/x-executable',
+ sourceZip: 'application/zip',
+ sourceTarGz: 'application/gzip',
+ };
+
+ for (const [platform, files] of Object.entries(artifacts)) {
+ if (files.length === 0) {
+ console.warn(`Warning: No artifact found for ${platform}`);
+ continue;
+ }
+
+ // Use the first matching file for each platform
+ const localPath = files[0];
+ const filename = path.basename(localPath);
+ const r2Key = `releases/${VERSION}/${filename}`;
+ const size = await uploadFile(localPath, r2Key, contentTypes[platform]);
+
+ assets[platform] = {
+ url: `${PUBLIC_URL}/releases/${VERSION}/${filename}`,
+ filename,
+ size,
+ arch:
+ platform === 'macosArm'
+ ? 'arm64'
+ : platform === 'sourceZip' || platform === 'sourceTarGz'
+ ? 'source'
+ : 'x64',
+ };
+ }
+
+ // Fetch and update releases.json
+ const releasesData = await fetchExistingReleases();
+
+ const newRelease = {
+ version: VERSION,
+ date: new Date().toISOString(),
+ assets,
+ githubReleaseUrl: `https://github.com/${GITHUB_REPO}/releases/tag/${RELEASE_TAG}`,
+ };
+
+ // Remove existing entry for this version if re-running
+ releasesData.releases = releasesData.releases.filter((r) => r.version !== VERSION);
+
+ // Prepend new release
+ releasesData.releases.unshift(newRelease);
+ releasesData.latestVersion = VERSION;
+
+ // Upload updated releases.json
+ await s3Client.send(
+ new PutObjectCommand({
+ Bucket: BUCKET,
+ Key: 'releases.json',
+ Body: JSON.stringify(releasesData, null, 2),
+ ContentType: 'application/json',
+ CacheControl: 'public, max-age=60',
+ })
+ );
+
+ console.log('Successfully updated releases.json');
+ console.log(`Latest version: ${VERSION}`);
+ console.log(`Total releases: ${releasesData.releases.length}`);
+}
+
+main().catch((err) => {
+ console.error('Failed to upload to R2:', err);
+ process.exit(1);
+});
diff --git a/temp_repo/.github/workflows/claude.yml b/temp_repo/.github/workflows/claude.yml
new file mode 100644
index 0000000000000000000000000000000000000000..9471a0591de36ca20ab974144e773510bffe15e5
--- /dev/null
+++ b/temp_repo/.github/workflows/claude.yml
@@ -0,0 +1,49 @@
+name: Claude Code
+
+on:
+ issue_comment:
+ types: [created]
+ pull_request_review_comment:
+ types: [created]
+ issues:
+ types: [opened, assigned]
+ pull_request_review:
+ types: [submitted]
+
+jobs:
+ claude:
+ if: |
+ (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
+ (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
+ (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
+ (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: read
+ issues: read
+ id-token: write
+ actions: read # Required for Claude to read CI results on PRs
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 1
+
+ - name: Run Claude Code
+ id: claude
+ uses: anthropics/claude-code-action@v1
+ with:
+ claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+
+ # This is an optional setting that allows Claude to read CI results on PRs
+ additional_permissions: |
+ actions: read
+
+ # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
+ # prompt: 'Update the pull request description to include a summary of changes.'
+
+ # Optional: Add claude_args to customize behavior and configuration
+ # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
+ # or https://code.claude.com/docs/en/cli-reference for available options
+ # claude_args: '--allowed-tools Bash(gh pr:*)'
diff --git a/temp_repo/.github/workflows/e2e-tests.yml b/temp_repo/.github/workflows/e2e-tests.yml
new file mode 100644
index 0000000000000000000000000000000000000000..4f682a0bd8122fea38acc13e346d21423837c12c
--- /dev/null
+++ b/temp_repo/.github/workflows/e2e-tests.yml
@@ -0,0 +1,202 @@
+name: E2E Tests
+
+on:
+ pull_request:
+ branches:
+ - '*'
+ push:
+ branches:
+ - main
+ - master
+
+jobs:
+ e2e:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ strategy:
+ fail-fast: false
+ matrix:
+ # shardIndex: [1, 2, 3]
+ # shardTotal: [3]
+ shardIndex: [1]
+ shardTotal: [1]
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup project
+ uses: ./.github/actions/setup-project
+ with:
+ check-lockfile: 'true'
+ rebuild-node-pty-path: 'apps/server'
+
+ - name: Install Playwright browsers
+ run: npx playwright install --with-deps chromium
+ working-directory: apps/ui
+
+ - name: Build server
+ run: npm run build --workspace=apps/server
+
+ - name: Set up Git user
+ run: |
+ git config --global user.name "GitHub CI"
+ git config --global user.email "ci@example.com"
+
+ - name: Start backend server
+ run: |
+ echo "Starting backend server..."
+ # Start server in background and save PID
+ npm run start --workspace=apps/server > backend.log 2>&1 &
+ SERVER_PID=$!
+ echo "Server started with PID: $SERVER_PID"
+ echo "SERVER_PID=$SERVER_PID" >> $GITHUB_ENV
+
+ env:
+ PORT: 3108
+ TEST_SERVER_PORT: 3108
+ NODE_ENV: test
+ # Use a deterministic API key so Playwright can log in reliably
+ AUTOMAKER_API_KEY: test-api-key-for-e2e-tests
+ # Reduce log noise in CI
+ AUTOMAKER_HIDE_API_KEY: 'true'
+ # Avoid real API calls during CI
+ AUTOMAKER_MOCK_AGENT: 'true'
+ # Simulate containerized environment to skip sandbox confirmation dialogs
+ IS_CONTAINERIZED: 'true'
+
+ - name: Wait for backend server
+ run: |
+ echo "Waiting for backend server to be ready..."
+
+ # Check if server process is running
+ if [ -z "$SERVER_PID" ]; then
+ echo "ERROR: Server PID not found in environment"
+ cat backend.log 2>/dev/null || echo "No backend log found"
+ exit 1
+ fi
+
+ # Check if process is actually running
+ if ! kill -0 $SERVER_PID 2>/dev/null; then
+ echo "ERROR: Server process $SERVER_PID is not running!"
+ echo "=== Backend logs ==="
+ cat backend.log
+ echo ""
+ echo "=== Recent system logs ==="
+ dmesg 2>/dev/null | tail -20 || echo "No dmesg available"
+ exit 1
+ fi
+
+ # Wait for health endpoint
+ for i in {1..60}; do
+ if curl -s -f http://localhost:3108/api/health > /dev/null 2>&1; then
+ echo "Backend server is ready!"
+ echo "=== Backend logs ==="
+ cat backend.log
+ echo ""
+ echo "Health check response:"
+ curl -s http://localhost:3108/api/health | jq . 2>/dev/null || echo "Health check: $(curl -s http://localhost:3108/api/health 2>/dev/null || echo 'No response')"
+ exit 0
+ fi
+
+ # Check if server process is still running
+ if ! kill -0 $SERVER_PID 2>/dev/null; then
+ echo "ERROR: Server process died during wait!"
+ echo "=== Backend logs ==="
+ cat backend.log
+ exit 1
+ fi
+
+ echo "Waiting... ($i/60)"
+ sleep 1
+ done
+
+ echo "ERROR: Backend server failed to start within 60 seconds!"
+ echo "=== Backend logs ==="
+ cat backend.log
+ echo ""
+ echo "=== Process status ==="
+ ps aux | grep -E "(node|tsx)" | grep -v grep || echo "No node processes found"
+ echo ""
+ echo "=== Port status ==="
+ netstat -tlnp 2>/dev/null | grep :3108 || echo "Port 3108 not listening"
+ lsof -i :3108 2>/dev/null || echo "lsof not available or port not in use"
+ echo ""
+ echo "=== Health endpoint test ==="
+ curl -v http://localhost:3108/api/health 2>&1 || echo "Health endpoint failed"
+
+ # Kill the server process if it's still hanging
+ if kill -0 $SERVER_PID 2>/dev/null; then
+ echo ""
+ echo "Killing stuck server process..."
+ kill -9 $SERVER_PID 2>/dev/null || true
+ fi
+
+ exit 1
+
+ - name: Run E2E tests (shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
+ # Playwright automatically starts the Vite frontend via webServer config
+ # (see apps/ui/playwright.config.ts) - no need to start it manually
+ run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
+ working-directory: apps/ui
+ env:
+ CI: true
+ VITE_SKIP_SETUP: 'true'
+ # Keep UI-side login/defaults consistent
+ AUTOMAKER_API_KEY: test-api-key-for-e2e-tests
+ # Backend is already started above - Playwright config sets
+ # AUTOMAKER_SERVER_PORT so the Vite proxy forwards /api/* to the backend.
+ # Do NOT set VITE_SERVER_URL here: it bypasses the Vite proxy and causes
+ # a cookie domain mismatch (cookies are bound to 127.0.0.1, but
+ # VITE_SERVER_URL=http://localhost:3108 makes the frontend call localhost).
+ TEST_USE_EXTERNAL_BACKEND: 'true'
+ TEST_SERVER_PORT: 3108
+
+ - name: Print backend logs on failure
+ if: failure()
+ run: |
+ echo "=== E2E Tests Failed - Backend Logs ==="
+ cat backend.log 2>/dev/null || echo "No backend log found"
+ echo ""
+ echo "=== Process status at failure ==="
+ ps aux | grep -E "(node|tsx)" | grep -v grep || echo "No node processes found"
+ echo ""
+ echo "=== Port status ==="
+ netstat -tlnp 2>/dev/null | grep :3108 || echo "Port 3108 not listening"
+
+ - name: Upload Playwright report
+ uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: playwright-report-shard-${{ matrix.shardIndex }}-of-${{ matrix.shardTotal }}
+ path: apps/ui/playwright-report/
+ retention-days: 7
+
+ - name: Upload test results (screenshots, traces, videos)
+ uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: test-results-shard-${{ matrix.shardIndex }}-of-${{ matrix.shardTotal }}
+ path: |
+ apps/ui/test-results/
+ retention-days: 7
+ if-no-files-found: ignore
+
+ - name: Upload blob report for merging
+ uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: blob-report-shard-${{ matrix.shardIndex }}-of-${{ matrix.shardTotal }}
+ path: apps/ui/blob-report/
+ retention-days: 1
+ if-no-files-found: ignore
+
+ - name: Cleanup - Kill backend server
+ if: always()
+ run: |
+ if [ -n "$SERVER_PID" ]; then
+ echo "Cleaning up backend server (PID: $SERVER_PID)..."
+ kill $SERVER_PID 2>/dev/null || true
+ kill -9 $SERVER_PID 2>/dev/null || true
+ echo "Backend server cleanup complete"
+ fi
diff --git a/temp_repo/.github/workflows/format-check.yml b/temp_repo/.github/workflows/format-check.yml
new file mode 100644
index 0000000000000000000000000000000000000000..d6904979b7ed3c8d1b374376c0e13910f140a75f
--- /dev/null
+++ b/temp_repo/.github/workflows/format-check.yml
@@ -0,0 +1,31 @@
+name: Format Check
+
+on:
+ pull_request:
+ branches:
+ - '*'
+ push:
+ branches:
+ - main
+ - master
+
+jobs:
+ format:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ cache: 'npm'
+ cache-dependency-path: package-lock.json
+
+ - name: Install dependencies
+ run: npm install --ignore-scripts --force
+
+ - name: Check formatting
+ run: npm run format:check
diff --git a/temp_repo/.github/workflows/pr-check.yml b/temp_repo/.github/workflows/pr-check.yml
new file mode 100644
index 0000000000000000000000000000000000000000..4311eeb0b2addf27f5b687933a3a59e541ad97ff
--- /dev/null
+++ b/temp_repo/.github/workflows/pr-check.yml
@@ -0,0 +1,26 @@
+name: PR Build Check
+
+on:
+ pull_request:
+ branches:
+ - '*'
+ push:
+ branches:
+ - main
+ - master
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup project
+ uses: ./.github/actions/setup-project
+ with:
+ check-lockfile: 'true'
+
+ - name: Run build:electron (dir only - faster CI)
+ run: npm run build:electron:dir
diff --git a/temp_repo/.github/workflows/release.yml b/temp_repo/.github/workflows/release.yml
new file mode 100644
index 0000000000000000000000000000000000000000..f4fe01f34d5fc05c1cc6d73aef07a91c66d72fdd
--- /dev/null
+++ b/temp_repo/.github/workflows/release.yml
@@ -0,0 +1,133 @@
+name: Release Build
+
+on:
+ release:
+ types: [published]
+
+permissions:
+ contents: write
+
+jobs:
+ build:
+ strategy:
+ matrix:
+ os: [ubuntu-latest, macos-latest, windows-latest]
+ runs-on: ${{ matrix.os }}
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Extract version from tag
+ id: version
+ shell: bash
+ run: |
+ # Remove 'v' prefix if present (e.g., "v1.2.3" -> "1.2.3")
+ VERSION="${{ github.event.release.tag_name }}"
+ VERSION="${VERSION#v}"
+ echo "version=${VERSION}" >> $GITHUB_OUTPUT
+ echo "Extracted version: ${VERSION}"
+
+ - name: Update package.json version
+ shell: bash
+ run: |
+ node apps/ui/scripts/update-version.mjs "${{ steps.version.outputs.version }}"
+
+ - name: Setup project
+ uses: ./.github/actions/setup-project
+ with:
+ check-lockfile: 'true'
+
+ - name: Install RPM build tools (Linux)
+ if: matrix.os == 'ubuntu-latest'
+ shell: bash
+ run: sudo apt-get update && sudo apt-get install -y rpm
+
+ - name: Build Electron app (macOS)
+ if: matrix.os == 'macos-latest'
+ shell: bash
+ run: npm run build:electron:mac --workspace=apps/ui
+ env:
+ CSC_IDENTITY_AUTO_DISCOVERY: false
+
+ - name: Build Electron app (Windows)
+ if: matrix.os == 'windows-latest'
+ shell: bash
+ run: npm run build:electron:win --workspace=apps/ui
+
+ - name: Build Electron app (Linux)
+ if: matrix.os == 'ubuntu-latest'
+ shell: bash
+ run: npm run build:electron:linux --workspace=apps/ui
+
+ - name: Upload macOS artifacts
+ if: matrix.os == 'macos-latest'
+ uses: actions/upload-artifact@v4
+ with:
+ name: macos-builds
+ path: |
+ apps/ui/release/*.dmg
+ apps/ui/release/*.zip
+ if-no-files-found: error
+ retention-days: 30
+
+ - name: Upload Windows artifacts
+ if: matrix.os == 'windows-latest'
+ uses: actions/upload-artifact@v4
+ with:
+ name: windows-builds
+ path: apps/ui/release/*.exe
+ if-no-files-found: error
+ retention-days: 30
+
+ - name: Upload Linux artifacts
+ if: matrix.os == 'ubuntu-latest'
+ uses: actions/upload-artifact@v4
+ with:
+ name: linux-builds
+ path: |
+ apps/ui/release/*.AppImage
+ apps/ui/release/*.deb
+ apps/ui/release/*.rpm
+ if-no-files-found: error
+ retention-days: 30
+
+ upload:
+ needs: build
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Download macOS artifacts
+ uses: actions/download-artifact@v4
+ with:
+ name: macos-builds
+ path: artifacts/macos-builds
+
+ - name: Download Windows artifacts
+ uses: actions/download-artifact@v4
+ with:
+ name: windows-builds
+ path: artifacts/windows-builds
+
+ - name: Download Linux artifacts
+ uses: actions/download-artifact@v4
+ with:
+ name: linux-builds
+ path: artifacts/linux-builds
+
+ - name: Upload to GitHub Release
+ uses: softprops/action-gh-release@v2
+ with:
+ fail_on_unmatched_files: true
+ files: |
+ artifacts/macos-builds/*.dmg
+ artifacts/macos-builds/*.zip
+ artifacts/windows-builds/*.exe
+ artifacts/linux-builds/*.AppImage
+ artifacts/linux-builds/*.deb
+ artifacts/linux-builds/*.rpm
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/temp_repo/.github/workflows/security-audit.yml b/temp_repo/.github/workflows/security-audit.yml
new file mode 100644
index 0000000000000000000000000000000000000000..7da30c5da9b4f2df0b83c585c01cdff8f8b983cd
--- /dev/null
+++ b/temp_repo/.github/workflows/security-audit.yml
@@ -0,0 +1,30 @@
+name: Security Audit
+
+on:
+ pull_request:
+ branches:
+ - '*'
+ push:
+ branches:
+ - main
+ - master
+ schedule:
+ # Run weekly on Mondays at 9 AM UTC
+ - cron: '0 9 * * 1'
+
+jobs:
+ audit:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup project
+ uses: ./.github/actions/setup-project
+ with:
+ check-lockfile: 'true'
+
+ - name: Run npm audit
+ run: npm audit --audit-level=critical
+ continue-on-error: false
diff --git a/temp_repo/.github/workflows/test.yml b/temp_repo/.github/workflows/test.yml
new file mode 100644
index 0000000000000000000000000000000000000000..dacea6312a962be1999c8086ebd644c4c710f794
--- /dev/null
+++ b/temp_repo/.github/workflows/test.yml
@@ -0,0 +1,44 @@
+name: Test Suite
+
+on:
+ pull_request:
+ branches:
+ - '*'
+ push:
+ branches:
+ - main
+ - master
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup project
+ uses: ./.github/actions/setup-project
+ with:
+ check-lockfile: 'true'
+ rebuild-node-pty-path: 'apps/server'
+
+ - name: Run package tests
+ run: npm run test:packages
+ env:
+ NODE_ENV: test
+
+ - name: Run server tests with coverage
+ run: npm run test:server:coverage
+ env:
+ NODE_ENV: test
+
+ # - name: Upload coverage reports
+ # uses: codecov/codecov-action@v4
+ # if: always()
+ # with:
+ # files: ./apps/server/coverage/coverage-final.json
+ # flags: server
+ # name: server-coverage
+ # env:
+ # CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
diff --git a/temp_repo/.gitignore b/temp_repo/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..1c2d1a3243cb076a2cba8d1eb37f8c0b4a04215b
--- /dev/null
+++ b/temp_repo/.gitignore
@@ -0,0 +1,116 @@
+#added by trueheads > will remove once supercombo adds multi-os support
+launch.sh
+
+# Dependencies
+node_modules/
+
+# Build outputs
+dist/
+build/
+out/
+.next/
+.turbo/
+
+# Automaker
+.automaker/images/
+.automaker/
+/.automaker/*
+/.automaker/
+
+.worktrees/
+
+/logs
+# Logs
+logs/
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+
+# OS-specific files
+.DS_Store
+.DS_Store?
+._*
+Thumbs.db
+ehthumbs.db
+Desktop.ini
+
+# IDE/Editor configs
+.vscode/
+.idea/
+*.sublime-workspace
+*.sublime-project
+
+# Editor backup/temp files
+*~
+*.bak
+*.backup
+*.orig
+*.swp
+*.swo
+*.tmp
+*.temp
+
+# Local settings (user-specific)
+*.local.json
+
+# Application state/backup
+backup.json
+
+# Test artifacts
+test-results/
+coverage/
+.nyc_output/
+*.lcov
+playwright-report/
+blob-report/
+test/**/test-project-[0-9]*/
+test/opus-thinking-*/
+test/agent-session-test-*/
+test/feature-backlog-test-*/
+test/running-task-display-test-*/
+test/agent-output-modal-responsive-*/
+test/fixtures/
+test/board-bg-test-*/
+test/edit-feature-test-*/
+test/open-project-test-*/
+
+
+# Environment files (keep .example)
+.env
+.env.local
+.env.*.local
+!.env.example
+!.env.local.example
+
+# Codex config (contains API keys)
+.codex/config.toml
+
+# TypeScript
+*.tsbuildinfo
+
+# Misc
+*.pem
+
+docker-compose.override.yml
+.claude/docker-compose.override.yml
+.claude/hans/
+
+pnpm-lock.yaml
+yarn.lock
+
+# Fork-specific workflow files (should never be committed)
+DEVELOPMENT_WORKFLOW.md
+check-sync.sh
+# API key files
+data/.api-key
+data/credentials.json
+data/
+.codex/
+
+# GSD planning docs (local-only)
+.planning/
+.mcp.json
+.planning
+.bg-shell/
\ No newline at end of file
diff --git a/temp_repo/.husky/pre-commit b/temp_repo/.husky/pre-commit
new file mode 100644
index 0000000000000000000000000000000000000000..4c156c16804f5d229c0bdf3bee05c12ace9c485c
--- /dev/null
+++ b/temp_repo/.husky/pre-commit
@@ -0,0 +1,63 @@
+#!/usr/bin/env sh
+
+# Try to load nvm if available (optional - works without it too)
+if [ -z "$NVM_DIR" ]; then
+ # Check for Herd's nvm first (macOS with Herd)
+ if [ -s "$HOME/Library/Application Support/Herd/config/nvm/nvm.sh" ]; then
+ export NVM_DIR="$HOME/Library/Application Support/Herd/config/nvm"
+ # Then check standard nvm location
+ elif [ -s "$HOME/.nvm/nvm.sh" ]; then
+ export NVM_DIR="$HOME/.nvm"
+ fi
+fi
+
+# Source nvm if found (silently skip if not available)
+[ -n "$NVM_DIR" ] && [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" 2>/dev/null
+
+# Load node version from .nvmrc if using nvm (silently skip if nvm not available or fails)
+if [ -f .nvmrc ] && command -v nvm >/dev/null 2>&1; then
+ # Check if Unix nvm was sourced (it's a shell function with NVM_DIR set)
+ if [ -n "$NVM_DIR" ] && type nvm 2>/dev/null | grep -q "function"; then
+ # Unix nvm: reads .nvmrc automatically
+ nvm use >/dev/null 2>&1 || true
+ else
+ # nvm-windows: needs explicit version from .nvmrc
+ NODE_VERSION=$(cat .nvmrc | tr -d '[:space:]')
+ if [ -n "$NODE_VERSION" ]; then
+ nvm use "$NODE_VERSION" >/dev/null 2>&1 || true
+ fi
+ fi
+fi
+
+# Ensure common system paths are in PATH (for systems without nvm)
+# This helps find node/npm installed via Homebrew, system packages, etc.
+if [ -n "$WINDIR" ]; then
+ export PATH="$PATH:/c/Program Files/nodejs:/c/Program Files (x86)/nodejs"
+ export PATH="$PATH:$APPDATA/npm:$LOCALAPPDATA/Programs/nodejs"
+else
+ export PATH="$PATH:/usr/local/bin:/opt/homebrew/bin:/usr/bin"
+fi
+
+# Auto-fix git+ssh:// URLs in package-lock.json if it's being committed
+# This prevents CI failures from SSH URLs that npm introduces for git dependencies
+if git diff --cached --name-only | grep -q "^package-lock.json$"; then
+ if command -v node >/dev/null 2>&1; then
+ if grep -q "git+ssh://" package-lock.json 2>/dev/null; then
+ echo "Fixing git+ssh:// URLs in package-lock.json..."
+ node scripts/fix-lockfile-urls.mjs
+ git add package-lock.json
+ fi
+ fi
+fi
+
+# Run lint-staged - works with or without nvm
+# Prefer npx, fallback to npm exec, both work with system-installed Node.js
+if command -v npx >/dev/null 2>&1; then
+ npx lint-staged
+elif command -v npm >/dev/null 2>&1; then
+ npm exec -- lint-staged
+else
+ echo "Error: Neither npx nor npm found in PATH."
+ echo "Please ensure Node.js is installed (via nvm, Homebrew, system package manager, etc.)"
+ exit 1
+fi
diff --git a/temp_repo/.npmrc b/temp_repo/.npmrc
new file mode 100644
index 0000000000000000000000000000000000000000..86aca125a81766646e00fa7bc871382166525bc5
--- /dev/null
+++ b/temp_repo/.npmrc
@@ -0,0 +1,16 @@
+# Cross-platform compatibility for Tailwind CSS v4 and lightningcss
+# These packages use platform-specific optional dependencies that npm
+# automatically resolves based on your OS (macOS, Linux, Windows, WSL)
+#
+# IMPORTANT: When switching platforms or getting platform mismatch errors:
+# 1. Delete node_modules: rm -rf node_modules apps/*/node_modules
+# 2. Run: npm install
+#
+# In CI/CD: Use "npm install" instead of "npm ci" to allow npm to resolve
+# the correct platform-specific binaries at install time.
+
+# Include bindings for all platforms in package-lock.json to support CI/CD
+# This ensures Linux, macOS, and Windows bindings are all present
+# NOTE: Only enable when regenerating package-lock.json, then comment out to keep installs fast
+# supportedArchitectures.os=linux,darwin,win32
+# supportedArchitectures.cpu=x64,arm64
diff --git a/temp_repo/.nvmrc b/temp_repo/.nvmrc
new file mode 100644
index 0000000000000000000000000000000000000000..42126c0545a2d2bd95d9c05030272464fabb93a3
--- /dev/null
+++ b/temp_repo/.nvmrc
@@ -0,0 +1,2 @@
+22
+
diff --git a/temp_repo/.prettierignore b/temp_repo/.prettierignore
new file mode 100644
index 0000000000000000000000000000000000000000..50ff1306c6aa777c9f4ad1cdcc6d221edaa82e5f
--- /dev/null
+++ b/temp_repo/.prettierignore
@@ -0,0 +1,41 @@
+# Dependencies
+node_modules/
+
+# Build outputs
+dist/
+build/
+out/
+.next/
+.turbo/
+release/
+
+# Automaker
+.automaker/
+
+# Logs
+logs/
+*.log
+
+# Lock files
+package-lock.json
+pnpm-lock.yaml
+
+# Generated files
+*.min.js
+*.min.css
+routeTree.gen.ts
+apps/ui/src/routeTree.gen.ts
+
+# Test artifacts
+test-results/
+coverage/
+playwright-report/
+blob-report/
+
+# IDE/Editor
+.vscode/
+.idea/
+
+# Electron
+dist-electron/
+server-bundle/
diff --git a/temp_repo/.prettierrc b/temp_repo/.prettierrc
new file mode 100644
index 0000000000000000000000000000000000000000..b6b0fde5e3f8b7b7db602b66349f51334b30ca2d
--- /dev/null
+++ b/temp_repo/.prettierrc
@@ -0,0 +1,10 @@
+{
+ "semi": true,
+ "singleQuote": true,
+ "tabWidth": 2,
+ "trailingComma": "es5",
+ "printWidth": 100,
+ "bracketSpacing": true,
+ "arrowParens": "always",
+ "endOfLine": "lf"
+}
diff --git a/temp_repo/CLAUDE.md b/temp_repo/CLAUDE.md
new file mode 100644
index 0000000000000000000000000000000000000000..84dd1fbb1e4edefd618ea6a6261f2cf04e18c9af
--- /dev/null
+++ b/temp_repo/CLAUDE.md
@@ -0,0 +1,176 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+Automaker is an autonomous AI development studio built as an npm workspace monorepo. It provides a Kanban-based workflow where AI agents (powered by Claude Agent SDK) implement features in isolated git worktrees.
+
+## Common Commands
+
+```bash
+# Development
+npm run dev # Interactive launcher (choose web or electron)
+npm run dev:web # Web browser mode (localhost:3007)
+npm run dev:electron # Desktop app mode
+npm run dev:electron:debug # Desktop with DevTools open
+
+# Building
+npm run build # Build web application
+npm run build:packages # Build all shared packages (required before other builds)
+npm run build:electron # Build desktop app for current platform
+npm run build:server # Build server only
+
+# Testing
+npm run test # E2E tests (Playwright, headless)
+npm run test:headed # E2E tests with browser visible
+npm run test:server # Server unit tests (Vitest)
+npm run test:packages # All shared package tests
+npm run test:all # All tests (packages + server)
+
+# Single test file
+npm run test:server -- tests/unit/specific.test.ts
+
+# Linting and formatting
+npm run lint # ESLint
+npm run format # Prettier write
+npm run format:check # Prettier check
+```
+
+## Architecture
+
+### Monorepo Structure
+
+```
+automaker/
+├── apps/
+│ ├── ui/ # React + Vite + Electron frontend (port 3007)
+│ └── server/ # Express + WebSocket backend (port 3008)
+└── libs/ # Shared packages (@automaker/*)
+ ├── types/ # Core TypeScript definitions (no dependencies)
+ ├── utils/ # Logging, errors, image processing, context loading
+ ├── prompts/ # AI prompt templates
+ ├── platform/ # Path management, security, process spawning
+ ├── model-resolver/ # Claude model alias resolution
+ ├── dependency-resolver/ # Feature dependency ordering
+ └── git-utils/ # Git operations & worktree management
+```
+
+### Package Dependency Chain
+
+Packages can only depend on packages above them:
+
+```
+@automaker/types (no dependencies)
+ ↓
+@automaker/utils, @automaker/prompts, @automaker/platform, @automaker/model-resolver, @automaker/dependency-resolver
+ ↓
+@automaker/git-utils
+ ↓
+@automaker/server, @automaker/ui
+```
+
+### Key Technologies
+
+- **Frontend**: React 19, Vite 7, Electron 39, TanStack Router, Zustand 5, Tailwind CSS 4
+- **Backend**: Express 5, WebSocket (ws), Claude Agent SDK, node-pty
+- **Testing**: Playwright (E2E), Vitest (unit)
+
+### Server Architecture
+
+The server (`apps/server/src/`) follows a modular pattern:
+
+- `routes/` - Express route handlers organized by feature (agent, features, auto-mode, worktree, etc.)
+- `services/` - Business logic (AgentService, AutoModeService, FeatureLoader, TerminalService)
+- `providers/` - AI provider abstraction (currently Claude via Claude Agent SDK)
+- `lib/` - Utilities (events, auth, worktree metadata)
+
+### Frontend Architecture
+
+The UI (`apps/ui/src/`) uses:
+
+- `routes/` - TanStack Router file-based routing
+- `components/views/` - Main view components (board, settings, terminal, etc.)
+- `store/` - Zustand stores with persistence (app-store.ts, setup-store.ts)
+- `hooks/` - Custom React hooks
+- `lib/` - Utilities and API client
+
+## Data Storage
+
+### Per-Project Data (`.automaker/`)
+
+```
+.automaker/
+├── features/ # Feature JSON files and images
+│ └── {featureId}/
+│ ├── feature.json
+│ ├── agent-output.md
+│ └── images/
+├── context/ # Context files for AI agents (CLAUDE.md, etc.)
+├── settings.json # Project-specific settings
+├── spec.md # Project specification
+└── analysis.json # Project structure analysis
+```
+
+### Global Data (`DATA_DIR`, default `./data`)
+
+```
+data/
+├── settings.json # Global settings, profiles, shortcuts
+├── credentials.json # API keys
+├── sessions-metadata.json # Chat session metadata
+└── agent-sessions/ # Conversation histories
+```
+
+## Import Conventions
+
+Always import from shared packages, never from old paths:
+
+```typescript
+// ✅ Correct
+import type { Feature, ExecuteOptions } from '@automaker/types';
+import { createLogger, classifyError } from '@automaker/utils';
+import { getEnhancementPrompt } from '@automaker/prompts';
+import { getFeatureDir, ensureAutomakerDir } from '@automaker/platform';
+import { resolveModelString } from '@automaker/model-resolver';
+import { resolveDependencies } from '@automaker/dependency-resolver';
+import { getGitRepositoryDiffs } from '@automaker/git-utils';
+
+// ❌ Never import from old paths
+import { Feature } from '../services/feature-loader'; // Wrong
+import { createLogger } from '../lib/logger'; // Wrong
+```
+
+## Key Patterns
+
+### Event-Driven Architecture
+
+All server operations emit events that stream to the frontend via WebSocket. Events are created using `createEventEmitter()` from `lib/events.ts`.
+
+### Git Worktree Isolation
+
+Each feature executes in an isolated git worktree, created via `@automaker/git-utils`. This protects the main branch during AI agent execution.
+
+### Context Files
+
+Project-specific rules are stored in `.automaker/context/` and automatically loaded into agent prompts via `loadContextFiles()` from `@automaker/utils`.
+
+### Model Resolution
+
+Use `resolveModelString()` from `@automaker/model-resolver` to convert model aliases:
+
+- `haiku` → `claude-haiku-4-5`
+- `sonnet` → `claude-sonnet-4-20250514`
+- `opus` → `claude-opus-4-6`
+
+## Environment Variables
+
+- `ANTHROPIC_API_KEY` - Anthropic API key (or use Claude Code CLI auth)
+- `HOST` - Host to bind server to (default: 0.0.0.0)
+- `HOSTNAME` - Hostname for user-facing URLs (default: localhost)
+- `PORT` - Server port (default: 3008)
+- `DATA_DIR` - Data storage directory (default: ./data)
+- `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory
+- `AUTOMAKER_MOCK_AGENT=true` - Enable mock agent mode for CI testing
+- `AUTOMAKER_AUTO_LOGIN=true` - Skip login prompt in development (disabled when NODE_ENV=production)
+- `VITE_HOSTNAME` - Hostname for frontend API URLs (default: localhost)
diff --git a/temp_repo/CONTRIBUTING.md b/temp_repo/CONTRIBUTING.md
new file mode 100644
index 0000000000000000000000000000000000000000..61ad83f43b10cd531a1cf25826761fa2de721a6c
--- /dev/null
+++ b/temp_repo/CONTRIBUTING.md
@@ -0,0 +1,740 @@
+# Contributing to Automaker
+
+Thank you for your interest in contributing to Automaker! We're excited to have you join our community of developers building the future of autonomous AI development.
+
+Automaker is an autonomous AI development studio that provides a Kanban-based workflow where AI agents implement features in isolated git worktrees. Whether you're fixing bugs, adding features, improving documentation, or suggesting ideas, your contributions help make this project better for everyone.
+
+This guide will help you get started with contributing to Automaker. Please take a moment to read through these guidelines to ensure a smooth contribution process.
+
+## Contribution License Agreement
+
+**Important:** By submitting, pushing, or contributing any code, documentation, pull requests, issues, or other materials to the Automaker project, you agree to assign all right, title, and interest in and to your contributions, including all copyrights, patents, and other intellectual property rights, to the Core Contributors of Automaker. This assignment is irrevocable and includes the right to use, modify, distribute, and monetize your contributions in any manner.
+
+**You understand and agree that you will have no right to receive any royalties, compensation, or other financial benefits from any revenue, income, or commercial use generated from your contributed code or any derivative works thereof.** All contributions are made without expectation of payment or financial return.
+
+For complete details on contribution terms and rights assignment, please review [Section 5 (CONTRIBUTIONS AND RIGHTS ASSIGNMENT) of the LICENSE](LICENSE#5-contributions-and-rights-assignment).
+
+## Table of Contents
+
+- [Contributing to Automaker](#contributing-to-automaker)
+ - [Table of Contents](#table-of-contents)
+ - [Getting Started](#getting-started)
+ - [Prerequisites](#prerequisites)
+ - [Fork and Clone](#fork-and-clone)
+ - [Development Setup](#development-setup)
+ - [Project Structure](#project-structure)
+ - [Pull Request Process](#pull-request-process)
+ - [Branching Strategy (RC Branches)](#branching-strategy-rc-branches)
+ - [Branch Naming Convention](#branch-naming-convention)
+ - [Commit Message Format](#commit-message-format)
+ - [Submitting a Pull Request](#submitting-a-pull-request)
+ - [1. Prepare Your Changes](#1-prepare-your-changes)
+ - [2. Run Pre-submission Checks](#2-run-pre-submission-checks)
+ - [3. Push Your Changes](#3-push-your-changes)
+ - [4. Open a Pull Request](#4-open-a-pull-request)
+ - [PR Requirements Checklist](#pr-requirements-checklist)
+ - [Review Process](#review-process)
+ - [What to Expect](#what-to-expect)
+ - [Review Focus Areas](#review-focus-areas)
+ - [Responding to Feedback](#responding-to-feedback)
+ - [Approval Criteria](#approval-criteria)
+ - [Getting Help](#getting-help)
+ - [Code Style Guidelines](#code-style-guidelines)
+ - [Testing Requirements](#testing-requirements)
+ - [Running Tests](#running-tests)
+ - [Test Frameworks](#test-frameworks)
+ - [End-to-End Tests (Playwright)](#end-to-end-tests-playwright)
+ - [Unit Tests (Vitest)](#unit-tests-vitest)
+ - [Writing Tests](#writing-tests)
+ - [When to Write Tests](#when-to-write-tests)
+ - [CI/CD Pipeline](#cicd-pipeline)
+ - [CI Checks](#ci-checks)
+ - [CI Testing Environment](#ci-testing-environment)
+ - [Viewing CI Results](#viewing-ci-results)
+ - [Common CI Failures](#common-ci-failures)
+ - [Coverage Requirements](#coverage-requirements)
+ - [Issue Reporting](#issue-reporting)
+ - [Bug Reports](#bug-reports)
+ - [Before Reporting](#before-reporting)
+ - [Bug Report Template](#bug-report-template)
+ - [Feature Requests](#feature-requests)
+ - [Before Requesting](#before-requesting)
+ - [Feature Request Template](#feature-request-template)
+ - [Security Issues](#security-issues)
+
+---
+
+## Getting Started
+
+### Prerequisites
+
+Before contributing to Automaker, ensure you have the following installed on your system:
+
+- **Node.js 18+** (tested with Node.js 22)
+ - Download from [nodejs.org](https://nodejs.org/)
+ - Verify installation: `node --version`
+- **npm** (comes with Node.js)
+ - Verify installation: `npm --version`
+- **Git** for version control
+ - Verify installation: `git --version`
+- **Claude Code CLI** or **Anthropic API Key** (for AI agent functionality)
+ - Required to run the AI development features
+
+**Optional but recommended:**
+
+- A code editor with TypeScript support (VS Code recommended)
+- GitHub CLI (`gh`) for easier PR management
+
+### Fork and Clone
+
+1. **Fork the repository** on GitHub
+ - Navigate to [https://github.com/AutoMaker-Org/automaker](https://github.com/AutoMaker-Org/automaker)
+ - Click the "Fork" button in the top-right corner
+ - This creates your own copy of the repository
+
+2. **Clone your fork locally**
+
+ ```bash
+ git clone https://github.com/YOUR_USERNAME/automaker.git
+ cd automaker
+ ```
+
+3. **Add the upstream remote** to keep your fork in sync
+
+ ```bash
+ git remote add upstream https://github.com/AutoMaker-Org/automaker.git
+ ```
+
+4. **Verify remotes**
+ ```bash
+ git remote -v
+ # Should show:
+ # origin https://github.com/YOUR_USERNAME/automaker.git (fetch)
+ # origin https://github.com/YOUR_USERNAME/automaker.git (push)
+ # upstream https://github.com/AutoMaker-Org/automaker.git (fetch)
+ # upstream https://github.com/AutoMaker-Org/automaker.git (push)
+ ```
+
+### Development Setup
+
+1. **Install dependencies**
+
+ ```bash
+ npm install
+ ```
+
+2. **Build shared packages** (required before running the app)
+
+ ```bash
+ npm run build:packages
+ ```
+
+3. **Start the development server**
+ ```bash
+ npm run dev # Interactive launcher - choose mode
+ npm run dev:web # Browser mode (web interface)
+ npm run dev:electron # Desktop app mode
+ ```
+
+**Common development commands:**
+
+| Command | Description |
+| ------------------------ | -------------------------------- |
+| `npm run dev` | Interactive development launcher |
+| `npm run dev:web` | Start in browser mode |
+| `npm run dev:electron` | Start desktop app |
+| `npm run build` | Build all packages and apps |
+| `npm run build:packages` | Build shared packages only |
+| `npm run lint` | Run ESLint checks |
+| `npm run format` | Format code with Prettier |
+| `npm run format:check` | Check formatting without changes |
+| `npm run test` | Run E2E tests (Playwright) |
+| `npm run test:server` | Run server unit tests |
+| `npm run test:packages` | Run package tests |
+| `npm run test:all` | Run all tests |
+
+### Project Structure
+
+Automaker is organized as an npm workspace monorepo:
+
+```
+automaker/
+├── apps/
+│ ├── ui/ # React + Vite + Electron frontend
+│ └── server/ # Express + WebSocket backend
+├── libs/
+│ ├── @automaker/types/ # Shared TypeScript types
+│ ├── @automaker/utils/ # Utility functions
+│ ├── @automaker/prompts/ # AI prompt templates
+│ ├── @automaker/platform/ # Platform abstractions
+│ ├── @automaker/model-resolver/ # AI model resolution
+│ ├── @automaker/dependency-resolver/ # Dependency management
+│ └── @automaker/git-utils/ # Git operations
+├── docs/ # Documentation
+└── package.json # Root package configuration
+```
+
+**Key conventions:**
+
+- Always import from `@automaker/*` shared packages, never use relative paths to `libs/`
+- Frontend code lives in `apps/ui/`
+- Backend code lives in `apps/server/`
+- Shared logic should be in the appropriate `libs/` package
+
+---
+
+## Pull Request Process
+
+This section covers everything you need to know about contributing changes through pull requests, from creating your branch to getting your code merged.
+
+### Branching Strategy (RC Branches)
+
+Automaker uses **Release Candidate (RC) branches** for all development work. Understanding this workflow is essential before contributing.
+
+**How it works:**
+
+1. **All development happens on RC branches** - We maintain version-specific RC branches (e.g., `v0.10.0rc`, `v0.11.0rc`) where all active development occurs
+2. **RC branches are eventually merged to main** - Once an RC branch is stable and ready for release, it gets merged into `main`
+3. **Main branch is for releases only** - The `main` branch contains only released, stable code
+
+**Before creating a PR:**
+
+1. **Check for the latest RC branch** - Before starting work, check the repository for the current RC branch:
+
+ ```bash
+ git fetch upstream
+ git branch -r | grep rc
+ ```
+
+2. **Base your work on the RC branch** - Create your feature branch from the latest RC branch, not from `main`:
+
+ ```bash
+ # Find the latest RC branch (e.g., v0.11.0rc)
+ git checkout upstream/v0.11.0rc
+ git checkout -b feature/your-feature-name
+ ```
+
+3. **Target the RC branch in your PR** - When opening your pull request, set the base branch to the current RC branch, not `main`
+
+**Example workflow:**
+
+```bash
+# 1. Fetch latest changes
+git fetch upstream
+
+# 2. Check for RC branches
+git branch -r | grep rc
+# Output: upstream/v0.11.0rc
+
+# 3. Create your branch from the RC
+git checkout -b feature/add-dark-mode upstream/v0.11.0rc
+
+# 4. Make your changes and commit
+git commit -m "feat: Add dark mode support"
+
+# 5. Push to your fork
+git push origin feature/add-dark-mode
+
+# 6. Open PR targeting the RC branch (v0.11.0rc), NOT main
+```
+
+**Important:** PRs opened directly against `main` will be asked to retarget to the current RC branch.
+
+### Branch Naming Convention
+
+We use a consistent branch naming pattern to keep our repository organized:
+
+```
+/
+```
+
+**Branch types:**
+
+| Type | Purpose | Example |
+| ---------- | ------------------------ | --------------------------------- |
+| `feature` | New functionality | `feature/add-user-authentication` |
+| `fix` | Bug fixes | `fix/resolve-memory-leak` |
+| `docs` | Documentation changes | `docs/update-contributing-guide` |
+| `refactor` | Code restructuring | `refactor/simplify-api-handlers` |
+| `test` | Adding or updating tests | `test/add-utils-unit-tests` |
+| `chore` | Maintenance tasks | `chore/update-dependencies` |
+
+**Guidelines:**
+
+- Use lowercase letters and hyphens (no underscores or spaces)
+- Keep descriptions short but descriptive
+- Include issue number when applicable: `feature/123-add-login`
+
+```bash
+# Create and checkout a new feature branch
+git checkout -b feature/add-dark-mode
+
+# Create a fix branch with issue reference
+git checkout -b fix/456-resolve-login-error
+```
+
+### Commit Message Format
+
+We follow the **Conventional Commits** style for clear, readable commit history:
+
+```
+:
+
+[optional body]
+```
+
+**Commit types:**
+
+| Type | Purpose |
+| ---------- | --------------------------- |
+| `feat` | New feature |
+| `fix` | Bug fix |
+| `docs` | Documentation only |
+| `style` | Formatting (no code change) |
+| `refactor` | Code restructuring |
+| `test` | Adding or updating tests |
+| `chore` | Maintenance tasks |
+
+**Guidelines:**
+
+- Use **imperative mood** ("Add feature" not "Added feature")
+- Keep first line under **72 characters**
+- Capitalize the first letter after the type prefix
+- No period at the end of the subject line
+- Add a blank line before the body for detailed explanations
+
+**Examples:**
+
+```bash
+# Simple commit
+git commit -m "feat: Add user authentication flow"
+
+# Commit with body for more context
+git commit -m "fix: Resolve memory leak in WebSocket handler
+
+The connection cleanup was not being called when clients
+disconnected unexpectedly. Added proper cleanup in the
+error handler to prevent memory accumulation."
+
+# Documentation update
+git commit -m "docs: Update API documentation"
+
+# Refactoring
+git commit -m "refactor: Simplify state management logic"
+```
+
+### Submitting a Pull Request
+
+Follow these steps to submit your contribution:
+
+#### 1. Prepare Your Changes
+
+Ensure you've synced with the latest upstream changes from the RC branch:
+
+```bash
+# Fetch latest changes from upstream
+git fetch upstream
+
+# Rebase your branch on the current RC branch (if needed)
+git rebase upstream/v0.11.0rc # Use the current RC branch name
+```
+
+#### 2. Run Pre-submission Checks
+
+Before opening your PR, verify everything passes locally:
+
+```bash
+# Run all tests
+npm run test:all
+
+# Check formatting
+npm run format:check
+
+# Run linter
+npm run lint
+
+# Build to verify no compile errors
+npm run build
+```
+
+#### 3. Push Your Changes
+
+```bash
+# Push your branch to your fork
+git push origin feature/your-feature-name
+```
+
+#### 4. Open a Pull Request
+
+1. Go to your fork on GitHub
+2. Click "Compare & pull request" for your branch
+3. **Important:** Set the base repository to `AutoMaker-Org/automaker` and the base branch to the **current RC branch** (e.g., `v0.11.0rc`), not `main`
+4. Fill out the PR template completely
+
+#### PR Requirements Checklist
+
+Your PR should include:
+
+- [ ] **Targets the current RC branch** (not `main`) - see [Branching Strategy](#branching-strategy-rc-branches)
+- [ ] **Clear title** describing the change (use conventional commit format)
+- [ ] **Description** explaining what changed and why
+- [ ] **Link to related issue** (if applicable): `Closes #123` or `Fixes #456`
+- [ ] **All CI checks passing** (format, lint, build, tests)
+- [ ] **No merge conflicts** with the RC branch
+- [ ] **Tests included** for new functionality
+- [ ] **Documentation updated** if adding/changing public APIs
+
+**Example PR Description:**
+
+```markdown
+## Summary
+
+This PR adds dark mode support to the Automaker UI.
+
+- Implements theme toggle in settings panel
+- Adds CSS custom properties for theme colors
+- Persists theme preference to localStorage
+
+## Related Issue
+
+Closes #123
+
+## Testing
+
+- [x] Tested toggle functionality in Chrome and Firefox
+- [x] Verified theme persists across page reloads
+- [x] Checked accessibility contrast ratios
+
+## Screenshots
+
+[Include before/after screenshots for UI changes]
+```
+
+### Review Process
+
+All contributions go through code review to maintain quality:
+
+#### What to Expect
+
+1. **CI Checks Run First** - Automated checks (format, lint, build, tests) must pass before review
+2. **Maintainer Review** - The project maintainers will review your PR and decide whether to merge it
+3. **Feedback & Discussion** - The reviewer may ask questions or request changes
+4. **Iteration** - Make requested changes and push updates to the same branch
+5. **Approval & Merge** - Once approved and checks pass, your PR will be merged
+
+#### Review Focus Areas
+
+The reviewer checks for:
+
+- **Correctness** - Does the code work as intended?
+- **Clean Code** - Does it follow our [code style guidelines](#code-style-guidelines)?
+- **Test Coverage** - Are new features properly tested?
+- **Documentation** - Are public APIs documented?
+- **Breaking Changes** - Are any breaking changes discussed first?
+
+#### Responding to Feedback
+
+- Respond to **all** review comments, even if just to acknowledge
+- Ask questions if feedback is unclear
+- Push additional commits to address feedback (don't force-push during review)
+- Mark conversations as resolved once addressed
+
+#### Approval Criteria
+
+Your PR is ready to merge when:
+
+- ✅ All CI checks pass
+- ✅ The maintainer has approved the changes
+- ✅ All review comments are addressed
+- ✅ No unresolved merge conflicts
+
+#### Getting Help
+
+If your PR seems stuck:
+
+- Comment asking for status update (mention @webdevcody if needed)
+- Reach out on [Discord](https://discord.gg/jjem7aEDKU)
+- Make sure all checks are passing and you've responded to all feedback
+
+---
+
+## Code Style Guidelines
+
+Automaker uses automated tooling to enforce code style. Run `npm run format` to format code and `npm run lint` to check for issues. Pre-commit hooks automatically format staged files before committing.
+
+---
+
+## Testing Requirements
+
+Testing helps prevent regressions. Automaker uses **Playwright** for end-to-end testing and **Vitest** for unit tests.
+
+### Running Tests
+
+Use these commands to run tests locally:
+
+| Command | Description |
+| ------------------------------ | ------------------------------------- |
+| `npm run test` | Run E2E tests (Playwright) |
+| `npm run test:server` | Run server unit tests (Vitest) |
+| `npm run test:packages` | Run shared package tests |
+| `npm run test:all` | Run all tests |
+| `npm run test:server:coverage` | Run server tests with coverage report |
+
+**Before submitting a PR**, always run the full test suite:
+
+```bash
+npm run test:all
+```
+
+### Test Frameworks
+
+#### End-to-End Tests (Playwright)
+
+E2E tests verify the entire application works correctly from a user's perspective.
+
+- **Framework:** [Playwright](https://playwright.dev/)
+- **Location:** `e2e/` directory
+- **Test ports:** UI on port 3007, Server on port 3008
+
+**Running E2E tests:**
+
+```bash
+# Run all E2E tests
+npm run test
+
+# Run with headed browser (useful for debugging)
+npx playwright test --headed
+
+# Run a specific test file
+npm test --workspace=@automaker/ui -- tests/example.spec.ts
+```
+
+**E2E Test Guidelines:**
+
+- Write tests from a user's perspective
+- Use descriptive test names that explain the scenario
+- Clean up test data after each test
+- Use appropriate timeouts for async operations
+- Prefer `locator` over direct selectors for resilience
+
+#### Unit Tests (Vitest)
+
+Unit tests verify individual functions and modules work correctly in isolation.
+
+- **Framework:** [Vitest](https://vitest.dev/)
+- **Location:** In the `tests/` directory within each package (e.g., `apps/server/tests/`)
+
+**Running unit tests:**
+
+```bash
+# Run all server unit tests
+npm run test:server
+
+# Run with coverage report
+npm run test:server:coverage
+
+# Run package tests
+npm run test:packages
+
+# Run in watch mode during development
+npx vitest --watch
+```
+
+**Unit Test Guidelines:**
+
+- Keep tests small and focused on one behavior
+- Use descriptive test names: `it('should return null when user is not found')`
+- Follow the AAA pattern: Arrange, Act, Assert
+- Mock external dependencies to isolate the unit under test
+- Aim for meaningful coverage, not just line coverage
+
+### Writing Tests
+
+#### When to Write Tests
+
+- **New features:** All new features should include tests
+- **Bug fixes:** Add a test that reproduces the bug before fixing
+- **Refactoring:** Ensure existing tests pass after refactoring
+- **Public APIs:** All public APIs must have test coverage
+
+### CI/CD Pipeline
+
+Automaker uses **GitHub Actions** for continuous integration. Every pull request triggers automated checks.
+
+#### CI Checks
+
+The following checks must pass before your PR can be merged:
+
+| Check | Description |
+| ----------------- | --------------------------------------------- |
+| **Format** | Verifies code is formatted with Prettier |
+| **Build** | Ensures the project compiles without errors |
+| **Package Tests** | Runs tests for shared `@automaker/*` packages |
+| **Server Tests** | Runs server unit tests with coverage |
+
+#### CI Testing Environment
+
+For CI environments, Automaker supports a mock agent mode:
+
+```bash
+# Enable mock agent mode for CI testing
+AUTOMAKER_MOCK_AGENT=true npm run test
+```
+
+This allows tests to run without requiring a real Claude API connection.
+
+#### Viewing CI Results
+
+1. Go to your PR on GitHub
+2. Scroll to the "Checks" section at the bottom
+3. Click on any failed check to see detailed logs
+4. Fix issues locally and push updates
+
+#### Common CI Failures
+
+| Issue | Solution |
+| ------------------- | --------------------------------------------- |
+| Format check failed | Run `npm run format` locally |
+| Build failed | Run `npm run build` and fix TypeScript errors |
+| Tests failed | Run `npm run test:all` locally to reproduce |
+| Coverage decreased | Add tests for new code paths |
+
+### Coverage Requirements
+
+While we don't enforce strict coverage percentages, we expect:
+
+- **New features:** Should include comprehensive tests
+- **Bug fixes:** Should include a regression test
+- **Critical paths:** Must have test coverage (authentication, data persistence, etc.)
+
+To view coverage reports locally:
+
+```bash
+npm run test:server:coverage
+```
+
+This generates an HTML report you can open in your browser to see which lines are covered.
+
+---
+
+## Issue Reporting
+
+Found a bug or have an idea for a new feature? We'd love to hear from you! This section explains how to report issues effectively.
+
+### Bug Reports
+
+When reporting a bug, please provide as much information as possible to help us understand and reproduce the issue.
+
+#### Before Reporting
+
+1. **Search existing issues** - Check if the bug has already been reported
+2. **Try the latest version** - Make sure you're running the latest version of Automaker
+3. **Reproduce the issue** - Verify you can consistently reproduce the bug
+
+#### Bug Report Template
+
+When creating a bug report, include:
+
+- **Title:** A clear, descriptive title summarizing the issue
+- **Environment:**
+ - Operating System and version
+ - Node.js version (`node --version`)
+ - Automaker version or commit hash
+- **Steps to Reproduce:** Numbered list of steps to reproduce the bug
+- **Expected Behavior:** What you expected to happen
+- **Actual Behavior:** What actually happened
+- **Logs/Screenshots:** Any relevant error messages, console output, or screenshots
+
+**Example Bug Report:**
+
+```markdown
+## Bug: WebSocket connection drops after 5 minutes of inactivity
+
+### Environment
+
+- OS: Windows 11
+- Node.js: 22.11.0
+- Automaker: commit abc1234
+
+### Steps to Reproduce
+
+1. Start the application with `npm run dev:web`
+2. Open the Kanban board
+3. Leave the browser tab open for 5+ minutes without interaction
+4. Try to move a card
+
+### Expected Behavior
+
+The card should move to the new column.
+
+### Actual Behavior
+
+The UI shows "Connection lost" and the card doesn't move.
+
+### Logs
+
+[WebSocket] Connection closed: 1006
+```
+
+### Feature Requests
+
+We welcome ideas for improving Automaker! Here's how to submit a feature request:
+
+#### Before Requesting
+
+1. **Check existing issues** - Your idea may already be proposed or in development
+2. **Consider scope** - Think about whether the feature fits Automaker's mission as an autonomous AI development studio
+
+#### Feature Request Template
+
+A good feature request includes:
+
+- **Title:** A brief, descriptive title
+- **Problem Statement:** What problem does this feature solve?
+- **Proposed Solution:** How do you envision this working?
+- **Alternatives Considered:** What other approaches did you consider?
+- **Additional Context:** Mockups, examples, or references that help explain your idea
+
+**Example Feature Request:**
+
+```markdown
+## Feature: Dark Mode Support
+
+### Problem Statement
+
+Working late at night, the bright UI causes eye strain and doesn't match
+my system's dark theme preference.
+
+### Proposed Solution
+
+Add a theme toggle in the settings panel that allows switching between
+light and dark modes. Ideally, it should also detect system preference.
+
+### Alternatives Considered
+
+- Browser extension to force dark mode (doesn't work well with custom styling)
+- Custom CSS override (breaks with updates)
+
+### Additional Context
+
+Similar to how VS Code handles themes - a dropdown in settings with
+immediate preview.
+```
+
+### Security Issues
+
+**Important:** If you discover a security vulnerability, please do NOT open a public issue. Instead:
+
+1. Join our [Discord server](https://discord.gg/jjem7aEDKU) and send a direct message to the user `@webdevcody`
+2. Include detailed steps to reproduce
+3. Allow time for us to address the issue before public disclosure
+
+We take security seriously and appreciate responsible disclosure.
+
+---
+
+For license and contribution terms, see the [LICENSE](LICENSE) file in the repository root and the [README.md](README.md#license) for more details.
+
+---
+
+Thank you for contributing to Automaker!
diff --git a/temp_repo/DISCLAIMER.md b/temp_repo/DISCLAIMER.md
new file mode 100644
index 0000000000000000000000000000000000000000..95ef7d16820e020c7c3ac550a1d28ea51acc4f88
--- /dev/null
+++ b/temp_repo/DISCLAIMER.md
@@ -0,0 +1,85 @@
+# Security Disclaimer
+
+## Important Warning
+
+**Automaker uses AI-powered tooling that has access to your operating system and can read, modify, and delete files. Use at your own risk.**
+
+## Risk Assessment
+
+This software utilizes AI agents (such as Claude) that can:
+
+- **Read files** from your file system
+- **Write and modify files** in your projects
+- **Delete files** when instructed
+- **Execute commands** on your operating system
+- **Access environment variables** and configuration files
+
+While we have made efforts to review this codebase for security vulnerabilities and implement safeguards, **you assume all risk** when running this software.
+
+## Recommendations
+
+### 1. Review the Code First
+
+Before running Automaker, we strongly recommend reviewing the source code yourself to understand what operations it performs and ensure you are comfortable with its behavior.
+
+### 2. Use Sandboxing (Highly Recommended)
+
+**We do not recommend running Automaker directly on your local computer** due to the risk of AI agents having access to your entire file system. Instead, consider:
+
+- **Docker**: Run Automaker in a Docker container to isolate it from your host system
+- **Virtual Machine**: Use a VM (such as VirtualBox, VMware, or Parallels) to create an isolated environment
+- **Cloud Development Environment**: Use a cloud-based development environment that provides isolation
+
+#### Running in Isolated Docker Container
+
+For maximum security, run Automaker in an isolated Docker container that **cannot access your laptop's files**:
+
+```bash
+# 1. Set your API key (bash/Linux/Mac - creates UTF-8 file)
+echo "ANTHROPIC_API_KEY=your-api-key-here" > .env
+
+# On Windows PowerShell, use instead:
+Set-Content -Path .env -Value "ANTHROPIC_API_KEY=your-api-key-here" -Encoding UTF8
+
+# 2. Build and run isolated container
+docker-compose up -d
+
+# 3. Access the UI at http://localhost:3007
+# API at http://localhost:3008/api/health
+```
+
+The container uses only Docker-managed volumes and has no access to your host filesystem. See [docker-isolation.md](docs/docker-isolation.md) for full documentation.
+
+### 3. Limit Access
+
+If you must run locally:
+
+- Create a dedicated user account with limited permissions
+- Only grant access to specific project directories
+- Avoid running with administrator/root privileges
+- Keep sensitive files and credentials outside of project directories
+
+### 4. Monitor Activity
+
+- Review the agent's actions in the output logs
+- Pay attention to file modifications and command executions
+- Stop the agent immediately if you notice unexpected behavior
+
+## No Warranty & Limitation of Liability
+
+THE SOFTWARE UTILIZES ARTIFICIAL INTELLIGENCE TO GENERATE CODE, EXECUTE COMMANDS, AND INTERACT WITH YOUR FILE SYSTEM. YOU ACKNOWLEDGE THAT AI SYSTEMS CAN BE UNPREDICTABLE, MAY GENERATE INCORRECT, INSECURE, OR DESTRUCTIVE CODE, AND MAY TAKE ACTIONS THAT COULD DAMAGE YOUR SYSTEM, FILES, OR HARDWARE.
+
+This software is provided "as is", without warranty of any kind, express or implied. In no event shall the authors or copyright holders be liable for any claim, damages, or other liability, including but not limited to hardware damage, data loss, financial loss, or business interruption, whether in an action of contract, tort, or otherwise, arising from, out of, or in connection with the software or the use or other dealings in the software.
+
+## Acknowledgment
+
+By using Automaker, you acknowledge that:
+
+1. You have read and understood this disclaimer
+2. You accept full responsibility for any consequences of using this software
+3. You understand the risks of AI agents having access to your operating system
+4. You agree to take appropriate precautions as outlined above
+
+---
+
+**If you are not comfortable with these risks, do not use this software.**
diff --git a/temp_repo/Dockerfile b/temp_repo/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..7d48e15fe16aa6737cde3fb4437a0e576b7dc119
--- /dev/null
+++ b/temp_repo/Dockerfile
@@ -0,0 +1,237 @@
+# Automaker Multi-Stage Dockerfile
+# Single Dockerfile for both server and UI builds
+# Usage:
+# docker build --target server -t automaker-server .
+# docker build --target ui -t automaker-ui .
+# Or use docker-compose which selects targets automatically
+
+# =============================================================================
+# BASE STAGE - Common setup for all builds (DRY: defined once, used by all)
+# =============================================================================
+FROM node:22-slim AS base
+
+# Install build dependencies for native modules (node-pty)
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ python3 make g++ \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+# Copy root package files
+COPY package*.json ./
+
+# Copy all libs package.json files (centralized - add new libs here)
+COPY libs/types/package*.json ./libs/types/
+COPY libs/utils/package*.json ./libs/utils/
+COPY libs/prompts/package*.json ./libs/prompts/
+COPY libs/platform/package*.json ./libs/platform/
+COPY libs/spec-parser/package*.json ./libs/spec-parser/
+COPY libs/model-resolver/package*.json ./libs/model-resolver/
+COPY libs/dependency-resolver/package*.json ./libs/dependency-resolver/
+COPY libs/git-utils/package*.json ./libs/git-utils/
+COPY libs/spec-parser/package*.json ./libs/spec-parser/
+
+# Copy scripts (needed by npm workspace)
+COPY scripts ./scripts
+
+# =============================================================================
+# SERVER BUILD STAGE
+# =============================================================================
+FROM base AS server-builder
+
+# Copy server-specific package.json
+COPY apps/server/package*.json ./apps/server/
+
+# Install dependencies (--ignore-scripts to skip husky/prepare, then rebuild native modules)
+RUN npm ci --ignore-scripts && npm rebuild node-pty
+
+# Copy all source files
+COPY libs ./libs
+COPY apps/server ./apps/server
+
+# Build packages in dependency order, then build server
+RUN npm run build:packages && npm run build --workspace=apps/server
+
+# =============================================================================
+# SERVER PRODUCTION STAGE
+# =============================================================================
+FROM node:22-slim AS server
+
+# Build argument for tracking which commit this image was built from
+ARG GIT_COMMIT_SHA=unknown
+LABEL automaker.git.commit.sha="${GIT_COMMIT_SHA}"
+
+# Build arguments for user ID matching (allows matching host user for mounted volumes)
+# Override at build time: docker build --build-arg UID=$(id -u) --build-arg GID=$(id -g) ...
+ARG UID=1001
+ARG GID=1001
+
+# Install git, curl, bash (for terminal), gosu (for user switching), and GitHub CLI (pinned version, multi-arch)
+# Also install Playwright/Chromium system dependencies (aligns with playwright install-deps on Debian/Ubuntu)
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ git curl bash gosu ca-certificates openssh-client \
+ # Playwright/Chromium dependencies
+ libglib2.0-0 libnss3 libnspr4 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 \
+ libcups2 libdrm2 libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 \
+ libxfixes3 libxrandr2 libgbm1 libasound2 libpango-1.0-0 libcairo2 \
+ libx11-6 libx11-xcb1 libxcb1 libxext6 libxrender1 libxss1 libxtst6 \
+ libxshmfence1 libgtk-3-0 libexpat1 libfontconfig1 fonts-liberation \
+ xdg-utils libpangocairo-1.0-0 libpangoft2-1.0-0 libu2f-udev libvulkan1 \
+ && GH_VERSION="2.63.2" \
+ && ARCH=$(uname -m) \
+ && case "$ARCH" in \
+ x86_64) GH_ARCH="amd64" ;; \
+ aarch64|arm64) GH_ARCH="arm64" ;; \
+ *) echo "Unsupported architecture: $ARCH" && exit 1 ;; \
+ esac \
+ && curl -L "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_${GH_ARCH}.tar.gz" -o gh.tar.gz \
+ && tar -xzf gh.tar.gz \
+ && mv gh_${GH_VERSION}_linux_${GH_ARCH}/bin/gh /usr/local/bin/gh \
+ && rm -rf gh.tar.gz gh_${GH_VERSION}_linux_${GH_ARCH} \
+ && rm -rf /var/lib/apt/lists/*
+
+# Install Claude CLI globally (available to all users via npm global bin)
+RUN npm install -g @anthropic-ai/claude-code
+
+# Create non-root user with home directory BEFORE installing Cursor CLI
+# Uses UID/GID build args to match host user for mounted volume permissions
+# Use -o flag to allow non-unique IDs (GID 1000 may already exist as 'node' group)
+RUN groupadd -o -g ${GID} automaker && \
+ useradd -o -u ${UID} -g automaker -m -d /home/automaker -s /bin/bash automaker && \
+ mkdir -p /home/automaker/.local/bin && \
+ mkdir -p /home/automaker/.cursor && \
+ chown -R automaker:automaker /home/automaker && \
+ chmod 700 /home/automaker/.cursor
+
+# Install Cursor CLI as the automaker user
+# Set HOME explicitly and install to /home/automaker/.local/bin/
+USER automaker
+ENV HOME=/home/automaker
+RUN curl https://cursor.com/install -fsS | bash && \
+ echo "=== Checking Cursor CLI installation ===" && \
+ ls -la /home/automaker/.local/bin/ && \
+ echo "=== PATH is: $PATH ===" && \
+ (which cursor-agent && cursor-agent --version) || echo "cursor-agent installed (may need auth setup)"
+
+# Install OpenCode CLI (for multi-provider AI model access)
+RUN curl -fsSL https://opencode.ai/install | bash && \
+ echo "=== Checking OpenCode CLI installation ===" && \
+ ls -la /home/automaker/.local/bin/ && \
+ (which opencode && opencode --version) || echo "opencode installed (may need auth setup)"
+
+USER root
+
+# Add PATH to profile so it's available in all interactive shells (for login shells)
+RUN mkdir -p /etc/profile.d && \
+ echo 'export PATH="/home/automaker/.local/bin:$PATH"' > /etc/profile.d/cursor-cli.sh && \
+ chmod +x /etc/profile.d/cursor-cli.sh
+
+# Add to automaker's .bashrc for bash interactive shells
+RUN echo 'export PATH="/home/automaker/.local/bin:$PATH"' >> /home/automaker/.bashrc && \
+ chown automaker:automaker /home/automaker/.bashrc
+
+# Also add to root's .bashrc since docker exec defaults to root
+RUN echo 'export PATH="/home/automaker/.local/bin:$PATH"' >> /root/.bashrc
+
+WORKDIR /app
+
+# Copy root package.json (needed for workspace resolution)
+COPY --from=server-builder /app/package*.json ./
+
+# Copy built libs (workspace packages are symlinked in node_modules)
+COPY --from=server-builder /app/libs ./libs
+
+# Copy built server
+COPY --from=server-builder /app/apps/server/dist ./apps/server/dist
+COPY --from=server-builder /app/apps/server/package*.json ./apps/server/
+
+# Copy node_modules (includes symlinks to libs)
+COPY --from=server-builder /app/node_modules ./node_modules
+
+# Install Playwright Chromium browser for AI agent verification tests
+# This adds ~300MB to the image but enables automated testing mode out of the box
+# Using the locally installed playwright ensures we use the pinned version from package-lock.json
+USER automaker
+RUN ./node_modules/.bin/playwright install chromium && \
+ echo "=== Playwright Chromium installed ===" && \
+ ls -la /home/automaker/.cache/ms-playwright/
+USER root
+
+# Create data and projects directories
+RUN mkdir -p /data /projects && chown automaker:automaker /data /projects
+
+# Configure git for mounted volumes and authentication
+# Use --system so it's not overwritten by mounted user .gitconfig
+RUN git config --system --add safe.directory '*' && \
+ # Use gh as credential helper (works with GH_TOKEN env var)
+ git config --system credential.helper '!gh auth git-credential'
+
+# Copy entrypoint script for fixing permissions on mounted volumes
+COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
+RUN chmod +x /usr/local/bin/docker-entrypoint.sh
+
+# Note: We stay as root here so entrypoint can fix permissions
+# The entrypoint script will switch to automaker user before running the command
+
+# Environment variables
+ENV PORT=3008
+ENV DATA_DIR=/data
+ENV HOME=/home/automaker
+# Add user's local bin to PATH for cursor-agent
+ENV PATH="/home/automaker/.local/bin:${PATH}"
+
+# Expose port
+EXPOSE 3008
+
+# Health check (using curl since it's already installed, more reliable than busybox wget)
+HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
+ CMD curl -f http://localhost:3008/api/health || exit 1
+
+# Use entrypoint to fix permissions before starting
+ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
+
+# Start server
+CMD ["node", "apps/server/dist/index.js"]
+
+# =============================================================================
+# UI BUILD STAGE
+# =============================================================================
+FROM base AS ui-builder
+
+# Copy UI-specific package.json
+COPY apps/ui/package*.json ./apps/ui/
+
+# Install dependencies (--ignore-scripts to skip husky and build:packages in prepare script)
+RUN npm ci --ignore-scripts
+
+# Copy all source files
+COPY libs ./libs
+COPY apps/ui ./apps/ui
+
+# Build packages in dependency order, then build UI
+# When VITE_SERVER_URL is empty, the UI uses relative URLs (e.g., /api/...) which nginx proxies
+# to the server container. This avoids CORS issues entirely in Docker Compose setups.
+# Override at build time if needed: --build-arg VITE_SERVER_URL=http://api.example.com
+ARG VITE_SERVER_URL=
+ENV VITE_SKIP_ELECTRON=true
+ENV VITE_SERVER_URL=${VITE_SERVER_URL}
+RUN npm run build:packages && npm run build --workspace=apps/ui
+
+# =============================================================================
+# UI PRODUCTION STAGE
+# =============================================================================
+FROM nginx:alpine AS ui
+
+# Build argument for tracking which commit this image was built from
+ARG GIT_COMMIT_SHA=unknown
+LABEL automaker.git.commit.sha="${GIT_COMMIT_SHA}"
+
+# Copy built files
+COPY --from=ui-builder /app/apps/ui/dist /usr/share/nginx/html
+
+# Copy nginx config for SPA routing
+COPY apps/ui/nginx.conf /etc/nginx/conf.d/default.conf
+
+EXPOSE 80
+
+CMD ["nginx", "-g", "daemon off;"]
diff --git a/temp_repo/Dockerfile.dev b/temp_repo/Dockerfile.dev
new file mode 100644
index 0000000000000000000000000000000000000000..60e445f2e15f5206a5aade94ea76a54aadf9a789
--- /dev/null
+++ b/temp_repo/Dockerfile.dev
@@ -0,0 +1,94 @@
+# Automaker Development Dockerfile
+# For development with live reload via volume mounting
+# Source code is NOT copied - it's mounted as a volume
+#
+# Usage:
+# docker compose -f docker-compose.dev.yml up
+
+FROM node:22-slim
+
+# Install build dependencies for native modules (node-pty) and runtime tools
+# Also install Playwright/Chromium system dependencies (aligns with playwright install-deps on Debian/Ubuntu)
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ python3 make g++ \
+ git curl bash gosu ca-certificates openssh-client \
+ # Playwright/Chromium dependencies
+ libglib2.0-0 libnss3 libnspr4 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 \
+ libcups2 libdrm2 libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 \
+ libxfixes3 libxrandr2 libgbm1 libasound2 libpango-1.0-0 libcairo2 \
+ libx11-6 libx11-xcb1 libxcb1 libxext6 libxrender1 libxss1 libxtst6 \
+ libxshmfence1 libgtk-3-0 libexpat1 libfontconfig1 fonts-liberation \
+ xdg-utils libpangocairo-1.0-0 libpangoft2-1.0-0 libu2f-udev libvulkan1 \
+ && GH_VERSION="2.63.2" \
+ && ARCH=$(uname -m) \
+ && case "$ARCH" in \
+ x86_64) GH_ARCH="amd64" ;; \
+ aarch64|arm64) GH_ARCH="arm64" ;; \
+ *) echo "Unsupported architecture: $ARCH" && exit 1 ;; \
+ esac \
+ && curl -L "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_${GH_ARCH}.tar.gz" -o gh.tar.gz \
+ && tar -xzf gh.tar.gz \
+ && mv gh_${GH_VERSION}_linux_${GH_ARCH}/bin/gh /usr/local/bin/gh \
+ && rm -rf gh.tar.gz gh_${GH_VERSION}_linux_${GH_ARCH} \
+ && rm -rf /var/lib/apt/lists/*
+
+# Install Claude CLI globally
+RUN npm install -g @anthropic-ai/claude-code
+
+# Build arguments for user ID matching (allows matching host user for mounted volumes)
+# Override at build time: docker-compose build --build-arg UID=$(id -u) --build-arg GID=$(id -g)
+ARG UID=1001
+ARG GID=1001
+
+# Create non-root user with configurable UID/GID
+# Use -o flag to allow non-unique IDs (GID 1000 may already exist as 'node' group)
+RUN groupadd -o -g ${GID} automaker && \
+ useradd -o -u ${UID} -g automaker -m -d /home/automaker -s /bin/bash automaker && \
+ mkdir -p /home/automaker/.local/bin && \
+ mkdir -p /home/automaker/.cursor && \
+ chown -R automaker:automaker /home/automaker && \
+ chmod 700 /home/automaker/.cursor
+
+# Install Cursor CLI as automaker user
+USER automaker
+ENV HOME=/home/automaker
+RUN curl https://cursor.com/install -fsS | bash || true
+USER root
+
+# Add PATH to profile for Cursor CLI
+RUN mkdir -p /etc/profile.d && \
+ echo 'export PATH="/home/automaker/.local/bin:$PATH"' > /etc/profile.d/cursor-cli.sh && \
+ chmod +x /etc/profile.d/cursor-cli.sh
+
+# Add to user bashrc files
+RUN echo 'export PATH="/home/automaker/.local/bin:$PATH"' >> /home/automaker/.bashrc && \
+ chown automaker:automaker /home/automaker/.bashrc
+RUN echo 'export PATH="/home/automaker/.local/bin:$PATH"' >> /root/.bashrc
+
+WORKDIR /app
+
+# Create directories with proper permissions
+RUN mkdir -p /data /projects && chown automaker:automaker /data /projects
+
+# Configure git for mounted volumes
+RUN git config --system --add safe.directory '*' && \
+ git config --system credential.helper '!gh auth git-credential'
+
+# Copy entrypoint script
+COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
+RUN chmod +x /usr/local/bin/docker-entrypoint.sh
+
+# Environment variables
+ENV PORT=3008
+ENV DATA_DIR=/data
+ENV HOME=/home/automaker
+ENV PATH="/home/automaker/.local/bin:${PATH}"
+
+# Expose both dev ports
+EXPOSE 3007 3008
+
+# Use entrypoint for permission handling
+ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
+
+# Default command - will be overridden by docker-compose
+CMD ["npm", "run", "dev:web"]
diff --git a/temp_repo/LICENSE b/temp_repo/LICENSE
new file mode 100644
index 0000000000000000000000000000000000000000..e388c0a7c472272fd7d4aca4e29b931ee8bad6b9
--- /dev/null
+++ b/temp_repo/LICENSE
@@ -0,0 +1,27 @@
+## Project Status
+
+**This project is no longer actively maintained.** The codebase is provided as-is for those who wish to use, study, or fork it. No bug fixes, security updates, or new features are being developed. Community contributions may still be accepted, but there is no guarantee of review or merge.
+
+---
+
+MIT License
+
+Copyright (c) 2025 Automaker Core Contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/temp_repo/OPENCODE_CONFIG_CONTENT b/temp_repo/OPENCODE_CONFIG_CONTENT
new file mode 100644
index 0000000000000000000000000000000000000000..9dabfe4921112c6cf3a3d451b3b258191376b862
--- /dev/null
+++ b/temp_repo/OPENCODE_CONFIG_CONTENT
@@ -0,0 +1,2 @@
+{
+ "$schema": "https://opencode.ai/config.json",}
\ No newline at end of file
diff --git a/temp_repo/README.md b/temp_repo/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..e13ad62e5378fce4551174bf5e1a693c80836265
--- /dev/null
+++ b/temp_repo/README.md
@@ -0,0 +1,714 @@
+
+
+
+
+> **[!TIP]**
+>
+> **Learn more about Agentic Coding!**
+>
+> Automaker itself was built by a group of engineers using AI and agentic coding techniques to build features faster than ever. By leveraging tools like Cursor IDE and Claude Code CLI, the team orchestrated AI agents to implement complex functionality in days instead of weeks.
+>
+> **Learn how:** Master these same techniques and workflows in the [Agentic Jumpstart course](https://agenticjumpstart.com/?utm=automaker-gh).
+
+# Automaker
+
+**Stop typing code. Start directing AI agents.**
+
+
+Table of Contents
+
+- [What Makes Automaker Different?](#what-makes-automaker-different)
+ - [The Workflow](#the-workflow)
+ - [Powered by Claude Agent SDK](#powered-by-claude-agent-sdk)
+ - [Why This Matters](#why-this-matters)
+- [Security Disclaimer](#security-disclaimer)
+- [Community & Support](#community--support)
+- [Getting Started](#getting-started)
+ - [Prerequisites](#prerequisites)
+ - [Quick Start](#quick-start)
+- [How to Run](#how-to-run)
+ - [Development Mode](#development-mode)
+ - [Interactive TUI Launcher](#interactive-tui-launcher-recommended-for-new-users)
+ - [Building for Production](#building-for-production)
+ - [Testing](#testing)
+ - [Linting](#linting)
+ - [Environment Configuration](#environment-configuration)
+ - [Authentication Setup](#authentication-setup)
+- [Features](#features)
+ - [Core Workflow](#core-workflow)
+ - [AI & Planning](#ai--planning)
+ - [Project Management](#project-management)
+ - [Collaboration & Review](#collaboration--review)
+ - [Developer Tools](#developer-tools)
+ - [Advanced Features](#advanced-features)
+- [Tech Stack](#tech-stack)
+ - [Frontend](#frontend)
+ - [Backend](#backend)
+ - [Testing & Quality](#testing--quality)
+ - [Shared Libraries](#shared-libraries)
+- [Available Views](#available-views)
+- [Architecture](#architecture)
+ - [Monorepo Structure](#monorepo-structure)
+ - [How It Works](#how-it-works)
+ - [Key Architectural Patterns](#key-architectural-patterns)
+ - [Security & Isolation](#security--isolation)
+ - [Data Storage](#data-storage)
+- [Learn More](#learn-more)
+- [License](#license)
+
+
+
+Automaker is an autonomous AI development studio that transforms how you build software. Instead of manually writing every line of code, you describe features on a Kanban board and watch as AI agents powered by Claude Agent SDK automatically implement them. Built with React, Vite, Electron, and Express, Automaker provides a complete workflow for managing AI agents through a desktop application (or web browser), with features like real-time streaming, git worktree isolation, plan approval, and multi-agent task execution.
+
+
+
+## What Makes Automaker Different?
+
+Traditional development tools help you write code. Automaker helps you **orchestrate AI agents** to build entire features autonomously. Think of it as having a team of AI developers working for you—you define what needs to be built, and Automaker handles the implementation.
+
+### The Workflow
+
+1. **Add Features** - Describe features you want built (with text, images, or screenshots)
+2. **Move to "In Progress"** - Automaker automatically assigns an AI agent to implement the feature
+3. **Watch It Build** - See real-time progress as the agent writes code, runs tests, and makes changes
+4. **Review & Verify** - Review the changes, run tests, and approve when ready
+5. **Ship Faster** - Build entire applications in days, not weeks
+
+### Powered by Claude Agent SDK
+
+Automaker leverages the [Claude Agent SDK](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk) to give AI agents full access to your codebase. Agents can read files, write code, execute commands, run tests, and make git commits—all while working in isolated git worktrees to keep your main branch safe. The SDK provides autonomous AI agents that can use tools, make decisions, and complete complex multi-step tasks without constant human intervention.
+
+### Why This Matters
+
+The future of software development is **agentic coding**—where developers become architects directing AI agents rather than manual coders. Automaker puts this future in your hands today, letting you experience what it's like to build software 10x faster with AI agents handling the implementation while you focus on architecture and business logic.
+
+## Community & Support
+
+Join the **Agentic Jumpstart** to connect with other builders exploring **agentic coding** and autonomous development workflows.
+
+In the Discord, you can:
+
+- 💬 Discuss agentic coding patterns and best practices
+- 🧠 Share ideas for AI-driven development workflows
+- 🛠️ Get help setting up or extending Automaker
+- 🚀 Show off projects built with AI agents
+- 🤝 Collaborate with other developers and contributors
+
+👉 **Join the Discord:** [Agentic Jumpstart Discord](https://discord.gg/jjem7aEDKU)
+
+---
+
+## Getting Started
+
+### Prerequisites
+
+- **Node.js 22+** (required: >=22.0.0 <23.0.0)
+- **npm** (comes with Node.js)
+- **[Claude Code CLI](https://code.claude.com/docs/en/overview)** - Install and authenticate with your Anthropic subscription. Automaker integrates with your authenticated Claude Code CLI to access Claude models.
+
+### Quick Start
+
+```bash
+# 1. Clone the repository
+git clone https://github.com/AutoMaker-Org/automaker.git
+cd automaker
+
+# 2. Install dependencies
+npm install
+
+# 3. Start Automaker
+npm run dev
+# Choose between:
+# 1. Web Application (browser at localhost:3007)
+# 2. Desktop Application (Electron - recommended)
+```
+
+**Authentication:** Automaker integrates with your authenticated Claude Code CLI. Make sure you have [installed and authenticated](https://code.claude.com/docs/en/quickstart) the Claude Code CLI before running Automaker. Your CLI credentials will be detected automatically.
+
+**For Development:** `npm run dev` starts the development server with Vite live reload and hot module replacement for fast refresh and instant updates as you make changes.
+
+## How to Run
+
+### Development Mode
+
+Start Automaker in development mode:
+
+```bash
+npm run dev
+```
+
+This will prompt you to choose your run mode, or you can specify a mode directly:
+
+#### Electron Desktop App (Recommended)
+
+```bash
+# Standard development mode
+npm run dev:electron
+
+# With DevTools open automatically
+npm run dev:electron:debug
+
+# For WSL (Windows Subsystem for Linux)
+npm run dev:electron:wsl
+
+# For WSL with GPU acceleration
+npm run dev:electron:wsl:gpu
+```
+
+#### Web Browser Mode
+
+```bash
+# Run in web browser (http://localhost:3007)
+npm run dev:web
+```
+
+### Interactive TUI Launcher (Recommended for New Users)
+
+For a user-friendly interactive menu, use the built-in TUI launcher script:
+
+```bash
+# Show interactive menu with all launch options
+./start-automaker.sh
+
+# Or launch directly without menu
+./start-automaker.sh web # Web browser
+./start-automaker.sh electron # Desktop app
+./start-automaker.sh electron-debug # Desktop + DevTools
+
+# Additional options
+./start-automaker.sh --help # Show all available options
+./start-automaker.sh --version # Show version information
+./start-automaker.sh --check-deps # Verify project dependencies
+./start-automaker.sh --no-colors # Disable colored output
+./start-automaker.sh --no-history # Don't remember last choice
+```
+
+**Features:**
+
+- 🎨 Beautiful terminal UI with gradient colors and ASCII art
+- ⌨️ Interactive menu (press 1-3 to select, Q to exit)
+- 💾 Remembers your last choice
+- ✅ Pre-flight checks (validates Node.js, npm, dependencies)
+- 📏 Responsive layout (adapts to terminal size)
+- ⏱️ 30-second timeout for hands-free selection
+- 🌐 Cross-shell compatible (bash/zsh)
+
+**History File:**
+Your last selected mode is saved in `~/.automaker_launcher_history` for quick re-runs.
+
+### Building for Production
+
+#### Web Application
+
+```bash
+# Build for web deployment (uses Vite)
+npm run build
+```
+
+#### Desktop Application
+
+```bash
+# Build for current platform (macOS/Windows/Linux)
+npm run build:electron
+
+# Platform-specific builds
+npm run build:electron:mac # macOS (DMG + ZIP, x64 + arm64)
+npm run build:electron:win # Windows (NSIS installer, x64)
+npm run build:electron:linux # Linux (AppImage + DEB + RPM, x64)
+
+# Output directory: apps/ui/release/
+```
+
+**Linux Distribution Packages:**
+
+- **AppImage**: Universal format, works on any Linux distribution
+- **DEB**: Ubuntu, Debian, Linux Mint, Pop!\_OS
+- **RPM**: Fedora, RHEL, Rocky Linux, AlmaLinux, openSUSE
+
+**Installing on Fedora/RHEL:**
+
+```bash
+# Download the RPM package
+wget https://github.com/AutoMaker-Org/automaker/releases/latest/download/Automaker--x86_64.rpm
+
+# Install with dnf (Fedora)
+sudo dnf install ./Automaker--x86_64.rpm
+
+# Or with yum (RHEL/CentOS)
+sudo yum localinstall ./Automaker--x86_64.rpm
+```
+
+#### Docker Deployment
+
+Docker provides the most secure way to run Automaker by isolating it from your host filesystem.
+
+```bash
+# Build and run with Docker Compose
+docker-compose up -d
+
+# Access UI at http://localhost:3007
+# API at http://localhost:3008
+
+# View logs
+docker-compose logs -f
+
+# Stop containers
+docker-compose down
+```
+
+##### Authentication
+
+Automaker integrates with your authenticated Claude Code CLI. To use CLI authentication in Docker, mount your Claude CLI config directory (see [Claude CLI Authentication](#claude-cli-authentication) below).
+
+##### Working with Projects (Host Directory Access)
+
+By default, the container is isolated from your host filesystem. To work on projects from your host machine, create a `docker-compose.override.yml` file (gitignored):
+
+```yaml
+services:
+ server:
+ volumes:
+ # Mount your project directories
+ - /path/to/your/project:/projects/your-project
+```
+
+##### Claude CLI Authentication
+
+Mount your Claude CLI config directory to use your authenticated CLI credentials:
+
+```yaml
+services:
+ server:
+ volumes:
+ # Linux/macOS
+ - ~/.claude:/home/automaker/.claude
+ # Windows
+ - C:/Users/YourName/.claude:/home/automaker/.claude
+```
+
+**Note:** The Claude CLI config must be writable (do not use `:ro` flag) as the CLI writes debug files.
+
+> **⚠️ Important: Linux/WSL Users**
+>
+> The container runs as UID 1001 by default. If your host user has a different UID (common on Linux/WSL where the first user is UID 1000), you must create a `.env` file to match your host user:
+>
+> ```bash
+> # Check your UID/GID
+> id -u # outputs your UID (e.g., 1000)
+> id -g # outputs your GID (e.g., 1000)
+> ```
+>
+> Create a `.env` file in the automaker directory:
+>
+> ```
+> UID=1000
+> GID=1000
+> ```
+>
+> Then rebuild the images:
+>
+> ```bash
+> docker compose build
+> ```
+>
+> Without this, files written by the container will be inaccessible to your host user.
+
+##### GitHub CLI Authentication (For Git Push/PR Operations)
+
+To enable git push and GitHub CLI operations inside the container:
+
+```yaml
+services:
+ server:
+ volumes:
+ # Mount GitHub CLI config
+ # Linux/macOS
+ - ~/.config/gh:/home/automaker/.config/gh
+ # Windows
+ - 'C:/Users/YourName/AppData/Roaming/GitHub CLI:/home/automaker/.config/gh'
+
+ # Mount git config for user identity (name, email)
+ - ~/.gitconfig:/home/automaker/.gitconfig:ro
+ environment:
+ # GitHub token (required on Windows where tokens are in Credential Manager)
+ # Get your token with: gh auth token
+ - GH_TOKEN=${GH_TOKEN}
+```
+
+Then add `GH_TOKEN` to your `.env` file:
+
+```bash
+GH_TOKEN=gho_your_github_token_here
+```
+
+##### Complete docker-compose.override.yml Example
+
+```yaml
+services:
+ server:
+ volumes:
+ # Your projects
+ - /path/to/project1:/projects/project1
+ - /path/to/project2:/projects/project2
+
+ # Authentication configs
+ - ~/.claude:/home/automaker/.claude
+ - ~/.config/gh:/home/automaker/.config/gh
+ - ~/.gitconfig:/home/automaker/.gitconfig:ro
+ environment:
+ - GH_TOKEN=${GH_TOKEN}
+```
+
+##### Architecture Support
+
+The Docker image supports both AMD64 and ARM64 architectures. The GitHub CLI and Claude CLI are automatically downloaded for the correct architecture during build.
+
+##### Playwright for Automated Testing
+
+The Docker image includes **Playwright Chromium pre-installed** for AI agent verification tests. When agents implement features in automated testing mode, they use Playwright to verify the implementation works correctly.
+
+**No additional setup required** - Playwright verification works out of the box.
+
+#### Optional: Persist browsers for manual updates
+
+By default, Playwright Chromium is pre-installed in the Docker image. If you need to manually update browsers or want to persist browser installations across container restarts (not image rebuilds), you can mount a volume.
+
+**Important:** When you first add this volume mount to an existing setup, the empty volume will override the pre-installed browsers. You must re-install them:
+
+```bash
+# After adding the volume mount for the first time
+docker exec --user automaker -w /app automaker-server npx playwright install chromium
+```
+
+Add this to your `docker-compose.override.yml`:
+
+```yaml
+services:
+ server:
+ volumes:
+ - playwright-cache:/home/automaker/.cache/ms-playwright
+
+volumes:
+ playwright-cache:
+ name: automaker-playwright-cache
+```
+
+**Updating browsers manually:**
+
+```bash
+docker exec --user automaker -w /app automaker-server npx playwright install chromium
+```
+
+### Testing
+
+#### End-to-End Tests (Playwright)
+
+```bash
+npm run test # Headless E2E tests
+npm run test:headed # Browser visible E2E tests
+```
+
+#### Unit Tests (Vitest)
+
+```bash
+npm run test:server # Server unit tests
+npm run test:server:coverage # Server tests with coverage
+npm run test:packages # All shared package tests
+npm run test:all # Packages + server tests
+```
+
+#### Test Configuration
+
+- E2E tests run on ports 3007 (UI) and 3008 (server)
+- Automatically starts test servers before running
+- Uses Chromium browser via Playwright
+- Mock agent mode available in CI with `AUTOMAKER_MOCK_AGENT=true`
+
+### Linting
+
+```bash
+# Run ESLint
+npm run lint
+```
+
+### Environment Configuration
+
+#### Optional - Server
+
+- `PORT` - Server port (default: 3008)
+- `DATA_DIR` - Data storage directory (default: ./data)
+- `ENABLE_REQUEST_LOGGING` - HTTP request logging (default: true)
+
+#### Optional - Security
+
+- `AUTOMAKER_API_KEY` - Optional API authentication for the server
+- `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory
+- `CORS_ORIGIN` - CORS allowed origins (comma-separated list; defaults to localhost only)
+
+#### Optional - Development
+
+- `VITE_SKIP_ELECTRON` - Skip Electron in dev mode
+- `OPEN_DEVTOOLS` - Auto-open DevTools in Electron
+- `AUTOMAKER_SKIP_SANDBOX_WARNING` - Skip sandbox warning dialog (useful for dev/CI)
+- `AUTOMAKER_AUTO_LOGIN=true` - Skip login prompt in development (ignored when NODE_ENV=production)
+
+### Authentication Setup
+
+Automaker integrates with your authenticated Claude Code CLI and uses your Anthropic subscription.
+
+Install and authenticate the Claude Code CLI following the [official quickstart guide](https://code.claude.com/docs/en/quickstart).
+
+Once authenticated, Automaker will automatically detect and use your CLI credentials. No additional configuration needed!
+
+## Features
+
+### Core Workflow
+
+- 📋 **Kanban Board** - Visual drag-and-drop board to manage features through backlog, in progress, waiting approval, and verified stages
+- 🤖 **AI Agent Integration** - Automatic AI agent assignment to implement features when moved to "In Progress"
+- 🔀 **Git Worktree Isolation** - Each feature executes in isolated git worktrees to protect your main branch
+- 📡 **Real-time Streaming** - Watch AI agents work in real-time with live tool usage, progress updates, and task completion
+- 🔄 **Follow-up Instructions** - Send additional instructions to running agents without stopping them
+
+### AI & Planning
+
+- 🧠 **Multi-Model Support** - Choose from Claude Opus, Sonnet, and Haiku per feature
+- 💭 **Extended Thinking** - Enable thinking modes (none, medium, deep, ultra) for complex problem-solving
+- 📝 **Planning Modes** - Four planning levels: skip (direct implementation), lite (quick plan), spec (task breakdown), full (phased execution)
+- ✅ **Plan Approval** - Review and approve AI-generated plans before implementation begins
+- 📊 **Multi-Agent Task Execution** - Spec mode spawns dedicated agents per task for focused implementation
+
+### Project Management
+
+- 🔍 **Project Analysis** - AI-powered codebase analysis to understand your project structure
+- 💡 **Feature Suggestions** - AI-generated feature suggestions based on project analysis
+- 📁 **Context Management** - Add markdown, images, and documentation files that agents automatically reference
+- 🔗 **Dependency Blocking** - Features can depend on other features, enforcing execution order
+- 🌳 **Graph View** - Visualize feature dependencies with interactive graph visualization
+- 📋 **GitHub Integration** - Import issues, validate feasibility, and convert to tasks automatically
+
+### Collaboration & Review
+
+- 🧪 **Verification Workflow** - Features move to "Waiting Approval" for review and testing
+- 💬 **Agent Chat** - Interactive chat sessions with AI agents for exploratory work
+- 👤 **AI Profiles** - Create custom agent configurations with different prompts, models, and settings
+- 📜 **Session History** - Persistent chat sessions across restarts with full conversation history
+- 🔍 **Git Diff Viewer** - Review changes made by agents before approving
+
+### Developer Tools
+
+- 🖥️ **Integrated Terminal** - Full terminal access with tabs, splits, and persistent sessions
+- 🖼️ **Image Support** - Attach screenshots and diagrams to feature descriptions for visual context
+- ⚡ **Concurrent Execution** - Configure how many features can run simultaneously (default: 3)
+- ⌨️ **Keyboard Shortcuts** - Fully customizable shortcuts for navigation and actions
+- 🎨 **Theme System** - 25+ themes including Dark, Light, Dracula, Nord, Catppuccin, and more
+- 🖥️ **Cross-Platform** - Desktop app for macOS (x64, arm64), Windows (x64), and Linux (x64)
+- 🌐 **Web Mode** - Run in browser or as Electron desktop app
+
+### Advanced Features
+
+- 🔐 **Docker Isolation** - Security-focused Docker deployment with no host filesystem access
+- 🎯 **Worktree Management** - Create, switch, commit, and create PRs from worktrees
+- 📊 **Usage Tracking** - Monitor Claude API usage with detailed metrics
+- 🔊 **Audio Notifications** - Optional completion sounds (mutable in settings)
+- 💾 **Auto-save** - All work automatically persisted to `.automaker/` directory
+
+## Tech Stack
+
+### Frontend
+
+- **React 19** - UI framework
+- **Vite 7** - Build tool and development server
+- **Electron 39** - Desktop application framework
+- **TypeScript 5.9** - Type safety
+- **TanStack Router** - File-based routing
+- **Zustand 5** - State management with persistence
+- **Tailwind CSS 4** - Utility-first styling with 25+ themes
+- **Radix UI** - Accessible component primitives
+- **dnd-kit** - Drag and drop for Kanban board
+- **@xyflow/react** - Graph visualization for dependencies
+- **xterm.js** - Integrated terminal emulator
+- **CodeMirror 6** - Code editor for XML/syntax highlighting
+- **Lucide Icons** - Icon library
+
+### Backend
+
+- **Node.js** - JavaScript runtime with ES modules
+- **Express 5** - HTTP server framework
+- **TypeScript 5.9** - Type safety
+- **Claude Agent SDK** - AI agent integration (@anthropic-ai/claude-agent-sdk)
+- **WebSocket (ws)** - Real-time event streaming
+- **node-pty** - PTY terminal sessions
+
+### Testing & Quality
+
+- **Playwright** - End-to-end testing
+- **Vitest** - Unit testing framework
+- **ESLint 9** - Code linting
+- **Prettier 3** - Code formatting
+- **Husky** - Git hooks for pre-commit formatting
+
+### Shared Libraries
+
+- **@automaker/types** - Shared TypeScript definitions
+- **@automaker/utils** - Logging, error handling, image processing
+- **@automaker/prompts** - AI prompt templates
+- **@automaker/platform** - Path management and security
+- **@automaker/model-resolver** - Claude model alias resolution
+- **@automaker/dependency-resolver** - Feature dependency ordering
+- **@automaker/git-utils** - Git operations and worktree management
+
+## Available Views
+
+Automaker provides several specialized views accessible via the sidebar or keyboard shortcuts:
+
+| View | Shortcut | Description |
+| ------------------ | -------- | ------------------------------------------------------------------------------------------------ |
+| **Board** | `K` | Kanban board for managing feature workflow (Backlog → In Progress → Waiting Approval → Verified) |
+| **Agent** | `A` | Interactive chat sessions with AI agents for exploratory work and questions |
+| **Spec** | `D` | Project specification editor with AI-powered generation and feature suggestions |
+| **Context** | `C` | Manage context files (markdown, images) that AI agents automatically reference |
+| **Settings** | `S` | Configure themes, shortcuts, defaults, authentication, and more |
+| **Terminal** | `T` | Integrated terminal with tabs, splits, and persistent sessions |
+| **Graph** | `H` | Visualize feature dependencies with interactive graph visualization |
+| **Ideation** | `I` | Brainstorm and generate ideas with AI assistance |
+| **Memory** | `Y` | View and manage agent memory and conversation history |
+| **GitHub Issues** | `G` | Import and validate GitHub issues, convert to tasks |
+| **GitHub PRs** | `R` | View and manage GitHub pull requests |
+| **Running Agents** | - | View all active agents across projects with status and progress |
+
+### Keyboard Navigation
+
+All shortcuts are customizable in Settings. Default shortcuts:
+
+- **Navigation:** `K` (Board), `A` (Agent), `D` (Spec), `C` (Context), `S` (Settings), `T` (Terminal), `H` (Graph), `I` (Ideation), `Y` (Memory), `G` (GitHub Issues), `R` (GitHub PRs)
+- **UI:** `` ` `` (Toggle sidebar)
+- **Actions:** `N` (New item in current view), `O` (Open project), `P` (Project picker)
+- **Projects:** `Q`/`E` (Cycle previous/next project)
+- **Terminal:** `Alt+D` (Split right), `Alt+S` (Split down), `Alt+W` (Close), `Alt+T` (New tab)
+
+## Architecture
+
+### Monorepo Structure
+
+Automaker is built as an npm workspace monorepo with two main applications and seven shared packages:
+
+```text
+automaker/
+├── apps/
+│ ├── ui/ # React + Vite + Electron frontend
+│ └── server/ # Express + WebSocket backend
+└── libs/ # Shared packages
+ ├── types/ # Core TypeScript definitions
+ ├── utils/ # Logging, errors, utilities
+ ├── prompts/ # AI prompt templates
+ ├── platform/ # Path management, security
+ ├── model-resolver/ # Claude model aliasing
+ ├── dependency-resolver/ # Feature dependency ordering
+ └── git-utils/ # Git operations & worktree management
+```
+
+### How It Works
+
+1. **Feature Definition** - Users create feature cards on the Kanban board with descriptions, images, and configuration
+2. **Git Worktree Creation** - When a feature starts, a git worktree is created for isolated development
+3. **Agent Execution** - Claude Agent SDK executes in the worktree with full file system and command access
+4. **Real-time Streaming** - Agent output streams via WebSocket to the frontend for live monitoring
+5. **Plan Approval** (optional) - For spec/full planning modes, agents generate plans that require user approval
+6. **Multi-Agent Tasks** (spec mode) - Each task in the spec gets a dedicated agent for focused implementation
+7. **Verification** - Features move to "Waiting Approval" where changes can be reviewed via git diff
+8. **Integration** - After approval, changes can be committed and PRs created from the worktree
+
+### Key Architectural Patterns
+
+- **Event-Driven Architecture** - All server operations emit events that stream to the frontend
+- **Provider Pattern** - Extensible AI provider system (currently Claude, designed for future providers)
+- **Service-Oriented Backend** - Modular services for agent management, features, terminals, settings
+- **State Management** - Zustand with persistence for frontend state across restarts
+- **File-Based Storage** - No database; features stored as JSON files in `.automaker/` directory
+
+### Security & Isolation
+
+- **Git Worktrees** - Each feature executes in an isolated git worktree, protecting your main branch
+- **Path Sandboxing** - Optional `ALLOWED_ROOT_DIRECTORY` restricts file access
+- **Docker Isolation** - Recommended deployment uses Docker with no host filesystem access
+- **Plan Approval** - Optional plan review before implementation prevents unwanted changes
+
+### Data Storage
+
+Automaker uses a file-based storage system (no database required):
+
+#### Per-Project Data
+
+Stored in `{projectPath}/.automaker/`:
+
+```text
+.automaker/
+├── features/ # Feature JSON files and images
+│ └── {featureId}/
+│ ├── feature.json # Feature metadata
+│ ├── agent-output.md # AI agent output log
+│ └── images/ # Attached images
+├── context/ # Context files for AI agents
+├── worktrees/ # Git worktree metadata
+├── validations/ # GitHub issue validation results
+├── ideation/ # Brainstorming and analysis data
+│ └── analysis.json # Project structure analysis
+├── board/ # Board-related data
+├── images/ # Project-level images
+├── settings.json # Project-specific settings
+├── app_spec.txt # Project specification (XML format)
+├── active-branches.json # Active git branches tracking
+└── execution-state.json # Auto-mode execution state
+```
+
+#### Global Data
+
+Stored in `DATA_DIR` (default `./data`):
+
+```text
+data/
+├── settings.json # Global settings, profiles, shortcuts
+├── credentials.json # API keys (encrypted)
+├── sessions-metadata.json # Chat session metadata
+└── agent-sessions/ # Conversation histories
+ └── {sessionId}.json
+```
+
+---
+
+> **[!CAUTION]**
+>
+> ## Security Disclaimer
+>
+> **This software uses AI-powered tooling that has access to your operating system and can read, modify, and delete files. Use at your own risk.**
+>
+> We have reviewed this codebase for security vulnerabilities, but you assume all risk when running this software. You should review the code yourself before running it.
+>
+> **We do not recommend running Automaker directly on your local computer** due to the risk of AI agents having access to your entire file system. Please sandbox this application using Docker or a virtual machine.
+>
+> **[Read the full disclaimer](./DISCLAIMER.md)**
+
+---
+
+## Learn More
+
+### Documentation
+
+- [Contributing Guide](./CONTRIBUTING.md) - How to contribute to Automaker
+- [Project Documentation](./docs/) - Architecture guides, patterns, and developer docs
+- [Shared Packages Guide](./docs/llm-shared-packages.md) - Using monorepo packages
+
+### Community
+
+Join the **Agentic Jumpstart** Discord to connect with other builders exploring **agentic coding**:
+
+👉 [Agentic Jumpstart Discord](https://discord.gg/jjem7aEDKU)
+
+## Project Status
+
+**This project is no longer actively maintained.** The codebase is provided as-is for those who wish to use, study, or fork it. No bug fixes, security updates, or new features are being developed. Community contributions may still be accepted, but there is no guarantee of review or merge.
+
+## License
+
+This project is licensed under the **MIT License**. See [LICENSE](LICENSE) for the full text.
diff --git a/temp_repo/apps/server/.env.example b/temp_repo/apps/server/.env.example
new file mode 100644
index 0000000000000000000000000000000000000000..bad631237876bc5614819aea3910fd359f2fed47
--- /dev/null
+++ b/temp_repo/apps/server/.env.example
@@ -0,0 +1,95 @@
+# Automaker Server Configuration
+# Copy this file to .env and configure your settings
+
+# ============================================
+# REQUIRED
+# ============================================
+
+# Your Anthropic API key for Claude models
+ANTHROPIC_API_KEY=sk-ant-...
+
+# ============================================
+# OPTIONAL - Additional API Keys
+# ============================================
+
+# OpenAI API key for Codex/GPT models
+OPENAI_API_KEY=sk-...
+
+# Cursor API key for Cursor models
+CURSOR_API_KEY=...
+
+# OAuth credentials for CLI authentication (extracted automatically)
+CLAUDE_OAUTH_CREDENTIALS=
+CURSOR_AUTH_TOKEN=
+
+# ============================================
+# OPTIONAL - Security
+# ============================================
+
+# API key for authenticating requests (leave empty to disable auth)
+# If set, all API requests must include X-API-Key header
+AUTOMAKER_API_KEY=
+
+# Root directory for projects and file operations
+# If set, users can only create/open projects and files within this directory
+# Recommended for sandboxed deployments (Docker, restricted environments)
+# Example: ALLOWED_ROOT_DIRECTORY=/projects
+ALLOWED_ROOT_DIRECTORY=
+
+# CORS origin - which domains can access the API
+# Use "*" for development, set specific origin for production
+CORS_ORIGIN=http://localhost:3007
+
+# ============================================
+# OPTIONAL - Server
+# ============================================
+
+# Host to bind the server to (default: 0.0.0.0)
+# Use 0.0.0.0 to listen on all interfaces (recommended for Docker/remote access)
+# Use 127.0.0.1 or localhost to restrict to local connections only
+HOST=0.0.0.0
+
+# Port to run the server on
+PORT=3008
+
+# Port to run the server on for testing
+TEST_SERVER_PORT=3108
+
+# Port to run the UI on for testing
+TEST_PORT=3107
+
+# Data directory for sessions and metadata
+DATA_DIR=./data
+
+# ============================================
+# OPTIONAL - Terminal Access
+# ============================================
+
+# Enable/disable terminal access (default: true)
+TERMINAL_ENABLED=true
+
+# Password to protect terminal access (leave empty for no password)
+# If set, users must enter this password before accessing terminal
+TERMINAL_PASSWORD=
+
+ENABLE_REQUEST_LOGGING=false
+
+# ============================================
+# OPTIONAL - UI Behavior
+# ============================================
+
+# Skip the sandbox warning dialog on startup (default: false)
+# Set to "true" to disable the warning entirely (useful for dev/CI environments)
+AUTOMAKER_SKIP_SANDBOX_WARNING=false
+
+# ============================================
+# OPTIONAL - Debugging
+# ============================================
+
+# Enable raw output logging for agent streams (default: false)
+# When enabled, saves unprocessed stream events to raw-output.jsonl
+# in each feature's directory (.automaker/features/{id}/raw-output.jsonl)
+# Useful for debugging provider streaming issues, improving log parsing,
+# or analyzing how different providers (Claude, Cursor) stream responses
+# Note: This adds disk I/O overhead, only enable when debugging
+AUTOMAKER_DEBUG_RAW_OUTPUT=false
diff --git a/temp_repo/apps/server/.gitignore b/temp_repo/apps/server/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..6e37bb00be5be9d7d9b661f530cdd58d6f8d6073
--- /dev/null
+++ b/temp_repo/apps/server/.gitignore
@@ -0,0 +1,4 @@
+.env
+data
+node_modules
+coverage
\ No newline at end of file
diff --git a/temp_repo/apps/server/eslint.config.mjs b/temp_repo/apps/server/eslint.config.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..008c1f68ea7c67fe8d8b0961db177cc192731cee
--- /dev/null
+++ b/temp_repo/apps/server/eslint.config.mjs
@@ -0,0 +1,74 @@
+import { defineConfig, globalIgnores } from 'eslint/config';
+import js from '@eslint/js';
+import ts from '@typescript-eslint/eslint-plugin';
+import tsParser from '@typescript-eslint/parser';
+
+const eslintConfig = defineConfig([
+ js.configs.recommended,
+ {
+ files: ['**/*.ts'],
+ languageOptions: {
+ parser: tsParser,
+ parserOptions: {
+ ecmaVersion: 'latest',
+ sourceType: 'module',
+ },
+ globals: {
+ // Node.js globals
+ console: 'readonly',
+ process: 'readonly',
+ Buffer: 'readonly',
+ __dirname: 'readonly',
+ __filename: 'readonly',
+ URL: 'readonly',
+ URLSearchParams: 'readonly',
+ AbortController: 'readonly',
+ AbortSignal: 'readonly',
+ fetch: 'readonly',
+ Response: 'readonly',
+ Request: 'readonly',
+ Headers: 'readonly',
+ FormData: 'readonly',
+ RequestInit: 'readonly',
+ // Timers
+ setTimeout: 'readonly',
+ setInterval: 'readonly',
+ clearTimeout: 'readonly',
+ clearInterval: 'readonly',
+ setImmediate: 'readonly',
+ clearImmediate: 'readonly',
+ queueMicrotask: 'readonly',
+ // Node.js types
+ NodeJS: 'readonly',
+ },
+ },
+ plugins: {
+ '@typescript-eslint': ts,
+ },
+ rules: {
+ ...ts.configs.recommended.rules,
+ '@typescript-eslint/no-unused-vars': [
+ 'warn',
+ {
+ argsIgnorePattern: '^_',
+ varsIgnorePattern: '^_',
+ caughtErrorsIgnorePattern: '^_',
+ ignoreRestSiblings: true,
+ },
+ ],
+ '@typescript-eslint/no-explicit-any': 'warn',
+ // Server code frequently works with terminal output containing ANSI escape codes
+ 'no-control-regex': 'off',
+ '@typescript-eslint/ban-ts-comment': [
+ 'error',
+ {
+ 'ts-nocheck': 'allow-with-description',
+ minimumDescriptionLength: 10,
+ },
+ ],
+ },
+ },
+ globalIgnores(['dist/**', 'node_modules/**']),
+]);
+
+export default eslintConfig;
diff --git a/temp_repo/apps/server/package.json b/temp_repo/apps/server/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..a552ff11b75f8c6e200ccc499f2e97131b10005b
--- /dev/null
+++ b/temp_repo/apps/server/package.json
@@ -0,0 +1,62 @@
+{
+ "name": "@automaker/server",
+ "version": "1.0.0",
+ "description": "Backend server for Automaker - provides API for both web and Electron modes",
+ "author": "AutoMaker Team",
+ "license": "SEE LICENSE IN LICENSE",
+ "private": true,
+ "engines": {
+ "node": ">=22.0.0 <23.0.0"
+ },
+ "type": "module",
+ "main": "dist/index.js",
+ "scripts": {
+ "dev": "tsx watch src/index.ts",
+ "dev:test": "tsx src/index.ts",
+ "build": "tsc",
+ "start": "node dist/index.js",
+ "lint": "eslint src/",
+ "test": "vitest",
+ "test:ui": "vitest --ui",
+ "test:run": "vitest run",
+ "test:cov": "vitest run --coverage",
+ "test:watch": "vitest watch",
+ "test:unit": "vitest run tests/unit"
+ },
+ "dependencies": {
+ "@anthropic-ai/claude-agent-sdk": "0.2.32",
+ "@automaker/dependency-resolver": "1.0.0",
+ "@automaker/git-utils": "1.0.0",
+ "@automaker/model-resolver": "1.0.0",
+ "@automaker/platform": "1.0.0",
+ "@automaker/prompts": "1.0.0",
+ "@automaker/types": "1.0.0",
+ "@automaker/utils": "1.0.0",
+ "@github/copilot-sdk": "0.1.16",
+ "@modelcontextprotocol/sdk": "1.25.2",
+ "@openai/codex-sdk": "^0.98.0",
+ "cookie-parser": "1.4.7",
+ "cors": "2.8.5",
+ "dotenv": "17.2.3",
+ "express": "5.2.1",
+ "morgan": "1.10.1",
+ "node-pty": "1.1.0-beta41",
+ "ws": "8.18.3",
+ "yaml": "2.7.0"
+ },
+ "devDependencies": {
+ "@playwright/test": "1.57.0",
+ "@types/cookie": "0.6.0",
+ "@types/cookie-parser": "1.4.10",
+ "@types/cors": "2.8.19",
+ "@types/express": "5.0.6",
+ "@types/morgan": "1.9.10",
+ "@types/node": "22.19.3",
+ "@types/ws": "8.18.1",
+ "@vitest/coverage-v8": "4.0.16",
+ "@vitest/ui": "4.0.16",
+ "tsx": "4.21.0",
+ "typescript": "5.9.3",
+ "vitest": "4.0.16"
+ }
+}
diff --git a/temp_repo/apps/server/src/index.ts b/temp_repo/apps/server/src/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..cc0c3fbc16f634dd1796ea2d6f2bf0b7f519fddc
--- /dev/null
+++ b/temp_repo/apps/server/src/index.ts
@@ -0,0 +1,979 @@
+/**
+ * Automaker Backend Server
+ *
+ * Provides HTTP/WebSocket API for both web and Electron modes.
+ * In Electron mode, this server runs locally.
+ * In web mode, this server runs on a remote host.
+ */
+
+import express from 'express';
+import cors from 'cors';
+import morgan from 'morgan';
+import cookieParser from 'cookie-parser';
+import cookie from 'cookie';
+import { WebSocketServer, WebSocket } from 'ws';
+import { createServer } from 'http';
+import dotenv from 'dotenv';
+
+import { createEventEmitter, type EventEmitter } from './lib/events.js';
+import { initAllowedPaths, getClaudeAuthIndicators } from '@automaker/platform';
+import { createLogger, setLogLevel, LogLevel } from '@automaker/utils';
+
+const logger = createLogger('Server');
+
+/**
+ * Map server log level string to LogLevel enum
+ */
+const LOG_LEVEL_MAP: Record = {
+ error: LogLevel.ERROR,
+ warn: LogLevel.WARN,
+ info: LogLevel.INFO,
+ debug: LogLevel.DEBUG,
+};
+import { authMiddleware, validateWsConnectionToken, checkRawAuthentication } from './lib/auth.js';
+import { requireJsonContentType } from './middleware/require-json-content-type.js';
+import { createAuthRoutes } from './routes/auth/index.js';
+import { createFsRoutes } from './routes/fs/index.js';
+import { createHealthRoutes, createDetailedHandler } from './routes/health/index.js';
+import { createAgentRoutes } from './routes/agent/index.js';
+import { createSessionsRoutes } from './routes/sessions/index.js';
+import { createFeaturesRoutes } from './routes/features/index.js';
+import { createAutoModeRoutes } from './routes/auto-mode/index.js';
+import { createEnhancePromptRoutes } from './routes/enhance-prompt/index.js';
+import { createWorktreeRoutes } from './routes/worktree/index.js';
+import { createGitRoutes } from './routes/git/index.js';
+import { createSetupRoutes } from './routes/setup/index.js';
+import { createModelsRoutes } from './routes/models/index.js';
+import { createRunningAgentsRoutes } from './routes/running-agents/index.js';
+import { createWorkspaceRoutes } from './routes/workspace/index.js';
+import { createTemplatesRoutes } from './routes/templates/index.js';
+import {
+ createTerminalRoutes,
+ validateTerminalToken,
+ isTerminalEnabled,
+ isTerminalPasswordRequired,
+} from './routes/terminal/index.js';
+import { createSettingsRoutes } from './routes/settings/index.js';
+import { AgentService } from './services/agent-service.js';
+import { FeatureLoader } from './services/feature-loader.js';
+import { AutoModeServiceCompat } from './services/auto-mode/index.js';
+import { getTerminalService } from './services/terminal-service.js';
+import { SettingsService } from './services/settings-service.js';
+import { createSpecRegenerationRoutes } from './routes/app-spec/index.js';
+import { createClaudeRoutes } from './routes/claude/index.js';
+import { ClaudeUsageService } from './services/claude-usage-service.js';
+import { createCodexRoutes } from './routes/codex/index.js';
+import { CodexUsageService } from './services/codex-usage-service.js';
+import { CodexAppServerService } from './services/codex-app-server-service.js';
+import { CodexModelCacheService } from './services/codex-model-cache-service.js';
+import { createZaiRoutes } from './routes/zai/index.js';
+import { ZaiUsageService } from './services/zai-usage-service.js';
+import { createGeminiRoutes } from './routes/gemini/index.js';
+import { GeminiUsageService } from './services/gemini-usage-service.js';
+import { createGitHubRoutes } from './routes/github/index.js';
+import { createContextRoutes } from './routes/context/index.js';
+import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js';
+import { cleanupStaleValidations } from './routes/github/routes/validation-common.js';
+import { createMCPRoutes } from './routes/mcp/index.js';
+import { MCPTestService } from './services/mcp-test-service.js';
+import { createPipelineRoutes } from './routes/pipeline/index.js';
+import { pipelineService } from './services/pipeline-service.js';
+import { createIdeationRoutes } from './routes/ideation/index.js';
+import { IdeationService } from './services/ideation-service.js';
+import { getDevServerService } from './services/dev-server-service.js';
+import { eventHookService } from './services/event-hook-service.js';
+import { createNotificationsRoutes } from './routes/notifications/index.js';
+import { getNotificationService } from './services/notification-service.js';
+import { createEventHistoryRoutes } from './routes/event-history/index.js';
+import { getEventHistoryService } from './services/event-history-service.js';
+import { getTestRunnerService } from './services/test-runner-service.js';
+import { createProjectsRoutes } from './routes/projects/index.js';
+
+// Load environment variables
+dotenv.config();
+
+const PORT = parseInt(process.env.PORT || '3008', 10);
+const HOST = process.env.HOST || '0.0.0.0';
+const HOSTNAME = process.env.HOSTNAME || 'localhost';
+const DATA_DIR = process.env.DATA_DIR || './data';
+logger.info('[SERVER_STARTUP] process.env.DATA_DIR:', process.env.DATA_DIR);
+logger.info('[SERVER_STARTUP] Resolved DATA_DIR:', DATA_DIR);
+logger.info('[SERVER_STARTUP] process.cwd():', process.cwd());
+const ENABLE_REQUEST_LOGGING_DEFAULT = process.env.ENABLE_REQUEST_LOGGING !== 'false'; // Default to true
+
+// Runtime-configurable request logging flag (can be changed via settings)
+let requestLoggingEnabled = ENABLE_REQUEST_LOGGING_DEFAULT;
+
+/**
+ * Enable or disable HTTP request logging at runtime
+ */
+export function setRequestLoggingEnabled(enabled: boolean): void {
+ requestLoggingEnabled = enabled;
+}
+
+/**
+ * Get current request logging state
+ */
+export function isRequestLoggingEnabled(): boolean {
+ return requestLoggingEnabled;
+}
+
+// Width for log box content (excluding borders)
+const BOX_CONTENT_WIDTH = 67;
+
+// Check for Claude authentication (async - runs in background)
+// The Claude Agent SDK can use either ANTHROPIC_API_KEY or Claude Code CLI authentication
+(async () => {
+ const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
+ const hasEnvOAuthToken = !!process.env.CLAUDE_CODE_OAUTH_TOKEN;
+
+ logger.debug('[CREDENTIAL_CHECK] Starting credential detection...');
+ logger.debug('[CREDENTIAL_CHECK] Environment variables:', {
+ hasAnthropicKey,
+ hasEnvOAuthToken,
+ });
+
+ if (hasAnthropicKey) {
+ logger.info('✓ ANTHROPIC_API_KEY detected');
+ return;
+ }
+
+ if (hasEnvOAuthToken) {
+ logger.info('✓ CLAUDE_CODE_OAUTH_TOKEN detected');
+ return;
+ }
+
+ // Check for Claude Code CLI authentication
+ // Store indicators outside the try block so we can use them in the warning message
+ let cliAuthIndicators: Awaited> | null = null;
+
+ try {
+ cliAuthIndicators = await getClaudeAuthIndicators();
+ const indicators = cliAuthIndicators;
+
+ // Log detailed credential detection results
+ const { checks, ...indicatorSummary } = indicators;
+ logger.debug('[CREDENTIAL_CHECK] Claude CLI auth indicators:', indicatorSummary);
+
+ logger.debug('[CREDENTIAL_CHECK] File check details:', checks);
+
+ const hasCliAuth =
+ indicators.hasStatsCacheWithActivity ||
+ (indicators.hasSettingsFile && indicators.hasProjectsSessions) ||
+ (indicators.hasCredentialsFile &&
+ (indicators.credentials?.hasOAuthToken || indicators.credentials?.hasApiKey));
+
+ logger.debug('[CREDENTIAL_CHECK] Auth determination:', {
+ hasCliAuth,
+ reason: hasCliAuth
+ ? indicators.hasStatsCacheWithActivity
+ ? 'stats cache with activity'
+ : indicators.hasSettingsFile && indicators.hasProjectsSessions
+ ? 'settings file + project sessions'
+ : indicators.credentials?.hasOAuthToken
+ ? 'credentials file with OAuth token'
+ : 'credentials file with API key'
+ : 'no valid credentials found',
+ });
+
+ if (hasCliAuth) {
+ logger.info('✓ Claude Code CLI authentication detected');
+ return;
+ }
+ } catch (error) {
+ // Ignore errors checking CLI auth - will fall through to warning
+ logger.warn('Error checking for Claude Code CLI authentication:', error);
+ }
+
+ // No authentication found - show warning with paths that were checked
+ const wHeader = '⚠️ WARNING: No Claude authentication configured'.padEnd(BOX_CONTENT_WIDTH);
+ const w1 = 'The Claude Agent SDK requires authentication to function.'.padEnd(BOX_CONTENT_WIDTH);
+ const w2 = 'Options:'.padEnd(BOX_CONTENT_WIDTH);
+ const w3 = '1. Install Claude Code CLI and authenticate with subscription'.padEnd(
+ BOX_CONTENT_WIDTH
+ );
+ const w4 = '2. Set your Anthropic API key:'.padEnd(BOX_CONTENT_WIDTH);
+ const w5 = ' export ANTHROPIC_API_KEY="sk-ant-..."'.padEnd(BOX_CONTENT_WIDTH);
+ const w6 = '3. Use the setup wizard in Settings to configure authentication.'.padEnd(
+ BOX_CONTENT_WIDTH
+ );
+
+ // Build paths checked summary from the indicators (if available)
+ let pathsCheckedInfo = '';
+ if (cliAuthIndicators) {
+ const pathsChecked: string[] = [];
+
+ // Collect paths that were checked (paths are always populated strings)
+ pathsChecked.push(`Settings: ${cliAuthIndicators.checks.settingsFile.path}`);
+ pathsChecked.push(`Stats cache: ${cliAuthIndicators.checks.statsCache.path}`);
+ pathsChecked.push(`Projects dir: ${cliAuthIndicators.checks.projectsDir.path}`);
+ for (const credFile of cliAuthIndicators.checks.credentialFiles) {
+ pathsChecked.push(`Credentials: ${credFile.path}`);
+ }
+
+ if (pathsChecked.length > 0) {
+ pathsCheckedInfo = `
+║ ║
+║ ${'Paths checked:'.padEnd(BOX_CONTENT_WIDTH)}║
+${pathsChecked
+ .map((p) => {
+ const maxLen = BOX_CONTENT_WIDTH - 4;
+ const display = p.length > maxLen ? '...' + p.slice(-(maxLen - 3)) : p;
+ return `║ ${display.padEnd(maxLen)} ║`;
+ })
+ .join('\n')}`;
+ }
+ }
+
+ logger.warn(`
+╔═════════════════════════════════════════════════════════════════════╗
+║ ${wHeader}║
+╠═════════════════════════════════════════════════════════════════════╣
+║ ║
+║ ${w1}║
+║ ║
+║ ${w2}║
+║ ${w3}║
+║ ${w4}║
+║ ${w5}║
+║ ${w6}║${pathsCheckedInfo}
+║ ║
+╚═════════════════════════════════════════════════════════════════════╝
+`);
+})();
+
+// Initialize security
+initAllowedPaths();
+
+// Create Express app
+const app = express();
+
+// Middleware
+// Custom colored logger showing only endpoint and status code (dynamically configurable)
+morgan.token('status-colored', (_req, res) => {
+ const status = res.statusCode;
+ if (status >= 500) return `\x1b[31m${status}\x1b[0m`; // Red for server errors
+ if (status >= 400) return `\x1b[33m${status}\x1b[0m`; // Yellow for client errors
+ if (status >= 300) return `\x1b[36m${status}\x1b[0m`; // Cyan for redirects
+ return `\x1b[32m${status}\x1b[0m`; // Green for success
+});
+
+app.use(
+ morgan(':method :url :status-colored', {
+ // Skip when request logging is disabled or for health check endpoints
+ skip: (req) =>
+ !requestLoggingEnabled ||
+ req.url === '/api/health' ||
+ req.url === '/api/auto-mode/context-exists',
+ })
+);
+// CORS configuration
+// When using credentials (cookies), origin cannot be '*'
+// We dynamically allow the requesting origin for local development
+
+// Check if origin is a local/private network address
+function isLocalOrigin(origin: string): boolean {
+ try {
+ const url = new URL(origin);
+ const hostname = url.hostname;
+ return (
+ hostname === 'localhost' ||
+ hostname === '127.0.0.1' ||
+ hostname === '[::1]' ||
+ hostname === '0.0.0.0' ||
+ hostname.startsWith('192.168.') ||
+ hostname.startsWith('10.') ||
+ /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname)
+ );
+ } catch {
+ return false;
+ }
+}
+
+app.use(
+ cors({
+ origin: (origin, callback) => {
+ // Allow requests with no origin (like mobile apps, curl, Electron)
+ if (!origin) {
+ callback(null, true);
+ return;
+ }
+
+ // If CORS_ORIGIN is set, use it (can be comma-separated list)
+ const allowedOrigins = process.env.CORS_ORIGIN?.split(',')
+ .map((o) => o.trim())
+ .filter(Boolean);
+ if (allowedOrigins && allowedOrigins.length > 0) {
+ if (allowedOrigins.includes('*')) {
+ callback(null, true);
+ return;
+ }
+ if (allowedOrigins.includes(origin)) {
+ callback(null, origin);
+ return;
+ }
+ // Fall through to local network check below
+ }
+
+ // Allow all localhost/loopback/private network origins (any port)
+ if (isLocalOrigin(origin)) {
+ callback(null, origin);
+ return;
+ }
+
+ // Reject other origins by default for security
+ callback(new Error('Not allowed by CORS'));
+ },
+ credentials: true,
+ })
+);
+app.use(express.json({ limit: '50mb' }));
+app.use(cookieParser());
+
+// Create shared event emitter for streaming
+const events: EventEmitter = createEventEmitter();
+
+// Create services
+// Note: settingsService is created first so it can be injected into other services
+const settingsService = new SettingsService(DATA_DIR);
+const agentService = new AgentService(DATA_DIR, events, settingsService);
+const featureLoader = new FeatureLoader();
+
+// Auto-mode services: compatibility layer provides old interface while using new architecture
+const autoModeService = new AutoModeServiceCompat(events, settingsService, featureLoader);
+const claudeUsageService = new ClaudeUsageService();
+const codexAppServerService = new CodexAppServerService();
+const codexModelCacheService = new CodexModelCacheService(DATA_DIR, codexAppServerService);
+const codexUsageService = new CodexUsageService(codexAppServerService);
+const zaiUsageService = new ZaiUsageService();
+const geminiUsageService = new GeminiUsageService();
+const mcpTestService = new MCPTestService(settingsService);
+const ideationService = new IdeationService(events, settingsService, featureLoader);
+
+// Initialize DevServerService with event emitter for real-time log streaming
+const devServerService = getDevServerService();
+devServerService.initialize(DATA_DIR, events).catch((err) => {
+ logger.error('Failed to initialize DevServerService:', err);
+});
+
+// Initialize Notification Service with event emitter for real-time updates
+const notificationService = getNotificationService();
+notificationService.setEventEmitter(events);
+
+// Initialize Event History Service
+const eventHistoryService = getEventHistoryService();
+
+// Initialize Test Runner Service with event emitter for real-time test output streaming
+const testRunnerService = getTestRunnerService();
+testRunnerService.setEventEmitter(events);
+
+// Initialize Event Hook Service for custom event triggers (with history storage)
+eventHookService.initialize(events, settingsService, eventHistoryService, featureLoader);
+
+// Initialize services
+(async () => {
+ // Migrate settings from legacy Electron userData location if needed
+ // This handles users upgrading from versions that stored settings in ~/.config/Automaker (Linux),
+ // ~/Library/Application Support/Automaker (macOS), or %APPDATA%\Automaker (Windows)
+ // to the new shared ./data directory
+ try {
+ const migrationResult = await settingsService.migrateFromLegacyElectronPath();
+ if (migrationResult.migrated) {
+ logger.info(`Settings migrated from legacy location: ${migrationResult.legacyPath}`);
+ logger.info(`Migrated files: ${migrationResult.migratedFiles.join(', ')}`);
+ }
+ if (migrationResult.errors.length > 0) {
+ logger.warn('Migration errors:', migrationResult.errors);
+ }
+ } catch (err) {
+ logger.warn('Failed to check for legacy settings migration:', err);
+ }
+
+ // Fetch global settings once and reuse for logging config and feature reconciliation
+ let globalSettings: Awaited> | null = null;
+ try {
+ globalSettings = await settingsService.getGlobalSettings();
+ } catch {
+ logger.warn('Failed to load global settings, using defaults');
+ }
+
+ // Apply logging settings from saved settings
+ if (globalSettings) {
+ try {
+ if (
+ globalSettings.serverLogLevel &&
+ LOG_LEVEL_MAP[globalSettings.serverLogLevel] !== undefined
+ ) {
+ setLogLevel(LOG_LEVEL_MAP[globalSettings.serverLogLevel]);
+ logger.info(`Server log level set to: ${globalSettings.serverLogLevel}`);
+ }
+ // Apply request logging setting (default true if not set)
+ const enableRequestLog = globalSettings.enableRequestLogging ?? true;
+ setRequestLoggingEnabled(enableRequestLog);
+ logger.info(`HTTP request logging: ${enableRequestLog ? 'enabled' : 'disabled'}`);
+ } catch {
+ logger.warn('Failed to apply logging settings, using defaults');
+ }
+ }
+
+ await agentService.initialize();
+ logger.info('Agent service initialized');
+
+ // Reconcile feature states on startup
+ // After any type of restart (clean, forced, crash), features may be stuck in
+ // transient states (in_progress, interrupted, pipeline_*) that don't match reality.
+ // Reconcile them back to resting states before the UI is served.
+ if (globalSettings) {
+ try {
+ if (globalSettings.projects && globalSettings.projects.length > 0) {
+ let totalReconciled = 0;
+ for (const project of globalSettings.projects) {
+ const count = await autoModeService.reconcileFeatureStates(project.path);
+ totalReconciled += count;
+ }
+ if (totalReconciled > 0) {
+ logger.info(
+ `[STARTUP] Reconciled ${totalReconciled} feature(s) across ${globalSettings.projects.length} project(s)`
+ );
+ } else {
+ logger.info('[STARTUP] Feature state reconciliation complete - no stale states found');
+ }
+
+ // Resume interrupted features in the background for all projects.
+ // This handles features stuck in transient states (in_progress, pipeline_*)
+ // or explicitly marked as interrupted. Running in background so it doesn't block startup.
+ for (const project of globalSettings.projects) {
+ autoModeService.resumeInterruptedFeatures(project.path).catch((err) => {
+ logger.warn(
+ `[STARTUP] Failed to resume interrupted features for ${project.path}:`,
+ err
+ );
+ });
+ }
+ logger.info('[STARTUP] Initiated background resume of interrupted features');
+ }
+ } catch (err) {
+ logger.warn('[STARTUP] Failed to reconcile feature states:', err);
+ }
+ }
+
+ // Bootstrap Codex model cache in background (don't block server startup)
+ void codexModelCacheService.getModels().catch((err) => {
+ logger.error('Failed to bootstrap Codex model cache:', err);
+ });
+})();
+
+// Run stale validation cleanup every hour to prevent memory leaks from crashed validations
+const VALIDATION_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
+setInterval(() => {
+ const cleaned = cleanupStaleValidations();
+ if (cleaned > 0) {
+ logger.info(`Cleaned up ${cleaned} stale validation entries`);
+ }
+}, VALIDATION_CLEANUP_INTERVAL_MS);
+
+// Require Content-Type: application/json for all API POST/PUT/PATCH requests
+// This helps prevent CSRF and content-type confusion attacks
+app.use('/api', requireJsonContentType);
+
+// Mount API routes - health, auth, and setup are unauthenticated
+app.use('/api/health', createHealthRoutes());
+app.use('/api/auth', createAuthRoutes());
+app.use('/api/setup', createSetupRoutes());
+
+// Apply authentication to all other routes
+app.use('/api', authMiddleware);
+
+// Protected health endpoint with detailed info
+app.get('/api/health/detailed', createDetailedHandler());
+
+app.use('/api/fs', createFsRoutes(events));
+app.use('/api/agent', createAgentRoutes(agentService, events));
+app.use('/api/sessions', createSessionsRoutes(agentService));
+app.use(
+ '/api/features',
+ createFeaturesRoutes(featureLoader, settingsService, events, autoModeService)
+);
+app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
+app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
+app.use('/api/worktree', createWorktreeRoutes(events, settingsService, featureLoader));
+app.use('/api/git', createGitRoutes());
+app.use('/api/models', createModelsRoutes());
+app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events, settingsService));
+app.use('/api/running-agents', createRunningAgentsRoutes(autoModeService));
+app.use('/api/workspace', createWorkspaceRoutes());
+app.use('/api/templates', createTemplatesRoutes());
+app.use('/api/terminal', createTerminalRoutes());
+app.use('/api/settings', createSettingsRoutes(settingsService));
+app.use('/api/claude', createClaudeRoutes(claudeUsageService));
+app.use('/api/codex', createCodexRoutes(codexUsageService, codexModelCacheService));
+app.use('/api/zai', createZaiRoutes(zaiUsageService, settingsService));
+app.use('/api/gemini', createGeminiRoutes(geminiUsageService, events));
+app.use('/api/github', createGitHubRoutes(events, settingsService));
+app.use('/api/context', createContextRoutes(settingsService));
+app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));
+app.use('/api/mcp', createMCPRoutes(mcpTestService));
+app.use('/api/pipeline', createPipelineRoutes(pipelineService));
+app.use('/api/ideation', createIdeationRoutes(events, ideationService, featureLoader));
+app.use('/api/notifications', createNotificationsRoutes(notificationService));
+app.use('/api/event-history', createEventHistoryRoutes(eventHistoryService, settingsService));
+app.use(
+ '/api/projects',
+ createProjectsRoutes(featureLoader, autoModeService, settingsService, notificationService)
+);
+
+// Create HTTP server
+const server = createServer(app);
+
+// WebSocket servers using noServer mode for proper multi-path support
+const wss = new WebSocketServer({ noServer: true });
+const terminalWss = new WebSocketServer({ noServer: true });
+const terminalService = getTerminalService(settingsService);
+
+/**
+ * Authenticate WebSocket upgrade requests
+ * Checks for API key in header/query, session token in header/query, OR valid session cookie
+ */
+function authenticateWebSocket(request: import('http').IncomingMessage): boolean {
+ const url = new URL(request.url || '', `http://${request.headers.host}`);
+
+ // Convert URL search params to query object
+ const query: Record = {};
+ url.searchParams.forEach((value, key) => {
+ query[key] = value;
+ });
+
+ // Parse cookies from header
+ const cookieHeader = request.headers.cookie;
+ const cookies = cookieHeader ? cookie.parse(cookieHeader) : {};
+
+ // Use shared authentication logic for standard auth methods
+ if (
+ checkRawAuthentication(
+ request.headers as Record,
+ query,
+ cookies
+ )
+ ) {
+ return true;
+ }
+
+ // Additionally check for short-lived WebSocket connection token (WebSocket-specific)
+ const wsToken = url.searchParams.get('wsToken');
+ if (wsToken && validateWsConnectionToken(wsToken)) {
+ return true;
+ }
+
+ return false;
+}
+
+// Handle HTTP upgrade requests manually to route to correct WebSocket server
+server.on('upgrade', (request, socket, head) => {
+ const { pathname } = new URL(request.url || '', `http://${request.headers.host}`);
+
+ // Authenticate all WebSocket connections
+ if (!authenticateWebSocket(request)) {
+ logger.info('Authentication failed, rejecting connection');
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
+ socket.destroy();
+ return;
+ }
+
+ if (pathname === '/api/events') {
+ wss.handleUpgrade(request, socket, head, (ws) => {
+ wss.emit('connection', ws, request);
+ });
+ } else if (pathname === '/api/terminal/ws') {
+ terminalWss.handleUpgrade(request, socket, head, (ws) => {
+ terminalWss.emit('connection', ws, request);
+ });
+ } else {
+ socket.destroy();
+ }
+});
+
+// Events WebSocket connection handler
+wss.on('connection', (ws: WebSocket) => {
+ logger.info('Client connected, ready state:', ws.readyState);
+
+ // Subscribe to all events and forward to this client
+ const unsubscribe = events.subscribe((type, payload) => {
+ // Use debug level for high-frequency events to avoid log spam
+ // that causes progressive memory growth and server slowdown
+ const isHighFrequency =
+ type === 'dev-server:output' || type === 'test-runner:output' || type === 'feature:progress';
+ const log = isHighFrequency ? logger.debug.bind(logger) : logger.info.bind(logger);
+
+ log('Event received:', {
+ type,
+ hasPayload: !!payload,
+ wsReadyState: ws.readyState,
+ });
+
+ if (ws.readyState === WebSocket.OPEN) {
+ const message = JSON.stringify({ type, payload });
+ ws.send(message);
+ } else {
+ logger.warn('Cannot send event, WebSocket not open. ReadyState:', ws.readyState);
+ }
+ });
+
+ ws.on('close', () => {
+ logger.info('Client disconnected');
+ unsubscribe();
+ });
+
+ ws.on('error', (error) => {
+ logger.error('ERROR:', error);
+ unsubscribe();
+ });
+});
+
+// Track WebSocket connections per session
+const terminalConnections: Map> = new Map();
+// Track last resize dimensions per session to deduplicate resize messages
+const lastResizeDimensions: Map = new Map();
+// Track last resize timestamp to rate-limit resize operations (prevents resize storm)
+const lastResizeTime: Map = new Map();
+const RESIZE_MIN_INTERVAL_MS = 100; // Minimum 100ms between resize operations
+
+// Clean up resize tracking when sessions actually exit (not just when connections close)
+terminalService.onExit((sessionId) => {
+ lastResizeDimensions.delete(sessionId);
+ lastResizeTime.delete(sessionId);
+ terminalConnections.delete(sessionId);
+});
+
+// Terminal WebSocket connection handler
+terminalWss.on('connection', (ws: WebSocket, req: import('http').IncomingMessage) => {
+ // Parse URL to get session ID and token
+ const url = new URL(req.url || '', `http://${req.headers.host}`);
+ const sessionId = url.searchParams.get('sessionId');
+ const token = url.searchParams.get('token');
+
+ logger.info(`Connection attempt for session: ${sessionId}`);
+
+ // Check if terminal is enabled
+ if (!isTerminalEnabled()) {
+ logger.info('Terminal is disabled');
+ ws.close(4003, 'Terminal access is disabled');
+ return;
+ }
+
+ // Validate token if password is required
+ if (isTerminalPasswordRequired() && !validateTerminalToken(token || undefined)) {
+ logger.info('Invalid or missing token');
+ ws.close(4001, 'Authentication required');
+ return;
+ }
+
+ if (!sessionId) {
+ logger.info('No session ID provided');
+ ws.close(4002, 'Session ID required');
+ return;
+ }
+
+ // Check if session exists
+ const session = terminalService.getSession(sessionId);
+ if (!session) {
+ logger.warn(
+ `Terminal session ${sessionId} not found. ` +
+ `The session may have exited, been deleted, or was never created. ` +
+ `Active terminal sessions: ${terminalService.getSessionCount()}`
+ );
+ ws.close(
+ 4004,
+ 'Session not found. The terminal session may have expired or been closed. Please create a new terminal.'
+ );
+ return;
+ }
+
+ logger.info(`Client connected to session ${sessionId}`);
+
+ // Track this connection
+ if (!terminalConnections.has(sessionId)) {
+ terminalConnections.set(sessionId, new Set());
+ }
+ terminalConnections.get(sessionId)!.add(ws);
+
+ // Send initial connection success FIRST
+ ws.send(
+ JSON.stringify({
+ type: 'connected',
+ sessionId,
+ shell: session.shell,
+ cwd: session.cwd,
+ })
+ );
+
+ // Send scrollback buffer BEFORE subscribing to prevent race condition
+ // Also clear pending output buffer to prevent duplicates from throttled flush
+ const scrollback = terminalService.getScrollbackAndClearPending(sessionId);
+ if (scrollback && scrollback.length > 0) {
+ ws.send(
+ JSON.stringify({
+ type: 'scrollback',
+ data: scrollback,
+ })
+ );
+ }
+
+ // NOW subscribe to terminal data (after scrollback is sent)
+ const unsubscribeData = terminalService.onData((sid, data) => {
+ if (sid === sessionId && ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({ type: 'data', data }));
+ }
+ });
+
+ // Subscribe to terminal exit
+ const unsubscribeExit = terminalService.onExit((sid, exitCode) => {
+ if (sid === sessionId && ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({ type: 'exit', exitCode }));
+ ws.close(1000, 'Session ended');
+ }
+ });
+
+ // Handle incoming messages
+ ws.on('message', (message) => {
+ try {
+ const msg = JSON.parse(message.toString());
+
+ switch (msg.type) {
+ case 'input':
+ // Validate input data type and length
+ if (typeof msg.data !== 'string') {
+ ws.send(JSON.stringify({ type: 'error', message: 'Invalid input type' }));
+ break;
+ }
+ // Limit input size to 1MB to prevent memory issues
+ if (msg.data.length > 1024 * 1024) {
+ ws.send(JSON.stringify({ type: 'error', message: 'Input too large' }));
+ break;
+ }
+ // Write user input to terminal
+ terminalService.write(sessionId, msg.data);
+ break;
+
+ case 'resize':
+ // Validate resize dimensions are positive integers within reasonable bounds
+ if (
+ typeof msg.cols !== 'number' ||
+ typeof msg.rows !== 'number' ||
+ !Number.isInteger(msg.cols) ||
+ !Number.isInteger(msg.rows) ||
+ msg.cols < 1 ||
+ msg.cols > 1000 ||
+ msg.rows < 1 ||
+ msg.rows > 500
+ ) {
+ break; // Silently ignore invalid resize requests
+ }
+ // Resize terminal with deduplication and rate limiting
+ if (msg.cols && msg.rows) {
+ const now = Date.now();
+ const lastTime = lastResizeTime.get(sessionId) || 0;
+ const lastDimensions = lastResizeDimensions.get(sessionId);
+
+ // Skip if resized too recently (prevents resize storm during splits)
+ if (now - lastTime < RESIZE_MIN_INTERVAL_MS) {
+ break;
+ }
+
+ // Check if dimensions are different from last resize
+ if (
+ !lastDimensions ||
+ lastDimensions.cols !== msg.cols ||
+ lastDimensions.rows !== msg.rows
+ ) {
+ // Only suppress output on subsequent resizes, not the first one
+ // The first resize happens on terminal open and we don't want to drop the initial prompt
+ const isFirstResize = !lastDimensions;
+ terminalService.resize(sessionId, msg.cols, msg.rows, !isFirstResize);
+ lastResizeDimensions.set(sessionId, {
+ cols: msg.cols,
+ rows: msg.rows,
+ });
+ lastResizeTime.set(sessionId, now);
+ }
+ }
+ break;
+
+ case 'ping':
+ // Respond to ping
+ ws.send(JSON.stringify({ type: 'pong' }));
+ break;
+
+ default:
+ logger.warn(`Unknown message type: ${msg.type}`);
+ }
+ } catch (error) {
+ logger.error('Error processing message:', error);
+ }
+ });
+
+ ws.on('close', () => {
+ logger.info(`Client disconnected from session ${sessionId}`);
+ unsubscribeData();
+ unsubscribeExit();
+
+ // Remove from connections tracking
+ const connections = terminalConnections.get(sessionId);
+ if (connections) {
+ connections.delete(ws);
+ if (connections.size === 0) {
+ terminalConnections.delete(sessionId);
+ // DON'T delete lastResizeDimensions/lastResizeTime here!
+ // The session still exists, and reconnecting clients need to know
+ // this isn't the "first resize" to prevent duplicate prompts.
+ // These get cleaned up when the session actually exits.
+ }
+ }
+ });
+
+ ws.on('error', (error) => {
+ logger.error(`Error on session ${sessionId}:`, error);
+ unsubscribeData();
+ unsubscribeExit();
+ });
+});
+
+// Start server with error handling for port conflicts
+const startServer = (port: number, host: string) => {
+ server.listen(port, host, () => {
+ const terminalStatus = isTerminalEnabled()
+ ? isTerminalPasswordRequired()
+ ? 'enabled (password protected)'
+ : 'enabled'
+ : 'disabled';
+
+ // Build URLs for display
+ const listenAddr = `${host}:${port}`;
+ const httpUrl = `http://${HOSTNAME}:${port}`;
+ const wsEventsUrl = `ws://${HOSTNAME}:${port}/api/events`;
+ const wsTerminalUrl = `ws://${HOSTNAME}:${port}/api/terminal/ws`;
+ const healthUrl = `http://${HOSTNAME}:${port}/api/health`;
+
+ const sHeader = '🚀 Automaker Backend Server'.padEnd(BOX_CONTENT_WIDTH);
+ const s1 = `Listening: ${listenAddr}`.padEnd(BOX_CONTENT_WIDTH);
+ const s2 = `HTTP API: ${httpUrl}`.padEnd(BOX_CONTENT_WIDTH);
+ const s3 = `WebSocket: ${wsEventsUrl}`.padEnd(BOX_CONTENT_WIDTH);
+ const s4 = `Terminal WS: ${wsTerminalUrl}`.padEnd(BOX_CONTENT_WIDTH);
+ const s5 = `Health: ${healthUrl}`.padEnd(BOX_CONTENT_WIDTH);
+ const s6 = `Terminal: ${terminalStatus}`.padEnd(BOX_CONTENT_WIDTH);
+
+ logger.info(`
+╔═════════════════════════════════════════════════════════════════════╗
+║ ${sHeader}║
+╠═════════════════════════════════════════════════════════════════════╣
+║ ║
+║ ${s1}║
+║ ${s2}║
+║ ${s3}║
+║ ${s4}║
+║ ${s5}║
+║ ${s6}║
+║ ║
+╚═════════════════════════════════════════════════════════════════════╝
+`);
+ });
+
+ server.on('error', (error: NodeJS.ErrnoException) => {
+ if (error.code === 'EADDRINUSE') {
+ const portStr = port.toString();
+ const nextPortStr = (port + 1).toString();
+ const killCmd = `lsof -ti:${portStr} | xargs kill -9`;
+ const altCmd = `PORT=${nextPortStr} npm run dev:server`;
+
+ const eHeader = `❌ ERROR: Port ${portStr} is already in use`.padEnd(BOX_CONTENT_WIDTH);
+ const e1 = 'Another process is using this port.'.padEnd(BOX_CONTENT_WIDTH);
+ const e2 = 'To fix this, try one of:'.padEnd(BOX_CONTENT_WIDTH);
+ const e3 = '1. Kill the process using the port:'.padEnd(BOX_CONTENT_WIDTH);
+ const e4 = ` ${killCmd}`.padEnd(BOX_CONTENT_WIDTH);
+ const e5 = '2. Use a different port:'.padEnd(BOX_CONTENT_WIDTH);
+ const e6 = ` ${altCmd}`.padEnd(BOX_CONTENT_WIDTH);
+ const e7 = '3. Use the init.sh script which handles this:'.padEnd(BOX_CONTENT_WIDTH);
+ const e8 = ' ./init.sh'.padEnd(BOX_CONTENT_WIDTH);
+
+ logger.error(`
+╔═════════════════════════════════════════════════════════════════════╗
+║ ${eHeader}║
+╠═════════════════════════════════════════════════════════════════════╣
+║ ║
+║ ${e1}║
+║ ║
+║ ${e2}║
+║ ║
+║ ${e3}║
+║ ${e4}║
+║ ║
+║ ${e5}║
+║ ${e6}║
+║ ║
+║ ${e7}║
+║ ${e8}║
+║ ║
+╚═════════════════════════════════════════════════════════════════════╝
+`);
+ process.exit(1);
+ } else {
+ logger.error('Error starting server:', error);
+ process.exit(1);
+ }
+ });
+};
+
+startServer(PORT, HOST);
+
+// Global error handlers to prevent crashes from uncaught errors
+process.on('unhandledRejection', (reason: unknown, _promise: Promise) => {
+ logger.error('Unhandled Promise Rejection:', {
+ reason: reason instanceof Error ? reason.message : String(reason),
+ stack: reason instanceof Error ? reason.stack : undefined,
+ });
+ // Don't exit - log the error and continue running
+ // This prevents the server from crashing due to unhandled rejections
+});
+
+process.on('uncaughtException', (error: Error) => {
+ logger.error('Uncaught Exception:', {
+ message: error.message,
+ stack: error.stack,
+ });
+ // Exit on uncaught exceptions to prevent undefined behavior
+ // The process is in an unknown state after an uncaught exception
+ process.exit(1);
+});
+
+// Graceful shutdown timeout (30 seconds)
+const SHUTDOWN_TIMEOUT_MS = 30000;
+
+// Graceful shutdown helper
+const gracefulShutdown = async (signal: string) => {
+ logger.info(`${signal} received, shutting down...`);
+
+ // Set up a force-exit timeout to prevent hanging
+ const forceExitTimeout = setTimeout(() => {
+ logger.error(`Shutdown timed out after ${SHUTDOWN_TIMEOUT_MS}ms, forcing exit`);
+ process.exit(1);
+ }, SHUTDOWN_TIMEOUT_MS);
+
+ // Mark all running features as interrupted before shutdown
+ // This ensures they can be resumed when the server restarts
+ // Note: markAllRunningFeaturesInterrupted handles errors internally and never rejects
+ await autoModeService.markAllRunningFeaturesInterrupted(`${signal} signal received`);
+
+ terminalService.cleanup();
+ server.close(() => {
+ clearTimeout(forceExitTimeout);
+ logger.info('Server closed');
+ process.exit(0);
+ });
+};
+
+process.on('SIGTERM', () => {
+ gracefulShutdown('SIGTERM');
+});
+
+process.on('SIGINT', () => {
+ gracefulShutdown('SIGINT');
+});
diff --git a/temp_repo/apps/server/src/lib/agent-discovery.ts b/temp_repo/apps/server/src/lib/agent-discovery.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b831bdecf65418627a67ffbfb2579cb97ad61180
--- /dev/null
+++ b/temp_repo/apps/server/src/lib/agent-discovery.ts
@@ -0,0 +1,257 @@
+/**
+ * Agent Discovery - Scans filesystem for AGENT.md files
+ *
+ * Discovers agents from:
+ * - ~/.claude/agents/ (user-level, global)
+ * - .claude/agents/ (project-level)
+ *
+ * Similar to Skills, but for custom subagents defined in AGENT.md files.
+ */
+
+import path from 'path';
+import os from 'os';
+import { createLogger } from '@automaker/utils';
+import { secureFs, systemPaths } from '@automaker/platform';
+import type { AgentDefinition } from '@automaker/types';
+
+const logger = createLogger('AgentDiscovery');
+
+export interface FilesystemAgent {
+ name: string; // Directory name (e.g., 'code-reviewer')
+ definition: AgentDefinition;
+ source: 'user' | 'project';
+ filePath: string; // Full path to AGENT.md
+}
+
+/**
+ * Parse agent content string into AgentDefinition
+ * Format:
+ * ---
+ * name: agent-name # Optional
+ * description: When to use this agent
+ * tools: tool1, tool2, tool3 # Optional (comma or space separated list)
+ * model: sonnet # Optional: sonnet, opus, haiku
+ * ---
+ * System prompt content here...
+ */
+function parseAgentContent(content: string, filePath: string): AgentDefinition | null {
+ // Extract frontmatter
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
+ if (!frontmatterMatch) {
+ logger.warn(`Invalid agent file format (missing frontmatter): ${filePath}`);
+ return null;
+ }
+
+ const [, frontmatter, prompt] = frontmatterMatch;
+
+ // Parse description (required)
+ const description = frontmatter.match(/description:\s*(.+)/)?.[1]?.trim();
+ if (!description) {
+ logger.warn(`Missing description in agent file: ${filePath}`);
+ return null;
+ }
+
+ // Parse tools (optional) - supports both comma-separated and space-separated
+ const toolsMatch = frontmatter.match(/tools:\s*(.+)/);
+ const tools = toolsMatch
+ ? toolsMatch[1]
+ .split(/[,\s]+/) // Split by comma or whitespace
+ .map((t) => t.trim())
+ .filter((t) => t && t !== '')
+ : undefined;
+
+ // Parse model (optional) - validate against allowed values
+ const modelMatch = frontmatter.match(/model:\s*(\w+)/);
+ const modelValue = modelMatch?.[1]?.trim();
+ const validModels = ['sonnet', 'opus', 'haiku', 'inherit'] as const;
+ const model =
+ modelValue && validModels.includes(modelValue as (typeof validModels)[number])
+ ? (modelValue as 'sonnet' | 'opus' | 'haiku' | 'inherit')
+ : undefined;
+
+ if (modelValue && !model) {
+ logger.warn(
+ `Invalid model "${modelValue}" in agent file: ${filePath}. Expected one of: ${validModels.join(', ')}`
+ );
+ }
+
+ return {
+ description,
+ prompt: prompt.trim(),
+ tools,
+ model,
+ };
+}
+
+/**
+ * Directory entry with type information
+ */
+interface DirEntry {
+ name: string;
+ isFile: boolean;
+ isDirectory: boolean;
+}
+
+/**
+ * Filesystem adapter interface for abstracting systemPaths vs secureFs
+ */
+interface FsAdapter {
+ exists: (filePath: string) => Promise;
+ readdir: (dirPath: string) => Promise;
+ readFile: (filePath: string) => Promise;
+}
+
+/**
+ * Create a filesystem adapter for system paths (user directory)
+ */
+function createSystemPathAdapter(): FsAdapter {
+ return {
+ exists: (filePath) => Promise.resolve(systemPaths.systemPathExists(filePath)),
+ readdir: async (dirPath) => {
+ const entryNames = await systemPaths.systemPathReaddir(dirPath);
+ const entries: DirEntry[] = [];
+ for (const name of entryNames) {
+ const stat = await systemPaths.systemPathStat(path.join(dirPath, name));
+ entries.push({
+ name,
+ isFile: stat.isFile(),
+ isDirectory: stat.isDirectory(),
+ });
+ }
+ return entries;
+ },
+ readFile: (filePath) => systemPaths.systemPathReadFile(filePath, 'utf-8') as Promise,
+ };
+}
+
+/**
+ * Create a filesystem adapter for project paths (secureFs)
+ */
+function createSecureFsAdapter(): FsAdapter {
+ return {
+ exists: (filePath) =>
+ secureFs
+ .access(filePath)
+ .then(() => true)
+ .catch(() => false),
+ readdir: async (dirPath) => {
+ const entries = await secureFs.readdir(dirPath, { withFileTypes: true });
+ return entries.map((entry) => ({
+ name: entry.name,
+ isFile: entry.isFile(),
+ isDirectory: entry.isDirectory(),
+ }));
+ },
+ readFile: (filePath) => secureFs.readFile(filePath, 'utf-8') as Promise,
+ };
+}
+
+/**
+ * Parse agent file using the provided filesystem adapter
+ */
+async function parseAgentFileWithAdapter(
+ filePath: string,
+ fsAdapter: FsAdapter
+): Promise {
+ try {
+ const content = await fsAdapter.readFile(filePath);
+ return parseAgentContent(content, filePath);
+ } catch (error) {
+ logger.error(`Failed to parse agent file: ${filePath}`, error);
+ return null;
+ }
+}
+
+/**
+ * Scan a directory for agent .md files
+ * Agents can be in two formats:
+ * 1. Flat: agent-name.md (file directly in agents/)
+ * 2. Subdirectory: agent-name/AGENT.md (folder + file, similar to Skills)
+ */
+async function scanAgentsDirectory(
+ baseDir: string,
+ source: 'user' | 'project'
+): Promise {
+ const agents: FilesystemAgent[] = [];
+ const fsAdapter = source === 'user' ? createSystemPathAdapter() : createSecureFsAdapter();
+
+ try {
+ // Check if directory exists
+ const exists = await fsAdapter.exists(baseDir);
+ if (!exists) {
+ logger.debug(`Directory does not exist: ${baseDir}`);
+ return agents;
+ }
+
+ // Read all entries in the directory
+ const entries = await fsAdapter.readdir(baseDir);
+
+ for (const entry of entries) {
+ // Check for flat .md file format (agent-name.md)
+ if (entry.isFile && entry.name.endsWith('.md')) {
+ const agentName = entry.name.slice(0, -3); // Remove .md extension
+ const agentFilePath = path.join(baseDir, entry.name);
+ const definition = await parseAgentFileWithAdapter(agentFilePath, fsAdapter);
+ if (definition) {
+ agents.push({
+ name: agentName,
+ definition,
+ source,
+ filePath: agentFilePath,
+ });
+ logger.debug(`Discovered ${source} agent (flat): ${agentName}`);
+ }
+ }
+ // Check for subdirectory format (agent-name/AGENT.md)
+ else if (entry.isDirectory) {
+ const agentFilePath = path.join(baseDir, entry.name, 'AGENT.md');
+ const agentFileExists = await fsAdapter.exists(agentFilePath);
+
+ if (agentFileExists) {
+ const definition = await parseAgentFileWithAdapter(agentFilePath, fsAdapter);
+ if (definition) {
+ agents.push({
+ name: entry.name,
+ definition,
+ source,
+ filePath: agentFilePath,
+ });
+ logger.debug(`Discovered ${source} agent (subdirectory): ${entry.name}`);
+ }
+ }
+ }
+ }
+ } catch (error) {
+ logger.error(`Failed to scan agents directory: ${baseDir}`, error);
+ }
+
+ return agents;
+}
+
+/**
+ * Discover all filesystem-based agents from user and project sources
+ */
+export async function discoverFilesystemAgents(
+ projectPath?: string,
+ sources: Array<'user' | 'project'> = ['user', 'project']
+): Promise {
+ const agents: FilesystemAgent[] = [];
+
+ // Discover user-level agents from ~/.claude/agents/
+ if (sources.includes('user')) {
+ const userAgentsDir = path.join(os.homedir(), '.claude', 'agents');
+ const userAgents = await scanAgentsDirectory(userAgentsDir, 'user');
+ agents.push(...userAgents);
+ logger.info(`Discovered ${userAgents.length} user-level agents from ${userAgentsDir}`);
+ }
+
+ // Discover project-level agents from .claude/agents/
+ if (sources.includes('project') && projectPath) {
+ const projectAgentsDir = path.join(projectPath, '.claude', 'agents');
+ const projectAgents = await scanAgentsDirectory(projectAgentsDir, 'project');
+ agents.push(...projectAgents);
+ logger.info(`Discovered ${projectAgents.length} project-level agents from ${projectAgentsDir}`);
+ }
+
+ return agents;
+}
diff --git a/temp_repo/apps/server/src/lib/app-spec-format.ts b/temp_repo/apps/server/src/lib/app-spec-format.ts
new file mode 100644
index 0000000000000000000000000000000000000000..f8393cf1e04bd1c02a95863a46a9228c9a54db06
--- /dev/null
+++ b/temp_repo/apps/server/src/lib/app-spec-format.ts
@@ -0,0 +1,210 @@
+/**
+ * XML Template Format Specification for app_spec.txt
+ *
+ * This format must be included in all prompts that generate, modify, or regenerate
+ * app specifications to ensure consistency across the application.
+ */
+
+// Import and re-export spec types from shared package
+export type { SpecOutput } from '@automaker/types';
+export { specOutputSchema } from '@automaker/types';
+
+/**
+ * Escape special XML characters
+ * Handles undefined/null values by converting them to empty strings
+ */
+export function escapeXml(str: string | undefined | null): string {
+ if (str == null) {
+ return '';
+ }
+ return str
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+/**
+ * Convert structured spec output to XML format
+ */
+export function specToXml(spec: import('@automaker/types').SpecOutput): string {
+ const indent = ' ';
+
+ let xml = `
+
+${indent}${escapeXml(spec.project_name)}
+
+${indent}
+${indent}${indent}${escapeXml(spec.overview)}
+${indent}
+
+${indent}
+${spec.technology_stack.map((t) => `${indent}${indent}${escapeXml(t)}`).join('\n')}
+${indent}
+
+${indent}
+${spec.core_capabilities.map((c) => `${indent}${indent}${escapeXml(c)}`).join('\n')}
+${indent}
+
+${indent}
+${spec.implemented_features
+ .map(
+ (f) => `${indent}${indent}
+${indent}${indent}${indent}${escapeXml(f.name)}
+${indent}${indent}${indent}${escapeXml(f.description)}${
+ f.file_locations && f.file_locations.length > 0
+ ? `\n${indent}${indent}${indent}
+${f.file_locations.map((loc) => `${indent}${indent}${indent}${indent}${escapeXml(loc)}`).join('\n')}
+${indent}${indent}${indent}`
+ : ''
+ }
+${indent}${indent}`
+ )
+ .join('\n')}
+${indent}`;
+
+ // Optional sections
+ if (spec.additional_requirements && spec.additional_requirements.length > 0) {
+ xml += `
+
+${indent}
+${spec.additional_requirements.map((r) => `${indent}${indent}${escapeXml(r)}`).join('\n')}
+${indent}`;
+ }
+
+ if (spec.development_guidelines && spec.development_guidelines.length > 0) {
+ xml += `
+
+${indent}
+${spec.development_guidelines.map((g) => `${indent}${indent}${escapeXml(g)}`).join('\n')}
+${indent}`;
+ }
+
+ if (spec.implementation_roadmap && spec.implementation_roadmap.length > 0) {
+ xml += `
+
+${indent}
+${spec.implementation_roadmap
+ .map(
+ (r) => `${indent}${indent}
+${indent}${indent}${indent}${escapeXml(r.phase)}
+${indent}${indent}${indent}${escapeXml(r.status)}
+${indent}${indent}${indent}${escapeXml(r.description)}
+${indent}${indent}`
+ )
+ .join('\n')}
+${indent}`;
+ }
+
+ xml += `
+`;
+
+ return xml;
+}
+
+/**
+ * Get prompt instruction for structured output (simpler than XML instructions)
+ */
+export function getStructuredSpecPromptInstruction(): string {
+ return `
+Analyze the project and provide a comprehensive specification with:
+
+1. **project_name**: The name of the project
+2. **overview**: A comprehensive description of what the project does, its purpose, and key goals
+3. **technology_stack**: List all technologies, frameworks, libraries, and tools used
+4. **core_capabilities**: List the main features and capabilities the project provides
+5. **implemented_features**: For each implemented feature, provide:
+ - name: Feature name
+ - description: What it does
+ - file_locations: Key files where it's implemented (optional)
+6. **additional_requirements**: Any system requirements, dependencies, or constraints (optional)
+7. **development_guidelines**: Development standards and best practices (optional)
+8. **implementation_roadmap**: Project phases with status (completed/in_progress/pending) (optional)
+
+Be thorough in your analysis. The output will be automatically formatted as structured JSON.
+`;
+}
+export const APP_SPEC_XML_FORMAT = `
+The app_spec.txt file MUST follow this exact XML format:
+
+
+ Project Name
+
+
+ A comprehensive description of what the project does, its purpose, and key goals.
+
+
+
+ Technology 1
+ Technology 2
+
+
+
+
+ Core capability 1
+ Core capability 2
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Guideline 1
+ Guideline 2
+
+
+
+
+
+
+
+
+IMPORTANT:
+- All content must be wrapped in valid XML tags
+- Use proper XML escaping for special characters (<, >, &)
+- Maintain proper indentation (2 spaces)
+- All sections should be populated based on project analysis
+- The format must be strictly followed - do not use markdown, JSON, or any other format
+`;
+
+/**
+ * Returns a prompt suffix that instructs the AI to format the response as XML
+ * following the app_spec.txt template format.
+ */
+export function getAppSpecFormatInstruction(): string {
+ return `
+${APP_SPEC_XML_FORMAT}
+
+CRITICAL FORMATTING REQUIREMENTS:
+- Do NOT use the Write, Edit, or Bash tools to create files - just OUTPUT the XML in your response
+- Your ENTIRE response MUST be valid XML following the exact template structure above
+- Do NOT use markdown formatting (no # headers, no **bold**, no - lists, etc.)
+- Do NOT include any explanatory text, prefix, or suffix outside the XML tags
+- Do NOT include phrases like "Based on my analysis...", "I'll create...", "Let me analyze..." before the XML
+- Do NOT include any text before or after
+- Your response must start IMMEDIATELY with with no preceding text
+- Your response must end IMMEDIATELY with with no following text
+- Use ONLY XML tags as shown in the template
+- Properly escape XML special characters (< for <, > for >, & for &)
+- Maintain 2-space indentation for readability
+- The output will be saved directly to app_spec.txt and must be parseable as valid XML
+- The response must contain exactly ONE root XML element:
+- Do not include code blocks, markdown fences, or any other formatting
+
+VERIFICATION: Before responding, verify that:
+1. Your response starts with (no spaces, no text before it)
+2. Your response ends with (no spaces, no text after it)
+3. There is exactly one root XML element
+4. There is no explanatory text, analysis, or commentary outside the XML tags
+
+Your response should be ONLY the XML content, nothing else.
+`;
+}
diff --git a/temp_repo/apps/server/src/lib/auth-utils.ts b/temp_repo/apps/server/src/lib/auth-utils.ts
new file mode 100644
index 0000000000000000000000000000000000000000..936d22779def91b1a1529c44b76efb15186696bc
--- /dev/null
+++ b/temp_repo/apps/server/src/lib/auth-utils.ts
@@ -0,0 +1,263 @@
+/**
+ * Secure authentication utilities that avoid environment variable race conditions
+ */
+
+import { spawn } from 'child_process';
+import { createLogger } from '@automaker/utils';
+
+const logger = createLogger('AuthUtils');
+
+export interface SecureAuthEnv {
+ [key: string]: string | undefined;
+}
+
+export interface AuthValidationResult {
+ isValid: boolean;
+ error?: string;
+ normalizedKey?: string;
+}
+
+/**
+ * Validates API key format without modifying process.env
+ */
+export function validateApiKey(
+ key: string,
+ provider: 'anthropic' | 'openai' | 'cursor'
+): AuthValidationResult {
+ if (!key || typeof key !== 'string' || key.trim().length === 0) {
+ return { isValid: false, error: 'API key is required' };
+ }
+
+ const trimmedKey = key.trim();
+
+ switch (provider) {
+ case 'anthropic':
+ if (!trimmedKey.startsWith('sk-ant-')) {
+ return {
+ isValid: false,
+ error: 'Invalid Anthropic API key format. Should start with "sk-ant-"',
+ };
+ }
+ if (trimmedKey.length < 20) {
+ return { isValid: false, error: 'Anthropic API key too short' };
+ }
+ break;
+
+ case 'openai':
+ if (!trimmedKey.startsWith('sk-')) {
+ return { isValid: false, error: 'Invalid OpenAI API key format. Should start with "sk-"' };
+ }
+ if (trimmedKey.length < 20) {
+ return { isValid: false, error: 'OpenAI API key too short' };
+ }
+ break;
+
+ case 'cursor':
+ // Cursor API keys might have different format
+ if (trimmedKey.length < 10) {
+ return { isValid: false, error: 'Cursor API key too short' };
+ }
+ break;
+ }
+
+ return { isValid: true, normalizedKey: trimmedKey };
+}
+
+/**
+ * Creates a secure environment object for authentication testing
+ * without modifying the global process.env
+ */
+export function createSecureAuthEnv(
+ authMethod: 'cli' | 'api_key',
+ apiKey?: string,
+ provider: 'anthropic' | 'openai' | 'cursor' = 'anthropic'
+): SecureAuthEnv {
+ const env: SecureAuthEnv = { ...process.env };
+
+ if (authMethod === 'cli') {
+ // For CLI auth, remove the API key to force CLI authentication
+ const envKey = provider === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY';
+ delete env[envKey];
+ } else if (authMethod === 'api_key' && apiKey) {
+ // For API key auth, validate and set the provided key
+ const validation = validateApiKey(apiKey, provider);
+ if (!validation.isValid) {
+ throw new Error(validation.error);
+ }
+ const envKey = provider === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY';
+ env[envKey] = validation.normalizedKey;
+ }
+
+ return env;
+}
+
+/**
+ * Creates a temporary environment override for the current process
+ * WARNING: This should only be used in isolated contexts and immediately cleaned up
+ */
+export function createTempEnvOverride(authEnv: SecureAuthEnv): () => void {
+ const originalEnv = { ...process.env };
+
+ // Apply the auth environment
+ Object.assign(process.env, authEnv);
+
+ // Return cleanup function
+ return () => {
+ // Restore original environment
+ Object.keys(process.env).forEach((key) => {
+ if (!(key in originalEnv)) {
+ delete process.env[key];
+ }
+ });
+ Object.assign(process.env, originalEnv);
+ };
+}
+
+/**
+ * Spawns a process with secure environment isolation
+ */
+export function spawnSecureAuth(
+ command: string,
+ args: string[],
+ authEnv: SecureAuthEnv,
+ options: {
+ cwd?: string;
+ timeout?: number;
+ } = {}
+): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
+ return new Promise((resolve, reject) => {
+ const { cwd = process.cwd(), timeout = 30000 } = options;
+
+ logger.debug(`Spawning secure auth process: ${command} ${args.join(' ')}`);
+
+ const child = spawn(command, args, {
+ cwd,
+ env: authEnv,
+ stdio: 'pipe',
+ shell: false,
+ });
+
+ let stdout = '';
+ let stderr = '';
+ let isResolved = false;
+
+ const timeoutId = setTimeout(() => {
+ if (!isResolved) {
+ child.kill('SIGTERM');
+ isResolved = true;
+ reject(new Error('Authentication process timed out'));
+ }
+ }, timeout);
+
+ child.stdout?.on('data', (data) => {
+ stdout += data.toString();
+ });
+
+ child.stderr?.on('data', (data) => {
+ stderr += data.toString();
+ });
+
+ child.on('close', (code) => {
+ clearTimeout(timeoutId);
+ if (!isResolved) {
+ isResolved = true;
+ resolve({ stdout, stderr, exitCode: code });
+ }
+ });
+
+ child.on('error', (error) => {
+ clearTimeout(timeoutId);
+ if (!isResolved) {
+ isResolved = true;
+ reject(error);
+ }
+ });
+ });
+}
+
+/**
+ * Safely extracts environment variable without race conditions
+ */
+export function safeGetEnv(key: string): string | undefined {
+ return process.env[key];
+}
+
+/**
+ * Checks if an environment variable would be modified without actually modifying it
+ */
+export function wouldModifyEnv(key: string, newValue: string): boolean {
+ const currentValue = safeGetEnv(key);
+ return currentValue !== newValue;
+}
+
+/**
+ * Secure auth session management
+ */
+export class AuthSessionManager {
+ private static activeSessions = new Map();
+
+ static createSession(
+ sessionId: string,
+ authMethod: 'cli' | 'api_key',
+ apiKey?: string,
+ provider: 'anthropic' | 'openai' | 'cursor' = 'anthropic'
+ ): SecureAuthEnv {
+ const env = createSecureAuthEnv(authMethod, apiKey, provider);
+ this.activeSessions.set(sessionId, env);
+ return env;
+ }
+
+ static getSession(sessionId: string): SecureAuthEnv | undefined {
+ return this.activeSessions.get(sessionId);
+ }
+
+ static destroySession(sessionId: string): void {
+ this.activeSessions.delete(sessionId);
+ }
+
+ static cleanup(): void {
+ this.activeSessions.clear();
+ }
+}
+
+/**
+ * Rate limiting for auth attempts to prevent abuse
+ */
+export class AuthRateLimiter {
+ private attempts = new Map();
+
+ constructor(
+ private maxAttempts = 5,
+ private windowMs = 60000
+ ) {}
+
+ canAttempt(identifier: string): boolean {
+ const now = Date.now();
+ const record = this.attempts.get(identifier);
+
+ if (!record || now - record.lastAttempt > this.windowMs) {
+ this.attempts.set(identifier, { count: 1, lastAttempt: now });
+ return true;
+ }
+
+ if (record.count >= this.maxAttempts) {
+ return false;
+ }
+
+ record.count++;
+ record.lastAttempt = now;
+ return true;
+ }
+
+ getRemainingAttempts(identifier: string): number {
+ const record = this.attempts.get(identifier);
+ if (!record) return this.maxAttempts;
+ return Math.max(0, this.maxAttempts - record.count);
+ }
+
+ getResetTime(identifier: string): Date | null {
+ const record = this.attempts.get(identifier);
+ if (!record) return null;
+ return new Date(record.lastAttempt + this.windowMs);
+ }
+}
diff --git a/temp_repo/apps/server/src/lib/auth.ts b/temp_repo/apps/server/src/lib/auth.ts
new file mode 100644
index 0000000000000000000000000000000000000000..60cb2d58cae036e33a0432be4d215e7fa9b34146
--- /dev/null
+++ b/temp_repo/apps/server/src/lib/auth.ts
@@ -0,0 +1,467 @@
+/**
+ * Authentication middleware for API security
+ *
+ * Supports two authentication methods:
+ * 1. Header-based (X-API-Key) - Used by Electron mode
+ * 2. Cookie-based (HTTP-only session cookie) - Used by web mode
+ *
+ * Auto-generates an API key on first run if none is configured.
+ */
+
+import type { Request, Response, NextFunction } from 'express';
+import crypto from 'crypto';
+import path from 'path';
+import * as secureFs from './secure-fs.js';
+import { createLogger } from '@automaker/utils';
+
+const logger = createLogger('Auth');
+
+const DATA_DIR = process.env.DATA_DIR || './data';
+const API_KEY_FILE = path.join(DATA_DIR, '.api-key');
+const SESSIONS_FILE = path.join(DATA_DIR, '.sessions');
+const SESSION_COOKIE_NAME = 'automaker_session';
+const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
+const WS_TOKEN_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes for WebSocket connection tokens
+
+/**
+ * Check if an environment variable is set to 'true'
+ */
+function isEnvTrue(envVar: string | undefined): boolean {
+ return envVar === 'true';
+}
+
+// Session store - persisted to file for survival across server restarts
+const validSessions = new Map();
+
+// Short-lived WebSocket connection tokens (in-memory only, not persisted)
+const wsConnectionTokens = new Map();
+
+// Clean up expired WebSocket tokens periodically
+setInterval(() => {
+ const now = Date.now();
+ wsConnectionTokens.forEach((data, token) => {
+ if (data.expiresAt <= now) {
+ wsConnectionTokens.delete(token);
+ }
+ });
+}, 60 * 1000); // Clean up every minute
+
+/**
+ * Load sessions from file on startup
+ */
+function loadSessions(): void {
+ try {
+ if (secureFs.existsSync(SESSIONS_FILE)) {
+ const data = secureFs.readFileSync(SESSIONS_FILE, 'utf-8') as string;
+ const sessions = JSON.parse(data) as Array<
+ [string, { createdAt: number; expiresAt: number }]
+ >;
+ const now = Date.now();
+ let loadedCount = 0;
+ let expiredCount = 0;
+
+ for (const [token, session] of sessions) {
+ // Only load non-expired sessions
+ if (session.expiresAt > now) {
+ validSessions.set(token, session);
+ loadedCount++;
+ } else {
+ expiredCount++;
+ }
+ }
+
+ if (loadedCount > 0 || expiredCount > 0) {
+ logger.info(`Loaded ${loadedCount} sessions (${expiredCount} expired)`);
+ }
+ }
+ } catch (error) {
+ logger.warn('Error loading sessions:', error);
+ }
+}
+
+/**
+ * Save sessions to file (async)
+ */
+async function saveSessions(): Promise {
+ try {
+ await secureFs.mkdir(path.dirname(SESSIONS_FILE), { recursive: true });
+ const sessions = Array.from(validSessions.entries());
+ await secureFs.writeFile(SESSIONS_FILE, JSON.stringify(sessions), {
+ encoding: 'utf-8',
+ mode: 0o600,
+ });
+ } catch (error) {
+ logger.error('Failed to save sessions:', error);
+ }
+}
+
+// Load existing sessions on startup
+loadSessions();
+
+/**
+ * Ensure an API key exists - either from env var, file, or generate new one.
+ * This provides CSRF protection by requiring a secret key for all API requests.
+ */
+function ensureApiKey(): string {
+ // First check environment variable (Electron passes it this way)
+ if (process.env.AUTOMAKER_API_KEY) {
+ logger.info('Using API key from environment variable');
+ return process.env.AUTOMAKER_API_KEY;
+ }
+
+ // Try to read from file
+ try {
+ if (secureFs.existsSync(API_KEY_FILE)) {
+ const key = (secureFs.readFileSync(API_KEY_FILE, 'utf-8') as string).trim();
+ if (key) {
+ logger.info('Loaded API key from file');
+ return key;
+ }
+ }
+ } catch (error) {
+ logger.warn('Error reading API key file:', error);
+ }
+
+ // Generate new key
+ const newKey = crypto.randomUUID();
+ try {
+ secureFs.mkdirSync(path.dirname(API_KEY_FILE), { recursive: true });
+ secureFs.writeFileSync(API_KEY_FILE, newKey, { encoding: 'utf-8', mode: 0o600 });
+ logger.info('Generated new API key');
+ } catch (error) {
+ logger.error('Failed to save API key:', error);
+ }
+ return newKey;
+}
+
+// API key - always generated/loaded on startup for CSRF protection
+const API_KEY = ensureApiKey();
+
+// Width for log box content (excluding borders)
+const BOX_CONTENT_WIDTH = 67;
+
+// Print API key to console for web mode users (unless suppressed for production logging)
+if (!isEnvTrue(process.env.AUTOMAKER_HIDE_API_KEY)) {
+ const autoLoginEnabled = isEnvTrue(process.env.AUTOMAKER_AUTO_LOGIN);
+ const autoLoginStatus = autoLoginEnabled ? 'enabled (auto-login active)' : 'disabled';
+
+ // Build box lines with exact padding
+ const header = '🔐 API Key for Web Mode Authentication'.padEnd(BOX_CONTENT_WIDTH);
+ const line1 = "When accessing via browser, you'll be prompted to enter this key:".padEnd(
+ BOX_CONTENT_WIDTH
+ );
+ const line2 = API_KEY.padEnd(BOX_CONTENT_WIDTH);
+ const line3 = 'In Electron mode, authentication is handled automatically.'.padEnd(
+ BOX_CONTENT_WIDTH
+ );
+ const line4 = `Auto-login (AUTOMAKER_AUTO_LOGIN): ${autoLoginStatus}`.padEnd(BOX_CONTENT_WIDTH);
+ const tipHeader = '💡 Tips'.padEnd(BOX_CONTENT_WIDTH);
+ const line5 = 'Set AUTOMAKER_API_KEY env var to use a fixed key'.padEnd(BOX_CONTENT_WIDTH);
+ const line6 = 'Set AUTOMAKER_AUTO_LOGIN=true to skip the login prompt'.padEnd(BOX_CONTENT_WIDTH);
+
+ logger.info(`
+╔═════════════════════════════════════════════════════════════════════╗
+║ ${header}║
+╠═════════════════════════════════════════════════════════════════════╣
+║ ║
+║ ${line1}║
+║ ║
+║ ${line2}║
+║ ║
+║ ${line3}║
+║ ║
+║ ${line4}║
+║ ║
+╠═════════════════════════════════════════════════════════════════════╣
+║ ${tipHeader}║
+╠═════════════════════════════════════════════════════════════════════╣
+║ ${line5}║
+║ ${line6}║
+╚═════════════════════════════════════════════════════════════════════╝
+`);
+} else {
+ logger.info('API key banner hidden (AUTOMAKER_HIDE_API_KEY=true)');
+}
+
+/**
+ * Generate a cryptographically secure session token
+ */
+function generateSessionToken(): string {
+ return crypto.randomBytes(32).toString('hex');
+}
+
+/**
+ * Create a new session and return the token
+ */
+export async function createSession(): Promise {
+ const token = generateSessionToken();
+ const now = Date.now();
+ validSessions.set(token, {
+ createdAt: now,
+ expiresAt: now + SESSION_MAX_AGE_MS,
+ });
+ await saveSessions(); // Persist to file
+ return token;
+}
+
+/**
+ * Validate a session token
+ * Note: This returns synchronously but triggers async persistence if session expired
+ */
+export function validateSession(token: string): boolean {
+ const session = validSessions.get(token);
+ if (!session) return false;
+
+ if (Date.now() > session.expiresAt) {
+ validSessions.delete(token);
+ // Fire-and-forget: persist removal asynchronously
+ saveSessions().catch((err) => logger.error('Error saving sessions:', err));
+ return false;
+ }
+
+ return true;
+}
+
+/**
+ * Invalidate a session token
+ */
+export async function invalidateSession(token: string): Promise {
+ validSessions.delete(token);
+ await saveSessions(); // Persist removal
+}
+
+/**
+ * Create a short-lived WebSocket connection token
+ * Used for initial WebSocket handshake authentication
+ */
+export function createWsConnectionToken(): string {
+ const token = generateSessionToken();
+ const now = Date.now();
+ wsConnectionTokens.set(token, {
+ createdAt: now,
+ expiresAt: now + WS_TOKEN_MAX_AGE_MS,
+ });
+ return token;
+}
+
+/**
+ * Validate a WebSocket connection token
+ * These tokens are single-use and short-lived (5 minutes)
+ * Token is invalidated immediately after first successful use
+ */
+export function validateWsConnectionToken(token: string): boolean {
+ const tokenData = wsConnectionTokens.get(token);
+ if (!tokenData) return false;
+
+ // Always delete the token (single-use)
+ wsConnectionTokens.delete(token);
+
+ // Check if expired
+ if (Date.now() > tokenData.expiresAt) {
+ return false;
+ }
+
+ return true;
+}
+
+/**
+ * Validate the API key using timing-safe comparison
+ * Prevents timing attacks that could leak information about the key
+ */
+export function validateApiKey(key: string): boolean {
+ if (!key || typeof key !== 'string') return false;
+
+ // Both buffers must be the same length for timingSafeEqual
+ const keyBuffer = Buffer.from(key);
+ const apiKeyBuffer = Buffer.from(API_KEY);
+
+ // If lengths differ, compare against a dummy to maintain constant time
+ if (keyBuffer.length !== apiKeyBuffer.length) {
+ crypto.timingSafeEqual(apiKeyBuffer, apiKeyBuffer);
+ return false;
+ }
+
+ return crypto.timingSafeEqual(keyBuffer, apiKeyBuffer);
+}
+
+/**
+ * Get session cookie options
+ */
+export function getSessionCookieOptions(): {
+ httpOnly: boolean;
+ secure: boolean;
+ sameSite: 'strict' | 'lax' | 'none';
+ maxAge: number;
+ path: string;
+} {
+ return {
+ httpOnly: true, // JavaScript cannot access this cookie
+ secure: process.env.NODE_ENV === 'production', // HTTPS only in production
+ sameSite: 'lax', // Sent for same-site requests and top-level navigations, but not cross-origin fetch/XHR
+ maxAge: SESSION_MAX_AGE_MS,
+ path: '/',
+ };
+}
+
+/**
+ * Get the session cookie name
+ */
+export function getSessionCookieName(): string {
+ return SESSION_COOKIE_NAME;
+}
+
+/**
+ * Authentication result type
+ */
+type AuthResult =
+ | { authenticated: true }
+ | { authenticated: false; errorType: 'invalid_api_key' | 'invalid_session' | 'no_auth' };
+
+/**
+ * Core authentication check - shared between middleware and status check
+ * Extracts auth credentials from various sources and validates them
+ */
+function checkAuthentication(
+ headers: Record,
+ query: Record,
+ cookies: Record
+): AuthResult {
+ // Check for API key in header (Electron mode)
+ const headerKey = headers['x-api-key'] as string | undefined;
+ if (headerKey) {
+ if (validateApiKey(headerKey)) {
+ return { authenticated: true };
+ }
+ return { authenticated: false, errorType: 'invalid_api_key' };
+ }
+
+ // Check for session token in header (web mode with explicit token)
+ const sessionTokenHeader = headers['x-session-token'] as string | undefined;
+ if (sessionTokenHeader) {
+ if (validateSession(sessionTokenHeader)) {
+ return { authenticated: true };
+ }
+ return { authenticated: false, errorType: 'invalid_session' };
+ }
+
+ // Check for API key in query parameter (fallback)
+ const queryKey = query.apiKey;
+ if (queryKey) {
+ if (validateApiKey(queryKey)) {
+ return { authenticated: true };
+ }
+ return { authenticated: false, errorType: 'invalid_api_key' };
+ }
+
+ // Check for session token in query parameter (web mode - needed for image loads)
+ const queryToken = query.token;
+ if (queryToken) {
+ if (validateSession(queryToken)) {
+ return { authenticated: true };
+ }
+ return { authenticated: false, errorType: 'invalid_session' };
+ }
+
+ // Check for session cookie (web mode)
+ const sessionToken = cookies[SESSION_COOKIE_NAME];
+ if (sessionToken && validateSession(sessionToken)) {
+ return { authenticated: true };
+ }
+
+ return { authenticated: false, errorType: 'no_auth' };
+}
+
+/**
+ * Authentication middleware
+ *
+ * Accepts either:
+ * 1. X-API-Key header (for Electron mode)
+ * 2. X-Session-Token header (for web mode with explicit token)
+ * 3. apiKey query parameter (fallback for Electron, cases where headers can't be set)
+ * 4. token query parameter (fallback for web mode, needed for image loads via CSS/img tags)
+ * 5. Session cookie (for web mode)
+ */
+export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
+ // Allow disabling auth for local/trusted networks
+ if (isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH)) {
+ next();
+ return;
+ }
+
+ const result = checkAuthentication(
+ req.headers as Record,
+ req.query as Record,
+ (req.cookies || {}) as Record
+ );
+
+ if (result.authenticated) {
+ next();
+ return;
+ }
+
+ // Return appropriate error based on what failed
+ switch (result.errorType) {
+ case 'invalid_api_key':
+ res.status(403).json({
+ success: false,
+ error: 'Invalid API key.',
+ });
+ break;
+ case 'invalid_session':
+ res.status(403).json({
+ success: false,
+ error: 'Invalid or expired session token.',
+ });
+ break;
+ case 'no_auth':
+ default:
+ res.status(401).json({
+ success: false,
+ error: 'Authentication required.',
+ });
+ }
+}
+
+/**
+ * Check if authentication is enabled (always true now)
+ */
+export function isAuthEnabled(): boolean {
+ return true;
+}
+
+/**
+ * Get authentication status for health endpoint
+ */
+export function getAuthStatus(): { enabled: boolean; method: string } {
+ const disabled = isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH);
+ return {
+ enabled: !disabled,
+ method: disabled ? 'disabled' : 'api_key_or_session',
+ };
+}
+
+/**
+ * Check if a request is authenticated (for status endpoint)
+ */
+export function isRequestAuthenticated(req: Request): boolean {
+ if (isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH)) return true;
+ const result = checkAuthentication(
+ req.headers as Record,
+ req.query as Record,
+ (req.cookies || {}) as Record
+ );
+ return result.authenticated;
+}
+
+/**
+ * Check if raw credentials are authenticated
+ * Used for WebSocket authentication where we don't have Express request objects
+ */
+export function checkRawAuthentication(
+ headers: Record,
+ query: Record,
+ cookies: Record
+): boolean {
+ if (isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH)) return true;
+ return checkAuthentication(headers, query, cookies).authenticated;
+}
diff --git a/temp_repo/apps/server/src/lib/cli-detection.ts b/temp_repo/apps/server/src/lib/cli-detection.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a7b5b14db36f57965dc6f77fcb0fed3b5c2a0246
--- /dev/null
+++ b/temp_repo/apps/server/src/lib/cli-detection.ts
@@ -0,0 +1,444 @@
+/**
+ * Unified CLI Detection Framework
+ *
+ * Provides consistent CLI detection and management across all providers
+ */
+
+import { spawn, execSync } from 'child_process';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+
+export interface CliInfo {
+ name: string;
+ command: string;
+ version?: string;
+ path?: string;
+ installed: boolean;
+ authenticated: boolean;
+ authMethod: 'cli' | 'api_key' | 'none';
+ platform?: string;
+ architectures?: string[];
+}
+
+export interface CliDetectionOptions {
+ timeout?: number;
+ includeWsl?: boolean;
+ wslDistribution?: string;
+}
+
+export interface CliDetectionResult {
+ cli: CliInfo;
+ detected: boolean;
+ issues: string[];
+}
+
+export interface UnifiedCliDetection {
+ claude?: CliDetectionResult;
+ codex?: CliDetectionResult;
+ cursor?: CliDetectionResult;
+}
+
+/**
+ * CLI Configuration for different providers
+ */
+const CLI_CONFIGS = {
+ claude: {
+ name: 'Claude CLI',
+ commands: ['claude'],
+ versionArgs: ['--version'],
+ installCommands: {
+ darwin: 'brew install anthropics/claude/claude',
+ linux: 'curl -fsSL https://claude.ai/install.sh | sh',
+ win32: 'iwr https://claude.ai/install.ps1 -UseBasicParsing | iex',
+ },
+ },
+ codex: {
+ name: 'Codex CLI',
+ commands: ['codex', 'openai'],
+ versionArgs: ['--version'],
+ installCommands: {
+ darwin: 'npm install -g @openai/codex-cli',
+ linux: 'npm install -g @openai/codex-cli',
+ win32: 'npm install -g @openai/codex-cli',
+ },
+ },
+ cursor: {
+ name: 'Cursor CLI',
+ commands: ['cursor-agent', 'cursor'],
+ versionArgs: ['--version'],
+ installCommands: {
+ darwin: 'brew install cursor/cursor/cursor-agent',
+ linux: 'curl -fsSL https://cursor.sh/install.sh | sh',
+ win32: 'iwr https://cursor.sh/install.ps1 -UseBasicParsing | iex',
+ },
+ },
+} as const;
+
+/**
+ * Detect if a CLI is installed and available
+ */
+export async function detectCli(
+ provider: keyof typeof CLI_CONFIGS,
+ options: CliDetectionOptions = {}
+): Promise {
+ const config = CLI_CONFIGS[provider];
+ const { timeout = 5000 } = options;
+ const issues: string[] = [];
+
+ const cliInfo: CliInfo = {
+ name: config.name,
+ command: '',
+ installed: false,
+ authenticated: false,
+ authMethod: 'none',
+ };
+
+ try {
+ // Find the command in PATH
+ const command = await findCommand([...config.commands]);
+ if (command) {
+ cliInfo.command = command;
+ }
+
+ if (!cliInfo.command) {
+ issues.push(`${config.name} not found in PATH`);
+ return { cli: cliInfo, detected: false, issues };
+ }
+
+ cliInfo.path = cliInfo.command;
+ cliInfo.installed = true;
+
+ // Get version
+ try {
+ cliInfo.version = await getCliVersion(cliInfo.command, [...config.versionArgs], timeout);
+ } catch (error) {
+ issues.push(`Failed to get ${config.name} version: ${error}`);
+ }
+
+ // Check authentication
+ cliInfo.authMethod = await checkCliAuth(provider, cliInfo.command);
+ cliInfo.authenticated = cliInfo.authMethod !== 'none';
+
+ return { cli: cliInfo, detected: true, issues };
+ } catch (error) {
+ issues.push(`Error detecting ${config.name}: ${error}`);
+ return { cli: cliInfo, detected: false, issues };
+ }
+}
+
+/**
+ * Detect all CLIs in the system
+ */
+export async function detectAllCLis(
+ options: CliDetectionOptions = {}
+): Promise {
+ const results: UnifiedCliDetection = {};
+
+ // Detect all providers in parallel
+ const providers = Object.keys(CLI_CONFIGS) as Array;
+ const detectionPromises = providers.map(async (provider) => {
+ const result = await detectCli(provider, options);
+ return { provider, result };
+ });
+
+ const detections = await Promise.all(detectionPromises);
+
+ for (const { provider, result } of detections) {
+ results[provider] = result;
+ }
+
+ return results;
+}
+
+/**
+ * Find the first available command from a list of alternatives
+ */
+export async function findCommand(commands: string[]): Promise {
+ for (const command of commands) {
+ try {
+ const whichCommand = process.platform === 'win32' ? 'where' : 'which';
+ const result = execSync(`${whichCommand} ${command}`, {
+ encoding: 'utf8',
+ timeout: 2000,
+ }).trim();
+
+ if (result) {
+ return result.split('\n')[0]; // Take first result on Windows
+ }
+ } catch {
+ // Command not found, try next
+ }
+ }
+ return null;
+}
+
+/**
+ * Get CLI version
+ */
+export async function getCliVersion(
+ command: string,
+ args: string[],
+ timeout: number = 5000
+): Promise {
+ return new Promise((resolve, reject) => {
+ const child = spawn(command, args, {
+ stdio: 'pipe',
+ timeout,
+ });
+
+ let stdout = '';
+ let stderr = '';
+
+ child.stdout?.on('data', (data) => {
+ stdout += data.toString();
+ });
+
+ child.stderr?.on('data', (data) => {
+ stderr += data.toString();
+ });
+
+ child.on('close', (code) => {
+ if (code === 0 && stdout) {
+ resolve(stdout.trim());
+ } else if (stderr) {
+ reject(stderr.trim());
+ } else {
+ reject(`Command exited with code ${code}`);
+ }
+ });
+
+ child.on('error', reject);
+ });
+}
+
+/**
+ * Check authentication status for a CLI
+ */
+export async function checkCliAuth(
+ provider: keyof typeof CLI_CONFIGS,
+ command: string
+): Promise<'cli' | 'api_key' | 'none'> {
+ try {
+ switch (provider) {
+ case 'claude':
+ return await checkClaudeAuth(command);
+ case 'codex':
+ return await checkCodexAuth(command);
+ case 'cursor':
+ return await checkCursorAuth(command);
+ default:
+ return 'none';
+ }
+ } catch {
+ return 'none';
+ }
+}
+
+/**
+ * Check Claude CLI authentication
+ */
+async function checkClaudeAuth(command: string): Promise<'cli' | 'api_key' | 'none'> {
+ try {
+ // Check for environment variable
+ if (process.env.ANTHROPIC_API_KEY) {
+ return 'api_key';
+ }
+
+ // Try running a simple command to check CLI auth
+ const result = await getCliVersion(command, ['--version'], 3000);
+ if (result) {
+ return 'cli'; // If version works, assume CLI is authenticated
+ }
+ } catch {
+ // Version command might work even without auth, so we need a better check
+ }
+
+ // Try a more specific auth check
+ return new Promise((resolve) => {
+ const child = spawn(command, ['whoami'], {
+ stdio: 'pipe',
+ timeout: 3000,
+ });
+
+ let stdout = '';
+ let stderr = '';
+
+ child.stdout?.on('data', (data) => {
+ stdout += data.toString();
+ });
+
+ child.stderr?.on('data', (data) => {
+ stderr += data.toString();
+ });
+
+ child.on('close', (code) => {
+ if (code === 0 && stdout && !stderr.includes('not authenticated')) {
+ resolve('cli');
+ } else {
+ resolve('none');
+ }
+ });
+
+ child.on('error', () => {
+ resolve('none');
+ });
+ });
+}
+
+/**
+ * Check Codex CLI authentication
+ */
+async function checkCodexAuth(command: string): Promise<'cli' | 'api_key' | 'none'> {
+ // Check for environment variable
+ if (process.env.OPENAI_API_KEY) {
+ return 'api_key';
+ }
+
+ try {
+ // Try a simple auth check
+ const result = await getCliVersion(command, ['--version'], 3000);
+ if (result) {
+ return 'cli';
+ }
+ } catch {
+ // Version check failed
+ }
+
+ return 'none';
+}
+
+/**
+ * Check Cursor CLI authentication
+ */
+async function checkCursorAuth(command: string): Promise<'cli' | 'api_key' | 'none'> {
+ // Check for environment variable
+ if (process.env.CURSOR_API_KEY) {
+ return 'api_key';
+ }
+
+ // Check for credentials files
+ const credentialPaths = [
+ path.join(os.homedir(), '.cursor', 'credentials.json'),
+ path.join(os.homedir(), '.config', 'cursor', 'credentials.json'),
+ path.join(os.homedir(), '.cursor', 'auth.json'),
+ path.join(os.homedir(), '.config', 'cursor', 'auth.json'),
+ ];
+
+ for (const credPath of credentialPaths) {
+ try {
+ if (fs.existsSync(credPath)) {
+ const content = fs.readFileSync(credPath, 'utf8');
+ const creds = JSON.parse(content);
+ if (creds.accessToken || creds.token || creds.apiKey) {
+ return 'cli';
+ }
+ }
+ } catch {
+ // Invalid credentials file
+ }
+ }
+
+ // Try a simple command
+ try {
+ const result = await getCliVersion(command, ['--version'], 3000);
+ if (result) {
+ return 'cli';
+ }
+ } catch {
+ // Version check failed
+ }
+
+ return 'none';
+}
+
+/**
+ * Get installation instructions for a provider
+ */
+export function getInstallInstructions(
+ provider: keyof typeof CLI_CONFIGS,
+ platform: NodeJS.Platform = process.platform
+): string {
+ const config = CLI_CONFIGS[provider];
+ const command = config.installCommands[platform as keyof typeof config.installCommands];
+
+ if (!command) {
+ return `No installation instructions available for ${provider} on ${platform}`;
+ }
+
+ return command;
+}
+
+/**
+ * Get platform-specific CLI paths and versions
+ */
+export function getPlatformCliPaths(provider: keyof typeof CLI_CONFIGS): string[] {
+ const config = CLI_CONFIGS[provider];
+ const platform = process.platform;
+
+ switch (platform) {
+ case 'darwin':
+ return [
+ `/usr/local/bin/${config.commands[0]}`,
+ `/opt/homebrew/bin/${config.commands[0]}`,
+ path.join(os.homedir(), '.local', 'bin', config.commands[0]),
+ ];
+
+ case 'linux':
+ return [
+ `/usr/bin/${config.commands[0]}`,
+ `/usr/local/bin/${config.commands[0]}`,
+ path.join(os.homedir(), '.local', 'bin', config.commands[0]),
+ path.join(os.homedir(), '.npm', 'global', 'bin', config.commands[0]),
+ ];
+
+ case 'win32':
+ return [
+ path.join(
+ os.homedir(),
+ 'AppData',
+ 'Local',
+ 'Programs',
+ config.commands[0],
+ `${config.commands[0]}.exe`
+ ),
+ path.join(process.env.ProgramFiles || '', config.commands[0], `${config.commands[0]}.exe`),
+ path.join(
+ process.env.ProgramFiles || '',
+ config.commands[0],
+ 'bin',
+ `${config.commands[0]}.exe`
+ ),
+ ];
+
+ default:
+ return [];
+ }
+}
+
+/**
+ * Validate CLI installation
+ */
+export function validateCliInstallation(cliInfo: CliInfo): {
+ valid: boolean;
+ issues: string[];
+} {
+ const issues: string[] = [];
+
+ if (!cliInfo.installed) {
+ issues.push('CLI is not installed');
+ }
+
+ if (cliInfo.installed && !cliInfo.version) {
+ issues.push('Could not determine CLI version');
+ }
+
+ if (cliInfo.installed && cliInfo.authMethod === 'none') {
+ issues.push('CLI is not authenticated');
+ }
+
+ return {
+ valid: issues.length === 0,
+ issues,
+ };
+}
diff --git a/temp_repo/apps/server/src/lib/codex-auth.ts b/temp_repo/apps/server/src/lib/codex-auth.ts
new file mode 100644
index 0000000000000000000000000000000000000000..94fadc8c7c6cf7289b7f122cd0f5066b655b2090
--- /dev/null
+++ b/temp_repo/apps/server/src/lib/codex-auth.ts
@@ -0,0 +1,68 @@
+/**
+ * Shared utility for checking Codex CLI authentication status
+ *
+ * Uses 'codex login status' command to verify authentication.
+ * Never assumes authenticated - only returns true if CLI confirms.
+ */
+
+import { spawnProcess } from '@automaker/platform';
+import { findCodexCliPath } from '@automaker/platform';
+import { createLogger } from '@automaker/utils';
+
+const logger = createLogger('CodexAuth');
+
+const CODEX_COMMAND = 'codex';
+const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY';
+
+export interface CodexAuthCheckResult {
+ authenticated: boolean;
+ method: 'api_key_env' | 'cli_authenticated' | 'none';
+}
+
+/**
+ * Check Codex authentication status using 'codex login status' command
+ *
+ * @param cliPath Optional CLI path. If not provided, will attempt to find it.
+ * @returns Authentication status and method
+ */
+export async function checkCodexAuthentication(
+ cliPath?: string | null
+): Promise {
+ const resolvedCliPath = cliPath || (await findCodexCliPath());
+ const hasApiKey = !!process.env[OPENAI_API_KEY_ENV];
+
+ // If CLI is not installed, cannot be authenticated
+ if (!resolvedCliPath) {
+ logger.info('CLI not found');
+ return { authenticated: false, method: 'none' };
+ }
+
+ try {
+ const result = await spawnProcess({
+ command: resolvedCliPath || CODEX_COMMAND,
+ args: ['login', 'status'],
+ cwd: process.cwd(),
+ env: {
+ ...process.env,
+ TERM: 'dumb', // Avoid interactive output
+ },
+ });
+
+ // Check both stdout and stderr for "logged in" - Codex CLI outputs to stderr
+ const combinedOutput = (result.stdout + result.stderr).toLowerCase();
+ const isLoggedIn = combinedOutput.includes('logged in');
+
+ if (result.exitCode === 0 && isLoggedIn) {
+ // Determine auth method based on what we know
+ const method = hasApiKey ? 'api_key_env' : 'cli_authenticated';
+ logger.info(`✓ Authenticated (${method})`);
+ return { authenticated: true, method };
+ }
+
+ logger.info('Not authenticated');
+ return { authenticated: false, method: 'none' };
+ } catch (error) {
+ logger.error('Failed to check authentication:', error);
+ return { authenticated: false, method: 'none' };
+ }
+}
diff --git a/temp_repo/apps/server/src/lib/enhancement-prompts.ts b/temp_repo/apps/server/src/lib/enhancement-prompts.ts
new file mode 100644
index 0000000000000000000000000000000000000000..03f85f6ee7fa7515955fe8984a19206d00e61969
--- /dev/null
+++ b/temp_repo/apps/server/src/lib/enhancement-prompts.ts
@@ -0,0 +1,25 @@
+/**
+ * Enhancement Prompts - Re-exported from @automaker/prompts
+ *
+ * This file now re-exports enhancement prompts from the shared @automaker/prompts package
+ * to maintain backward compatibility with existing imports in the server codebase.
+ */
+
+export {
+ IMPROVE_SYSTEM_PROMPT,
+ TECHNICAL_SYSTEM_PROMPT,
+ SIMPLIFY_SYSTEM_PROMPT,
+ ACCEPTANCE_SYSTEM_PROMPT,
+ IMPROVE_EXAMPLES,
+ TECHNICAL_EXAMPLES,
+ SIMPLIFY_EXAMPLES,
+ ACCEPTANCE_EXAMPLES,
+ getEnhancementPrompt,
+ getSystemPrompt,
+ getExamples,
+ buildUserPrompt,
+ isValidEnhancementMode,
+ getAvailableEnhancementModes,
+} from '@automaker/prompts';
+
+export type { EnhancementMode, EnhancementExample } from '@automaker/prompts';
diff --git a/temp_repo/apps/server/src/lib/error-handler.ts b/temp_repo/apps/server/src/lib/error-handler.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d672009848abe5efa51c1c1c657fd5907fa6cdb9
--- /dev/null
+++ b/temp_repo/apps/server/src/lib/error-handler.ts
@@ -0,0 +1,415 @@
+/**
+ * Unified Error Handling System for CLI Providers
+ *
+ * Provides consistent error classification, user-friendly messages, and debugging support
+ * across all AI providers (Claude, Codex, Cursor)
+ */
+
+import { createLogger } from '@automaker/utils';
+
+const logger = createLogger('ErrorHandler');
+
+export enum ErrorType {
+ AUTHENTICATION = 'authentication',
+ BILLING = 'billing',
+ RATE_LIMIT = 'rate_limit',
+ NETWORK = 'network',
+ TIMEOUT = 'timeout',
+ VALIDATION = 'validation',
+ PERMISSION = 'permission',
+ CLI_NOT_FOUND = 'cli_not_found',
+ CLI_NOT_INSTALLED = 'cli_not_installed',
+ MODEL_NOT_SUPPORTED = 'model_not_supported',
+ INVALID_REQUEST = 'invalid_request',
+ SERVER_ERROR = 'server_error',
+ UNKNOWN = 'unknown',
+}
+
+export enum ErrorSeverity {
+ LOW = 'low',
+ MEDIUM = 'medium',
+ HIGH = 'high',
+ CRITICAL = 'critical',
+}
+
+export interface ErrorClassification {
+ type: ErrorType;
+ severity: ErrorSeverity;
+ userMessage: string;
+ technicalMessage: string;
+ suggestedAction?: string;
+ retryable: boolean;
+ provider?: string;
+ context?: Record;
+}
+
+export interface ErrorPattern {
+ type: ErrorType;
+ severity: ErrorSeverity;
+ patterns: RegExp[];
+ userMessage: string;
+ suggestedAction?: string;
+ retryable: boolean;
+}
+
+/**
+ * Error patterns for different types of errors
+ */
+const ERROR_PATTERNS: ErrorPattern[] = [
+ // Authentication errors
+ {
+ type: ErrorType.AUTHENTICATION,
+ severity: ErrorSeverity.HIGH,
+ patterns: [
+ /unauthorized/i,
+ /authentication.*fail/i,
+ /invalid_api_key/i,
+ /invalid api key/i,
+ /not authenticated/i,
+ /please.*log/i,
+ /token.*revoked/i,
+ /oauth.*error/i,
+ /credentials.*invalid/i,
+ ],
+ userMessage: 'Authentication failed. Please check your API key or login credentials.',
+ suggestedAction:
+ "Verify your API key is correct and hasn't expired, or run the CLI login command.",
+ retryable: false,
+ },
+
+ // Billing errors
+ {
+ type: ErrorType.BILLING,
+ severity: ErrorSeverity.HIGH,
+ patterns: [
+ /credit.*balance.*low/i,
+ /insufficient.*credit/i,
+ /billing.*issue/i,
+ /payment.*required/i,
+ /usage.*exceeded/i,
+ /quota.*exceeded/i,
+ /add.*credit/i,
+ ],
+ userMessage: 'Account has insufficient credits or billing issues.',
+ suggestedAction: 'Please add credits to your account or check your billing settings.',
+ retryable: false,
+ },
+
+ // Rate limit errors
+ {
+ type: ErrorType.RATE_LIMIT,
+ severity: ErrorSeverity.MEDIUM,
+ patterns: [
+ /rate.*limit/i,
+ /too.*many.*request/i,
+ /limit.*reached/i,
+ /try.*later/i,
+ /429/i,
+ /reset.*time/i,
+ /upgrade.*plan/i,
+ ],
+ userMessage: 'Rate limit reached. Please wait before trying again.',
+ suggestedAction: 'Wait a few minutes before retrying, or consider upgrading your plan.',
+ retryable: true,
+ },
+
+ // Network errors
+ {
+ type: ErrorType.NETWORK,
+ severity: ErrorSeverity.MEDIUM,
+ patterns: [/network/i, /connection/i, /dns/i, /timeout/i, /econnrefused/i, /enotfound/i],
+ userMessage: 'Network connection issue.',
+ suggestedAction: 'Check your internet connection and try again.',
+ retryable: true,
+ },
+
+ // Timeout errors
+ {
+ type: ErrorType.TIMEOUT,
+ severity: ErrorSeverity.MEDIUM,
+ patterns: [/timeout/i, /aborted/i, /time.*out/i],
+ userMessage: 'Operation timed out.',
+ suggestedAction: 'Try again with a simpler request or check your connection.',
+ retryable: true,
+ },
+
+ // Permission errors
+ {
+ type: ErrorType.PERMISSION,
+ severity: ErrorSeverity.HIGH,
+ patterns: [/permission.*denied/i, /access.*denied/i, /forbidden/i, /403/i, /not.*authorized/i],
+ userMessage: 'Permission denied.',
+ suggestedAction: 'Check if you have the required permissions for this operation.',
+ retryable: false,
+ },
+
+ // CLI not found
+ {
+ type: ErrorType.CLI_NOT_FOUND,
+ severity: ErrorSeverity.HIGH,
+ patterns: [/command not found/i, /not recognized/i, /not.*installed/i, /ENOENT/i],
+ userMessage: 'CLI tool not found.',
+ suggestedAction: "Please install the required CLI tool and ensure it's in your PATH.",
+ retryable: false,
+ },
+
+ // Model not supported
+ {
+ type: ErrorType.MODEL_NOT_SUPPORTED,
+ severity: ErrorSeverity.HIGH,
+ patterns: [/model.*not.*support/i, /unknown.*model/i, /invalid.*model/i],
+ userMessage: 'Model not supported.',
+ suggestedAction: 'Check available models and use a supported one.',
+ retryable: false,
+ },
+
+ // Server errors
+ {
+ type: ErrorType.SERVER_ERROR,
+ severity: ErrorSeverity.HIGH,
+ patterns: [/internal.*server/i, /server.*error/i, /500/i, /502/i, /503/i, /504/i],
+ userMessage: 'Server error occurred.',
+ suggestedAction: 'Try again in a few minutes or contact support if the issue persists.',
+ retryable: true,
+ },
+];
+
+/**
+ * Classify an error into a specific type with user-friendly message
+ */
+export function classifyError(
+ error: unknown,
+ provider?: string,
+ context?: Record
+): ErrorClassification {
+ const errorText = getErrorText(error);
+
+ // Try to match against known patterns
+ for (const pattern of ERROR_PATTERNS) {
+ for (const regex of pattern.patterns) {
+ if (regex.test(errorText)) {
+ return {
+ type: pattern.type,
+ severity: pattern.severity,
+ userMessage: pattern.userMessage,
+ technicalMessage: errorText,
+ suggestedAction: pattern.suggestedAction,
+ retryable: pattern.retryable,
+ provider,
+ context,
+ };
+ }
+ }
+ }
+
+ // Unknown error
+ return {
+ type: ErrorType.UNKNOWN,
+ severity: ErrorSeverity.MEDIUM,
+ userMessage: 'An unexpected error occurred.',
+ technicalMessage: errorText,
+ suggestedAction: 'Please try again or contact support if the issue persists.',
+ retryable: true,
+ provider,
+ context,
+ };
+}
+
+/**
+ * Get a user-friendly error message
+ */
+export function getUserFriendlyErrorMessage(error: unknown, provider?: string): string {
+ const classification = classifyError(error, provider);
+
+ let message = classification.userMessage;
+
+ if (classification.suggestedAction) {
+ message += ` ${classification.suggestedAction}`;
+ }
+
+ // Add provider-specific context if available
+ if (provider) {
+ message = `[${provider.toUpperCase()}] ${message}`;
+ }
+
+ return message;
+}
+
+/**
+ * Check if an error is retryable
+ */
+export function isRetryableError(error: unknown): boolean {
+ const classification = classifyError(error);
+ return classification.retryable;
+}
+
+/**
+ * Check if an error is authentication-related
+ */
+export function isAuthenticationError(error: unknown): boolean {
+ const classification = classifyError(error);
+ return classification.type === ErrorType.AUTHENTICATION;
+}
+
+/**
+ * Check if an error is billing-related
+ */
+export function isBillingError(error: unknown): boolean {
+ const classification = classifyError(error);
+ return classification.type === ErrorType.BILLING;
+}
+
+/**
+ * Check if an error is rate limit related
+ */
+export function isRateLimitError(error: unknown): boolean {
+ const classification = classifyError(error);
+ return classification.type === ErrorType.RATE_LIMIT;
+}
+
+/**
+ * Get error text from various error types
+ */
+function getErrorText(error: unknown): string {
+ if (typeof error === 'string') {
+ return error;
+ }
+
+ if (error instanceof Error) {
+ return error.message;
+ }
+
+ if (typeof error === 'object' && error !== null) {
+ // Handle structured error objects
+ const errorObj = error as Record;
+
+ if (typeof errorObj.message === 'string') {
+ return errorObj.message;
+ }
+
+ const nestedError = errorObj.error;
+ if (typeof nestedError === 'object' && nestedError !== null && 'message' in nestedError) {
+ return String((nestedError as Record).message);
+ }
+
+ if (nestedError) {
+ return typeof nestedError === 'string' ? nestedError : JSON.stringify(nestedError);
+ }
+
+ return JSON.stringify(error);
+ }
+
+ return String(error);
+}
+
+/**
+ * Create a standardized error response
+ */
+export function createErrorResponse(
+ error: unknown,
+ provider?: string,
+ context?: Record
+): {
+ success: false;
+ error: string;
+ errorType: ErrorType;
+ severity: ErrorSeverity;
+ retryable: boolean;
+ suggestedAction?: string;
+} {
+ const classification = classifyError(error, provider, context);
+
+ return {
+ success: false,
+ error: classification.userMessage,
+ errorType: classification.type,
+ severity: classification.severity,
+ retryable: classification.retryable,
+ suggestedAction: classification.suggestedAction,
+ };
+}
+
+/**
+ * Log error with full context
+ */
+export function logError(
+ error: unknown,
+ provider?: string,
+ operation?: string,
+ additionalContext?: Record
+): void {
+ const classification = classifyError(error, provider, {
+ operation,
+ ...additionalContext,
+ });
+
+ logger.error(`Error in ${provider || 'unknown'}${operation ? ` during ${operation}` : ''}`, {
+ type: classification.type,
+ severity: classification.severity,
+ message: classification.userMessage,
+ technicalMessage: classification.technicalMessage,
+ retryable: classification.retryable,
+ suggestedAction: classification.suggestedAction,
+ context: classification.context,
+ });
+}
+
+/**
+ * Provider-specific error handlers
+ */
+export const ProviderErrorHandler = {
+ claude: {
+ classify: (error: unknown) => classifyError(error, 'claude'),
+ getUserMessage: (error: unknown) => getUserFriendlyErrorMessage(error, 'claude'),
+ isAuth: (error: unknown) => isAuthenticationError(error),
+ isBilling: (error: unknown) => isBillingError(error),
+ isRateLimit: (error: unknown) => isRateLimitError(error),
+ },
+
+ codex: {
+ classify: (error: unknown) => classifyError(error, 'codex'),
+ getUserMessage: (error: unknown) => getUserFriendlyErrorMessage(error, 'codex'),
+ isAuth: (error: unknown) => isAuthenticationError(error),
+ isBilling: (error: unknown) => isBillingError(error),
+ isRateLimit: (error: unknown) => isRateLimitError(error),
+ },
+
+ cursor: {
+ classify: (error: unknown) => classifyError(error, 'cursor'),
+ getUserMessage: (error: unknown) => getUserFriendlyErrorMessage(error, 'cursor'),
+ isAuth: (error: unknown) => isAuthenticationError(error),
+ isBilling: (error: unknown) => isBillingError(error),
+ isRateLimit: (error: unknown) => isRateLimitError(error),
+ },
+};
+
+/**
+ * Create a retry handler for retryable errors
+ */
+export function createRetryHandler(maxRetries: number = 3, baseDelay: number = 1000) {
+ return async function (
+ operation: () => Promise,
+ shouldRetry: (error: unknown) => boolean = isRetryableError
+ ): Promise {
+ let lastError: unknown;
+
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
+ try {
+ return await operation();
+ } catch (error) {
+ lastError = error;
+
+ if (attempt === maxRetries || !shouldRetry(error)) {
+ throw error;
+ }
+
+ // Exponential backoff with jitter
+ const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 1000;
+ logger.debug(`Retrying operation in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`);
+ await new Promise((resolve) => setTimeout(resolve, delay));
+ }
+ }
+
+ throw lastError;
+ };
+}
diff --git a/temp_repo/apps/server/src/lib/events.ts b/temp_repo/apps/server/src/lib/events.ts
new file mode 100644
index 0000000000000000000000000000000000000000..7f224c4e74094cf4c6d31eb84a7558e168d48132
--- /dev/null
+++ b/temp_repo/apps/server/src/lib/events.ts
@@ -0,0 +1,39 @@
+/**
+ * Event emitter for streaming events to WebSocket clients
+ */
+
+import type { EventType, EventCallback } from '@automaker/types';
+import { createLogger } from '@automaker/utils';
+
+const logger = createLogger('Events');
+
+// Re-export event types from shared package
+export type { EventType, EventCallback };
+
+export interface EventEmitter {
+ emit: (type: EventType, payload: unknown) => void;
+ subscribe: (callback: EventCallback) => () => void;
+}
+
+export function createEventEmitter(): EventEmitter {
+ const subscribers = new Set();
+
+ return {
+ emit(type: EventType, payload: unknown) {
+ for (const callback of subscribers) {
+ try {
+ callback(type, payload);
+ } catch (error) {
+ logger.error('Error in event subscriber:', error);
+ }
+ }
+ },
+
+ subscribe(callback: EventCallback) {
+ subscribers.add(callback);
+ return () => {
+ subscribers.delete(callback);
+ };
+ },
+ };
+}
diff --git a/temp_repo/apps/server/src/lib/exec-utils.ts b/temp_repo/apps/server/src/lib/exec-utils.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0073f6956046811ddd6d2d8d470b667b1e30b24f
--- /dev/null
+++ b/temp_repo/apps/server/src/lib/exec-utils.ts
@@ -0,0 +1,37 @@
+/**
+ * Shared execution utilities
+ *
+ * Common helpers for spawning child processes with the correct environment.
+ * Used by both route handlers and service layers.
+ */
+
+import { createLogger } from '@automaker/utils';
+
+const logger = createLogger('ExecUtils');
+
+// Extended PATH to include common tool installation locations
+export const extendedPath = [
+ process.env.PATH,
+ '/opt/homebrew/bin',
+ '/usr/local/bin',
+ '/home/linuxbrew/.linuxbrew/bin',
+ `${process.env.HOME}/.local/bin`,
+]
+ .filter(Boolean)
+ .join(':');
+
+export const execEnv = {
+ ...process.env,
+ PATH: extendedPath,
+};
+
+export function getErrorMessage(error: unknown): string {
+ if (error instanceof Error) {
+ return error.message;
+ }
+ return String(error);
+}
+
+export function logError(error: unknown, context: string): void {
+ logger.error(`${context}:`, error);
+}
diff --git a/temp_repo/apps/server/src/lib/git-log-parser.ts b/temp_repo/apps/server/src/lib/git-log-parser.ts
new file mode 100644
index 0000000000000000000000000000000000000000..85b0cb58c14efcd0c0ac088eca42097705d6ba9f
--- /dev/null
+++ b/temp_repo/apps/server/src/lib/git-log-parser.ts
@@ -0,0 +1,62 @@
+export interface CommitFields {
+ hash: string;
+ shortHash: string;
+ author: string;
+ authorEmail: string;
+ date: string;
+ subject: string;
+ body: string;
+}
+
+export function parseGitLogOutput(output: string): CommitFields[] {
+ const commits: CommitFields[] = [];
+
+ // Split by NUL character to separate commits
+ const commitBlocks = output.split('\0').filter((block) => block.trim());
+
+ for (const block of commitBlocks) {
+ const allLines = block.split('\n');
+
+ // Skip leading empty lines that may appear at block boundaries
+ let startIndex = 0;
+ while (startIndex < allLines.length && allLines[startIndex].trim() === '') {
+ startIndex++;
+ }
+ const fields = allLines.slice(startIndex);
+
+ // Validate we have all expected fields (at least hash, shortHash, author, authorEmail, date, subject)
+ if (fields.length < 6) {
+ continue; // Skip malformed blocks
+ }
+
+ const commit: CommitFields = {
+ hash: fields[0].trim(),
+ shortHash: fields[1].trim(),
+ author: fields[2].trim(),
+ authorEmail: fields[3].trim(),
+ date: fields[4].trim(),
+ subject: fields[5].trim(),
+ body: fields.slice(6).join('\n').trim(),
+ };
+
+ commits.push(commit);
+ }
+
+ return commits;
+}
+
+/**
+ * Creates a commit object from parsed fields, matching the expected API response format
+ */
+export function createCommitFromFields(fields: CommitFields, files?: string[]) {
+ return {
+ hash: fields.hash,
+ shortHash: fields.shortHash,
+ author: fields.author,
+ authorEmail: fields.authorEmail,
+ date: fields.date,
+ subject: fields.subject,
+ body: fields.body,
+ files: files || [],
+ };
+}
diff --git a/temp_repo/apps/server/src/lib/git.ts b/temp_repo/apps/server/src/lib/git.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d60ccadead57b8dd7b054320573c6a3868b51562
--- /dev/null
+++ b/temp_repo/apps/server/src/lib/git.ts
@@ -0,0 +1,236 @@
+/**
+ * Shared git command execution utilities.
+ *
+ * This module provides the canonical `execGitCommand` helper and common
+ * git utilities used across services and routes. All consumers should
+ * import from here rather than defining their own copy.
+ */
+
+import fs from 'fs/promises';
+import path from 'path';
+import { spawnProcess } from '@automaker/platform';
+import { createLogger } from '@automaker/utils';
+
+const logger = createLogger('GitLib');
+
+// Extended PATH so git is found when the process does not inherit a full shell PATH
+// (e.g. Electron, some CI, or IDE-launched processes).
+const pathSeparator = process.platform === 'win32' ? ';' : ':';
+const extraPaths: string[] =
+ process.platform === 'win32'
+ ? ([
+ process.env.LOCALAPPDATA && `${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`,
+ process.env.PROGRAMFILES && `${process.env.PROGRAMFILES}\\Git\\cmd`,
+ process.env['ProgramFiles(x86)'] && `${process.env['ProgramFiles(x86)']}\\Git\\cmd`,
+ ].filter(Boolean) as string[])
+ : [
+ '/opt/homebrew/bin',
+ '/usr/local/bin',
+ '/usr/bin',
+ '/home/linuxbrew/.linuxbrew/bin',
+ process.env.HOME ? `${process.env.HOME}/.local/bin` : '',
+ ].filter(Boolean);
+
+const extendedPath = [process.env.PATH, ...extraPaths].filter(Boolean).join(pathSeparator);
+const gitEnv = { ...process.env, PATH: extendedPath };
+
+// ============================================================================
+// Secure Command Execution
+// ============================================================================
+
+/**
+ * Execute git command with array arguments to prevent command injection.
+ * Uses spawnProcess from @automaker/platform for secure, cross-platform execution.
+ *
+ * @param args - Array of git command arguments (e.g., ['worktree', 'add', path])
+ * @param cwd - Working directory to execute the command in
+ * @param env - Optional additional environment variables to pass to the git process.
+ * These are merged on top of the current process environment. Pass
+ * `{ LC_ALL: 'C' }` to force git to emit English output regardless of the
+ * system locale so that text-based output parsing remains reliable.
+ * @param abortController - Optional AbortController to cancel the git process.
+ * When the controller is aborted the underlying process is sent SIGTERM and
+ * the returned promise rejects with an Error whose message is 'Process aborted'.
+ * @returns Promise resolving to stdout output
+ * @throws Error with stderr/stdout message if command fails. The thrown error
+ * also has `stdout` and `stderr` string properties for structured access.
+ *
+ * @example
+ * ```typescript
+ * // Safe: no injection possible
+ * await execGitCommand(['branch', '-D', branchName], projectPath);
+ *
+ * // Force English output for reliable text parsing:
+ * await execGitCommand(['rebase', '--', 'main'], worktreePath, { LC_ALL: 'C' });
+ *
+ * // With a process-level timeout:
+ * const controller = new AbortController();
+ * const timerId = setTimeout(() => controller.abort(), 30_000);
+ * try {
+ * await execGitCommand(['fetch', '--all', '--quiet'], cwd, undefined, controller);
+ * } finally {
+ * clearTimeout(timerId);
+ * }
+ *
+ * // Instead of unsafe:
+ * // await execAsync(`git branch -D ${branchName}`, { cwd });
+ * ```
+ */
+export async function execGitCommand(
+ args: string[],
+ cwd: string,
+ env?: Record,
+ abortController?: AbortController
+): Promise {
+ const result = await spawnProcess({
+ command: 'git',
+ args,
+ cwd,
+ env:
+ env !== undefined
+ ? {
+ ...gitEnv,
+ ...env,
+ PATH: [gitEnv.PATH, env.PATH].filter(Boolean).join(pathSeparator),
+ }
+ : gitEnv,
+ ...(abortController !== undefined ? { abortController } : {}),
+ });
+
+ // spawnProcess returns { stdout, stderr, exitCode }
+ if (result.exitCode === 0) {
+ return result.stdout;
+ } else {
+ const errorMessage =
+ result.stderr || result.stdout || `Git command failed with code ${result.exitCode}`;
+ throw Object.assign(new Error(errorMessage), {
+ stdout: result.stdout,
+ stderr: result.stderr,
+ });
+ }
+}
+
+// ============================================================================
+// Common Git Utilities
+// ============================================================================
+
+/**
+ * Get the current branch name for the given worktree.
+ *
+ * This is the canonical implementation shared across services. Services
+ * should import this rather than duplicating the logic locally.
+ *
+ * @param worktreePath - Path to the git worktree
+ * @returns The current branch name (trimmed)
+ */
+export async function getCurrentBranch(worktreePath: string): Promise {
+ const branchOutput = await execGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath);
+ return branchOutput.trim();
+}
+
+// ============================================================================
+// Index Lock Recovery
+// ============================================================================
+
+/**
+ * Check whether an error message indicates a stale git index lock file.
+ *
+ * Git operations that write to the index (e.g. `git stash push`) will fail
+ * with "could not write index" or "Unable to create ... .lock" when a
+ * `.git/index.lock` file exists from a previously interrupted operation.
+ *
+ * @param errorMessage - The error string from a failed git command
+ * @returns true if the error looks like a stale index lock issue
+ */
+export function isIndexLockError(errorMessage: string): boolean {
+ const lower = errorMessage.toLowerCase();
+ return (
+ lower.includes('could not write index') ||
+ (lower.includes('unable to create') && lower.includes('index.lock')) ||
+ lower.includes('index.lock')
+ );
+}
+
+/**
+ * Attempt to remove a stale `.git/index.lock` file for the given worktree.
+ *
+ * Uses `git rev-parse --git-dir` to locate the correct `.git` directory,
+ * which works for both regular repositories and linked worktrees.
+ *
+ * @param worktreePath - Path to the git worktree (or main repo)
+ * @returns true if a lock file was found and removed, false otherwise
+ */
+export async function removeStaleIndexLock(worktreePath: string): Promise {
+ try {
+ // Resolve the .git directory (handles worktrees correctly)
+ const gitDirRaw = await execGitCommand(['rev-parse', '--git-dir'], worktreePath);
+ const gitDir = path.resolve(worktreePath, gitDirRaw.trim());
+ const lockFilePath = path.join(gitDir, 'index.lock');
+
+ // Check if the lock file exists
+ try {
+ await fs.access(lockFilePath);
+ } catch {
+ // Lock file does not exist — nothing to remove
+ return false;
+ }
+
+ // Remove the stale lock file
+ await fs.unlink(lockFilePath);
+ logger.info('Removed stale index.lock file', { worktreePath, lockFilePath });
+ return true;
+ } catch (err) {
+ logger.warn('Failed to remove stale index.lock file', {
+ worktreePath,
+ error: err instanceof Error ? err.message : String(err),
+ });
+ return false;
+ }
+}
+
+/**
+ * Execute a git command with automatic retry when a stale index.lock is detected.
+ *
+ * If the command fails with an error indicating a locked index file, this
+ * helper will attempt to remove the stale `.git/index.lock` and retry the
+ * command exactly once.
+ *
+ * This is particularly useful for `git stash push` which writes to the
+ * index and commonly fails when a previous git operation was interrupted.
+ *
+ * @param args - Array of git command arguments
+ * @param cwd - Working directory to execute the command in
+ * @param env - Optional additional environment variables
+ * @returns Promise resolving to stdout output
+ * @throws The original error if retry also fails, or a non-lock error
+ */
+export async function execGitCommandWithLockRetry(
+ args: string[],
+ cwd: string,
+ env?: Record
+): Promise {
+ try {
+ return await execGitCommand(args, cwd, env);
+ } catch (error: unknown) {
+ const err = error as { message?: string; stderr?: string };
+ const errorMessage = err.stderr || err.message || '';
+
+ if (!isIndexLockError(errorMessage)) {
+ throw error;
+ }
+
+ logger.info('Git command failed due to index lock, attempting cleanup and retry', {
+ cwd,
+ args: args.join(' '),
+ });
+
+ const removed = await removeStaleIndexLock(cwd);
+ if (!removed) {
+ // Could not remove the lock file — re-throw the original error
+ throw error;
+ }
+
+ // Retry the command once after removing the lock file
+ return await execGitCommand(args, cwd, env);
+ }
+}
diff --git a/temp_repo/apps/server/src/lib/json-extractor.ts b/temp_repo/apps/server/src/lib/json-extractor.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a1a97dd89c28e110f09a056bba801e5064479e06
--- /dev/null
+++ b/temp_repo/apps/server/src/lib/json-extractor.ts
@@ -0,0 +1,211 @@
+/**
+ * JSON Extraction Utilities
+ *
+ * Robust JSON extraction from AI responses that may contain markdown,
+ * code blocks, or other text mixed with JSON content.
+ *
+ * Used by various routes that parse structured output from Cursor or
+ * Claude responses when structured output is not available.
+ */
+
+import { createLogger } from '@automaker/utils';
+
+const logger = createLogger('JsonExtractor');
+
+/**
+ * Logger interface for optional custom logging
+ */
+export interface JsonExtractorLogger {
+ debug: (message: string, ...args: unknown[]) => void;
+ warn?: (message: string, ...args: unknown[]) => void;
+}
+
+/**
+ * Options for JSON extraction
+ */
+export interface ExtractJsonOptions {
+ /** Custom logger (defaults to internal logger) */
+ logger?: JsonExtractorLogger;
+ /** Required key that must be present in the extracted JSON */
+ requiredKey?: string;
+ /** Whether the required key's value must be an array */
+ requireArray?: boolean;
+}
+
+/**
+ * Extract JSON from response text using multiple strategies.
+ *
+ * Strategies tried in order:
+ * 1. JSON in ```json code block
+ * 2. JSON in ``` code block (no language)
+ * 3. Find JSON object by matching braces (starting with requiredKey if specified)
+ * 4. Find any JSON object by matching braces
+ * 5. Parse entire response as JSON
+ *
+ * @param responseText - The raw response text that may contain JSON
+ * @param options - Optional extraction options
+ * @returns Parsed JSON object or null if extraction fails
+ */
+export function extractJson>(
+ responseText: string,
+ options: ExtractJsonOptions = {}
+): T | null {
+ const log = options.logger || logger;
+ const requiredKey = options.requiredKey;
+ const requireArray = options.requireArray ?? false;
+
+ /**
+ * Validate that the result has the required key/structure
+ */
+ const validateResult = (result: unknown): result is T => {
+ if (!result || typeof result !== 'object') return false;
+ if (requiredKey) {
+ const obj = result as Record;
+ if (!(requiredKey in obj)) return false;
+ if (requireArray && !Array.isArray(obj[requiredKey])) return false;
+ }
+ return true;
+ };
+
+ /**
+ * Find matching closing brace by counting brackets
+ */
+ const findMatchingBrace = (text: string, startIdx: number): number => {
+ let depth = 0;
+ for (let i = startIdx; i < text.length; i++) {
+ if (text[i] === '{') depth++;
+ if (text[i] === '}') {
+ depth--;
+ if (depth === 0) {
+ return i + 1;
+ }
+ }
+ }
+ return -1;
+ };
+
+ const strategies = [
+ // Strategy 1: JSON in ```json code block
+ () => {
+ const match = responseText.match(/```json\s*([\s\S]*?)```/);
+ if (match) {
+ log.debug('Extracting JSON from ```json code block');
+ return JSON.parse(match[1].trim());
+ }
+ return null;
+ },
+
+ // Strategy 2: JSON in ``` code block (no language specified)
+ () => {
+ const match = responseText.match(/```\s*([\s\S]*?)```/);
+ if (match) {
+ const content = match[1].trim();
+ // Only try if it looks like JSON (starts with { or [)
+ if (content.startsWith('{') || content.startsWith('[')) {
+ log.debug('Extracting JSON from ``` code block');
+ return JSON.parse(content);
+ }
+ }
+ return null;
+ },
+
+ // Strategy 3: Find JSON object containing the required key (if specified)
+ () => {
+ if (!requiredKey) return null;
+
+ const searchPattern = `{"${requiredKey}"`;
+ const startIdx = responseText.indexOf(searchPattern);
+ if (startIdx === -1) return null;
+
+ const endIdx = findMatchingBrace(responseText, startIdx);
+ if (endIdx > startIdx) {
+ log.debug(`Extracting JSON with required key "${requiredKey}"`);
+ return JSON.parse(responseText.slice(startIdx, endIdx));
+ }
+ return null;
+ },
+
+ // Strategy 4: Find any JSON object by matching braces
+ () => {
+ const startIdx = responseText.indexOf('{');
+ if (startIdx === -1) return null;
+
+ const endIdx = findMatchingBrace(responseText, startIdx);
+ if (endIdx > startIdx) {
+ log.debug('Extracting JSON by brace matching');
+ return JSON.parse(responseText.slice(startIdx, endIdx));
+ }
+ return null;
+ },
+
+ // Strategy 5: Find JSON using first { to last } (may be less accurate)
+ () => {
+ const firstBrace = responseText.indexOf('{');
+ const lastBrace = responseText.lastIndexOf('}');
+ if (firstBrace !== -1 && lastBrace > firstBrace) {
+ log.debug('Extracting JSON from first { to last }');
+ return JSON.parse(responseText.slice(firstBrace, lastBrace + 1));
+ }
+ return null;
+ },
+
+ // Strategy 6: Try parsing the entire response as JSON
+ () => {
+ const trimmed = responseText.trim();
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
+ log.debug('Parsing entire response as JSON');
+ return JSON.parse(trimmed);
+ }
+ return null;
+ },
+ ];
+
+ for (const strategy of strategies) {
+ try {
+ const result = strategy();
+ if (validateResult(result)) {
+ log.debug('Successfully extracted JSON');
+ return result as T;
+ }
+ } catch {
+ // Strategy failed, try next
+ }
+ }
+
+ log.debug('Failed to extract JSON from response');
+ return null;
+}
+
+/**
+ * Extract JSON with a specific required key.
+ * Convenience wrapper around extractJson.
+ *
+ * @param responseText - The raw response text
+ * @param requiredKey - Key that must be present in the extracted JSON
+ * @param options - Additional options
+ * @returns Parsed JSON object or null
+ */
+export function extractJsonWithKey>(
+ responseText: string,
+ requiredKey: string,
+ options: Omit = {}
+): T | null {
+ return extractJson(responseText, { ...options, requiredKey });
+}
+
+/**
+ * Extract JSON that has a required array property.
+ * Useful for extracting responses like { "suggestions": [...] }
+ *
+ * @param responseText - The raw response text
+ * @param arrayKey - Key that must contain an array
+ * @param options - Additional options
+ * @returns Parsed JSON object or null
+ */
+export function extractJsonWithArray>(
+ responseText: string,
+ arrayKey: string,
+ options: Omit = {}
+): T | null {
+ return extractJson(responseText, { ...options, requiredKey: arrayKey, requireArray: true });
+}
diff --git a/temp_repo/apps/server/src/lib/permission-enforcer.ts b/temp_repo/apps/server/src/lib/permission-enforcer.ts
new file mode 100644
index 0000000000000000000000000000000000000000..714f7d40a06c4285c1065bea956b7414bef3c3c4
--- /dev/null
+++ b/temp_repo/apps/server/src/lib/permission-enforcer.ts
@@ -0,0 +1,184 @@
+/**
+ * Permission enforcement utilities for Cursor provider
+ */
+
+import type { CursorCliConfigFile } from '@automaker/types';
+import { createLogger } from '@automaker/utils';
+
+const logger = createLogger('PermissionEnforcer');
+
+export interface PermissionCheckResult {
+ allowed: boolean;
+ reason?: string;
+}
+
+/** Minimal shape of a Cursor tool call used for permission checking */
+interface CursorToolCall {
+ shellToolCall?: { args?: { command: string } };
+ readToolCall?: { args?: { path: string } };
+ writeToolCall?: { args?: { path: string } };
+}
+
+/**
+ * Check if a tool call is allowed based on permissions
+ */
+export function checkToolCallPermission(
+ toolCall: CursorToolCall,
+ permissions: CursorCliConfigFile | null
+): PermissionCheckResult {
+ if (!permissions || !permissions.permissions) {
+ // If no permissions are configured, allow everything (backward compatibility)
+ return { allowed: true };
+ }
+
+ const { allow = [], deny = [] } = permissions.permissions;
+
+ // Check shell tool calls
+ if (toolCall.shellToolCall?.args?.command) {
+ const command = toolCall.shellToolCall.args.command;
+ const toolName = `Shell(${extractCommandName(command)})`;
+
+ // Check deny list first (deny takes precedence)
+ for (const denyRule of deny) {
+ if (matchesRule(toolName, denyRule)) {
+ return {
+ allowed: false,
+ reason: `Operation blocked by permission rule: ${denyRule}`,
+ };
+ }
+ }
+
+ // Then check allow list
+ for (const allowRule of allow) {
+ if (matchesRule(toolName, allowRule)) {
+ return { allowed: true };
+ }
+ }
+
+ return {
+ allowed: false,
+ reason: `Operation not in allow list: ${toolName}`,
+ };
+ }
+
+ // Check read tool calls
+ if (toolCall.readToolCall?.args?.path) {
+ const path = toolCall.readToolCall.args.path;
+ const toolName = `Read(${path})`;
+
+ // Check deny list first
+ for (const denyRule of deny) {
+ if (matchesRule(toolName, denyRule)) {
+ return {
+ allowed: false,
+ reason: `Read operation blocked by permission rule: ${denyRule}`,
+ };
+ }
+ }
+
+ // Then check allow list
+ for (const allowRule of allow) {
+ if (matchesRule(toolName, allowRule)) {
+ return { allowed: true };
+ }
+ }
+
+ return {
+ allowed: false,
+ reason: `Read operation not in allow list: ${toolName}`,
+ };
+ }
+
+ // Check write tool calls
+ if (toolCall.writeToolCall?.args?.path) {
+ const path = toolCall.writeToolCall.args.path;
+ const toolName = `Write(${path})`;
+
+ // Check deny list first
+ for (const denyRule of deny) {
+ if (matchesRule(toolName, denyRule)) {
+ return {
+ allowed: false,
+ reason: `Write operation blocked by permission rule: ${denyRule}`,
+ };
+ }
+ }
+
+ // Then check allow list
+ for (const allowRule of allow) {
+ if (matchesRule(toolName, allowRule)) {
+ return { allowed: true };
+ }
+ }
+
+ return {
+ allowed: false,
+ reason: `Write operation not in allow list: ${toolName}`,
+ };
+ }
+
+ // For other tool types, allow by default for now
+ return { allowed: true };
+}
+
+/**
+ * Extract the base command name from a shell command
+ */
+function extractCommandName(command: string): string {
+ // Remove leading spaces and get the first word
+ const trimmed = command.trim();
+ const firstWord = trimmed.split(/\s+/)[0];
+ return firstWord || 'unknown';
+}
+
+/**
+ * Check if a tool name matches a permission rule
+ */
+function matchesRule(toolName: string, rule: string): boolean {
+ // Exact match
+ if (toolName === rule) {
+ return true;
+ }
+
+ // Wildcard patterns
+ if (rule.includes('*')) {
+ const regex = new RegExp(rule.replace(/\*/g, '.*'));
+ return regex.test(toolName);
+ }
+
+ // Prefix match for shell commands (e.g., "Shell(git)" matches "Shell(git status)")
+ if (rule.startsWith('Shell(') && toolName.startsWith('Shell(')) {
+ const ruleCommand = rule.slice(6, -1); // Remove "Shell(" and ")"
+ const toolCommand = extractCommandName(toolName.slice(6, -1)); // Remove "Shell(" and ")"
+ return toolCommand.startsWith(ruleCommand);
+ }
+
+ return false;
+}
+
+/**
+ * Log permission violations
+ */
+export function logPermissionViolation(
+ toolCall: CursorToolCall,
+ reason: string,
+ sessionId?: string
+): void {
+ const sessionIdStr = sessionId ? ` [${sessionId}]` : '';
+
+ if (toolCall.shellToolCall?.args?.command) {
+ logger.warn(
+ `Permission violation${sessionIdStr}: Shell command blocked - ${toolCall.shellToolCall.args.command} (${reason})`
+ );
+ } else if (toolCall.readToolCall?.args?.path) {
+ logger.warn(
+ `Permission violation${sessionIdStr}: Read operation blocked - ${toolCall.readToolCall.args.path} (${reason})`
+ );
+ } else if (toolCall.writeToolCall?.args?.path) {
+ logger.warn(
+ `Permission violation${sessionIdStr}: Write operation blocked - ${toolCall.writeToolCall.args.path} (${reason})`
+ );
+ } else {
+ logger.warn(`Permission violation${sessionIdStr}: Tool call blocked (${reason})`, { toolCall });
+ }
+}
diff --git a/temp_repo/apps/server/src/lib/sdk-options.ts b/temp_repo/apps/server/src/lib/sdk-options.ts
new file mode 100644
index 0000000000000000000000000000000000000000..7044221e17de2568f7fbe5ef6061fd08130c3b60
--- /dev/null
+++ b/temp_repo/apps/server/src/lib/sdk-options.ts
@@ -0,0 +1,623 @@
+/**
+ * SDK Options Factory - Centralized configuration for Claude Agent SDK
+ *
+ * Provides presets for common use cases:
+ * - Spec generation: Long-running analysis with read-only tools
+ * - Feature generation: Quick JSON generation from specs
+ * - Feature building: Autonomous feature implementation with full tool access
+ * - Suggestions: Analysis with read-only tools
+ * - Chat: Full tool access for interactive coding
+ *
+ * Uses model-resolver for consistent model handling across the application.
+ *
+ * SECURITY: All factory functions validate the working directory (cwd) against
+ * ALLOWED_ROOT_DIRECTORY before returning options. This provides a centralized
+ * security check that applies to ALL AI model invocations, regardless of provider.
+ */
+
+import type { Options } from '@anthropic-ai/claude-agent-sdk';
+import path from 'path';
+import { resolveModelString } from '@automaker/model-resolver';
+import { createLogger } from '@automaker/utils';
+
+const logger = createLogger('SdkOptions');
+import {
+ DEFAULT_MODELS,
+ CLAUDE_MODEL_MAP,
+ type McpServerConfig,
+ type ThinkingLevel,
+ getThinkingTokenBudget,
+} from '@automaker/types';
+import { isPathAllowed, PathNotAllowedError, getAllowedRootDirectory } from '@automaker/platform';
+
+/**
+ * Result of sandbox compatibility check
+ */
+export interface SandboxCompatibilityResult {
+ /** Whether sandbox mode can be enabled for this path */
+ enabled: boolean;
+ /** Optional message explaining why sandbox is disabled */
+ message?: string;
+}
+
+/**
+ * Check if a working directory is compatible with sandbox mode.
+ * Some paths (like cloud storage mounts) may not work with sandboxed execution.
+ *
+ * @param cwd - The working directory to check
+ * @param sandboxRequested - Whether sandbox mode was requested by settings
+ * @returns Object indicating if sandbox can be enabled and why not if disabled
+ */
+export function checkSandboxCompatibility(
+ cwd: string,
+ sandboxRequested: boolean
+): SandboxCompatibilityResult {
+ if (!sandboxRequested) {
+ return { enabled: false };
+ }
+
+ const resolvedCwd = path.resolve(cwd);
+
+ // Check for cloud storage paths that may not be compatible with sandbox
+ const cloudStoragePatterns = [
+ // macOS mounted volumes
+ /^\/Volumes\/GoogleDrive/i,
+ /^\/Volumes\/Dropbox/i,
+ /^\/Volumes\/OneDrive/i,
+ /^\/Volumes\/iCloud/i,
+ // macOS home directory
+ /^\/Users\/[^/]+\/Google Drive/i,
+ /^\/Users\/[^/]+\/Dropbox/i,
+ /^\/Users\/[^/]+\/OneDrive/i,
+ /^\/Users\/[^/]+\/Library\/Mobile Documents/i, // iCloud
+ // Linux home directory
+ /^\/home\/[^/]+\/Google Drive/i,
+ /^\/home\/[^/]+\/Dropbox/i,
+ /^\/home\/[^/]+\/OneDrive/i,
+ // Windows
+ /^C:\\Users\\[^\\]+\\Google Drive/i,
+ /^C:\\Users\\[^\\]+\\Dropbox/i,
+ /^C:\\Users\\[^\\]+\\OneDrive/i,
+ ];
+
+ for (const pattern of cloudStoragePatterns) {
+ if (pattern.test(resolvedCwd)) {
+ return {
+ enabled: false,
+ message: `Sandbox disabled: Cloud storage path detected (${resolvedCwd}). Sandbox mode may not work correctly with cloud-synced directories.`,
+ };
+ }
+ }
+
+ return { enabled: true };
+}
+
+/**
+ * Validate that a working directory is allowed by ALLOWED_ROOT_DIRECTORY.
+ * This is the centralized security check for ALL AI model invocations.
+ *
+ * @param cwd - The working directory to validate
+ * @throws PathNotAllowedError if the directory is not within ALLOWED_ROOT_DIRECTORY
+ *
+ * This function is called by all create*Options() factory functions to ensure
+ * that AI models can only operate within allowed directories. This applies to:
+ * - All current models (Claude, future models)
+ * - All invocation types (chat, auto-mode, spec generation, etc.)
+ */
+export function validateWorkingDirectory(cwd: string): void {
+ const resolvedCwd = path.resolve(cwd);
+
+ if (!isPathAllowed(resolvedCwd)) {
+ const allowedRoot = getAllowedRootDirectory();
+ throw new PathNotAllowedError(
+ `Working directory "${cwd}" (resolved: ${resolvedCwd}) is not allowed. ` +
+ (allowedRoot
+ ? `Must be within ALLOWED_ROOT_DIRECTORY: ${allowedRoot}`
+ : 'ALLOWED_ROOT_DIRECTORY is configured but path is not within allowed directories.')
+ );
+ }
+}
+
+/**
+ * Tool presets for different use cases
+ */
+export const TOOL_PRESETS = {
+ /** Read-only tools for analysis */
+ readOnly: ['Read', 'Glob', 'Grep'] as const,
+
+ /** Tools for spec generation that needs to read the codebase */
+ specGeneration: ['Read', 'Glob', 'Grep'] as const,
+
+ /** Full tool access for feature implementation */
+ fullAccess: [
+ 'Read',
+ 'Write',
+ 'Edit',
+ 'MultiEdit',
+ 'Glob',
+ 'Grep',
+ 'LS',
+ 'Bash',
+ 'WebSearch',
+ 'WebFetch',
+ 'TodoWrite',
+ 'Task',
+ 'Skill',
+ ] as const,
+
+ /** Tools for chat/interactive mode */
+ chat: [
+ 'Read',
+ 'Write',
+ 'Edit',
+ 'MultiEdit',
+ 'Glob',
+ 'Grep',
+ 'LS',
+ 'Bash',
+ 'WebSearch',
+ 'WebFetch',
+ 'TodoWrite',
+ 'Task',
+ 'Skill',
+ ] as const,
+} as const;
+
+/**
+ * Max turns presets for different use cases
+ */
+export const MAX_TURNS = {
+ /** Quick operations that shouldn't need many iterations */
+ quick: 50,
+
+ /** Standard operations */
+ standard: 100,
+
+ /** Long-running operations like full spec generation */
+ extended: 250,
+
+ /** Very long operations that may require extensive exploration */
+ maximum: 1000,
+} as const;
+
+/**
+ * Model presets for different use cases
+ *
+ * These can be overridden via environment variables:
+ * - AUTOMAKER_MODEL_SPEC: Model for spec generation
+ * - AUTOMAKER_MODEL_FEATURES: Model for feature generation
+ * - AUTOMAKER_MODEL_SUGGESTIONS: Model for suggestions
+ * - AUTOMAKER_MODEL_CHAT: Model for chat
+ * - AUTOMAKER_MODEL_DEFAULT: Fallback model for all operations
+ */
+export function getModelForUseCase(
+ useCase: 'spec' | 'features' | 'suggestions' | 'chat' | 'auto' | 'default',
+ explicitModel?: string
+): string {
+ // Explicit model takes precedence
+ if (explicitModel) {
+ return resolveModelString(explicitModel);
+ }
+
+ // Check environment variable override for this use case
+ const envVarMap: Record = {
+ spec: process.env.AUTOMAKER_MODEL_SPEC,
+ features: process.env.AUTOMAKER_MODEL_FEATURES,
+ suggestions: process.env.AUTOMAKER_MODEL_SUGGESTIONS,
+ chat: process.env.AUTOMAKER_MODEL_CHAT,
+ auto: process.env.AUTOMAKER_MODEL_AUTO,
+ default: process.env.AUTOMAKER_MODEL_DEFAULT,
+ };
+
+ const envModel = envVarMap[useCase] || envVarMap.default;
+ if (envModel) {
+ return resolveModelString(envModel);
+ }
+
+ const defaultModels: Record = {
+ spec: CLAUDE_MODEL_MAP['haiku'], // used to generate app specs
+ features: CLAUDE_MODEL_MAP['haiku'], // used to generate features from app specs
+ suggestions: CLAUDE_MODEL_MAP['haiku'], // used for suggestions
+ chat: CLAUDE_MODEL_MAP['haiku'], // used for chat
+ auto: CLAUDE_MODEL_MAP['opus'], // used to implement kanban cards
+ default: CLAUDE_MODEL_MAP['opus'],
+ };
+
+ return resolveModelString(defaultModels[useCase] || DEFAULT_MODELS.claude);
+}
+
+/**
+ * Base options that apply to all SDK calls
+ * AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation
+ */
+function getBaseOptions(): Partial {
+ return {
+ permissionMode: 'bypassPermissions',
+ allowDangerouslySkipPermissions: true,
+ };
+}
+
+/**
+ * MCP options result
+ */
+interface McpOptions {
+ /** Options to spread for MCP servers */
+ mcpServerOptions: Partial;
+}
+
+/**
+ * Build MCP-related options based on configuration.
+ *
+ * @param config - The SDK options config
+ * @returns Object with MCP server settings to spread into final options
+ */
+function buildMcpOptions(config: CreateSdkOptionsConfig): McpOptions {
+ return {
+ // Include MCP servers if configured
+ mcpServerOptions: config.mcpServers ? { mcpServers: config.mcpServers } : {},
+ };
+}
+
+/**
+ * Build thinking options for SDK configuration.
+ * Converts ThinkingLevel to maxThinkingTokens for the Claude SDK.
+ * For adaptive thinking (Opus 4.6), omits maxThinkingTokens to let the model
+ * decide its own reasoning depth.
+ *
+ * @param thinkingLevel - The thinking level to convert
+ * @returns Object with maxThinkingTokens if thinking is enabled with a budget
+ */
+function buildThinkingOptions(thinkingLevel?: ThinkingLevel): Partial {
+ if (!thinkingLevel || thinkingLevel === 'none') {
+ return {};
+ }
+
+ // Adaptive thinking (Opus 4.6): don't set maxThinkingTokens
+ // The model will use adaptive thinking by default
+ if (thinkingLevel === 'adaptive') {
+ logger.debug(
+ `buildThinkingOptions: thinkingLevel="adaptive" -> no maxThinkingTokens (model decides)`
+ );
+ return {};
+ }
+
+ // Manual budget-based thinking for Haiku/Sonnet
+ const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel);
+ logger.debug(
+ `buildThinkingOptions: thinkingLevel="${thinkingLevel}" -> maxThinkingTokens=${maxThinkingTokens}`
+ );
+ return maxThinkingTokens ? { maxThinkingTokens } : {};
+}
+
+/**
+ * Build system prompt and settingSources based on two independent settings:
+ * - useClaudeCodeSystemPrompt: controls whether to use the 'claude_code' preset as the base prompt
+ * - autoLoadClaudeMd: controls whether to add settingSources for SDK to load CLAUDE.md files
+ *
+ * These combine independently (4 possible states):
+ * 1. Both ON: preset + settingSources (full Claude Code experience)
+ * 2. useClaudeCodeSystemPrompt ON, autoLoadClaudeMd OFF: preset only (no CLAUDE.md auto-loading)
+ * 3. useClaudeCodeSystemPrompt OFF, autoLoadClaudeMd ON: plain string + settingSources
+ * 4. Both OFF: plain string only
+ *
+ * @param config - The SDK options config
+ * @returns Object with systemPrompt and settingSources for SDK options
+ */
+function buildClaudeMdOptions(config: CreateSdkOptionsConfig): {
+ systemPrompt?: string | SystemPromptConfig;
+ settingSources?: Array<'user' | 'project' | 'local'>;
+} {
+ const result: {
+ systemPrompt?: string | SystemPromptConfig;
+ settingSources?: Array<'user' | 'project' | 'local'>;
+ } = {};
+
+ // Determine system prompt format based on useClaudeCodeSystemPrompt
+ if (config.useClaudeCodeSystemPrompt) {
+ // Use Claude Code's built-in system prompt as the base
+ const presetConfig: SystemPromptConfig = {
+ type: 'preset',
+ preset: 'claude_code',
+ };
+ // If there's a custom system prompt, append it to the preset
+ if (config.systemPrompt) {
+ presetConfig.append = config.systemPrompt;
+ }
+ result.systemPrompt = presetConfig;
+ } else {
+ // Standard mode - just pass through the system prompt as-is
+ if (config.systemPrompt) {
+ result.systemPrompt = config.systemPrompt;
+ }
+ }
+
+ // Determine settingSources based on autoLoadClaudeMd
+ if (config.autoLoadClaudeMd) {
+ // Load both user (~/.claude/CLAUDE.md) and project (.claude/CLAUDE.md) settings
+ result.settingSources = ['user', 'project'];
+ }
+
+ return result;
+}
+
+/**
+ * System prompt configuration for SDK options
+ * The 'claude_code' preset provides the system prompt only — it does NOT auto-load
+ * CLAUDE.md files. CLAUDE.md auto-loading is controlled independently by
+ * settingSources (set via autoLoadClaudeMd). These two settings are orthogonal.
+ */
+export interface SystemPromptConfig {
+ /** Use preset mode to select the base system prompt */
+ type: 'preset';
+ /** The preset to use - 'claude_code' uses the Claude Code system prompt */
+ preset: 'claude_code';
+ /** Optional additional prompt to append to the preset */
+ append?: string;
+}
+
+/**
+ * Options configuration for creating SDK options
+ */
+export interface CreateSdkOptionsConfig {
+ /** Working directory for the agent */
+ cwd: string;
+
+ /** Optional explicit model override */
+ model?: string;
+
+ /** Optional session model (used as fallback if explicit model not provided) */
+ sessionModel?: string;
+
+ /** Optional system prompt */
+ systemPrompt?: string;
+
+ /** Optional abort controller for cancellation */
+ abortController?: AbortController;
+
+ /** Optional output format for structured outputs */
+ outputFormat?: {
+ type: 'json_schema';
+ schema: Record;
+ };
+
+ /** Enable auto-loading of CLAUDE.md files via SDK's settingSources */
+ autoLoadClaudeMd?: boolean;
+
+ /** Use Claude Code's built-in system prompt (claude_code preset) as the base prompt */
+ useClaudeCodeSystemPrompt?: boolean;
+
+ /** MCP servers to make available to the agent */
+ mcpServers?: Record;
+
+ /** Extended thinking level for Claude models */
+ thinkingLevel?: ThinkingLevel;
+
+ /** Optional user-configured max turns override (from settings).
+ * When provided, overrides the preset MAX_TURNS for the use case.
+ * Range: 1-2000. */
+ maxTurns?: number;
+}
+
+// Re-export MCP types from @automaker/types for convenience
+export type {
+ McpServerConfig,
+ McpStdioServerConfig,
+ McpSSEServerConfig,
+ McpHttpServerConfig,
+} from '@automaker/types';
+
+/**
+ * Create SDK options for spec generation
+ *
+ * Configuration:
+ * - Uses read-only tools for codebase analysis
+ * - Extended turns for thorough exploration
+ * - Opus model by default (can be overridden)
+ * - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
+ */
+export function createSpecGenerationOptions(config: CreateSdkOptionsConfig): Options {
+ // Validate working directory before creating options
+ validateWorkingDirectory(config.cwd);
+
+ // Build CLAUDE.md auto-loading options if enabled
+ const claudeMdOptions = buildClaudeMdOptions(config);
+
+ // Build thinking options
+ const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
+
+ return {
+ ...getBaseOptions(),
+ // Override permissionMode - spec generation only needs read-only tools
+ // Using "acceptEdits" can cause Claude to write files to unexpected locations
+ // See: https://github.com/AutoMaker-Org/automaker/issues/149
+ permissionMode: 'default',
+ model: getModelForUseCase('spec', config.model),
+ maxTurns: config.maxTurns ?? MAX_TURNS.maximum,
+ cwd: config.cwd,
+ allowedTools: [...TOOL_PRESETS.specGeneration],
+ ...claudeMdOptions,
+ ...thinkingOptions,
+ ...(config.abortController && { abortController: config.abortController }),
+ ...(config.outputFormat && { outputFormat: config.outputFormat }),
+ };
+}
+
+/**
+ * Create SDK options for feature generation from specs
+ *
+ * Configuration:
+ * - Uses read-only tools (just needs to read the spec)
+ * - Quick turns since it's mostly JSON generation
+ * - Sonnet model by default for speed
+ * - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
+ */
+export function createFeatureGenerationOptions(config: CreateSdkOptionsConfig): Options {
+ // Validate working directory before creating options
+ validateWorkingDirectory(config.cwd);
+
+ // Build CLAUDE.md auto-loading options if enabled
+ const claudeMdOptions = buildClaudeMdOptions(config);
+
+ // Build thinking options
+ const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
+
+ return {
+ ...getBaseOptions(),
+ // Override permissionMode - feature generation only needs read-only tools
+ permissionMode: 'default',
+ model: getModelForUseCase('features', config.model),
+ maxTurns: config.maxTurns ?? MAX_TURNS.quick,
+ cwd: config.cwd,
+ allowedTools: [...TOOL_PRESETS.readOnly],
+ ...claudeMdOptions,
+ ...thinkingOptions,
+ ...(config.abortController && { abortController: config.abortController }),
+ };
+}
+
+/**
+ * Create SDK options for generating suggestions
+ *
+ * Configuration:
+ * - Uses read-only tools for analysis
+ * - Standard turns to allow thorough codebase exploration and structured output generation
+ * - Opus model by default for thorough analysis
+ * - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
+ */
+export function createSuggestionsOptions(config: CreateSdkOptionsConfig): Options {
+ // Validate working directory before creating options
+ validateWorkingDirectory(config.cwd);
+
+ // Build CLAUDE.md auto-loading options if enabled
+ const claudeMdOptions = buildClaudeMdOptions(config);
+
+ // Build thinking options
+ const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
+
+ return {
+ ...getBaseOptions(),
+ model: getModelForUseCase('suggestions', config.model),
+ maxTurns: config.maxTurns ?? MAX_TURNS.extended,
+ cwd: config.cwd,
+ allowedTools: [...TOOL_PRESETS.readOnly],
+ ...claudeMdOptions,
+ ...thinkingOptions,
+ ...(config.abortController && { abortController: config.abortController }),
+ ...(config.outputFormat && { outputFormat: config.outputFormat }),
+ };
+}
+
+/**
+ * Create SDK options for chat/interactive mode
+ *
+ * Configuration:
+ * - Full tool access for code modification
+ * - Standard turns for interactive sessions
+ * - Model priority: explicit model > session model > chat default
+ * - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
+ */
+export function createChatOptions(config: CreateSdkOptionsConfig): Options {
+ // Validate working directory before creating options
+ validateWorkingDirectory(config.cwd);
+
+ // Model priority: explicit model > session model > chat default
+ const effectiveModel = config.model || config.sessionModel;
+
+ // Build CLAUDE.md auto-loading options if enabled
+ const claudeMdOptions = buildClaudeMdOptions(config);
+
+ // Build MCP-related options
+ const mcpOptions = buildMcpOptions(config);
+
+ // Build thinking options
+ const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
+
+ return {
+ ...getBaseOptions(),
+ model: getModelForUseCase('chat', effectiveModel),
+ maxTurns: config.maxTurns ?? MAX_TURNS.standard,
+ cwd: config.cwd,
+ allowedTools: [...TOOL_PRESETS.chat],
+ ...claudeMdOptions,
+ ...thinkingOptions,
+ ...(config.abortController && { abortController: config.abortController }),
+ ...mcpOptions.mcpServerOptions,
+ };
+}
+
+/**
+ * Create SDK options for autonomous feature building/implementation
+ *
+ * Configuration:
+ * - Full tool access for code modification and implementation
+ * - Extended turns for thorough feature implementation
+ * - Uses default model (can be overridden)
+ * - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
+ */
+export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
+ // Validate working directory before creating options
+ validateWorkingDirectory(config.cwd);
+
+ // Build CLAUDE.md auto-loading options if enabled
+ const claudeMdOptions = buildClaudeMdOptions(config);
+
+ // Build MCP-related options
+ const mcpOptions = buildMcpOptions(config);
+
+ // Build thinking options
+ const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
+
+ return {
+ ...getBaseOptions(),
+ model: getModelForUseCase('auto', config.model),
+ maxTurns: config.maxTurns ?? MAX_TURNS.maximum,
+ cwd: config.cwd,
+ allowedTools: [...TOOL_PRESETS.fullAccess],
+ ...claudeMdOptions,
+ ...thinkingOptions,
+ ...(config.abortController && { abortController: config.abortController }),
+ ...mcpOptions.mcpServerOptions,
+ };
+}
+
+/**
+ * Create custom SDK options with explicit configuration
+ *
+ * Use this when the preset options don't fit your use case.
+ * When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
+ */
+export function createCustomOptions(
+ config: CreateSdkOptionsConfig & {
+ maxTurns?: number;
+ allowedTools?: readonly string[];
+ }
+): Options {
+ // Validate working directory before creating options
+ validateWorkingDirectory(config.cwd);
+
+ // Build CLAUDE.md auto-loading options if enabled
+ const claudeMdOptions = buildClaudeMdOptions(config);
+
+ // Build MCP-related options
+ const mcpOptions = buildMcpOptions(config);
+
+ // Build thinking options
+ const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
+
+ // For custom options: use explicit allowedTools if provided, otherwise default to readOnly
+ const effectiveAllowedTools = config.allowedTools
+ ? [...config.allowedTools]
+ : [...TOOL_PRESETS.readOnly];
+
+ return {
+ ...getBaseOptions(),
+ model: getModelForUseCase('default', config.model),
+ maxTurns: config.maxTurns ?? MAX_TURNS.maximum,
+ cwd: config.cwd,
+ allowedTools: effectiveAllowedTools,
+ ...claudeMdOptions,
+ ...thinkingOptions,
+ ...(config.abortController && { abortController: config.abortController }),
+ ...mcpOptions.mcpServerOptions,
+ };
+}
diff --git a/temp_repo/apps/server/src/lib/secure-fs.ts b/temp_repo/apps/server/src/lib/secure-fs.ts
new file mode 100644
index 0000000000000000000000000000000000000000..de8dba26de401b0932f1dd6b7e306542fc00e05a
--- /dev/null
+++ b/temp_repo/apps/server/src/lib/secure-fs.ts
@@ -0,0 +1,39 @@
+/**
+ * Re-export secure file system utilities from @automaker/platform
+ * This file exists for backward compatibility with existing imports
+ */
+
+import { secureFs } from '@automaker/platform';
+
+export const {
+ // Async methods
+ access,
+ readFile,
+ writeFile,
+ mkdir,
+ readdir,
+ stat,
+ rm,
+ unlink,
+ copyFile,
+ appendFile,
+ rename,
+ lstat,
+ joinPath,
+ resolvePath,
+ // Sync methods
+ existsSync,
+ readFileSync,
+ writeFileSync,
+ mkdirSync,
+ readdirSync,
+ statSync,
+ accessSync,
+ unlinkSync,
+ rmSync,
+ // Throttling configuration and monitoring
+ configureThrottling,
+ getThrottlingConfig,
+ getPendingOperations,
+ getActiveOperations,
+} = secureFs;
diff --git a/temp_repo/apps/server/src/lib/settings-helpers.ts b/temp_repo/apps/server/src/lib/settings-helpers.ts
new file mode 100644
index 0000000000000000000000000000000000000000..66db5b1ac232a207576166fa9a38c7cc4eaee780
--- /dev/null
+++ b/temp_repo/apps/server/src/lib/settings-helpers.ts
@@ -0,0 +1,954 @@
+/**
+ * Helper utilities for loading settings and context file handling across different parts of the server
+ */
+
+import type { SettingsService } from '../services/settings-service.js';
+import type { ContextFilesResult, ContextFileInfo } from '@automaker/utils';
+import { createLogger } from '@automaker/utils';
+import type {
+ MCPServerConfig,
+ McpServerConfig,
+ PromptCustomization,
+ ClaudeApiProfile,
+ ClaudeCompatibleProvider,
+ PhaseModelKey,
+ PhaseModelEntry,
+ Credentials,
+} from '@automaker/types';
+import { DEFAULT_PHASE_MODELS } from '@automaker/types';
+import {
+ mergeAutoModePrompts,
+ mergeAgentPrompts,
+ mergeBacklogPlanPrompts,
+ mergeEnhancementPrompts,
+ mergeCommitMessagePrompts,
+ mergeTitleGenerationPrompts,
+ mergeIssueValidationPrompts,
+ mergeIdeationPrompts,
+ mergeAppSpecPrompts,
+ mergeContextDescriptionPrompts,
+ mergeSuggestionsPrompts,
+ mergeTaskExecutionPrompts,
+} from '@automaker/prompts';
+
+const logger = createLogger('SettingsHelper');
+
+/** Default number of agent turns used when no value is configured. */
+export const DEFAULT_MAX_TURNS = 10000;
+
+/** Upper bound for the max-turns clamp; values above this are capped here. */
+export const MAX_ALLOWED_TURNS = 10000;
+
+/**
+ * Get the autoLoadClaudeMd setting, with project settings taking precedence over global.
+ * Falls back to global settings and defaults to true when unset.
+ * Returns true if settings service is not available.
+ *
+ * @param projectPath - Path to the project
+ * @param settingsService - Optional settings service instance
+ * @param logPrefix - Prefix for log messages (e.g., '[DescribeImage]')
+ * @returns Promise resolving to the autoLoadClaudeMd setting value
+ */
+export async function getAutoLoadClaudeMdSetting(
+ projectPath: string,
+ settingsService?: SettingsService | null,
+ logPrefix = '[SettingsHelper]'
+): Promise {
+ if (!settingsService) {
+ logger.info(`${logPrefix} SettingsService not available, autoLoadClaudeMd defaulting to true`);
+ return true;
+ }
+
+ try {
+ // Check project settings first (takes precedence)
+ const projectSettings = await settingsService.getProjectSettings(projectPath);
+ if (projectSettings.autoLoadClaudeMd !== undefined) {
+ logger.info(
+ `${logPrefix} autoLoadClaudeMd from project settings: ${projectSettings.autoLoadClaudeMd}`
+ );
+ return projectSettings.autoLoadClaudeMd;
+ }
+
+ // Fall back to global settings
+ const globalSettings = await settingsService.getGlobalSettings();
+ const result = globalSettings.autoLoadClaudeMd ?? true;
+ logger.info(`${logPrefix} autoLoadClaudeMd from global settings: ${result}`);
+ return result;
+ } catch (error) {
+ logger.error(`${logPrefix} Failed to load autoLoadClaudeMd setting:`, error);
+ throw error;
+ }
+}
+
+/**
+ * Get the useClaudeCodeSystemPrompt setting, with project settings taking precedence over global.
+ * Falls back to global settings and defaults to true when unset.
+ * Returns true if settings service is not available.
+ *
+ * @param projectPath - Path to the project
+ * @param settingsService - Optional settings service instance
+ * @param logPrefix - Prefix for log messages (e.g., '[AgentService]')
+ * @returns Promise resolving to the useClaudeCodeSystemPrompt setting value
+ */
+export async function getUseClaudeCodeSystemPromptSetting(
+ projectPath: string,
+ settingsService?: SettingsService | null,
+ logPrefix = '[SettingsHelper]'
+): Promise {
+ if (!settingsService) {
+ logger.info(
+ `${logPrefix} SettingsService not available, useClaudeCodeSystemPrompt defaulting to true`
+ );
+ return true;
+ }
+
+ try {
+ // Check project settings first (takes precedence)
+ const projectSettings = await settingsService.getProjectSettings(projectPath);
+ if (projectSettings.useClaudeCodeSystemPrompt !== undefined) {
+ logger.info(
+ `${logPrefix} useClaudeCodeSystemPrompt from project settings: ${projectSettings.useClaudeCodeSystemPrompt}`
+ );
+ return projectSettings.useClaudeCodeSystemPrompt;
+ }
+
+ // Fall back to global settings
+ const globalSettings = await settingsService.getGlobalSettings();
+ const result = globalSettings.useClaudeCodeSystemPrompt ?? true;
+ logger.info(`${logPrefix} useClaudeCodeSystemPrompt from global settings: ${result}`);
+ return result;
+ } catch (error) {
+ logger.error(`${logPrefix} Failed to load useClaudeCodeSystemPrompt setting:`, error);
+ throw error;
+ }
+}
+
+/**
+ * Get the default max turns setting from global settings.
+ *
+ * Reads the user's configured `defaultMaxTurns` setting, which controls the maximum
+ * number of agent turns (tool-call round-trips) for feature execution.
+ *
+ * @param settingsService - Settings service instance (may be null)
+ * @param logPrefix - Logging prefix for debugging
+ * @returns The user's configured max turns, or {@link DEFAULT_MAX_TURNS} as default
+ */
+export async function getDefaultMaxTurnsSetting(
+ settingsService?: SettingsService | null,
+ logPrefix = '[SettingsHelper]'
+): Promise {
+ if (!settingsService) {
+ logger.info(
+ `${logPrefix} SettingsService not available, using default maxTurns=${DEFAULT_MAX_TURNS}`
+ );
+ return DEFAULT_MAX_TURNS;
+ }
+
+ try {
+ const globalSettings = await settingsService.getGlobalSettings();
+ const raw = globalSettings.defaultMaxTurns;
+ const result = Number.isFinite(raw) ? (raw as number) : DEFAULT_MAX_TURNS;
+ // Clamp to valid range
+ const clamped = Math.max(1, Math.min(MAX_ALLOWED_TURNS, Math.floor(result)));
+ logger.debug(`${logPrefix} defaultMaxTurns from global settings: ${clamped}`);
+ return clamped;
+ } catch (error) {
+ logger.error(`${logPrefix} Failed to load defaultMaxTurns setting:`, error);
+ return DEFAULT_MAX_TURNS;
+ }
+}
+
+/**
+ * Filters out CLAUDE.md from context files when autoLoadClaudeMd is enabled
+ * and rebuilds the formatted prompt without it.
+ *
+ * When autoLoadClaudeMd is true, the SDK handles CLAUDE.md loading via settingSources,
+ * so we need to exclude it from the manual context loading to avoid duplication.
+ * Other context files (CODE_QUALITY.md, CONVENTIONS.md, etc.) are preserved.
+ *
+ * @param contextResult - Result from loadContextFiles
+ * @param autoLoadClaudeMd - Whether SDK auto-loading is enabled
+ * @returns Filtered context prompt (empty string if no non-CLAUDE.md files)
+ */
+export function filterClaudeMdFromContext(
+ contextResult: ContextFilesResult,
+ autoLoadClaudeMd: boolean
+): string {
+ // If autoLoadClaudeMd is disabled, return the original prompt unchanged
+ if (!autoLoadClaudeMd || contextResult.files.length === 0) {
+ return contextResult.formattedPrompt;
+ }
+
+ // Filter out CLAUDE.md (case-insensitive)
+ const nonClaudeFiles = contextResult.files.filter((f) => f.name.toLowerCase() !== 'claude.md');
+
+ // If all files were CLAUDE.md, return empty string
+ if (nonClaudeFiles.length === 0) {
+ return '';
+ }
+
+ // Rebuild prompt without CLAUDE.md using the same format as loadContextFiles
+ const formattedFiles = nonClaudeFiles.map((file) => formatContextFileEntry(file));
+
+ return `# Project Context Files
+
+The following context files provide project-specific rules, conventions, and guidelines.
+Each file serves a specific purpose - use the description to understand when to reference it.
+If you need more details about a context file, you can read the full file at the path provided.
+
+**IMPORTANT**: You MUST follow the rules and conventions specified in these files.
+- Follow ALL commands exactly as shown (e.g., if the project uses \`pnpm\`, NEVER use \`npm\` or \`npx\`)
+- Follow ALL coding conventions, commit message formats, and architectural patterns specified
+- Reference these rules before running ANY shell commands or making commits
+
+---
+
+${formattedFiles.join('\n\n---\n\n')}
+
+---
+
+**REMINDER**: Before taking any action, verify you are following the conventions specified above.
+`;
+}
+
+/**
+ * Format a single context file entry for the prompt
+ * (Matches the format used in @automaker/utils/context-loader.ts)
+ */
+function formatContextFileEntry(file: ContextFileInfo): string {
+ const header = `## ${file.name}`;
+ const pathInfo = `**Path:** \`${file.path}\``;
+ const descriptionInfo = file.description ? `\n**Purpose:** ${file.description}` : '';
+ return `${header}\n${pathInfo}${descriptionInfo}\n\n${file.content}`;
+}
+
+/**
+ * Get enabled MCP servers from global settings, converted to SDK format.
+ * Returns an empty object if settings service is not available or no servers are configured.
+ *
+ * @param settingsService - Optional settings service instance
+ * @param logPrefix - Prefix for log messages (e.g., '[AgentService]')
+ * @returns Promise resolving to MCP servers in SDK format (keyed by name)
+ */
+export async function getMCPServersFromSettings(
+ settingsService?: SettingsService | null,
+ logPrefix = '[SettingsHelper]'
+): Promise> {
+ if (!settingsService) {
+ return {};
+ }
+
+ try {
+ const globalSettings = await settingsService.getGlobalSettings();
+ const mcpServers = globalSettings.mcpServers || [];
+
+ // Filter to only enabled servers and convert to SDK format
+ const enabledServers = mcpServers.filter((s) => s.enabled !== false);
+
+ if (enabledServers.length === 0) {
+ return {};
+ }
+
+ // Convert settings format to SDK format (keyed by name)
+ const sdkServers: Record = {};
+ for (const server of enabledServers) {
+ sdkServers[server.name] = convertToSdkFormat(server);
+ }
+
+ logger.info(
+ `${logPrefix} Loaded ${enabledServers.length} MCP server(s): ${enabledServers.map((s) => s.name).join(', ')}`
+ );
+
+ return sdkServers;
+ } catch (error) {
+ logger.error(`${logPrefix} Failed to load MCP servers setting:`, error);
+ return {};
+ }
+}
+
+/**
+ * Convert a settings MCPServerConfig to SDK McpServerConfig format.
+ * Validates required fields and throws informative errors if missing.
+ */
+function convertToSdkFormat(server: MCPServerConfig): McpServerConfig {
+ if (server.type === 'sse') {
+ if (!server.url) {
+ throw new Error(`SSE MCP server "${server.name}" is missing a URL.`);
+ }
+ return {
+ type: 'sse',
+ url: server.url,
+ headers: server.headers,
+ };
+ }
+
+ if (server.type === 'http') {
+ if (!server.url) {
+ throw new Error(`HTTP MCP server "${server.name}" is missing a URL.`);
+ }
+ return {
+ type: 'http',
+ url: server.url,
+ headers: server.headers,
+ };
+ }
+
+ // Default to stdio
+ if (!server.command) {
+ throw new Error(`Stdio MCP server "${server.name}" is missing a command.`);
+ }
+ return {
+ type: 'stdio',
+ command: server.command,
+ args: server.args,
+ env: server.env,
+ };
+}
+
+/**
+ * Get prompt customization from global settings and merge with defaults.
+ * Returns prompts merged with built-in defaults - custom prompts override defaults.
+ *
+ * @param settingsService - Optional settings service instance
+ * @param logPrefix - Prefix for log messages
+ * @returns Promise resolving to merged prompts for all categories
+ */
+export async function getPromptCustomization(
+ settingsService?: SettingsService | null,
+ logPrefix = '[PromptHelper]'
+): Promise<{
+ autoMode: ReturnType;
+ agent: ReturnType;
+ backlogPlan: ReturnType;
+ enhancement: ReturnType;
+ commitMessage: ReturnType;
+ titleGeneration: ReturnType;
+ issueValidation: ReturnType;
+ ideation: ReturnType;
+ appSpec: ReturnType;
+ contextDescription: ReturnType;
+ suggestions: ReturnType;
+ taskExecution: ReturnType;
+}> {
+ let customization: PromptCustomization = {};
+
+ if (settingsService) {
+ try {
+ const globalSettings = await settingsService.getGlobalSettings();
+ customization = globalSettings.promptCustomization || {};
+ logger.info(`${logPrefix} Loaded prompt customization from settings`);
+ } catch (error) {
+ logger.error(`${logPrefix} Failed to load prompt customization:`, error);
+ // Fall through to use empty customization (all defaults)
+ }
+ } else {
+ logger.info(`${logPrefix} SettingsService not available, using default prompts`);
+ }
+
+ return {
+ autoMode: mergeAutoModePrompts(customization.autoMode),
+ agent: mergeAgentPrompts(customization.agent),
+ backlogPlan: mergeBacklogPlanPrompts(customization.backlogPlan),
+ enhancement: mergeEnhancementPrompts(customization.enhancement),
+ commitMessage: mergeCommitMessagePrompts(customization.commitMessage),
+ titleGeneration: mergeTitleGenerationPrompts(customization.titleGeneration),
+ issueValidation: mergeIssueValidationPrompts(customization.issueValidation),
+ ideation: mergeIdeationPrompts(customization.ideation),
+ appSpec: mergeAppSpecPrompts(customization.appSpec),
+ contextDescription: mergeContextDescriptionPrompts(customization.contextDescription),
+ suggestions: mergeSuggestionsPrompts(customization.suggestions),
+ taskExecution: mergeTaskExecutionPrompts(customization.taskExecution),
+ };
+}
+
+/**
+ * Get Skills configuration from settings.
+ * Returns configuration for enabling skills and which sources to load from.
+ *
+ * @param settingsService - Settings service instance
+ * @returns Skills configuration with enabled state, sources, and tool inclusion flag
+ */
+export async function getSkillsConfiguration(settingsService: SettingsService): Promise<{
+ enabled: boolean;
+ sources: Array<'user' | 'project'>;
+ shouldIncludeInTools: boolean;
+}> {
+ const settings = await settingsService.getGlobalSettings();
+ const enabled = settings.enableSkills ?? true; // Default enabled
+ const sources = settings.skillsSources ?? ['user', 'project']; // Default both sources
+
+ return {
+ enabled,
+ sources,
+ shouldIncludeInTools: enabled && sources.length > 0,
+ };
+}
+
+/**
+ * Get Subagents configuration from settings.
+ * Returns configuration for enabling subagents and which sources to load from.
+ *
+ * @param settingsService - Settings service instance
+ * @returns Subagents configuration with enabled state, sources, and tool inclusion flag
+ */
+export async function getSubagentsConfiguration(settingsService: SettingsService): Promise<{
+ enabled: boolean;
+ sources: Array<'user' | 'project'>;
+ shouldIncludeInTools: boolean;
+}> {
+ const settings = await settingsService.getGlobalSettings();
+ const enabled = settings.enableSubagents ?? true; // Default enabled
+ const sources = settings.subagentsSources ?? ['user', 'project']; // Default both sources
+
+ return {
+ enabled,
+ sources,
+ shouldIncludeInTools: enabled && sources.length > 0,
+ };
+}
+
+/**
+ * Get custom subagents from settings, merging global and project-level definitions.
+ * Project-level subagents take precedence over global ones with the same name.
+ *
+ * @param settingsService - Settings service instance
+ * @param projectPath - Path to the project for loading project-specific subagents
+ * @returns Record of agent names to definitions, or undefined if none configured
+ */
+export async function getCustomSubagents(
+ settingsService: SettingsService,
+ projectPath?: string
+): Promise | undefined> {
+ // Get global subagents
+ const globalSettings = await settingsService.getGlobalSettings();
+ const globalSubagents = globalSettings.customSubagents || {};
+
+ // If no project path, return only global subagents
+ if (!projectPath) {
+ return Object.keys(globalSubagents).length > 0 ? globalSubagents : undefined;
+ }
+
+ // Get project-specific subagents
+ const projectSettings = await settingsService.getProjectSettings(projectPath);
+ const projectSubagents = projectSettings.customSubagents || {};
+
+ // Merge: project-level takes precedence
+ const merged = {
+ ...globalSubagents,
+ ...projectSubagents,
+ };
+
+ return Object.keys(merged).length > 0 ? merged : undefined;
+}
+
+/** Result from getActiveClaudeApiProfile */
+export interface ActiveClaudeApiProfileResult {
+ /** The active profile, or undefined if using direct Anthropic API */
+ profile: ClaudeApiProfile | undefined;
+ /** Credentials for resolving 'credentials' apiKeySource */
+ credentials: import('@automaker/types').Credentials | undefined;
+}
+
+/**
+ * Get the active Claude API profile and credentials from settings.
+ * Checks project settings first for per-project overrides, then falls back to global settings.
+ * Returns both the profile and credentials for resolving 'credentials' apiKeySource.
+ *
+ * @deprecated Use getProviderById and getPhaseModelWithOverrides instead for the new provider system.
+ * This function is kept for backward compatibility during migration.
+ *
+ * @param settingsService - Optional settings service instance
+ * @param logPrefix - Prefix for log messages (e.g., '[AgentService]')
+ * @param projectPath - Optional project path for per-project override
+ * @returns Promise resolving to object with profile and credentials
+ */
+export async function getActiveClaudeApiProfile(
+ settingsService?: SettingsService | null,
+ logPrefix = '[SettingsHelper]',
+ projectPath?: string
+): Promise {
+ if (!settingsService) {
+ return { profile: undefined, credentials: undefined };
+ }
+
+ try {
+ const globalSettings = await settingsService.getGlobalSettings();
+ const credentials = await settingsService.getCredentials();
+ const profiles = globalSettings.claudeApiProfiles || [];
+
+ // Check for project-level override first
+ let activeProfileId: string | null | undefined;
+ let isProjectOverride = false;
+
+ if (projectPath) {
+ const projectSettings = await settingsService.getProjectSettings(projectPath);
+ // undefined = use global, null = explicit no profile, string = specific profile
+ if (projectSettings.activeClaudeApiProfileId !== undefined) {
+ activeProfileId = projectSettings.activeClaudeApiProfileId;
+ isProjectOverride = true;
+ }
+ }
+
+ // Fall back to global if project doesn't specify
+ if (activeProfileId === undefined && !isProjectOverride) {
+ activeProfileId = globalSettings.activeClaudeApiProfileId;
+ }
+
+ // No active profile selected - use direct Anthropic API
+ if (!activeProfileId) {
+ if (isProjectOverride && activeProfileId === null) {
+ logger.info(`${logPrefix} Project explicitly using Direct Anthropic API`);
+ }
+ return { profile: undefined, credentials };
+ }
+
+ // Find the active profile by ID
+ const activeProfile = profiles.find((p) => p.id === activeProfileId);
+
+ if (activeProfile) {
+ const overrideSuffix = isProjectOverride ? ' (project override)' : '';
+ logger.info(`${logPrefix} Using Claude API profile: ${activeProfile.name}${overrideSuffix}`);
+ return { profile: activeProfile, credentials };
+ } else {
+ logger.warn(
+ `${logPrefix} Active profile ID "${activeProfileId}" not found, falling back to direct Anthropic API`
+ );
+ return { profile: undefined, credentials };
+ }
+ } catch (error) {
+ logger.error(`${logPrefix} Failed to load Claude API profile:`, error);
+ return { profile: undefined, credentials: undefined };
+ }
+}
+
+// ============================================================================
+// New Provider System Helpers
+// ============================================================================
+
+/** Result from getProviderById */
+export interface ProviderByIdResult {
+ /** The provider, or undefined if not found */
+ provider: ClaudeCompatibleProvider | undefined;
+ /** Credentials for resolving 'credentials' apiKeySource */
+ credentials: Credentials | undefined;
+}
+
+/**
+ * Get a ClaudeCompatibleProvider by its ID.
+ * Returns the provider configuration and credentials for API key resolution.
+ *
+ * @param providerId - The provider ID to look up
+ * @param settingsService - Settings service instance
+ * @param logPrefix - Prefix for log messages
+ * @returns Promise resolving to object with provider and credentials
+ */
+export async function getProviderById(
+ providerId: string,
+ settingsService: SettingsService,
+ logPrefix = '[SettingsHelper]'
+): Promise {
+ try {
+ const globalSettings = await settingsService.getGlobalSettings();
+ const credentials = await settingsService.getCredentials();
+ const providers = globalSettings.claudeCompatibleProviders || [];
+
+ const provider = providers.find((p) => p.id === providerId);
+
+ if (provider) {
+ if (provider.enabled === false) {
+ logger.warn(`${logPrefix} Provider "${provider.name}" (${providerId}) is disabled`);
+ } else {
+ logger.debug(`${logPrefix} Found provider: ${provider.name}`);
+ }
+ return { provider, credentials };
+ } else {
+ logger.warn(`${logPrefix} Provider not found: ${providerId}`);
+ return { provider: undefined, credentials };
+ }
+ } catch (error) {
+ logger.error(`${logPrefix} Failed to load provider by ID:`, error);
+ return { provider: undefined, credentials: undefined };
+ }
+}
+
+/** Result from getPhaseModelWithOverrides */
+export interface PhaseModelWithOverridesResult {
+ /** The resolved phase model entry */
+ phaseModel: PhaseModelEntry;
+ /** Whether a project override was applied */
+ isProjectOverride: boolean;
+ /** The provider if providerId is set and found */
+ provider: ClaudeCompatibleProvider | undefined;
+ /** Credentials for API key resolution */
+ credentials: Credentials | undefined;
+}
+
+/**
+ * Get the phase model configuration for a specific phase, applying project overrides if available.
+ * Also resolves the provider if the phase model has a providerId.
+ *
+ * @param phase - The phase key (e.g., 'enhancementModel', 'specGenerationModel')
+ * @param settingsService - Optional settings service instance (returns defaults if undefined)
+ * @param projectPath - Optional project path for checking overrides
+ * @param logPrefix - Prefix for log messages
+ * @returns Promise resolving to phase model with provider info
+ */
+export async function getPhaseModelWithOverrides(
+ phase: PhaseModelKey,
+ settingsService?: SettingsService | null,
+ projectPath?: string,
+ logPrefix = '[SettingsHelper]'
+): Promise {
+ // Handle undefined settingsService gracefully
+ if (!settingsService) {
+ logger.info(`${logPrefix} SettingsService not available, using default for ${phase}`);
+ return {
+ phaseModel: DEFAULT_PHASE_MODELS[phase] || { model: 'sonnet' },
+ isProjectOverride: false,
+ provider: undefined,
+ credentials: undefined,
+ };
+ }
+
+ try {
+ const globalSettings = await settingsService.getGlobalSettings();
+ const credentials = await settingsService.getCredentials();
+ const globalPhaseModels = globalSettings.phaseModels || {};
+
+ // Start with global phase model
+ let phaseModel = globalPhaseModels[phase];
+ let isProjectOverride = false;
+
+ // Check for project override
+ if (projectPath) {
+ const projectSettings = await settingsService.getProjectSettings(projectPath);
+ const projectOverrides = projectSettings.phaseModelOverrides || {};
+
+ if (projectOverrides[phase]) {
+ phaseModel = projectOverrides[phase];
+ isProjectOverride = true;
+ logger.debug(`${logPrefix} Using project override for ${phase}`);
+ }
+ }
+
+ // If no phase model found, use per-phase default
+ if (!phaseModel) {
+ phaseModel = DEFAULT_PHASE_MODELS[phase] || { model: 'sonnet' };
+ logger.debug(`${logPrefix} No ${phase} configured, using default: ${phaseModel.model}`);
+ }
+
+ // Resolve provider if providerId is set
+ let provider: ClaudeCompatibleProvider | undefined;
+ if (phaseModel.providerId) {
+ const providers = globalSettings.claudeCompatibleProviders || [];
+ provider = providers.find((p) => p.id === phaseModel.providerId);
+
+ if (provider) {
+ if (provider.enabled === false) {
+ logger.warn(
+ `${logPrefix} Provider "${provider.name}" for ${phase} is disabled, falling back to direct API`
+ );
+ provider = undefined;
+ } else {
+ logger.debug(`${logPrefix} Using provider "${provider.name}" for ${phase}`);
+ }
+ } else {
+ logger.warn(
+ `${logPrefix} Provider ${phaseModel.providerId} not found for ${phase}, falling back to direct API`
+ );
+ }
+ }
+
+ return {
+ phaseModel,
+ isProjectOverride,
+ provider,
+ credentials,
+ };
+ } catch (error) {
+ logger.error(`${logPrefix} Failed to get phase model with overrides:`, error);
+ // Return a safe default
+ return {
+ phaseModel: { model: 'sonnet' },
+ isProjectOverride: false,
+ provider: undefined,
+ credentials: undefined,
+ };
+ }
+}
+
+/** Result from getProviderByModelId */
+export interface ProviderByModelIdResult {
+ /** The provider that contains this model, or undefined if not found */
+ provider: ClaudeCompatibleProvider | undefined;
+ /** The model configuration if found */
+ modelConfig: import('@automaker/types').ProviderModel | undefined;
+ /** Credentials for API key resolution */
+ credentials: Credentials | undefined;
+ /** The resolved Claude model ID to use for API calls (from mapsToClaudeModel) */
+ resolvedModel: string | undefined;
+}
+
+/** Result from resolveProviderContext */
+export interface ProviderContextResult {
+ /** The provider configuration */
+ provider: ClaudeCompatibleProvider | undefined;
+ /** Credentials for API key resolution */
+ credentials: Credentials | undefined;
+ /** The resolved Claude model ID for SDK configuration */
+ resolvedModel: string | undefined;
+ /** The original model config from the provider if found */
+ modelConfig: import('@automaker/types').ProviderModel | undefined;
+}
+
+/**
+ * Checks if a provider is enabled.
+ * Providers with enabled: undefined are treated as enabled (default state).
+ * Only explicitly set enabled: false means the provider is disabled.
+ */
+function isProviderEnabled(provider: ClaudeCompatibleProvider): boolean {
+ return provider.enabled !== false;
+}
+
+/**
+ * Finds a model config in a provider's models array by ID (case-insensitive).
+ */
+function findModelInProvider(
+ provider: ClaudeCompatibleProvider,
+ modelId: string
+): import('@automaker/types').ProviderModel | undefined {
+ return provider.models?.find(
+ (m) => m.id === modelId || m.id.toLowerCase() === modelId.toLowerCase()
+ );
+}
+
+/**
+ * Resolves the provider and Claude-compatible model configuration.
+ *
+ * This is the central logic for resolving provider context, supporting:
+ * 1. Explicit lookup by providerId (most reliable for persistence)
+ * 2. Fallback lookup by modelId across all enabled providers
+ * 3. Resolution of mapsToClaudeModel for SDK configuration
+ *
+ * @param settingsService - Settings service instance
+ * @param modelId - The model ID to resolve
+ * @param providerId - Optional explicit provider ID
+ * @param logPrefix - Prefix for log messages
+ * @returns Promise resolving to the provider context
+ */
+export async function resolveProviderContext(
+ settingsService: SettingsService,
+ modelId: string,
+ providerId?: string,
+ logPrefix = '[SettingsHelper]'
+): Promise {
+ try {
+ const globalSettings = await settingsService.getGlobalSettings();
+ const credentials = await settingsService.getCredentials();
+ const providers = globalSettings.claudeCompatibleProviders || [];
+
+ logger.debug(
+ `${logPrefix} Resolving provider context: modelId="${modelId}", providerId="${providerId ?? 'none'}", providers count=${providers.length}`
+ );
+
+ let provider: ClaudeCompatibleProvider | undefined;
+ let modelConfig: import('@automaker/types').ProviderModel | undefined;
+
+ // 1. Try resolving by explicit providerId first (most reliable)
+ if (providerId) {
+ provider = providers.find((p) => p.id === providerId);
+ if (provider) {
+ if (!isProviderEnabled(provider)) {
+ logger.warn(
+ `${logPrefix} Explicitly requested provider "${provider.name}" (${providerId}) is disabled (enabled=${provider.enabled})`
+ );
+ } else {
+ logger.debug(
+ `${logPrefix} Found provider "${provider.name}" (${providerId}), enabled=${provider.enabled ?? 'undefined (treated as enabled)'}`
+ );
+ // Find the model config within this provider to check for mappings
+ modelConfig = findModelInProvider(provider, modelId);
+ if (!modelConfig && provider.models && provider.models.length > 0) {
+ logger.debug(
+ `${logPrefix} Model "${modelId}" not found in provider "${provider.name}". Available models: ${provider.models.map((m) => m.id).join(', ')}`
+ );
+ }
+ }
+ } else {
+ logger.warn(
+ `${logPrefix} Explicitly requested provider "${providerId}" not found. Available providers: ${providers.map((p) => p.id).join(', ')}`
+ );
+ }
+ }
+
+ // 2. Fallback to model-based lookup across all providers if modelConfig not found
+ // Note: We still search even if provider was found, to get the modelConfig for mapping
+ if (!modelConfig) {
+ for (const p of providers) {
+ if (!isProviderEnabled(p) || p.id === providerId) continue; // Skip disabled or already checked
+
+ const config = findModelInProvider(p, modelId);
+
+ if (config) {
+ // Only override provider if we didn't find one by explicit ID
+ if (!provider) {
+ provider = p;
+ }
+ modelConfig = config;
+ logger.debug(`${logPrefix} Found model "${modelId}" in provider "${p.name}" (fallback)`);
+ break;
+ }
+ }
+ }
+
+ // 3. Resolve the mapped Claude model if specified
+ let resolvedModel: string | undefined;
+ if (modelConfig?.mapsToClaudeModel) {
+ const { resolveModelString } = await import('@automaker/model-resolver');
+ resolvedModel = resolveModelString(modelConfig.mapsToClaudeModel);
+ logger.debug(
+ `${logPrefix} Model "${modelId}" maps to Claude model "${modelConfig.mapsToClaudeModel}" -> "${resolvedModel}"`
+ );
+ }
+
+ // Log final result for debugging
+ logger.debug(
+ `${logPrefix} Provider context resolved: provider=${provider?.name ?? 'none'}, modelConfig=${modelConfig ? 'found' : 'not found'}, resolvedModel=${resolvedModel ?? modelId}`
+ );
+
+ return { provider, credentials, resolvedModel, modelConfig };
+ } catch (error) {
+ logger.error(`${logPrefix} Failed to resolve provider context:`, error);
+ return {
+ provider: undefined,
+ credentials: undefined,
+ resolvedModel: undefined,
+ modelConfig: undefined,
+ };
+ }
+}
+
+/**
+ * Find a ClaudeCompatibleProvider by one of its model IDs.
+ * Searches through all enabled providers to find one that contains the specified model.
+ * This is useful when you have a model string from the UI but need the provider config.
+ *
+ * Also resolves the `mapsToClaudeModel` field to get the actual Claude model ID to use
+ * when calling the API (e.g., "GLM-4.5-Air" -> "claude-haiku-4-5").
+ *
+ * @param modelId - The model ID to search for (e.g., "GLM-4.7", "MiniMax-M2.1")
+ * @param settingsService - Settings service instance
+ * @param logPrefix - Prefix for log messages
+ * @returns Promise resolving to object with provider, model config, credentials, and resolved model
+ */
+export async function getProviderByModelId(
+ modelId: string,
+ settingsService: SettingsService,
+ logPrefix = '[SettingsHelper]'
+): Promise {
+ try {
+ const globalSettings = await settingsService.getGlobalSettings();
+ const credentials = await settingsService.getCredentials();
+ const providers = globalSettings.claudeCompatibleProviders || [];
+
+ // Search through all enabled providers for this model
+ for (const provider of providers) {
+ // Skip disabled providers
+ if (provider.enabled === false) {
+ continue;
+ }
+
+ // Check if this provider has the model
+ const modelConfig = provider.models?.find(
+ (m) => m.id === modelId || m.id.toLowerCase() === modelId.toLowerCase()
+ );
+
+ if (modelConfig) {
+ logger.info(`${logPrefix} Found model "${modelId}" in provider "${provider.name}"`);
+
+ // Resolve the mapped Claude model if specified
+ let resolvedModel: string | undefined;
+ if (modelConfig.mapsToClaudeModel) {
+ // Import resolveModelString to convert alias to full model ID
+ const { resolveModelString } = await import('@automaker/model-resolver');
+ resolvedModel = resolveModelString(modelConfig.mapsToClaudeModel);
+ logger.info(
+ `${logPrefix} Model "${modelId}" maps to Claude model "${modelConfig.mapsToClaudeModel}" -> "${resolvedModel}"`
+ );
+ }
+
+ return { provider, modelConfig, credentials, resolvedModel };
+ }
+ }
+
+ // Model not found in any provider
+ logger.debug(`${logPrefix} Model "${modelId}" not found in any provider`);
+ return {
+ provider: undefined,
+ modelConfig: undefined,
+ credentials: undefined,
+ resolvedModel: undefined,
+ };
+ } catch (error) {
+ logger.error(`${logPrefix} Failed to find provider by model ID:`, error);
+ return {
+ provider: undefined,
+ modelConfig: undefined,
+ credentials: undefined,
+ resolvedModel: undefined,
+ };
+ }
+}
+
+/**
+ * Get all enabled provider models for use in model dropdowns.
+ * Returns models from all enabled ClaudeCompatibleProviders.
+ *
+ * @param settingsService - Settings service instance
+ * @param logPrefix - Prefix for log messages
+ * @returns Promise resolving to array of provider models with their provider info
+ */
+export async function getAllProviderModels(
+ settingsService: SettingsService,
+ logPrefix = '[SettingsHelper]'
+): Promise<
+ Array<{
+ providerId: string;
+ providerName: string;
+ model: import('@automaker/types').ProviderModel;
+ }>
+> {
+ try {
+ const globalSettings = await settingsService.getGlobalSettings();
+ const providers = globalSettings.claudeCompatibleProviders || [];
+
+ const allModels: Array<{
+ providerId: string;
+ providerName: string;
+ model: import('@automaker/types').ProviderModel;
+ }> = [];
+
+ for (const provider of providers) {
+ // Skip disabled providers
+ if (provider.enabled === false) {
+ continue;
+ }
+
+ for (const model of provider.models || []) {
+ allModels.push({
+ providerId: provider.id,
+ providerName: provider.name,
+ model,
+ });
+ }
+ }
+
+ logger.debug(
+ `${logPrefix} Found ${allModels.length} models from ${providers.length} providers`
+ );
+ return allModels;
+ } catch (error) {
+ logger.error(`${logPrefix} Failed to get all provider models:`, error);
+ return [];
+ }
+}
diff --git a/temp_repo/apps/server/src/lib/terminal-themes-data.ts b/temp_repo/apps/server/src/lib/terminal-themes-data.ts
new file mode 100644
index 0000000000000000000000000000000000000000..854bf1a80e91b730ab90c13866dddf486f656a96
--- /dev/null
+++ b/temp_repo/apps/server/src/lib/terminal-themes-data.ts
@@ -0,0 +1,25 @@
+/**
+ * Terminal Theme Data - Re-export terminal themes from platform package
+ *
+ * This module re-exports terminal theme data for use in the server.
+ */
+
+import { terminalThemeColors, getTerminalThemeColors as getThemeColors } from '@automaker/platform';
+import type { ThemeMode } from '@automaker/types';
+import type { TerminalTheme } from '@automaker/platform';
+
+/**
+ * Get terminal theme colors for a given theme mode
+ */
+export function getTerminalThemeColors(theme: ThemeMode): TerminalTheme {
+ return getThemeColors(theme);
+}
+
+/**
+ * Get all terminal themes
+ */
+export function getAllTerminalThemes(): Record {
+ return terminalThemeColors;
+}
+
+export default terminalThemeColors;
diff --git a/temp_repo/apps/server/src/lib/validation-storage.ts b/temp_repo/apps/server/src/lib/validation-storage.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1ca66653402a323e45834a8c35d95c3dc1aa008c
--- /dev/null
+++ b/temp_repo/apps/server/src/lib/validation-storage.ts
@@ -0,0 +1,181 @@
+/**
+ * Validation Storage - CRUD operations for GitHub issue validation results
+ *
+ * Stores validation results in .automaker/validations/{issueNumber}/validation.json
+ * Results include the validation verdict, metadata, and timestamp for cache invalidation.
+ */
+
+import * as secureFs from './secure-fs.js';
+import { getValidationsDir, getValidationDir, getValidationPath } from '@automaker/platform';
+import type { StoredValidation } from '@automaker/types';
+
+// Re-export StoredValidation for convenience
+export type { StoredValidation };
+
+/** Number of hours before a validation is considered stale */
+const VALIDATION_CACHE_TTL_HOURS = 24;
+
+/**
+ * Write validation result to storage
+ *
+ * Creates the validation directory if needed and stores the result as JSON.
+ *
+ * @param projectPath - Absolute path to project directory
+ * @param issueNumber - GitHub issue number
+ * @param data - Validation data to store
+ */
+export async function writeValidation(
+ projectPath: string,
+ issueNumber: number,
+ data: StoredValidation
+): Promise {
+ const validationDir = getValidationDir(projectPath, issueNumber);
+ const validationPath = getValidationPath(projectPath, issueNumber);
+
+ // Ensure directory exists
+ await secureFs.mkdir(validationDir, { recursive: true });
+
+ // Write validation result
+ await secureFs.writeFile(validationPath, JSON.stringify(data, null, 2), 'utf-8');
+}
+
+/**
+ * Read validation result from storage
+ *
+ * @param projectPath - Absolute path to project directory
+ * @param issueNumber - GitHub issue number
+ * @returns Stored validation or null if not found
+ */
+export async function readValidation(
+ projectPath: string,
+ issueNumber: number
+): Promise {
+ try {
+ const validationPath = getValidationPath(projectPath, issueNumber);
+ const content = (await secureFs.readFile(validationPath, 'utf-8')) as string;
+ return JSON.parse(content) as StoredValidation;
+ } catch {
+ // File doesn't exist or can't be read
+ return null;
+ }
+}
+
+/**
+ * Get all stored validations for a project
+ *
+ * @param projectPath - Absolute path to project directory
+ * @returns Array of stored validations
+ */
+export async function getAllValidations(projectPath: string): Promise {
+ const validationsDir = getValidationsDir(projectPath);
+
+ try {
+ const dirs = await secureFs.readdir(validationsDir, { withFileTypes: true });
+
+ // Read all validation files in parallel for better performance
+ const promises = dirs
+ .filter((dir) => dir.isDirectory())
+ .map((dir) => {
+ const issueNumber = parseInt(dir.name, 10);
+ if (!isNaN(issueNumber)) {
+ return readValidation(projectPath, issueNumber);
+ }
+ return Promise.resolve(null);
+ });
+
+ const results = await Promise.all(promises);
+ const validations = results.filter((v): v is StoredValidation => v !== null);
+
+ // Sort by issue number
+ validations.sort((a, b) => a.issueNumber - b.issueNumber);
+
+ return validations;
+ } catch {
+ // Directory doesn't exist
+ return [];
+ }
+}
+
+/**
+ * Delete a validation from storage
+ *
+ * @param projectPath - Absolute path to project directory
+ * @param issueNumber - GitHub issue number
+ * @returns true if validation was deleted, false if not found
+ */
+export async function deleteValidation(projectPath: string, issueNumber: number): Promise {
+ try {
+ const validationDir = getValidationDir(projectPath, issueNumber);
+ await secureFs.rm(validationDir, { recursive: true, force: true });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Check if a validation is stale (older than TTL)
+ *
+ * @param validation - Stored validation to check
+ * @returns true if validation is older than 24 hours
+ */
+export function isValidationStale(validation: StoredValidation): boolean {
+ const validatedAt = new Date(validation.validatedAt);
+ const now = new Date();
+ const hoursDiff = (now.getTime() - validatedAt.getTime()) / (1000 * 60 * 60);
+ return hoursDiff > VALIDATION_CACHE_TTL_HOURS;
+}
+
+/**
+ * Get validation with freshness info
+ *
+ * @param projectPath - Absolute path to project directory
+ * @param issueNumber - GitHub issue number
+ * @returns Object with validation and isStale flag, or null if not found
+ */
+export async function getValidationWithFreshness(
+ projectPath: string,
+ issueNumber: number
+): Promise<{ validation: StoredValidation; isStale: boolean } | null> {
+ const validation = await readValidation(projectPath, issueNumber);
+ if (!validation) {
+ return null;
+ }
+
+ return {
+ validation,
+ isStale: isValidationStale(validation),
+ };
+}
+
+/**
+ * Mark a validation as viewed by the user
+ *
+ * @param projectPath - Absolute path to project directory
+ * @param issueNumber - GitHub issue number
+ * @returns true if validation was marked as viewed, false if not found
+ */
+export async function markValidationViewed(
+ projectPath: string,
+ issueNumber: number
+): Promise {
+ const validation = await readValidation(projectPath, issueNumber);
+ if (!validation) {
+ return false;
+ }
+
+ validation.viewedAt = new Date().toISOString();
+ await writeValidation(projectPath, issueNumber, validation);
+ return true;
+}
+
+/**
+ * Get count of unviewed, non-stale validations for a project
+ *
+ * @param projectPath - Absolute path to project directory
+ * @returns Number of unviewed validations
+ */
+export async function getUnviewedValidationsCount(projectPath: string): Promise {
+ const validations = await getAllValidations(projectPath);
+ return validations.filter((v) => !v.viewedAt && !isValidationStale(v)).length;
+}
diff --git a/temp_repo/apps/server/src/lib/version.ts b/temp_repo/apps/server/src/lib/version.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9fd8eeec75f280f01afcd651b8d62597ee1c7464
--- /dev/null
+++ b/temp_repo/apps/server/src/lib/version.ts
@@ -0,0 +1,49 @@
+/**
+ * Version utility - Reads version from package.json
+ */
+
+import { readFileSync, existsSync } from 'fs';
+import { fileURLToPath } from 'url';
+import { dirname, join } from 'path';
+import { createLogger } from '@automaker/utils';
+
+const logger = createLogger('Version');
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+let cachedVersion: string | null = null;
+
+/**
+ * Get the version from package.json
+ * Caches the result for performance
+ */
+export function getVersion(): string {
+ if (cachedVersion) {
+ return cachedVersion;
+ }
+
+ try {
+ const candidatePaths = [
+ // Development via tsx: src/lib -> project root
+ join(__dirname, '..', '..', 'package.json'),
+ // Packaged/build output: lib -> server bundle root
+ join(__dirname, '..', 'package.json'),
+ ];
+
+ const packageJsonPath = candidatePaths.find((candidate) => existsSync(candidate));
+ if (!packageJsonPath) {
+ throw new Error(
+ `package.json not found in any expected location: ${candidatePaths.join(', ')}`
+ );
+ }
+
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
+ const version = packageJson.version || '0.0.0';
+ cachedVersion = version;
+ return version;
+ } catch (error) {
+ logger.warn('Failed to read version from package.json:', error);
+ return '0.0.0';
+ }
+}
diff --git a/temp_repo/apps/server/src/lib/worktree-metadata.ts b/temp_repo/apps/server/src/lib/worktree-metadata.ts
new file mode 100644
index 0000000000000000000000000000000000000000..aa6e24870777d6f2f6c3446bd2a36c46d5ee9ad0
--- /dev/null
+++ b/temp_repo/apps/server/src/lib/worktree-metadata.ts
@@ -0,0 +1,182 @@
+/**
+ * Worktree metadata storage utilities
+ * Stores worktree-specific data in .automaker/worktrees/:branch/worktree.json
+ */
+
+import * as secureFs from './secure-fs.js';
+import * as path from 'path';
+import type { PRState, WorktreePRInfo } from '@automaker/types';
+
+// Re-export types for backwards compatibility
+export type { PRState, WorktreePRInfo };
+
+/** Maximum length for sanitized branch names in filesystem paths */
+const MAX_SANITIZED_BRANCH_PATH_LENGTH = 200;
+
+export interface WorktreeMetadata {
+ branch: string;
+ createdAt: string;
+ pr?: WorktreePRInfo;
+ /** Whether the init script has been executed for this worktree */
+ initScriptRan?: boolean;
+ /** Status of the init script execution */
+ initScriptStatus?: 'running' | 'success' | 'failed';
+ /** Error message if init script failed */
+ initScriptError?: string;
+}
+
+/**
+ * Sanitize branch name for cross-platform filesystem safety
+ */
+function sanitizeBranchName(branch: string): string {
+ // Replace characters that are invalid or problematic on various filesystems:
+ // - Forward and backslashes (path separators)
+ // - Windows invalid chars: : * ? " < > |
+ // - Other potentially problematic chars
+ let safeBranch = branch
+ .replace(/[/\\:*?"<>|]/g, '-') // Replace invalid chars with dash
+ .replace(/\s+/g, '_') // Replace spaces with underscores
+ .replace(/\.+$/g, '') // Remove trailing dots (Windows issue)
+ .replace(/-+/g, '-') // Collapse multiple dashes
+ .replace(/^-|-$/g, ''); // Remove leading/trailing dashes
+
+ // Truncate to safe length (leave room for path components)
+ safeBranch = safeBranch.substring(0, MAX_SANITIZED_BRANCH_PATH_LENGTH);
+
+ // Handle Windows reserved names (CON, PRN, AUX, NUL, COM1-9, LPT1-9)
+ const windowsReserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i;
+ if (windowsReserved.test(safeBranch) || safeBranch.length === 0) {
+ safeBranch = `_${safeBranch || 'branch'}`;
+ }
+
+ return safeBranch;
+}
+
+/**
+ * Get the path to the worktree metadata directory
+ */
+function getWorktreeMetadataDir(projectPath: string, branch: string): string {
+ const safeBranch = sanitizeBranchName(branch);
+ return path.join(projectPath, '.automaker', 'worktrees', safeBranch);
+}
+
+/**
+ * Get the path to the worktree metadata file
+ */
+function getWorktreeMetadataPath(projectPath: string, branch: string): string {
+ return path.join(getWorktreeMetadataDir(projectPath, branch), 'worktree.json');
+}
+
+/**
+ * Read worktree metadata for a branch
+ */
+export async function readWorktreeMetadata(
+ projectPath: string,
+ branch: string
+): Promise {
+ try {
+ const metadataPath = getWorktreeMetadataPath(projectPath, branch);
+ const content = (await secureFs.readFile(metadataPath, 'utf-8')) as string;
+ return JSON.parse(content) as WorktreeMetadata;
+ } catch (_error) {
+ // File doesn't exist or can't be read
+ return null;
+ }
+}
+
+/**
+ * Write worktree metadata for a branch
+ */
+export async function writeWorktreeMetadata(
+ projectPath: string,
+ branch: string,
+ metadata: WorktreeMetadata
+): Promise {
+ const metadataDir = getWorktreeMetadataDir(projectPath, branch);
+ const metadataPath = getWorktreeMetadataPath(projectPath, branch);
+
+ // Ensure directory exists
+ await secureFs.mkdir(metadataDir, { recursive: true });
+
+ // Write metadata
+ await secureFs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), 'utf-8');
+}
+
+/**
+ * Update PR info in worktree metadata
+ */
+export async function updateWorktreePRInfo(
+ projectPath: string,
+ branch: string,
+ prInfo: WorktreePRInfo
+): Promise {
+ // Read existing metadata or create new
+ let metadata = await readWorktreeMetadata(projectPath, branch);
+
+ if (!metadata) {
+ metadata = {
+ branch,
+ createdAt: new Date().toISOString(),
+ };
+ }
+
+ // Update PR info
+ metadata.pr = prInfo;
+
+ // Write back
+ await writeWorktreeMetadata(projectPath, branch, metadata);
+}
+
+/**
+ * Get PR info for a branch from metadata
+ */
+export async function getWorktreePRInfo(
+ projectPath: string,
+ branch: string
+): Promise {
+ const metadata = await readWorktreeMetadata(projectPath, branch);
+ return metadata?.pr || null;
+}
+
+/**
+ * Read all worktree metadata for a project
+ */
+export async function readAllWorktreeMetadata(
+ projectPath: string
+): Promise
+ const projectSpecEnd = '';
+ const fallbackIndex = specContent.indexOf(projectSpecEnd);
+
+ if (fallbackIndex !== -1) {
+ log.debug('Inserting implemented_features before ');
+ return (
+ specContent.slice(0, fallbackIndex) +
+ indent +
+ newSection +
+ '\n' +
+ specContent.slice(fallbackIndex)
+ );
+ }
+
+ log.warn?.('Could not find appropriate insertion point for implemented_features');
+ log.debug('Could not find appropriate insertion point for implemented_features');
+ return specContent;
+}
+
+/**
+ * Add a new feature to the implemented_features section
+ *
+ * @param specContent - The full XML content
+ * @param newFeature - The feature to add
+ * @param options - Optional extraction options
+ * @returns Updated XML content with the new feature added
+ */
+export function addImplementedFeature(
+ specContent: string,
+ newFeature: ImplementedFeature,
+ options: ExtractXmlOptions = {}
+): string {
+ const log = options.logger || logger;
+
+ // Extract existing features
+ const existingFeatures = extractImplementedFeatures(specContent, options);
+
+ // Check for duplicates by name
+ const isDuplicate = existingFeatures.some(
+ (f) => f.name.toLowerCase() === newFeature.name.toLowerCase()
+ );
+
+ if (isDuplicate) {
+ log.debug(`Feature "${newFeature.name}" already exists, skipping`);
+ return specContent;
+ }
+
+ // Add the new feature
+ const updatedFeatures = [...existingFeatures, newFeature];
+
+ log.debug(`Adding feature "${newFeature.name}"`);
+ return updateImplementedFeaturesSection(specContent, updatedFeatures, options);
+}
+
+/**
+ * Remove a feature from the implemented_features section by name
+ *
+ * @param specContent - The full XML content
+ * @param featureName - The name of the feature to remove
+ * @param options - Optional extraction options
+ * @returns Updated XML content with the feature removed
+ */
+export function removeImplementedFeature(
+ specContent: string,
+ featureName: string,
+ options: ExtractXmlOptions = {}
+): string {
+ const log = options.logger || logger;
+
+ // Extract existing features
+ const existingFeatures = extractImplementedFeatures(specContent, options);
+
+ // Filter out the feature to remove
+ const updatedFeatures = existingFeatures.filter(
+ (f) => f.name.toLowerCase() !== featureName.toLowerCase()
+ );
+
+ if (updatedFeatures.length === existingFeatures.length) {
+ log.debug(`Feature "${featureName}" not found, no changes made`);
+ return specContent;
+ }
+
+ log.debug(`Removing feature "${featureName}"`);
+ return updateImplementedFeaturesSection(specContent, updatedFeatures, options);
+}
+
+/**
+ * Update an existing feature in the implemented_features section
+ *
+ * @param specContent - The full XML content
+ * @param featureName - The name of the feature to update
+ * @param updates - Partial updates to apply to the feature
+ * @param options - Optional extraction options
+ * @returns Updated XML content with the feature modified
+ */
+export function updateImplementedFeature(
+ specContent: string,
+ featureName: string,
+ updates: Partial,
+ options: ExtractXmlOptions = {}
+): string {
+ const log = options.logger || logger;
+
+ // Extract existing features
+ const existingFeatures = extractImplementedFeatures(specContent, options);
+
+ // Find and update the feature
+ let found = false;
+ const updatedFeatures = existingFeatures.map((f) => {
+ if (f.name.toLowerCase() === featureName.toLowerCase()) {
+ found = true;
+ return {
+ ...f,
+ ...updates,
+ // Preserve the original name if not explicitly updated
+ name: updates.name ?? f.name,
+ };
+ }
+ return f;
+ });
+
+ if (!found) {
+ log.debug(`Feature "${featureName}" not found, no changes made`);
+ return specContent;
+ }
+
+ log.debug(`Updating feature "${featureName}"`);
+ return updateImplementedFeaturesSection(specContent, updatedFeatures, options);
+}
+
+/**
+ * Check if a feature exists in the implemented_features section
+ *
+ * @param specContent - The full XML content
+ * @param featureName - The name of the feature to check
+ * @param options - Optional extraction options
+ * @returns True if the feature exists
+ */
+export function hasImplementedFeature(
+ specContent: string,
+ featureName: string,
+ options: ExtractXmlOptions = {}
+): boolean {
+ const features = extractImplementedFeatures(specContent, options);
+ return features.some((f) => f.name.toLowerCase() === featureName.toLowerCase());
+}
+
+/**
+ * Convert extracted features to SpecOutput.implemented_features format
+ *
+ * @param features - Array of extracted features
+ * @returns Features in SpecOutput format
+ */
+export function toSpecOutputFeatures(
+ features: ImplementedFeature[]
+): SpecOutput['implemented_features'] {
+ return features.map((f) => ({
+ name: f.name,
+ description: f.description,
+ ...(f.file_locations && f.file_locations.length > 0
+ ? { file_locations: f.file_locations }
+ : {}),
+ }));
+}
+
+/**
+ * Convert SpecOutput.implemented_features to ImplementedFeature format
+ *
+ * @param specFeatures - Features from SpecOutput
+ * @returns Features in ImplementedFeature format
+ */
+export function fromSpecOutputFeatures(
+ specFeatures: SpecOutput['implemented_features']
+): ImplementedFeature[] {
+ return specFeatures.map((f) => ({
+ name: f.name,
+ description: f.description,
+ ...(f.file_locations && f.file_locations.length > 0
+ ? { file_locations: f.file_locations }
+ : {}),
+ }));
+}
+
+/**
+ * Represents a roadmap phase extracted from XML
+ */
+export interface RoadmapPhase {
+ name: string;
+ status: string;
+ description?: string;
+}
+
+/**
+ * Extract the technology stack from app_spec.txt XML content
+ *
+ * @param specContent - The full XML content
+ * @param options - Optional extraction options
+ * @returns Array of technology names
+ */
+export function extractTechnologyStack(
+ specContent: string,
+ options: ExtractXmlOptions = {}
+): string[] {
+ const log = options.logger || logger;
+
+ const techSection = extractXmlSection(specContent, 'technology_stack', options);
+ if (!techSection) {
+ log.debug('No technology_stack section found');
+ return [];
+ }
+
+ const technologies = extractXmlElements(techSection, 'technology', options);
+ log.debug(`Extracted ${technologies.length} technologies`);
+ return technologies;
+}
+
+/**
+ * Update the technology_stack section in XML content
+ *
+ * @param specContent - The full XML content
+ * @param technologies - The new technology list
+ * @param options - Optional extraction options
+ * @returns Updated XML content
+ */
+export function updateTechnologyStack(
+ specContent: string,
+ technologies: string[],
+ options: ExtractXmlOptions = {}
+): string {
+ const log = options.logger || logger;
+ const indent = ' ';
+ const i2 = indent.repeat(2);
+
+ // Generate new section content
+ const techXml = technologies
+ .map((t) => `${i2}${escapeXml(t)}`)
+ .join('\n');
+ const newSection = `\n${techXml}\n${indent}`;
+
+ // Check if section exists
+ const sectionRegex = /[\s\S]*?<\/technology_stack>/;
+
+ if (sectionRegex.test(specContent)) {
+ log.debug('Replacing existing technology_stack section');
+ return specContent.replace(sectionRegex, newSection);
+ }
+
+ log.debug('No technology_stack section found to update');
+ return specContent;
+}
+
+/**
+ * Extract roadmap phases from app_spec.txt XML content
+ *
+ * @param specContent - The full XML content
+ * @param options - Optional extraction options
+ * @returns Array of roadmap phases
+ */
+export function extractRoadmapPhases(
+ specContent: string,
+ options: ExtractXmlOptions = {}
+): RoadmapPhase[] {
+ const log = options.logger || logger;
+ const phases: RoadmapPhase[] = [];
+
+ const roadmapSection = extractXmlSection(specContent, 'implementation_roadmap', options);
+ if (!roadmapSection) {
+ log.debug('No implementation_roadmap section found');
+ return phases;
+ }
+
+ // Extract individual phase blocks
+ const phaseRegex = /([\s\S]*?)<\/phase>/g;
+ const phaseMatches = roadmapSection.matchAll(phaseRegex);
+
+ for (const phaseMatch of phaseMatches) {
+ const phaseContent = phaseMatch[1];
+
+ const nameMatch = phaseContent.match(/([\s\S]*?)<\/name>/);
+ const name = nameMatch ? unescapeXml(nameMatch[1].trim()) : '';
+
+ const statusMatch = phaseContent.match(/([\s\S]*?)<\/status>/);
+ const status = statusMatch ? unescapeXml(statusMatch[1].trim()) : 'pending';
+
+ const descMatch = phaseContent.match(/([\s\S]*?)<\/description>/);
+ const description = descMatch ? unescapeXml(descMatch[1].trim()) : undefined;
+
+ if (name) {
+ phases.push({ name, status, description });
+ }
+ }
+
+ log.debug(`Extracted ${phases.length} roadmap phases`);
+ return phases;
+}
+
+/**
+ * Update a roadmap phase status in XML content
+ *
+ * @param specContent - The full XML content
+ * @param phaseName - The name of the phase to update
+ * @param newStatus - The new status value
+ * @param options - Optional extraction options
+ * @returns Updated XML content
+ */
+export function updateRoadmapPhaseStatus(
+ specContent: string,
+ phaseName: string,
+ newStatus: string,
+ options: ExtractXmlOptions = {}
+): string {
+ const log = options.logger || logger;
+
+ // Find the phase and update its status
+ // Match the phase block containing the specific name
+ const phaseRegex = new RegExp(
+ `(\\s*\\s*${escapeXml(phaseName)}\\s*<\\/name>\\s*)[\\s\\S]*?(<\\/status>)`,
+ 'i'
+ );
+
+ if (phaseRegex.test(specContent)) {
+ log.debug(`Updating phase "${phaseName}" status to "${newStatus}"`);
+ return specContent.replace(phaseRegex, `$1${escapeXml(newStatus)}$2`);
+ }
+
+ log.debug(`Phase "${phaseName}" not found`);
+ return specContent;
+}
diff --git a/temp_repo/apps/server/src/middleware/require-json-content-type.ts b/temp_repo/apps/server/src/middleware/require-json-content-type.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ea02f480a8a18139a5fe3d8983e29920d854e357
--- /dev/null
+++ b/temp_repo/apps/server/src/middleware/require-json-content-type.ts
@@ -0,0 +1,50 @@
+/**
+ * Middleware to enforce Content-Type: application/json for request bodies
+ *
+ * This security middleware prevents malicious requests by requiring proper
+ * Content-Type headers for all POST, PUT, and PATCH requests.
+ *
+ * Rejecting requests without proper Content-Type helps prevent:
+ * - CSRF attacks via form submissions (which use application/x-www-form-urlencoded)
+ * - Content-type confusion attacks
+ * - Malformed request exploitation
+ */
+
+import type { Request, Response, NextFunction } from 'express';
+
+// HTTP methods that typically include request bodies
+const METHODS_REQUIRING_JSON = ['POST', 'PUT', 'PATCH'];
+
+/**
+ * Middleware that requires Content-Type: application/json for POST/PUT/PATCH requests
+ *
+ * Returns 415 Unsupported Media Type if:
+ * - The request method is POST, PUT, or PATCH
+ * - AND the Content-Type header is missing or not application/json
+ *
+ * Allows requests to pass through if:
+ * - The request method is GET, DELETE, OPTIONS, HEAD, etc.
+ * - OR the Content-Type is properly set to application/json (with optional charset)
+ */
+export function requireJsonContentType(req: Request, res: Response, next: NextFunction): void {
+ // Skip validation for methods that don't require a body
+ if (!METHODS_REQUIRING_JSON.includes(req.method)) {
+ next();
+ return;
+ }
+
+ const contentType = req.headers['content-type'];
+
+ // Check if Content-Type header exists and contains application/json
+ // Allows for charset parameter: "application/json; charset=utf-8"
+ if (!contentType || !contentType.toLowerCase().includes('application/json')) {
+ res.status(415).json({
+ success: false,
+ error: 'Unsupported Media Type',
+ message: 'Content-Type header must be application/json',
+ });
+ return;
+ }
+
+ next();
+}
diff --git a/temp_repo/apps/server/src/middleware/validate-paths.ts b/temp_repo/apps/server/src/middleware/validate-paths.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1f7f38760bfcf5204da873717857b0c7406a8790
--- /dev/null
+++ b/temp_repo/apps/server/src/middleware/validate-paths.ts
@@ -0,0 +1,87 @@
+/**
+ * Middleware for validating path parameters against ALLOWED_ROOT_DIRECTORY
+ * Provides a clean, reusable way to validate paths without repeating the same
+ * try-catch block in every route handler
+ */
+
+import type { Request, Response, NextFunction } from 'express';
+import { validatePath, PathNotAllowedError } from '@automaker/platform';
+
+/**
+ * Helper to get parameter value from request (checks body first, then query)
+ */
+function getParamValue(req: Request, paramName: string): unknown {
+ // Check body first (for POST/PUT/PATCH requests)
+ if (req.body && req.body[paramName] !== undefined) {
+ return req.body[paramName];
+ }
+ // Fall back to query params (for GET requests)
+ if (req.query && req.query[paramName] !== undefined) {
+ return req.query[paramName];
+ }
+ return undefined;
+}
+
+/**
+ * Creates a middleware that validates specified path parameters in req.body or req.query
+ * @param paramNames - Names of parameters to validate (e.g., 'projectPath', 'worktreePath')
+ * @example
+ * router.post('/create', validatePathParams('projectPath'), handler);
+ * router.post('/delete', validatePathParams('projectPath', 'worktreePath'), handler);
+ * router.post('/send', validatePathParams('workingDirectory?', 'imagePaths[]'), handler);
+ * router.get('/logs', validatePathParams('worktreePath'), handler); // Works with query params too
+ *
+ * Special syntax:
+ * - 'paramName?' - Optional parameter (only validated if present)
+ * - 'paramName[]' - Array parameter (validates each element)
+ */
+export function validatePathParams(...paramNames: string[]) {
+ return (req: Request, res: Response, next: NextFunction): void => {
+ try {
+ for (const paramName of paramNames) {
+ // Handle optional parameters (paramName?)
+ if (paramName.endsWith('?')) {
+ const actualName = paramName.slice(0, -1);
+ const value = getParamValue(req, actualName);
+ if (value && typeof value === 'string') {
+ validatePath(value);
+ }
+ continue;
+ }
+
+ // Handle array parameters (paramName[])
+ if (paramName.endsWith('[]')) {
+ const actualName = paramName.slice(0, -2);
+ const values = getParamValue(req, actualName);
+ if (Array.isArray(values) && values.length > 0) {
+ for (const value of values) {
+ if (typeof value === 'string') {
+ validatePath(value);
+ }
+ }
+ }
+ continue;
+ }
+
+ // Handle regular parameters
+ const value = getParamValue(req, paramName);
+ if (value && typeof value === 'string') {
+ validatePath(value);
+ }
+ }
+
+ next();
+ } catch (error) {
+ if (error instanceof PathNotAllowedError) {
+ res.status(403).json({
+ success: false,
+ error: error.message,
+ });
+ return;
+ }
+
+ // Re-throw unexpected errors
+ throw error;
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/providers/base-provider.ts b/temp_repo/apps/server/src/providers/base-provider.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2b1880d3c39e72d6fa9a7fb4fbc5a4a555f0f3ef
--- /dev/null
+++ b/temp_repo/apps/server/src/providers/base-provider.ts
@@ -0,0 +1,94 @@
+/**
+ * Abstract base class for AI model providers
+ */
+
+import type {
+ ProviderConfig,
+ ExecuteOptions,
+ ProviderMessage,
+ InstallationStatus,
+ ValidationResult,
+ ModelDefinition,
+} from './types.js';
+
+/**
+ * Base provider class that all provider implementations must extend
+ */
+export abstract class BaseProvider {
+ protected config: ProviderConfig;
+ protected name: string;
+
+ constructor(config: ProviderConfig = {}) {
+ this.config = config;
+ this.name = this.getName();
+ }
+
+ /**
+ * Get the provider name (e.g., "claude", "cursor")
+ */
+ abstract getName(): string;
+
+ /**
+ * Execute a query and stream responses
+ * @param options Execution options
+ * @returns AsyncGenerator yielding provider messages
+ */
+ abstract executeQuery(options: ExecuteOptions): AsyncGenerator;
+
+ /**
+ * Detect if the provider is installed and configured
+ * @returns Installation status
+ */
+ abstract detectInstallation(): Promise;
+
+ /**
+ * Get available models for this provider
+ * @returns Array of model definitions
+ */
+ abstract getAvailableModels(): ModelDefinition[];
+
+ /**
+ * Validate the provider configuration
+ * @returns Validation result
+ */
+ validateConfig(): ValidationResult {
+ const errors: string[] = [];
+ const warnings: string[] = [];
+
+ // Base validation (can be overridden)
+ if (!this.config) {
+ errors.push('Provider config is missing');
+ }
+
+ return {
+ valid: errors.length === 0,
+ errors,
+ warnings,
+ };
+ }
+
+ /**
+ * Check if the provider supports a specific feature
+ * @param feature Feature name (e.g., "vision", "tools", "mcp")
+ * @returns Whether the feature is supported
+ */
+ supportsFeature(feature: string): boolean {
+ // Default implementation - override in subclasses
+ const commonFeatures = ['tools', 'text'];
+ return commonFeatures.includes(feature);
+ }
+
+ /**
+ * Get provider configuration
+ */
+ getConfig(): ProviderConfig {
+ return this.config;
+ }
+
+ /**
+ * Update provider configuration
+ */
+ setConfig(config: Partial): void {
+ this.config = { ...this.config, ...config };
+ }
+}
diff --git a/temp_repo/apps/server/src/providers/claude-provider.ts b/temp_repo/apps/server/src/providers/claude-provider.ts
new file mode 100644
index 0000000000000000000000000000000000000000..fe471e210ae9fb699d37f292994fd3b4673763ed
--- /dev/null
+++ b/temp_repo/apps/server/src/providers/claude-provider.ts
@@ -0,0 +1,448 @@
+/**
+ * Claude Provider - Executes queries using Claude Agent SDK
+ *
+ * Wraps the @anthropic-ai/claude-agent-sdk for seamless integration
+ * with the provider architecture.
+ */
+
+import { query, type Options, type SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
+import { BaseProvider } from './base-provider.js';
+import { classifyError, getUserFriendlyErrorMessage, createLogger } from '@automaker/utils';
+import { getClaudeAuthIndicators } from '@automaker/platform';
+import {
+ getThinkingTokenBudget,
+ validateBareModelId,
+ type ClaudeApiProfile,
+ type ClaudeCompatibleProvider,
+ type Credentials,
+} from '@automaker/types';
+import type {
+ ExecuteOptions,
+ ProviderMessage,
+ InstallationStatus,
+ ModelDefinition,
+} from './types.js';
+
+const logger = createLogger('ClaudeProvider');
+
+/**
+ * ProviderConfig - Union type for provider configuration
+ *
+ * Accepts either the legacy ClaudeApiProfile or new ClaudeCompatibleProvider.
+ * Both share the same connection settings structure.
+ */
+type ProviderConfig = ClaudeApiProfile | ClaudeCompatibleProvider;
+
+// System vars are always passed from process.env regardless of profile.
+// Includes filesystem, locale, and temp directory vars that the Claude CLI
+// needs internally for config resolution and temp file creation.
+const SYSTEM_ENV_VARS = [
+ 'PATH',
+ 'HOME',
+ 'SHELL',
+ 'TERM',
+ 'USER',
+ 'LANG',
+ 'LC_ALL',
+ 'TMPDIR',
+ 'XDG_CONFIG_HOME',
+ 'XDG_DATA_HOME',
+ 'XDG_CACHE_HOME',
+ 'XDG_STATE_HOME',
+];
+
+/**
+ * Check if the config is a ClaudeCompatibleProvider (new system)
+ * by checking for the 'models' array property
+ */
+function isClaudeCompatibleProvider(config: ProviderConfig): config is ClaudeCompatibleProvider {
+ return 'models' in config && Array.isArray(config.models);
+}
+
+/**
+ * Build environment for the SDK with only explicitly allowed variables.
+ * When a provider/profile is provided, uses its configuration (clean switch - don't inherit from process.env).
+ * When no provider is provided, uses direct Anthropic API settings from process.env.
+ *
+ * Supports both:
+ * - ClaudeCompatibleProvider (new system with models[] array)
+ * - ClaudeApiProfile (legacy system with modelMappings)
+ *
+ * @param providerConfig - Optional provider configuration for alternative endpoint
+ * @param credentials - Optional credentials object for resolving 'credentials' apiKeySource
+ */
+function buildEnv(
+ providerConfig?: ProviderConfig,
+ credentials?: Credentials
+): Record {
+ const env: Record = {};
+
+ if (providerConfig) {
+ // Use provider configuration (clean switch - don't inherit non-system vars from process.env)
+ logger.debug('[buildEnv] Using provider configuration:', {
+ name: providerConfig.name,
+ baseUrl: providerConfig.baseUrl,
+ apiKeySource: providerConfig.apiKeySource ?? 'inline',
+ isNewProvider: isClaudeCompatibleProvider(providerConfig),
+ });
+
+ // Resolve API key based on source strategy
+ let apiKey: string | undefined;
+ const source = providerConfig.apiKeySource ?? 'inline'; // Default to inline for backwards compat
+
+ switch (source) {
+ case 'inline':
+ apiKey = providerConfig.apiKey;
+ break;
+ case 'env':
+ apiKey = process.env.ANTHROPIC_API_KEY;
+ break;
+ case 'credentials':
+ apiKey = credentials?.apiKeys?.anthropic;
+ break;
+ }
+
+ // Warn if no API key found
+ if (!apiKey) {
+ logger.warn(`No API key found for provider "${providerConfig.name}" with source "${source}"`);
+ }
+
+ // Authentication
+ if (providerConfig.useAuthToken) {
+ env['ANTHROPIC_AUTH_TOKEN'] = apiKey;
+ } else {
+ env['ANTHROPIC_API_KEY'] = apiKey;
+ }
+
+ // Endpoint configuration
+ env['ANTHROPIC_BASE_URL'] = providerConfig.baseUrl;
+ logger.debug(`[buildEnv] Set ANTHROPIC_BASE_URL to: ${providerConfig.baseUrl}`);
+
+ if (providerConfig.timeoutMs) {
+ env['API_TIMEOUT_MS'] = String(providerConfig.timeoutMs);
+ }
+
+ // Model mappings - only for legacy ClaudeApiProfile
+ // For ClaudeCompatibleProvider, the model is passed directly (no mapping needed)
+ if (!isClaudeCompatibleProvider(providerConfig) && providerConfig.modelMappings) {
+ if (providerConfig.modelMappings.haiku) {
+ env['ANTHROPIC_DEFAULT_HAIKU_MODEL'] = providerConfig.modelMappings.haiku;
+ }
+ if (providerConfig.modelMappings.sonnet) {
+ env['ANTHROPIC_DEFAULT_SONNET_MODEL'] = providerConfig.modelMappings.sonnet;
+ }
+ if (providerConfig.modelMappings.opus) {
+ env['ANTHROPIC_DEFAULT_OPUS_MODEL'] = providerConfig.modelMappings.opus;
+ }
+ }
+
+ // Traffic control
+ if (providerConfig.disableNonessentialTraffic) {
+ env['CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC'] = '1';
+ }
+ } else {
+ // Use direct Anthropic API - pass through credentials or environment variables
+ // This supports:
+ // 1. API Key mode: ANTHROPIC_API_KEY from credentials (UI settings) or env
+ // 2. Claude Max plan: Uses CLI OAuth auth (SDK handles this automatically)
+ // 3. Custom endpoints via ANTHROPIC_BASE_URL env var (backward compatibility)
+ //
+ // Priority: credentials file (UI settings) -> environment variable
+ // Note: Only auth and endpoint vars are passed. Model mappings and traffic
+ // control are NOT passed (those require a profile for explicit configuration).
+ if (credentials?.apiKeys?.anthropic) {
+ env['ANTHROPIC_API_KEY'] = credentials.apiKeys.anthropic;
+ } else if (process.env.ANTHROPIC_API_KEY) {
+ env['ANTHROPIC_API_KEY'] = process.env.ANTHROPIC_API_KEY;
+ }
+ // If using Claude Max plan via CLI auth, the SDK handles auth automatically
+ // when no API key is provided. We don't set ANTHROPIC_AUTH_TOKEN here
+ // unless it was explicitly set in process.env (rare edge case).
+ if (process.env.ANTHROPIC_AUTH_TOKEN) {
+ env['ANTHROPIC_AUTH_TOKEN'] = process.env.ANTHROPIC_AUTH_TOKEN;
+ }
+ // Pass through ANTHROPIC_BASE_URL if set in environment (backward compatibility)
+ if (process.env.ANTHROPIC_BASE_URL) {
+ env['ANTHROPIC_BASE_URL'] = process.env.ANTHROPIC_BASE_URL;
+ }
+ }
+
+ // Always add system vars from process.env
+ for (const key of SYSTEM_ENV_VARS) {
+ if (process.env[key]) {
+ env[key] = process.env[key];
+ }
+ }
+
+ return env;
+}
+
+export class ClaudeProvider extends BaseProvider {
+ getName(): string {
+ return 'claude';
+ }
+
+ /**
+ * Execute a query using Claude Agent SDK
+ */
+ async *executeQuery(options: ExecuteOptions): AsyncGenerator {
+ // Validate that model doesn't have a provider prefix
+ // AgentService should strip prefixes before passing to providers
+ // Claude doesn't use a provider prefix, so we don't need to specify an expected provider
+ validateBareModelId(options.model, 'ClaudeProvider');
+
+ const {
+ prompt,
+ model,
+ cwd,
+ systemPrompt,
+ maxTurns = 1000,
+ allowedTools,
+ abortController,
+ conversationHistory,
+ sdkSessionId,
+ thinkingLevel,
+ claudeApiProfile,
+ claudeCompatibleProvider,
+ credentials,
+ } = options;
+
+ // Determine which provider config to use
+ // claudeCompatibleProvider takes precedence over claudeApiProfile
+ const providerConfig = claudeCompatibleProvider || claudeApiProfile;
+
+ // Build thinking configuration
+ // Adaptive thinking (Opus 4.6): don't set maxThinkingTokens, model uses adaptive by default
+ // Manual thinking (Haiku/Sonnet): use budget_tokens
+ const maxThinkingTokens =
+ thinkingLevel === 'adaptive' ? undefined : getThinkingTokenBudget(thinkingLevel);
+
+ // Build Claude SDK options
+ const sdkOptions: Options = {
+ model,
+ systemPrompt,
+ maxTurns,
+ cwd,
+ // Pass only explicitly allowed environment variables to SDK
+ // When a provider is active, uses provider settings (clean switch)
+ // When no provider, uses direct Anthropic API (from process.env or CLI OAuth)
+ env: buildEnv(providerConfig, credentials),
+ // Pass through allowedTools if provided by caller (decided by sdk-options.ts)
+ ...(allowedTools && { allowedTools }),
+ // Restrict available built-in tools if specified (tools: [] disables all tools)
+ ...(options.tools && { tools: options.tools }),
+ // AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation
+ permissionMode: 'bypassPermissions',
+ allowDangerouslySkipPermissions: true,
+ abortController,
+ // Resume existing SDK session if we have a session ID
+ ...(sdkSessionId && conversationHistory && conversationHistory.length > 0
+ ? { resume: sdkSessionId }
+ : {}),
+ // Forward settingSources for CLAUDE.md file loading
+ ...(options.settingSources && { settingSources: options.settingSources }),
+ // Forward MCP servers configuration
+ ...(options.mcpServers && { mcpServers: options.mcpServers }),
+ // Extended thinking configuration
+ ...(maxThinkingTokens && { maxThinkingTokens }),
+ // Subagents configuration for specialized task delegation
+ ...(options.agents && { agents: options.agents }),
+ // Pass through outputFormat for structured JSON outputs
+ ...(options.outputFormat && { outputFormat: options.outputFormat }),
+ };
+
+ // Build prompt payload
+ let promptPayload: string | AsyncIterable;
+
+ if (Array.isArray(prompt)) {
+ // Multi-part prompt (with images)
+ promptPayload = (async function* () {
+ const multiPartPrompt: SDKUserMessage = {
+ type: 'user' as const,
+ session_id: sdkSessionId || '',
+ message: {
+ role: 'user' as const,
+ content: prompt,
+ },
+ parent_tool_use_id: null,
+ };
+ yield multiPartPrompt;
+ })();
+ } else {
+ // Simple text prompt
+ promptPayload = prompt;
+ }
+
+ // Log the environment being passed to the SDK for debugging
+ const envForSdk = sdkOptions.env as Record;
+ logger.debug('[ClaudeProvider] SDK Configuration:', {
+ model: sdkOptions.model,
+ baseUrl: envForSdk?.['ANTHROPIC_BASE_URL'] || '(default Anthropic API)',
+ hasApiKey: !!envForSdk?.['ANTHROPIC_API_KEY'],
+ hasAuthToken: !!envForSdk?.['ANTHROPIC_AUTH_TOKEN'],
+ providerName: providerConfig?.name || '(direct Anthropic)',
+ maxTurns: sdkOptions.maxTurns,
+ maxThinkingTokens: sdkOptions.maxThinkingTokens,
+ });
+
+ // Execute via Claude Agent SDK
+ try {
+ const stream = query({ prompt: promptPayload, options: sdkOptions });
+
+ // Stream messages directly - they're already in the correct format
+ for await (const msg of stream) {
+ yield msg as ProviderMessage;
+ }
+ } catch (error) {
+ // Enhance error with user-friendly message and classification
+ const errorInfo = classifyError(error);
+ const userMessage = getUserFriendlyErrorMessage(error);
+
+ logger.error('executeQuery() error during execution:', {
+ type: errorInfo.type,
+ message: errorInfo.message,
+ isRateLimit: errorInfo.isRateLimit,
+ retryAfter: errorInfo.retryAfter,
+ stack: (error as Error).stack,
+ });
+
+ // Build enhanced error message with additional guidance for rate limits
+ const message = errorInfo.isRateLimit
+ ? `${userMessage}\n\nTip: If you're running multiple features in auto-mode, consider reducing concurrency (maxConcurrency setting) to avoid hitting rate limits.`
+ : userMessage;
+
+ const enhancedError = new Error(message) as Error & {
+ originalError: unknown;
+ type: string;
+ retryAfter?: number;
+ };
+ enhancedError.originalError = error;
+ enhancedError.type = errorInfo.type;
+
+ if (errorInfo.isRateLimit) {
+ enhancedError.retryAfter = errorInfo.retryAfter;
+ }
+
+ throw enhancedError;
+ }
+ }
+
+ /**
+ * Detect Claude SDK installation (always available via npm)
+ */
+ async detectInstallation(): Promise {
+ // Claude SDK is always available since it's a dependency
+ // Check all four supported auth methods, mirroring the logic in buildEnv():
+ // 1. ANTHROPIC_API_KEY environment variable
+ // 2. ANTHROPIC_AUTH_TOKEN environment variable
+ // 3. credentials?.apiKeys?.anthropic (credentials file, checked via platform indicators)
+ // 4. Claude Max CLI OAuth (SDK handles this automatically; detected via getClaudeAuthIndicators)
+ const hasEnvApiKey = !!process.env.ANTHROPIC_API_KEY;
+ const hasEnvAuthToken = !!process.env.ANTHROPIC_AUTH_TOKEN;
+
+ // Check credentials file and CLI OAuth indicators (same sources used by buildEnv)
+ let hasCredentialsApiKey = false;
+ let hasCliOAuth = false;
+ try {
+ const indicators = await getClaudeAuthIndicators();
+ hasCredentialsApiKey = !!indicators.credentials?.hasApiKey;
+ hasCliOAuth = !!(
+ indicators.credentials?.hasOAuthToken ||
+ indicators.hasStatsCacheWithActivity ||
+ (indicators.hasSettingsFile && indicators.hasProjectsSessions)
+ );
+ } catch {
+ // If we can't check indicators, fall back to env vars only
+ }
+
+ const hasApiKey = hasEnvApiKey || hasCredentialsApiKey;
+ const authenticated = hasEnvApiKey || hasEnvAuthToken || hasCredentialsApiKey || hasCliOAuth;
+
+ const status: InstallationStatus = {
+ installed: true,
+ method: 'sdk',
+ hasApiKey,
+ authenticated,
+ };
+
+ return status;
+ }
+
+ /**
+ * Get available Claude models
+ */
+ getAvailableModels(): ModelDefinition[] {
+ const models = [
+ {
+ id: 'claude-opus-4-6',
+ name: 'Claude Opus 4.6',
+ modelString: 'claude-opus-4-6',
+ provider: 'anthropic',
+ description: 'Most capable Claude model with adaptive thinking',
+ contextWindow: 200000,
+ maxOutputTokens: 128000,
+ supportsVision: true,
+ supportsTools: true,
+ tier: 'premium' as const,
+ default: true,
+ },
+ {
+ id: 'claude-sonnet-4-6',
+ name: 'Claude Sonnet 4.6',
+ modelString: 'claude-sonnet-4-6',
+ provider: 'anthropic',
+ description: 'Balanced performance and cost with enhanced reasoning',
+ contextWindow: 200000,
+ maxOutputTokens: 64000,
+ supportsVision: true,
+ supportsTools: true,
+ tier: 'standard' as const,
+ },
+ {
+ id: 'claude-sonnet-4-20250514',
+ name: 'Claude Sonnet 4',
+ modelString: 'claude-sonnet-4-20250514',
+ provider: 'anthropic',
+ description: 'Balanced performance and cost',
+ contextWindow: 200000,
+ maxOutputTokens: 16000,
+ supportsVision: true,
+ supportsTools: true,
+ tier: 'standard' as const,
+ },
+ {
+ id: 'claude-3-5-sonnet-20241022',
+ name: 'Claude 3.5 Sonnet',
+ modelString: 'claude-3-5-sonnet-20241022',
+ provider: 'anthropic',
+ description: 'Fast and capable',
+ contextWindow: 200000,
+ maxOutputTokens: 8000,
+ supportsVision: true,
+ supportsTools: true,
+ tier: 'standard' as const,
+ },
+ {
+ id: 'claude-haiku-4-5-20251001',
+ name: 'Claude Haiku 4.5',
+ modelString: 'claude-haiku-4-5-20251001',
+ provider: 'anthropic',
+ description: 'Fastest Claude model',
+ contextWindow: 200000,
+ maxOutputTokens: 8000,
+ supportsVision: true,
+ supportsTools: true,
+ tier: 'basic' as const,
+ },
+ ] satisfies ModelDefinition[];
+ return models;
+ }
+
+ /**
+ * Check if the provider supports a specific feature
+ */
+ supportsFeature(feature: string): boolean {
+ const supportedFeatures = ['tools', 'text', 'vision', 'thinking'];
+ return supportedFeatures.includes(feature);
+ }
+}
diff --git a/temp_repo/apps/server/src/providers/cli-provider.ts b/temp_repo/apps/server/src/providers/cli-provider.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ea636cb8e68e3b48c0dced97c7e179d253324523
--- /dev/null
+++ b/temp_repo/apps/server/src/providers/cli-provider.ts
@@ -0,0 +1,625 @@
+/**
+ * CliProvider - Abstract base class for CLI-based AI providers
+ *
+ * Provides common infrastructure for CLI tools that spawn subprocesses
+ * and stream JSONL output. Handles:
+ * - Platform-specific CLI detection (PATH, common locations)
+ * - Windows execution strategies (WSL, npx, direct, cmd)
+ * - JSONL subprocess spawning and streaming
+ * - Error mapping infrastructure
+ *
+ * @example
+ * ```typescript
+ * class CursorProvider extends CliProvider {
+ * getCliName(): string { return 'cursor-agent'; }
+ * getSpawnConfig(): CliSpawnConfig {
+ * return {
+ * windowsStrategy: 'wsl',
+ * commonPaths: {
+ * linux: ['~/.local/bin/cursor-agent'],
+ * darwin: ['~/.local/bin/cursor-agent'],
+ * }
+ * };
+ * }
+ * // ... implement abstract methods
+ * }
+ * ```
+ */
+
+import {
+ createWslCommand,
+ findCliInWsl,
+ isWslAvailable,
+ spawnJSONLProcess,
+ windowsToWslPath,
+ type SubprocessOptions,
+ type WslCliResult,
+} from '@automaker/platform';
+import { calculateReasoningTimeout } from '@automaker/types';
+import { createLogger, isAbortError } from '@automaker/utils';
+import { execSync } from 'child_process';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { BaseProvider } from './base-provider.js';
+import type { ExecuteOptions, ProviderConfig, ProviderMessage } from './types.js';
+
+/**
+ * Spawn strategy for CLI tools on Windows
+ *
+ * Different CLI tools require different execution strategies:
+ * - 'wsl': Requires WSL, CLI only available on Linux/macOS (e.g., cursor-agent)
+ * - 'npx': Installed globally via npm/npx, use `npx ` to run
+ * - 'direct': Native Windows binary, can spawn directly
+ * - 'cmd': Windows batch file (.cmd/.bat), needs cmd.exe shell
+ */
+export type SpawnStrategy = 'wsl' | 'npx' | 'direct' | 'cmd';
+
+/**
+ * Configuration for CLI tool spawning
+ */
+export interface CliSpawnConfig {
+ /** How to spawn on Windows */
+ windowsStrategy: SpawnStrategy;
+
+ /** NPX package name (required if windowsStrategy is 'npx') */
+ npxPackage?: string;
+
+ /** Preferred WSL distribution (if windowsStrategy is 'wsl') */
+ wslDistribution?: string;
+
+ /**
+ * Common installation paths per platform
+ * Use ~ for home directory (will be expanded)
+ * Keys: 'linux', 'darwin', 'win32'
+ */
+ commonPaths: Record;
+
+ /** Version check command (defaults to --version) */
+ versionCommand?: string;
+}
+
+/**
+ * CLI error information for consistent error handling
+ */
+export interface CliErrorInfo {
+ code: string;
+ message: string;
+ recoverable: boolean;
+ suggestion?: string;
+}
+
+/**
+ * Detection result from CLI path finding
+ */
+export interface CliDetectionResult {
+ /** Path to the CLI (or 'npx' for npx strategy) */
+ cliPath: string | null;
+ /** Whether using WSL mode */
+ useWsl: boolean;
+ /** WSL path if using WSL */
+ wslCliPath?: string;
+ /** WSL distribution if using WSL */
+ wslDistribution?: string;
+ /** Detected strategy used */
+ strategy: SpawnStrategy | 'native';
+}
+
+// Create logger for CLI operations
+const cliLogger = createLogger('CliProvider');
+
+/**
+ * Base timeout for CLI operations in milliseconds.
+ * CLI tools have longer startup and processing times compared to direct API calls,
+ * so we use a higher base timeout (120s) than the default provider timeout (30s).
+ * This is multiplied by reasoning effort multipliers when applicable.
+ * @see calculateReasoningTimeout from @automaker/types
+ */
+const CLI_BASE_TIMEOUT_MS = 120000;
+
+/**
+ * Abstract base class for CLI-based providers
+ *
+ * Subclasses must implement:
+ * - getCliName(): CLI executable name
+ * - getSpawnConfig(): Platform-specific spawn configuration
+ * - buildCliArgs(): Convert ExecuteOptions to CLI arguments
+ * - normalizeEvent(): Convert CLI output to ProviderMessage
+ */
+export abstract class CliProvider extends BaseProvider {
+ // CLI detection results (cached after first detection)
+ protected cliPath: string | null = null;
+ protected useWsl: boolean = false;
+ protected wslCliPath: string | null = null;
+ protected wslDistribution: string | undefined = undefined;
+ protected detectedStrategy: SpawnStrategy | 'native' = 'native';
+
+ // NPX args (used when strategy is 'npx')
+ protected npxArgs: string[] = [];
+
+ constructor(config: ProviderConfig = {}) {
+ super(config);
+ // Detection happens lazily on first use
+ }
+
+ // ==========================================================================
+ // Abstract methods - must be implemented by subclasses
+ // ==========================================================================
+
+ /**
+ * Get the CLI executable name (e.g., 'cursor-agent', 'aider')
+ */
+ abstract getCliName(): string;
+
+ /**
+ * Get spawn configuration for this CLI
+ */
+ abstract getSpawnConfig(): CliSpawnConfig;
+
+ /**
+ * Build CLI arguments from execution options
+ * @param options Execution options
+ * @returns Array of CLI arguments
+ */
+ abstract buildCliArgs(options: ExecuteOptions): string[];
+
+ /**
+ * Normalize a raw CLI event to ProviderMessage format
+ * @param event Raw event from CLI JSONL output
+ * @returns Normalized ProviderMessage or null to skip
+ */
+ abstract normalizeEvent(event: unknown): ProviderMessage | null;
+
+ // ==========================================================================
+ // Optional overrides
+ // ==========================================================================
+
+ /**
+ * Map CLI stderr/exit code to error info
+ * Override to provide CLI-specific error mapping
+ */
+ protected mapError(stderr: string, exitCode: number | null): CliErrorInfo {
+ const lower = stderr.toLowerCase();
+
+ // Common authentication errors
+ if (
+ lower.includes('not authenticated') ||
+ lower.includes('please log in') ||
+ lower.includes('unauthorized')
+ ) {
+ return {
+ code: 'NOT_AUTHENTICATED',
+ message: `${this.getCliName()} is not authenticated`,
+ recoverable: true,
+ suggestion: `Run "${this.getCliName()} login" to authenticate`,
+ };
+ }
+
+ // Rate limiting
+ if (
+ lower.includes('rate limit') ||
+ lower.includes('too many requests') ||
+ lower.includes('429')
+ ) {
+ return {
+ code: 'RATE_LIMITED',
+ message: 'API rate limit exceeded',
+ recoverable: true,
+ suggestion: 'Wait a few minutes and try again',
+ };
+ }
+
+ // Network errors
+ if (
+ lower.includes('network') ||
+ lower.includes('connection') ||
+ lower.includes('econnrefused') ||
+ lower.includes('timeout')
+ ) {
+ return {
+ code: 'NETWORK_ERROR',
+ message: 'Network connection error',
+ recoverable: true,
+ suggestion: 'Check your internet connection and try again',
+ };
+ }
+
+ // Process killed
+ if (exitCode === 137 || lower.includes('killed') || lower.includes('sigterm')) {
+ return {
+ code: 'PROCESS_CRASHED',
+ message: 'Process was terminated',
+ recoverable: true,
+ suggestion: 'The process may have run out of memory. Try a simpler task.',
+ };
+ }
+
+ // Generic error
+ return {
+ code: 'UNKNOWN_ERROR',
+ message: stderr || `Process exited with code ${exitCode}`,
+ recoverable: false,
+ };
+ }
+
+ /**
+ * Get installation instructions for this CLI
+ * Override to provide CLI-specific instructions
+ */
+ protected getInstallInstructions(): string {
+ const cliName = this.getCliName();
+ const config = this.getSpawnConfig();
+
+ if (process.platform === 'win32') {
+ switch (config.windowsStrategy) {
+ case 'wsl':
+ return `${cliName} requires WSL on Windows. Install WSL, then run inside WSL to install.`;
+ case 'npx':
+ return `Install with: npm install -g ${config.npxPackage || cliName}`;
+ case 'cmd':
+ case 'direct':
+ return `${cliName} is not installed. Check the documentation for installation instructions.`;
+ }
+ }
+
+ return `${cliName} is not installed. Check the documentation for installation instructions.`;
+ }
+
+ // ==========================================================================
+ // CLI Detection
+ // ==========================================================================
+
+ /**
+ * Expand ~ to home directory in path
+ */
+ private expandPath(p: string): string {
+ if (p.startsWith('~')) {
+ return path.join(os.homedir(), p.slice(1));
+ }
+ return p;
+ }
+
+ /**
+ * Find CLI in PATH using 'which' (Unix) or 'where' (Windows)
+ */
+ private findCliInPath(): string | null {
+ const cliName = this.getCliName();
+
+ try {
+ const command = process.platform === 'win32' ? 'where' : 'which';
+ const result = execSync(`${command} ${cliName}`, {
+ encoding: 'utf8',
+ timeout: 5000,
+ stdio: ['pipe', 'pipe', 'pipe'],
+ windowsHide: true,
+ })
+ .trim()
+ .split('\n')[0];
+
+ if (result && fs.existsSync(result)) {
+ cliLogger.debug(`Found ${cliName} in PATH: ${result}`);
+ return result;
+ }
+ } catch {
+ // Not in PATH
+ }
+
+ return null;
+ }
+
+ /**
+ * Find CLI in common installation paths for current platform
+ */
+ private findCliInCommonPaths(): string | null {
+ const config = this.getSpawnConfig();
+ const cliName = this.getCliName();
+ const platform = process.platform as 'linux' | 'darwin' | 'win32';
+ const paths = config.commonPaths[platform] || [];
+
+ for (const p of paths) {
+ const expandedPath = this.expandPath(p);
+ if (fs.existsSync(expandedPath)) {
+ cliLogger.debug(`Found ${cliName} at: ${expandedPath}`);
+ return expandedPath;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Detect CLI installation using appropriate strategy
+ */
+ protected detectCli(): CliDetectionResult {
+ const config = this.getSpawnConfig();
+ const cliName = this.getCliName();
+ const wslLogger = (msg: string) => cliLogger.debug(msg);
+
+ // Windows - use configured strategy
+ if (process.platform === 'win32') {
+ switch (config.windowsStrategy) {
+ case 'wsl': {
+ // Check WSL for CLI
+ if (isWslAvailable({ logger: wslLogger })) {
+ const wslResult: WslCliResult | null = findCliInWsl(cliName, {
+ logger: wslLogger,
+ distribution: config.wslDistribution,
+ });
+ if (wslResult) {
+ cliLogger.debug(
+ `Using ${cliName} via WSL (${wslResult.distribution || 'default'}): ${wslResult.wslPath}`
+ );
+ return {
+ cliPath: 'wsl.exe',
+ useWsl: true,
+ wslCliPath: wslResult.wslPath,
+ wslDistribution: wslResult.distribution,
+ strategy: 'wsl',
+ };
+ }
+ }
+ cliLogger.debug(`${cliName} not found (WSL not available or CLI not installed in WSL)`);
+ return { cliPath: null, useWsl: false, strategy: 'wsl' };
+ }
+
+ case 'npx': {
+ // For npx, we don't need to find the CLI, just return npx
+ cliLogger.debug(`Using ${cliName} via npx (package: ${config.npxPackage})`);
+ return {
+ cliPath: 'npx',
+ useWsl: false,
+ strategy: 'npx',
+ };
+ }
+
+ case 'direct':
+ case 'cmd': {
+ // Native Windows - check PATH and common paths
+ const pathResult = this.findCliInPath();
+ if (pathResult) {
+ return { cliPath: pathResult, useWsl: false, strategy: config.windowsStrategy };
+ }
+
+ const commonResult = this.findCliInCommonPaths();
+ if (commonResult) {
+ return { cliPath: commonResult, useWsl: false, strategy: config.windowsStrategy };
+ }
+
+ cliLogger.debug(`${cliName} not found on Windows`);
+ return { cliPath: null, useWsl: false, strategy: config.windowsStrategy };
+ }
+ }
+ }
+
+ // Linux/macOS - native execution
+ const pathResult = this.findCliInPath();
+ if (pathResult) {
+ return { cliPath: pathResult, useWsl: false, strategy: 'native' };
+ }
+
+ const commonResult = this.findCliInCommonPaths();
+ if (commonResult) {
+ return { cliPath: commonResult, useWsl: false, strategy: 'native' };
+ }
+
+ cliLogger.debug(`${cliName} not found`);
+ return { cliPath: null, useWsl: false, strategy: 'native' };
+ }
+
+ /**
+ * Ensure CLI is detected (lazy initialization)
+ */
+ protected ensureCliDetected(): void {
+ if (this.cliPath !== null || this.detectedStrategy !== 'native') {
+ return; // Already detected
+ }
+
+ const result = this.detectCli();
+ this.cliPath = result.cliPath;
+ this.useWsl = result.useWsl;
+ this.wslCliPath = result.wslCliPath || null;
+ this.wslDistribution = result.wslDistribution;
+ this.detectedStrategy = result.strategy;
+
+ // Set up npx args if using npx strategy
+ const config = this.getSpawnConfig();
+ if (result.strategy === 'npx' && config.npxPackage) {
+ this.npxArgs = [config.npxPackage];
+ }
+ }
+
+ /**
+ * Check if CLI is installed
+ */
+ async isInstalled(): Promise {
+ this.ensureCliDetected();
+ return this.cliPath !== null;
+ }
+
+ // ==========================================================================
+ // Subprocess Spawning
+ // ==========================================================================
+
+ /**
+ * Build subprocess options based on detected strategy
+ */
+ protected buildSubprocessOptions(options: ExecuteOptions, cliArgs: string[]): SubprocessOptions {
+ this.ensureCliDetected();
+
+ if (!this.cliPath) {
+ throw new Error(`${this.getCliName()} CLI not found. ${this.getInstallInstructions()}`);
+ }
+
+ const cwd = options.cwd || process.cwd();
+
+ // Filter undefined values from process.env
+ const filteredEnv: Record = {};
+ for (const [key, value] of Object.entries(process.env)) {
+ if (value !== undefined) {
+ filteredEnv[key] = value;
+ }
+ }
+
+ // Calculate dynamic timeout based on reasoning effort.
+ // This addresses GitHub issue #530 where reasoning models with 'xhigh' effort would timeout.
+ const timeout = calculateReasoningTimeout(options.reasoningEffort, CLI_BASE_TIMEOUT_MS);
+
+ // WSL strategy
+ if (this.useWsl && this.wslCliPath) {
+ const wslCwd = windowsToWslPath(cwd);
+ const wslCmd = createWslCommand(this.wslCliPath, cliArgs, {
+ distribution: this.wslDistribution,
+ });
+
+ // Add --cd flag to change directory inside WSL
+ let args: string[];
+ if (this.wslDistribution) {
+ args = ['-d', this.wslDistribution, '--cd', wslCwd, this.wslCliPath, ...cliArgs];
+ } else {
+ args = ['--cd', wslCwd, this.wslCliPath, ...cliArgs];
+ }
+
+ cliLogger.debug(`WSL spawn: ${wslCmd.command} ${args.slice(0, 6).join(' ')}...`);
+
+ return {
+ command: wslCmd.command,
+ args,
+ cwd, // Windows cwd for spawn
+ env: filteredEnv,
+ abortController: options.abortController,
+ timeout,
+ };
+ }
+
+ // NPX strategy
+ if (this.detectedStrategy === 'npx') {
+ const allArgs = [...this.npxArgs, ...cliArgs];
+ cliLogger.debug(`NPX spawn: npx ${allArgs.slice(0, 6).join(' ')}...`);
+
+ return {
+ command: 'npx',
+ args: allArgs,
+ cwd,
+ env: filteredEnv,
+ abortController: options.abortController,
+ timeout,
+ };
+ }
+
+ // Direct strategy (native Unix or Windows direct/cmd)
+ cliLogger.debug(`Direct spawn: ${this.cliPath} ${cliArgs.slice(0, 6).join(' ')}...`);
+
+ return {
+ command: this.cliPath,
+ args: cliArgs,
+ cwd,
+ env: filteredEnv,
+ abortController: options.abortController,
+ timeout,
+ };
+ }
+
+ /**
+ * Execute a query using the CLI with JSONL streaming
+ *
+ * This is a default implementation that:
+ * 1. Builds CLI args from options
+ * 2. Spawns the subprocess with appropriate strategy
+ * 3. Streams and normalizes events
+ *
+ * Subclasses can override for custom behavior.
+ */
+ async *executeQuery(options: ExecuteOptions): AsyncGenerator {
+ this.ensureCliDetected();
+
+ if (!this.cliPath) {
+ throw new Error(`${this.getCliName()} CLI not found. ${this.getInstallInstructions()}`);
+ }
+
+ // Many CLI-based providers do not support a separate "system" message.
+ // If a systemPrompt is provided, embed it into the prompt so downstream models
+ // still receive critical formatting/schema instructions (e.g., JSON-only outputs).
+ const effectiveOptions = this.embedSystemPromptIntoPrompt(options);
+
+ const cliArgs = this.buildCliArgs(effectiveOptions);
+ const subprocessOptions = this.buildSubprocessOptions(effectiveOptions, cliArgs);
+
+ try {
+ for await (const rawEvent of spawnJSONLProcess(subprocessOptions)) {
+ const normalized = this.normalizeEvent(rawEvent);
+ if (normalized) {
+ yield normalized;
+ }
+ }
+ } catch (error) {
+ if (isAbortError(error)) {
+ cliLogger.debug('Query aborted');
+ return;
+ }
+
+ // Map CLI errors
+ if (error instanceof Error && 'stderr' in error) {
+ const errorInfo = this.mapError(
+ (error as { stderr?: string }).stderr || error.message,
+ (error as { exitCode?: number | null }).exitCode ?? null
+ );
+
+ const cliError = new Error(errorInfo.message) as Error & CliErrorInfo;
+ cliError.code = errorInfo.code;
+ cliError.recoverable = errorInfo.recoverable;
+ cliError.suggestion = errorInfo.suggestion;
+ throw cliError;
+ }
+
+ throw error;
+ }
+ }
+
+ /**
+ * Embed system prompt text into the user prompt for CLI providers.
+ *
+ * Most CLI providers we integrate with only accept a single prompt via stdin/args.
+ * When upstream code supplies `options.systemPrompt`, we prepend it to the prompt
+ * content and clear `systemPrompt` to avoid any accidental double-injection by
+ * subclasses.
+ */
+ protected embedSystemPromptIntoPrompt(options: ExecuteOptions): ExecuteOptions {
+ if (!options.systemPrompt) {
+ return options;
+ }
+
+ // Only string system prompts can be reliably embedded for CLI providers.
+ // Presets are provider-specific (e.g., Claude SDK) and cannot be represented
+ // universally. If a preset is provided, we only embed its optional `append`.
+ const systemText =
+ typeof options.systemPrompt === 'string'
+ ? options.systemPrompt
+ : options.systemPrompt.append
+ ? options.systemPrompt.append
+ : '';
+
+ if (!systemText) {
+ return { ...options, systemPrompt: undefined };
+ }
+
+ // Preserve original prompt structure.
+ if (typeof options.prompt === 'string') {
+ return {
+ ...options,
+ prompt: `${systemText}\n\n---\n\n${options.prompt}`,
+ systemPrompt: undefined,
+ };
+ }
+
+ if (Array.isArray(options.prompt)) {
+ return {
+ ...options,
+ prompt: [{ type: 'text', text: systemText }, ...options.prompt],
+ systemPrompt: undefined,
+ };
+ }
+
+ // Should be unreachable due to ExecuteOptions typing, but keep safe.
+ return { ...options, systemPrompt: undefined };
+ }
+}
diff --git a/temp_repo/apps/server/src/providers/codex-config-manager.ts b/temp_repo/apps/server/src/providers/codex-config-manager.ts
new file mode 100644
index 0000000000000000000000000000000000000000..33031c4a25c180bfe1cec3cdf25a4ed4d7fd23fd
--- /dev/null
+++ b/temp_repo/apps/server/src/providers/codex-config-manager.ts
@@ -0,0 +1,85 @@
+/**
+ * Codex Config Manager - Writes MCP server configuration for Codex CLI
+ */
+
+import path from 'path';
+import type { McpServerConfig } from '@automaker/types';
+import * as secureFs from '../lib/secure-fs.js';
+
+const CODEX_CONFIG_DIR = '.codex';
+const CODEX_CONFIG_FILENAME = 'config.toml';
+const CODEX_MCP_SECTION = 'mcp_servers';
+
+function formatTomlString(value: string): string {
+ return JSON.stringify(value);
+}
+
+function formatTomlArray(values: string[]): string {
+ const formatted = values.map((value) => formatTomlString(value)).join(', ');
+ return `[${formatted}]`;
+}
+
+function formatTomlInlineTable(values: Record): string {
+ const entries = Object.entries(values).map(
+ ([key, value]) => `${key} = ${formatTomlString(value)}`
+ );
+ return `{ ${entries.join(', ')} }`;
+}
+
+function formatTomlKey(key: string): string {
+ return `"${key.replace(/"/g, '\\"')}"`;
+}
+
+function buildServerBlock(name: string, server: McpServerConfig): string[] {
+ const lines: string[] = [];
+ const section = `${CODEX_MCP_SECTION}.${formatTomlKey(name)}`;
+ lines.push(`[${section}]`);
+
+ if (server.type) {
+ lines.push(`type = ${formatTomlString(server.type)}`);
+ }
+
+ if ('command' in server && server.command) {
+ lines.push(`command = ${formatTomlString(server.command)}`);
+ }
+
+ if ('args' in server && server.args && server.args.length > 0) {
+ lines.push(`args = ${formatTomlArray(server.args)}`);
+ }
+
+ if ('env' in server && server.env && Object.keys(server.env).length > 0) {
+ lines.push(`env = ${formatTomlInlineTable(server.env)}`);
+ }
+
+ if ('url' in server && server.url) {
+ lines.push(`url = ${formatTomlString(server.url)}`);
+ }
+
+ if ('headers' in server && server.headers && Object.keys(server.headers).length > 0) {
+ lines.push(`headers = ${formatTomlInlineTable(server.headers)}`);
+ }
+
+ return lines;
+}
+
+export class CodexConfigManager {
+ async configureMcpServers(
+ cwd: string,
+ mcpServers: Record
+ ): Promise {
+ const configDir = path.join(cwd, CODEX_CONFIG_DIR);
+ const configPath = path.join(configDir, CODEX_CONFIG_FILENAME);
+
+ await secureFs.mkdir(configDir, { recursive: true });
+
+ const blocks: string[] = [];
+ for (const [name, server] of Object.entries(mcpServers)) {
+ blocks.push(...buildServerBlock(name, server), '');
+ }
+
+ const content = blocks.join('\n').trim();
+ if (content) {
+ await secureFs.writeFile(configPath, content + '\n', 'utf-8');
+ }
+ }
+}
diff --git a/temp_repo/apps/server/src/providers/codex-models.ts b/temp_repo/apps/server/src/providers/codex-models.ts
new file mode 100644
index 0000000000000000000000000000000000000000..22839e28b341eb9fa7b9d80393b1bd3b33bf2e4c
--- /dev/null
+++ b/temp_repo/apps/server/src/providers/codex-models.ts
@@ -0,0 +1,188 @@
+/**
+ * Codex Model Definitions
+ *
+ * Official Codex CLI models as documented at https://developers.openai.com/codex/models/
+ */
+
+import { CODEX_MODEL_MAP } from '@automaker/types';
+import type { ModelDefinition } from './types.js';
+
+const CONTEXT_WINDOW_256K = 256000;
+const CONTEXT_WINDOW_128K = 128000;
+const MAX_OUTPUT_32K = 32000;
+const MAX_OUTPUT_16K = 16000;
+
+/**
+ * All available Codex models with their specifications
+ * Based on https://developers.openai.com/codex/models/
+ */
+export const CODEX_MODELS: ModelDefinition[] = [
+ // ========== Recommended Codex Models ==========
+ {
+ id: CODEX_MODEL_MAP.gpt53Codex,
+ name: 'GPT-5.3-Codex',
+ modelString: CODEX_MODEL_MAP.gpt53Codex,
+ provider: 'openai',
+ description: 'Latest frontier agentic coding model.',
+ contextWindow: CONTEXT_WINDOW_256K,
+ maxOutputTokens: MAX_OUTPUT_32K,
+ supportsVision: true,
+ supportsTools: true,
+ tier: 'premium' as const,
+ default: true,
+ hasReasoning: true,
+ },
+ {
+ id: CODEX_MODEL_MAP.gpt53CodexSpark,
+ name: 'GPT-5.3-Codex-Spark',
+ modelString: CODEX_MODEL_MAP.gpt53CodexSpark,
+ provider: 'openai',
+ description: 'Near-instant real-time coding model, 1000+ tokens/sec.',
+ contextWindow: CONTEXT_WINDOW_256K,
+ maxOutputTokens: MAX_OUTPUT_32K,
+ supportsVision: true,
+ supportsTools: true,
+ tier: 'premium' as const,
+ hasReasoning: true,
+ },
+ {
+ id: CODEX_MODEL_MAP.gpt52Codex,
+ name: 'GPT-5.2-Codex',
+ modelString: CODEX_MODEL_MAP.gpt52Codex,
+ provider: 'openai',
+ description: 'Frontier agentic coding model.',
+ contextWindow: CONTEXT_WINDOW_256K,
+ maxOutputTokens: MAX_OUTPUT_32K,
+ supportsVision: true,
+ supportsTools: true,
+ tier: 'premium' as const,
+ hasReasoning: true,
+ },
+ {
+ id: CODEX_MODEL_MAP.gpt51CodexMax,
+ name: 'GPT-5.1-Codex-Max',
+ modelString: CODEX_MODEL_MAP.gpt51CodexMax,
+ provider: 'openai',
+ description: 'Codex-optimized flagship for deep and fast reasoning.',
+ contextWindow: CONTEXT_WINDOW_256K,
+ maxOutputTokens: MAX_OUTPUT_32K,
+ supportsVision: true,
+ supportsTools: true,
+ tier: 'premium' as const,
+ hasReasoning: true,
+ },
+ {
+ id: CODEX_MODEL_MAP.gpt51CodexMini,
+ name: 'GPT-5.1-Codex-Mini',
+ modelString: CODEX_MODEL_MAP.gpt51CodexMini,
+ provider: 'openai',
+ description: 'Optimized for codex. Cheaper, faster, but less capable.',
+ contextWindow: CONTEXT_WINDOW_128K,
+ maxOutputTokens: MAX_OUTPUT_16K,
+ supportsVision: true,
+ supportsTools: true,
+ tier: 'basic' as const,
+ hasReasoning: false,
+ },
+ {
+ id: CODEX_MODEL_MAP.gpt51Codex,
+ name: 'GPT-5.1-Codex',
+ modelString: CODEX_MODEL_MAP.gpt51Codex,
+ provider: 'openai',
+ description: 'Original GPT-5.1 Codex agentic coding model.',
+ contextWindow: CONTEXT_WINDOW_256K,
+ maxOutputTokens: MAX_OUTPUT_32K,
+ supportsVision: true,
+ supportsTools: true,
+ tier: 'standard' as const,
+ hasReasoning: true,
+ },
+ {
+ id: CODEX_MODEL_MAP.gpt5Codex,
+ name: 'GPT-5-Codex',
+ modelString: CODEX_MODEL_MAP.gpt5Codex,
+ provider: 'openai',
+ description: 'Original GPT-5 Codex model.',
+ contextWindow: CONTEXT_WINDOW_128K,
+ maxOutputTokens: MAX_OUTPUT_16K,
+ supportsVision: true,
+ supportsTools: true,
+ tier: 'standard' as const,
+ hasReasoning: true,
+ },
+ {
+ id: CODEX_MODEL_MAP.gpt5CodexMini,
+ name: 'GPT-5-Codex-Mini',
+ modelString: CODEX_MODEL_MAP.gpt5CodexMini,
+ provider: 'openai',
+ description: 'Smaller, cheaper GPT-5 Codex variant.',
+ contextWindow: CONTEXT_WINDOW_128K,
+ maxOutputTokens: MAX_OUTPUT_16K,
+ supportsVision: true,
+ supportsTools: true,
+ tier: 'basic' as const,
+ hasReasoning: false,
+ },
+
+ // ========== General-Purpose GPT Models ==========
+ {
+ id: CODEX_MODEL_MAP.gpt52,
+ name: 'GPT-5.2',
+ modelString: CODEX_MODEL_MAP.gpt52,
+ provider: 'openai',
+ description: 'Latest frontier model with improvements across knowledge, reasoning and coding.',
+ contextWindow: CONTEXT_WINDOW_256K,
+ maxOutputTokens: MAX_OUTPUT_32K,
+ supportsVision: true,
+ supportsTools: true,
+ tier: 'standard' as const,
+ hasReasoning: true,
+ },
+ {
+ id: CODEX_MODEL_MAP.gpt51,
+ name: 'GPT-5.1',
+ modelString: CODEX_MODEL_MAP.gpt51,
+ provider: 'openai',
+ description: 'Great for coding and agentic tasks across domains.',
+ contextWindow: CONTEXT_WINDOW_256K,
+ maxOutputTokens: MAX_OUTPUT_32K,
+ supportsVision: true,
+ supportsTools: true,
+ tier: 'standard' as const,
+ hasReasoning: true,
+ },
+ {
+ id: CODEX_MODEL_MAP.gpt5,
+ name: 'GPT-5',
+ modelString: CODEX_MODEL_MAP.gpt5,
+ provider: 'openai',
+ description: 'Base GPT-5 model.',
+ contextWindow: CONTEXT_WINDOW_128K,
+ maxOutputTokens: MAX_OUTPUT_16K,
+ supportsVision: true,
+ supportsTools: true,
+ tier: 'standard' as const,
+ hasReasoning: true,
+ },
+];
+
+/**
+ * Get model definition by ID
+ */
+export function getCodexModelById(modelId: string): ModelDefinition | undefined {
+ return CODEX_MODELS.find((m) => m.id === modelId || m.modelString === modelId);
+}
+
+/**
+ * Get all models that support reasoning
+ */
+export function getReasoningModels(): ModelDefinition[] {
+ return CODEX_MODELS.filter((m) => m.hasReasoning);
+}
+
+/**
+ * Get models by tier
+ */
+export function getModelsByTier(tier: 'premium' | 'standard' | 'basic'): ModelDefinition[] {
+ return CODEX_MODELS.filter((m) => m.tier === tier);
+}
diff --git a/temp_repo/apps/server/src/providers/codex-provider.ts b/temp_repo/apps/server/src/providers/codex-provider.ts
new file mode 100644
index 0000000000000000000000000000000000000000..3288f42ffd7907e43a488a69e4b23ddb5ca715c2
--- /dev/null
+++ b/temp_repo/apps/server/src/providers/codex-provider.ts
@@ -0,0 +1,1229 @@
+/**
+ * Codex Provider - Executes queries using Codex CLI
+ *
+ * Spawns the Codex CLI and converts JSONL output into ProviderMessage format.
+ */
+
+import path from 'path';
+import { BaseProvider } from './base-provider.js';
+import {
+ spawnJSONLProcess,
+ spawnProcess,
+ findCodexCliPath,
+ getCodexAuthIndicators,
+ secureFs,
+ getDataDirectory,
+ getCodexConfigDir,
+} from '@automaker/platform';
+import { checkCodexAuthentication } from '../lib/codex-auth.js';
+import {
+ formatHistoryAsText,
+ extractTextFromContent,
+ classifyError,
+ getUserFriendlyErrorMessage,
+ createLogger,
+} from '@automaker/utils';
+import type {
+ ExecuteOptions,
+ ProviderMessage,
+ InstallationStatus,
+ ModelDefinition,
+} from './types.js';
+import {
+ supportsReasoningEffort,
+ validateBareModelId,
+ calculateReasoningTimeout,
+ type CodexApprovalPolicy,
+ type CodexSandboxMode,
+ type CodexAuthStatus,
+} from '@automaker/types';
+import { CodexConfigManager } from './codex-config-manager.js';
+import { executeCodexSdkQuery } from './codex-sdk-client.js';
+import {
+ resolveCodexToolCall,
+ extractCodexTodoItems,
+ getCodexTodoToolName,
+} from './codex-tool-mapping.js';
+import { SettingsService } from '../services/settings-service.js';
+import { createTempEnvOverride } from '../lib/auth-utils.js';
+import { checkSandboxCompatibility } from '../lib/sdk-options.js';
+import { CODEX_MODELS } from './codex-models.js';
+
+const CODEX_COMMAND = 'codex';
+const CODEX_EXEC_SUBCOMMAND = 'exec';
+const CODEX_RESUME_SUBCOMMAND = 'resume';
+const CODEX_JSON_FLAG = '--json';
+const CODEX_MODEL_FLAG = '--model';
+const CODEX_VERSION_FLAG = '--version';
+const CODEX_CONFIG_FLAG = '--config';
+const CODEX_ADD_DIR_FLAG = '--add-dir';
+const CODEX_OUTPUT_SCHEMA_FLAG = '--output-schema';
+const CODEX_SKIP_GIT_REPO_CHECK_FLAG = '--skip-git-repo-check';
+const CODEX_REASONING_EFFORT_KEY = 'reasoning_effort';
+const CODEX_YOLO_FLAG = '--dangerously-bypass-approvals-and-sandbox';
+const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY';
+const CODEX_EXECUTION_MODE_CLI = 'cli';
+const CODEX_EXECUTION_MODE_SDK = 'sdk';
+const ERROR_CODEX_CLI_REQUIRED =
+ 'Codex CLI is required for tool-enabled requests. Please install Codex CLI and run `codex login`.';
+const ERROR_CODEX_AUTH_REQUIRED = "Codex authentication is required. Please run 'codex login'.";
+const ERROR_CODEX_SDK_AUTH_REQUIRED = 'OpenAI API key required for Codex SDK execution.';
+
+const CODEX_EVENT_TYPES = {
+ itemCompleted: 'item.completed',
+ itemStarted: 'item.started',
+ itemUpdated: 'item.updated',
+ turnCompleted: 'turn.completed',
+ error: 'error',
+} as const;
+
+const CODEX_ITEM_TYPES = {
+ reasoning: 'reasoning',
+ agentMessage: 'agent_message',
+ commandExecution: 'command_execution',
+ todoList: 'todo_list',
+} as const;
+
+const SYSTEM_PROMPT_LABEL = 'System instructions';
+const HISTORY_HEADER = 'Current request:\n';
+const TEXT_ENCODING = 'utf-8';
+/**
+ * Default timeout for Codex CLI operations in milliseconds.
+ * This is the "no output" timeout - if the CLI doesn't produce any JSONL output
+ * for this duration, the process is killed. For reasoning models with high
+ * reasoning effort, this timeout is dynamically extended via calculateReasoningTimeout().
+ *
+ * For feature generation (which can generate 50+ features), we use a much longer
+ * base timeout (5 minutes) since Codex models are slower at generating large JSON responses.
+ *
+ * @see calculateReasoningTimeout from @automaker/types
+ */
+const CODEX_CLI_TIMEOUT_MS = 120000; // 2 minutes — matches CLI provider base timeout
+const CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS = 300000; // 5 minutes for feature generation
+const SYSTEM_PROMPT_SEPARATOR = '\n\n';
+const CODEX_INSTRUCTIONS_DIR = '.codex';
+const CODEX_INSTRUCTIONS_SECTION = 'Codex Project Instructions';
+const CODEX_INSTRUCTIONS_PATH_LABEL = 'Path';
+const CODEX_INSTRUCTIONS_SOURCE_LABEL = 'Source';
+const CODEX_INSTRUCTIONS_USER_SOURCE = 'User instructions';
+const CODEX_INSTRUCTIONS_PROJECT_SOURCE = 'Project instructions';
+const CODEX_USER_INSTRUCTIONS_FILE = 'AGENTS.md';
+const CODEX_PROJECT_INSTRUCTIONS_FILES = ['AGENTS.md'] as const;
+const CODEX_SETTINGS_DIR_FALLBACK = './data';
+const DEFAULT_CODEX_AUTO_LOAD_AGENTS = false;
+const DEFAULT_CODEX_SANDBOX_MODE: CodexSandboxMode = 'workspace-write';
+const DEFAULT_CODEX_APPROVAL_POLICY: CodexApprovalPolicy = 'on-request';
+const TOOL_USE_ID_PREFIX = 'codex-tool-';
+const ITEM_ID_KEYS = ['id', 'item_id', 'call_id', 'tool_use_id', 'command_id'] as const;
+const EVENT_ID_KEYS = ['id', 'event_id', 'request_id'] as const;
+const COMMAND_OUTPUT_FIELDS = ['output', 'stdout', 'stderr', 'result'] as const;
+const COMMAND_OUTPUT_SEPARATOR = '\n';
+const OUTPUT_SCHEMA_FILENAME = 'output-schema.json';
+const OUTPUT_SCHEMA_INDENT_SPACES = 2;
+const IMAGE_TEMP_DIR = '.codex-images';
+const IMAGE_FILE_PREFIX = 'image-';
+const IMAGE_FILE_EXT = '.png';
+const DEFAULT_ALLOWED_TOOLS = [
+ 'Read',
+ 'Write',
+ 'Edit',
+ 'MultiEdit',
+ 'Glob',
+ 'Grep',
+ 'LS',
+ 'Bash',
+ 'WebSearch',
+ 'WebFetch',
+ 'TodoWrite',
+ 'Task',
+ 'Skill',
+] as const;
+const SEARCH_TOOL_NAMES = new Set(['WebSearch', 'WebFetch']);
+const MIN_MAX_TURNS = 1;
+const CONFIG_KEY_MAX_TURNS = 'max_turns';
+const CONSTRAINTS_SECTION_TITLE = 'Codex Execution Constraints';
+const CONSTRAINTS_MAX_TURNS_LABEL = 'Max turns';
+const CONSTRAINTS_ALLOWED_TOOLS_LABEL = 'Allowed tools';
+const CONSTRAINTS_OUTPUT_SCHEMA_LABEL = 'Output format';
+const CONSTRAINTS_SESSION_ID_LABEL = 'Session ID';
+const CONSTRAINTS_NO_TOOLS_VALUE = 'none';
+const CONSTRAINTS_OUTPUT_SCHEMA_VALUE = 'Respond with JSON that matches the provided schema.';
+
+type CodexExecutionMode = typeof CODEX_EXECUTION_MODE_CLI | typeof CODEX_EXECUTION_MODE_SDK;
+type CodexExecutionPlan = {
+ mode: CodexExecutionMode;
+ cliPath: string | null;
+ openAiApiKey?: string | null;
+};
+
+const ALLOWED_ENV_VARS = [
+ OPENAI_API_KEY_ENV,
+ 'PATH',
+ 'HOME',
+ 'SHELL',
+ 'TERM',
+ 'USER',
+ 'LANG',
+ 'LC_ALL',
+];
+
+function buildEnv(): Record {
+ const env: Record = {};
+ for (const key of ALLOWED_ENV_VARS) {
+ const value = process.env[key];
+ if (value) {
+ env[key] = value;
+ }
+ }
+ return env;
+}
+
+async function resolveOpenAiApiKey(): Promise {
+ const envKey = process.env[OPENAI_API_KEY_ENV];
+ if (envKey) {
+ return envKey;
+ }
+
+ try {
+ const settingsService = new SettingsService(getCodexSettingsDir());
+ const credentials = await settingsService.getCredentials();
+ const storedKey = credentials.apiKeys.openai?.trim();
+ return storedKey ? storedKey : null;
+ } catch {
+ return null;
+ }
+}
+
+function hasMcpServersConfigured(options: ExecuteOptions): boolean {
+ return Boolean(options.mcpServers && Object.keys(options.mcpServers).length > 0);
+}
+
+function isNoToolsRequested(options: ExecuteOptions): boolean {
+ return Array.isArray(options.allowedTools) && options.allowedTools.length === 0;
+}
+
+function isSdkEligible(options: ExecuteOptions): boolean {
+ return isNoToolsRequested(options) && !hasMcpServersConfigured(options);
+}
+
+function isSdkEligibleWithApiKey(options: ExecuteOptions): boolean {
+ // When using an API key (not CLI OAuth), prefer SDK over CLI to avoid OAuth issues.
+ // SDK mode is used when MCP servers are not configured (MCP requires CLI).
+ // Tool requests are handled by the SDK, so we allow SDK mode even with tools.
+ return !hasMcpServersConfigured(options);
+}
+
+async function resolveCodexExecutionPlan(options: ExecuteOptions): Promise {
+ const cliPath = await findCodexCliPath();
+ const authIndicators = await getCodexAuthIndicators();
+ const openAiApiKey = await resolveOpenAiApiKey();
+ const hasApiKey = Boolean(openAiApiKey);
+ const cliAvailable = Boolean(cliPath);
+ // CLI OAuth login takes priority: if the user has logged in via `codex login`,
+ // use the CLI regardless of whether an API key is also stored.
+ // hasOAuthToken = OAuth session from `codex login`
+ // authIndicators.hasApiKey = API key stored in Codex's own auth file (via `codex login --api-key`)
+ // Both are "CLI-native" auth — distinct from an API key stored in Automaker's credentials.
+ const hasCliNativeAuth = authIndicators.hasOAuthToken || authIndicators.hasApiKey;
+ const sdkEligible = isSdkEligible(options);
+
+ // If CLI is available and the user authenticated via the CLI (`codex login`),
+ // prefer CLI mode over SDK. This ensures `codex login` sessions take priority
+ // over API keys stored in Automaker's credentials.
+ if (cliAvailable && hasCliNativeAuth) {
+ return {
+ mode: CODEX_EXECUTION_MODE_CLI,
+ cliPath,
+ openAiApiKey,
+ };
+ }
+
+ // No CLI-native auth — prefer SDK when an API key is available.
+ // Using SDK with an API key avoids OAuth issues that can arise with the CLI.
+ // MCP servers still require CLI mode since the SDK doesn't support MCP.
+ if (hasApiKey && isSdkEligibleWithApiKey(options)) {
+ return {
+ mode: CODEX_EXECUTION_MODE_SDK,
+ cliPath,
+ openAiApiKey,
+ };
+ }
+
+ // MCP servers are requested with an API key but no CLI-native auth — use CLI mode
+ // with the API key passed as an environment variable.
+ if (hasApiKey && cliAvailable) {
+ return {
+ mode: CODEX_EXECUTION_MODE_CLI,
+ cliPath,
+ openAiApiKey,
+ };
+ }
+
+ if (sdkEligible) {
+ if (!cliAvailable) {
+ throw new Error(ERROR_CODEX_SDK_AUTH_REQUIRED);
+ }
+ }
+
+ if (!cliAvailable) {
+ throw new Error(ERROR_CODEX_CLI_REQUIRED);
+ }
+
+ // At this point, neither hasCliNativeAuth nor hasApiKey is true,
+ // so authentication is required regardless.
+ throw new Error(ERROR_CODEX_AUTH_REQUIRED);
+}
+
+function getEventType(event: Record): string | null {
+ if (typeof event.type === 'string') {
+ return event.type;
+ }
+ if (typeof event.event === 'string') {
+ return event.event;
+ }
+ return null;
+}
+
+function extractText(value: unknown): string | null {
+ if (typeof value === 'string') {
+ return value;
+ }
+ if (Array.isArray(value)) {
+ return value
+ .map((item) => extractText(item))
+ .filter(Boolean)
+ .join('\n');
+ }
+ if (value && typeof value === 'object') {
+ const record = value as Record;
+ if (typeof record.text === 'string') {
+ return record.text;
+ }
+ if (typeof record.content === 'string') {
+ return record.content;
+ }
+ if (typeof record.message === 'string') {
+ return record.message;
+ }
+ }
+ return null;
+}
+
+function extractCommandText(item: Record): string | null {
+ const direct = extractText(item.command ?? item.input ?? item.content);
+ if (direct) {
+ return direct;
+ }
+ return null;
+}
+
+function extractCommandOutput(item: Record): string | null {
+ const outputs: string[] = [];
+ for (const field of COMMAND_OUTPUT_FIELDS) {
+ const value = item[field];
+ const text = extractText(value);
+ if (text) {
+ outputs.push(text);
+ }
+ }
+
+ if (outputs.length === 0) {
+ return null;
+ }
+
+ const uniqueOutputs = outputs.filter((output, index) => outputs.indexOf(output) === index);
+ return uniqueOutputs.join(COMMAND_OUTPUT_SEPARATOR);
+}
+
+function extractItemType(item: Record): string | null {
+ if (typeof item.type === 'string') {
+ return item.type;
+ }
+ if (typeof item.kind === 'string') {
+ return item.kind;
+ }
+ return null;
+}
+
+function resolveSystemPrompt(systemPrompt?: unknown): string | null {
+ if (!systemPrompt) {
+ return null;
+ }
+ if (typeof systemPrompt === 'string') {
+ return systemPrompt;
+ }
+ if (typeof systemPrompt === 'object' && systemPrompt !== null) {
+ const record = systemPrompt as Record;
+ if (typeof record.append === 'string') {
+ return record.append;
+ }
+ }
+ return null;
+}
+
+function buildPromptText(options: ExecuteOptions): string {
+ return typeof options.prompt === 'string'
+ ? options.prompt
+ : extractTextFromContent(options.prompt);
+}
+
+function buildCombinedPrompt(options: ExecuteOptions, systemPromptText?: string | null): string {
+ const promptText = buildPromptText(options);
+ const historyText = options.conversationHistory
+ ? formatHistoryAsText(options.conversationHistory)
+ : '';
+ const resolvedSystemPrompt = systemPromptText ?? resolveSystemPrompt(options.systemPrompt);
+
+ const systemSection = resolvedSystemPrompt
+ ? `${SYSTEM_PROMPT_LABEL}:\n${resolvedSystemPrompt}\n\n`
+ : '';
+
+ return `${historyText}${systemSection}${HISTORY_HEADER}${promptText}`;
+}
+
+function buildResumePrompt(options: ExecuteOptions): string {
+ const promptText = buildPromptText(options);
+ return `${HISTORY_HEADER}${promptText}`;
+}
+
+function formatConfigValue(value: string | number | boolean): string {
+ return String(value);
+}
+
+function buildConfigOverrides(
+ overrides: Array<{ key: string; value: string | number | boolean }>
+): string[] {
+ const args: string[] = [];
+ for (const override of overrides) {
+ args.push(CODEX_CONFIG_FLAG, `${override.key}=${formatConfigValue(override.value)}`);
+ }
+ return args;
+}
+
+function resolveMaxTurns(maxTurns?: number): number | null {
+ if (typeof maxTurns !== 'number' || Number.isNaN(maxTurns) || !Number.isFinite(maxTurns)) {
+ return null;
+ }
+ const normalized = Math.floor(maxTurns);
+ return normalized >= MIN_MAX_TURNS ? normalized : null;
+}
+
+function resolveSearchEnabled(allowedTools: string[], restrictTools: boolean): boolean {
+ const toolsToCheck = restrictTools ? allowedTools : Array.from(DEFAULT_ALLOWED_TOOLS);
+ return toolsToCheck.some((tool) => SEARCH_TOOL_NAMES.has(tool));
+}
+
+function buildCodexConstraintsPrompt(
+ options: ExecuteOptions,
+ config: {
+ allowedTools: string[];
+ restrictTools: boolean;
+ maxTurns: number | null;
+ hasOutputSchema: boolean;
+ }
+): string | null {
+ const lines: string[] = [];
+
+ if (config.maxTurns !== null) {
+ lines.push(`${CONSTRAINTS_MAX_TURNS_LABEL}: ${config.maxTurns}`);
+ }
+
+ if (config.restrictTools) {
+ const allowed =
+ config.allowedTools.length > 0 ? config.allowedTools.join(', ') : CONSTRAINTS_NO_TOOLS_VALUE;
+ lines.push(`${CONSTRAINTS_ALLOWED_TOOLS_LABEL}: ${allowed}`);
+ }
+
+ if (config.hasOutputSchema) {
+ lines.push(`${CONSTRAINTS_OUTPUT_SCHEMA_LABEL}: ${CONSTRAINTS_OUTPUT_SCHEMA_VALUE}`);
+ }
+
+ if (options.sdkSessionId) {
+ lines.push(`${CONSTRAINTS_SESSION_ID_LABEL}: ${options.sdkSessionId}`);
+ }
+
+ if (lines.length === 0) {
+ return null;
+ }
+
+ return `## ${CONSTRAINTS_SECTION_TITLE}\n${lines.map((line) => `- ${line}`).join('\n')}`;
+}
+
+async function writeOutputSchemaFile(
+ cwd: string,
+ outputFormat?: ExecuteOptions['outputFormat']
+): Promise {
+ if (!outputFormat || outputFormat.type !== 'json_schema') {
+ return null;
+ }
+ if (!outputFormat.schema || typeof outputFormat.schema !== 'object') {
+ throw new Error('Codex output schema must be a JSON object.');
+ }
+
+ const schemaDir = path.join(cwd, CODEX_INSTRUCTIONS_DIR);
+ await secureFs.mkdir(schemaDir, { recursive: true });
+ const schemaPath = path.join(schemaDir, OUTPUT_SCHEMA_FILENAME);
+ const schemaContent = JSON.stringify(outputFormat.schema, null, OUTPUT_SCHEMA_INDENT_SPACES);
+ await secureFs.writeFile(schemaPath, schemaContent, TEXT_ENCODING);
+ return schemaPath;
+}
+
+type ImageBlock = {
+ type: 'image';
+ source: {
+ type: string;
+ media_type: string;
+ data: string;
+ };
+};
+
+function extractImageBlocks(prompt: ExecuteOptions['prompt']): ImageBlock[] {
+ if (typeof prompt === 'string') {
+ return [];
+ }
+ if (!Array.isArray(prompt)) {
+ return [];
+ }
+
+ const images: ImageBlock[] = [];
+ for (const block of prompt) {
+ if (
+ block &&
+ typeof block === 'object' &&
+ 'type' in block &&
+ block.type === 'image' &&
+ 'source' in block &&
+ block.source &&
+ typeof block.source === 'object' &&
+ 'data' in block.source &&
+ 'media_type' in block.source
+ ) {
+ images.push(block as ImageBlock);
+ }
+ }
+ return images;
+}
+
+async function writeImageFiles(cwd: string, imageBlocks: ImageBlock[]): Promise {
+ if (imageBlocks.length === 0) {
+ return [];
+ }
+
+ const imageDir = path.join(cwd, CODEX_INSTRUCTIONS_DIR, IMAGE_TEMP_DIR);
+ await secureFs.mkdir(imageDir, { recursive: true });
+
+ const imagePaths: string[] = [];
+ for (let i = 0; i < imageBlocks.length; i++) {
+ const imageBlock = imageBlocks[i];
+ const imageName = `${IMAGE_FILE_PREFIX}${Date.now()}-${i}${IMAGE_FILE_EXT}`;
+ const imagePath = path.join(imageDir, imageName);
+
+ // Convert base64 to buffer
+ const imageData = Buffer.from(imageBlock.source.data, 'base64');
+ await secureFs.writeFile(imagePath, imageData);
+ imagePaths.push(imagePath);
+ }
+
+ return imagePaths;
+}
+
+function normalizeIdentifier(value: unknown): string | null {
+ if (typeof value === 'string') {
+ const trimmed = value.trim();
+ return trimmed ? trimmed : null;
+ }
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ return String(value);
+ }
+ return null;
+}
+
+function getIdentifierFromRecord(
+ record: Record,
+ keys: readonly string[]
+): string | null {
+ for (const key of keys) {
+ const id = normalizeIdentifier(record[key]);
+ if (id) {
+ return id;
+ }
+ }
+ return null;
+}
+
+function getItemIdentifier(
+ event: Record,
+ item: Record
+): string | null {
+ return (
+ getIdentifierFromRecord(item, ITEM_ID_KEYS) ?? getIdentifierFromRecord(event, EVENT_ID_KEYS)
+ );
+}
+
+class CodexToolUseTracker {
+ private readonly toolUseIdsByItem = new Map();
+ private readonly anonymousToolUses: string[] = [];
+ private sequence = 0;
+
+ register(event: Record, item: Record): string {
+ const itemId = getItemIdentifier(event, item);
+ const toolUseId = this.nextToolUseId();
+ if (itemId) {
+ this.toolUseIdsByItem.set(itemId, toolUseId);
+ } else {
+ this.anonymousToolUses.push(toolUseId);
+ }
+ return toolUseId;
+ }
+
+ resolve(event: Record, item: Record): string | null {
+ const itemId = getItemIdentifier(event, item);
+ if (itemId) {
+ const toolUseId = this.toolUseIdsByItem.get(itemId);
+ if (toolUseId) {
+ this.toolUseIdsByItem.delete(itemId);
+ return toolUseId;
+ }
+ }
+
+ if (this.anonymousToolUses.length > 0) {
+ return this.anonymousToolUses.shift() || null;
+ }
+
+ return null;
+ }
+
+ private nextToolUseId(): string {
+ this.sequence += 1;
+ return `${TOOL_USE_ID_PREFIX}${this.sequence}`;
+ }
+}
+
+type CodexCliSettings = {
+ autoLoadAgents: boolean;
+ sandboxMode: CodexSandboxMode;
+ approvalPolicy: CodexApprovalPolicy;
+ enableWebSearch: boolean;
+ enableImages: boolean;
+ additionalDirs: string[];
+ threadId?: string;
+};
+
+function getCodexSettingsDir(): string {
+ const configured = getDataDirectory() ?? process.env.DATA_DIR;
+ return configured ? path.resolve(configured) : path.resolve(CODEX_SETTINGS_DIR_FALLBACK);
+}
+
+async function loadCodexCliSettings(
+ overrides?: ExecuteOptions['codexSettings']
+): Promise {
+ const defaults: CodexCliSettings = {
+ autoLoadAgents: DEFAULT_CODEX_AUTO_LOAD_AGENTS,
+ sandboxMode: DEFAULT_CODEX_SANDBOX_MODE,
+ approvalPolicy: DEFAULT_CODEX_APPROVAL_POLICY,
+ enableWebSearch: false,
+ enableImages: true,
+ additionalDirs: [],
+ threadId: undefined,
+ };
+
+ try {
+ const settingsService = new SettingsService(getCodexSettingsDir());
+ const settings = await settingsService.getGlobalSettings();
+ const resolved: CodexCliSettings = {
+ autoLoadAgents: settings.codexAutoLoadAgents ?? defaults.autoLoadAgents,
+ sandboxMode: settings.codexSandboxMode ?? defaults.sandboxMode,
+ approvalPolicy: settings.codexApprovalPolicy ?? defaults.approvalPolicy,
+ enableWebSearch: settings.codexEnableWebSearch ?? defaults.enableWebSearch,
+ enableImages: settings.codexEnableImages ?? defaults.enableImages,
+ additionalDirs: settings.codexAdditionalDirs ?? defaults.additionalDirs,
+ threadId: settings.codexThreadId,
+ };
+
+ if (!overrides) {
+ return resolved;
+ }
+
+ return {
+ autoLoadAgents: overrides.autoLoadAgents ?? resolved.autoLoadAgents,
+ sandboxMode: overrides.sandboxMode ?? resolved.sandboxMode,
+ approvalPolicy: overrides.approvalPolicy ?? resolved.approvalPolicy,
+ enableWebSearch: overrides.enableWebSearch ?? resolved.enableWebSearch,
+ enableImages: overrides.enableImages ?? resolved.enableImages,
+ additionalDirs: overrides.additionalDirs ?? resolved.additionalDirs,
+ threadId: overrides.threadId ?? resolved.threadId,
+ };
+ } catch {
+ return {
+ autoLoadAgents: overrides?.autoLoadAgents ?? defaults.autoLoadAgents,
+ sandboxMode: overrides?.sandboxMode ?? defaults.sandboxMode,
+ approvalPolicy: overrides?.approvalPolicy ?? defaults.approvalPolicy,
+ enableWebSearch: overrides?.enableWebSearch ?? defaults.enableWebSearch,
+ enableImages: overrides?.enableImages ?? defaults.enableImages,
+ additionalDirs: overrides?.additionalDirs ?? defaults.additionalDirs,
+ threadId: overrides?.threadId ?? defaults.threadId,
+ };
+ }
+}
+
+function buildCodexInstructionsPrompt(
+ filePath: string,
+ content: string,
+ sourceLabel: string
+): string {
+ return `## ${CODEX_INSTRUCTIONS_SECTION}\n**${CODEX_INSTRUCTIONS_SOURCE_LABEL}:** ${sourceLabel}\n**${CODEX_INSTRUCTIONS_PATH_LABEL}:** \`${filePath}\`\n\n${content}`;
+}
+
+async function readCodexInstructionFile(filePath: string): Promise {
+ try {
+ const raw = await secureFs.readFile(filePath, TEXT_ENCODING);
+ const content = String(raw).trim();
+ return content ? content : null;
+ } catch {
+ return null;
+ }
+}
+
+async function loadCodexInstructions(cwd: string, enabled: boolean): Promise {
+ if (!enabled) {
+ return null;
+ }
+
+ const sources: Array<{ path: string; content: string; sourceLabel: string }> = [];
+ const userInstructionsPath = path.join(getCodexConfigDir(), CODEX_USER_INSTRUCTIONS_FILE);
+ const userContent = await readCodexInstructionFile(userInstructionsPath);
+ if (userContent) {
+ sources.push({
+ path: userInstructionsPath,
+ content: userContent,
+ sourceLabel: CODEX_INSTRUCTIONS_USER_SOURCE,
+ });
+ }
+
+ for (const fileName of CODEX_PROJECT_INSTRUCTIONS_FILES) {
+ const projectPath = path.join(cwd, CODEX_INSTRUCTIONS_DIR, fileName);
+ const projectContent = await readCodexInstructionFile(projectPath);
+ if (projectContent) {
+ sources.push({
+ path: projectPath,
+ content: projectContent,
+ sourceLabel: CODEX_INSTRUCTIONS_PROJECT_SOURCE,
+ });
+ }
+ }
+
+ if (sources.length === 0) {
+ return null;
+ }
+
+ const seen = new Set();
+ const uniqueSources = sources.filter((source) => {
+ const normalized = source.content.trim();
+ if (seen.has(normalized)) {
+ return false;
+ }
+ seen.add(normalized);
+ return true;
+ });
+
+ return uniqueSources
+ .map((source) => buildCodexInstructionsPrompt(source.path, source.content, source.sourceLabel))
+ .join('\n\n');
+}
+
+const logger = createLogger('CodexProvider');
+
+export class CodexProvider extends BaseProvider {
+ getName(): string {
+ return 'codex';
+ }
+
+ async *executeQuery(options: ExecuteOptions): AsyncGenerator {
+ // Validate that model doesn't have a provider prefix (except codex- which should already be stripped)
+ // AgentService should strip prefixes before passing to providers
+ validateBareModelId(options.model, 'CodexProvider', 'codex');
+
+ try {
+ const mcpServers = options.mcpServers ?? {};
+ const hasMcpServers = Object.keys(mcpServers).length > 0;
+ const codexSettings = await loadCodexCliSettings(options.codexSettings);
+ const codexInstructions = await loadCodexInstructions(
+ options.cwd,
+ codexSettings.autoLoadAgents
+ );
+ const baseSystemPrompt = resolveSystemPrompt(options.systemPrompt);
+ const resolvedMaxTurns = resolveMaxTurns(options.maxTurns);
+ if (resolvedMaxTurns === null && options.maxTurns === undefined) {
+ logger.warn(
+ `[executeQuery] maxTurns not provided — Codex CLI will use its internal default. ` +
+ `This may cause premature completion. Model: ${options.model}`
+ );
+ } else {
+ logger.info(
+ `[executeQuery] maxTurns: requested=${options.maxTurns}, resolved=${resolvedMaxTurns}, model=${options.model}`
+ );
+ }
+ const resolvedAllowedTools = options.allowedTools ?? Array.from(DEFAULT_ALLOWED_TOOLS);
+ const restrictTools = !hasMcpServers || options.mcpUnrestrictedTools === false;
+ const wantsOutputSchema = Boolean(
+ options.outputFormat && options.outputFormat.type === 'json_schema'
+ );
+ const constraintsPrompt = buildCodexConstraintsPrompt(options, {
+ allowedTools: resolvedAllowedTools,
+ restrictTools,
+ maxTurns: resolvedMaxTurns,
+ hasOutputSchema: wantsOutputSchema,
+ });
+ const systemPromptParts = [codexInstructions, baseSystemPrompt, constraintsPrompt].filter(
+ (part): part is string => Boolean(part)
+ );
+ const combinedSystemPrompt = systemPromptParts.length
+ ? systemPromptParts.join(SYSTEM_PROMPT_SEPARATOR)
+ : null;
+
+ const executionPlan = await resolveCodexExecutionPlan(options);
+ if (executionPlan.mode === CODEX_EXECUTION_MODE_SDK) {
+ const cleanupEnv = executionPlan.openAiApiKey
+ ? createTempEnvOverride({ [OPENAI_API_KEY_ENV]: executionPlan.openAiApiKey })
+ : null;
+ try {
+ yield* executeCodexSdkQuery(options, combinedSystemPrompt);
+ } finally {
+ cleanupEnv?.();
+ }
+ return;
+ }
+
+ if (hasMcpServers) {
+ const configManager = new CodexConfigManager();
+ await configManager.configureMcpServers(options.cwd, options.mcpServers!);
+ }
+
+ const toolUseTracker = new CodexToolUseTracker();
+ const sandboxCheck = checkSandboxCompatibility(
+ options.cwd,
+ codexSettings.sandboxMode !== 'danger-full-access'
+ );
+ if (!sandboxCheck.enabled && sandboxCheck.message) {
+ console.warn(`[CodexProvider] ${sandboxCheck.message}`);
+ }
+ const searchEnabled =
+ codexSettings.enableWebSearch || resolveSearchEnabled(resolvedAllowedTools, restrictTools);
+ const isResumeQuery = Boolean(options.sdkSessionId);
+ const schemaPath = isResumeQuery
+ ? null
+ : await writeOutputSchemaFile(options.cwd, options.outputFormat);
+ const imageBlocks =
+ !isResumeQuery && codexSettings.enableImages ? extractImageBlocks(options.prompt) : [];
+ const imagePaths = isResumeQuery ? [] : await writeImageFiles(options.cwd, imageBlocks);
+ const approvalPolicy =
+ hasMcpServers && options.mcpAutoApproveTools !== undefined
+ ? options.mcpAutoApproveTools
+ ? 'never'
+ : 'on-request'
+ : codexSettings.approvalPolicy;
+ const promptText = isResumeQuery
+ ? buildResumePrompt(options)
+ : buildCombinedPrompt(options, combinedSystemPrompt);
+ const commandPath = executionPlan.cliPath || CODEX_COMMAND;
+
+ // Build config overrides for max turns and reasoning effort
+ const overrides: Array<{ key: string; value: string | number | boolean }> = [];
+ if (resolvedMaxTurns !== null) {
+ overrides.push({ key: CONFIG_KEY_MAX_TURNS, value: resolvedMaxTurns });
+ }
+
+ // Add reasoning effort if model supports it and reasoningEffort is specified
+ if (
+ options.reasoningEffort &&
+ supportsReasoningEffort(options.model) &&
+ options.reasoningEffort !== 'none'
+ ) {
+ overrides.push({ key: CODEX_REASONING_EFFORT_KEY, value: options.reasoningEffort });
+ }
+
+ // Add approval policy
+ overrides.push({ key: 'approval_policy', value: approvalPolicy });
+
+ // Add web search if enabled
+ if (searchEnabled) {
+ overrides.push({ key: 'features.web_search_request', value: true });
+ }
+
+ const configOverrideArgs = buildConfigOverrides(overrides);
+ const preExecArgs: string[] = [];
+
+ // Add additional directories with write access
+ if (
+ !isResumeQuery &&
+ codexSettings.additionalDirs &&
+ codexSettings.additionalDirs.length > 0
+ ) {
+ for (const dir of codexSettings.additionalDirs) {
+ preExecArgs.push(CODEX_ADD_DIR_FLAG, dir);
+ }
+ }
+
+ // If images were written to disk, add the image directory so the CLI can access them.
+ // Note: imagePaths is set to [] when isResumeQuery is true, so this check is sufficient.
+ if (imagePaths.length > 0) {
+ const imageDir = path.join(options.cwd, CODEX_INSTRUCTIONS_DIR, IMAGE_TEMP_DIR);
+ preExecArgs.push(CODEX_ADD_DIR_FLAG, imageDir);
+ }
+
+ // Model is already bare (no prefix) - validated by executeQuery
+ const codexCommand = isResumeQuery
+ ? [CODEX_EXEC_SUBCOMMAND, CODEX_RESUME_SUBCOMMAND]
+ : [CODEX_EXEC_SUBCOMMAND];
+
+ const args = [
+ ...codexCommand,
+ CODEX_YOLO_FLAG,
+ CODEX_SKIP_GIT_REPO_CHECK_FLAG,
+ ...preExecArgs,
+ CODEX_MODEL_FLAG,
+ options.model,
+ CODEX_JSON_FLAG,
+ ...configOverrideArgs,
+ ...(schemaPath ? [CODEX_OUTPUT_SCHEMA_FLAG, schemaPath] : []),
+ ...(options.sdkSessionId ? [options.sdkSessionId] : []),
+ '-', // Read prompt from stdin to avoid shell escaping issues
+ ];
+
+ const envOverrides = buildEnv();
+ if (executionPlan.openAiApiKey && !envOverrides[OPENAI_API_KEY_ENV]) {
+ envOverrides[OPENAI_API_KEY_ENV] = executionPlan.openAiApiKey;
+ }
+
+ // Calculate dynamic timeout based on reasoning effort.
+ // Higher reasoning effort (e.g., 'xhigh' for "xtra thinking" mode) requires more time
+ // for the model to generate reasoning tokens before producing output.
+ // This fixes GitHub issue #530 where features would get stuck with reasoning models.
+ //
+ // For feature generation with 'xhigh', use the extended 5-minute base timeout
+ // since generating 50+ features takes significantly longer than normal operations.
+ const baseTimeout =
+ options.reasoningEffort === 'xhigh'
+ ? CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS
+ : CODEX_CLI_TIMEOUT_MS;
+ const timeout = calculateReasoningTimeout(options.reasoningEffort, baseTimeout);
+
+ const stream = spawnJSONLProcess({
+ command: commandPath,
+ args,
+ cwd: options.cwd,
+ env: envOverrides,
+ abortController: options.abortController,
+ timeout,
+ stdinData: promptText, // Pass prompt via stdin
+ });
+
+ for await (const rawEvent of stream) {
+ const event = rawEvent as Record;
+ const eventType = getEventType(event);
+
+ // Track thread/session ID from events
+ const threadId = event.thread_id;
+ if (threadId && typeof threadId === 'string') {
+ this._lastSessionId = threadId;
+ }
+
+ if (eventType === CODEX_EVENT_TYPES.error) {
+ const errorText = extractText(event.error ?? event.message) || 'Codex CLI error';
+
+ // Enhance error message with helpful context
+ let enhancedError = errorText;
+ const errorLower = errorText.toLowerCase();
+ if (errorLower.includes('rate limit')) {
+ enhancedError = `${errorText}\n\nTip: You're being rate limited. Try reducing concurrent tasks or waiting a few minutes before retrying.`;
+ } else if (errorLower.includes('authentication') || errorLower.includes('unauthorized')) {
+ enhancedError = `${errorText}\n\nTip: Check that your OPENAI_API_KEY is set correctly or run 'codex login' to authenticate.`;
+ } else if (
+ errorLower.includes('model does not exist') ||
+ errorLower.includes('requested model does not exist') ||
+ errorLower.includes('do not have access') ||
+ errorLower.includes('model_not_found') ||
+ errorLower.includes('invalid_model')
+ ) {
+ enhancedError =
+ `${errorText}\n\nTip: The model '${options.model}' may not be available on your OpenAI plan. ` +
+ `See https://platform.openai.com/docs/models for available models. ` +
+ `Some models require a ChatGPT Pro/Plus subscription—authenticate with 'codex login' instead of an API key.`;
+ } else if (
+ errorLower.includes('stream disconnected') ||
+ errorLower.includes('stream ended') ||
+ errorLower.includes('connection reset')
+ ) {
+ enhancedError =
+ `${errorText}\n\nTip: The connection to OpenAI was interrupted. This can happen due to:\n` +
+ `- Network instability\n` +
+ `- The model not being available on your plan\n` +
+ `- Server-side timeouts for long-running requests\n` +
+ `Try again, or switch to a different model.`;
+ } else if (
+ errorLower.includes('command not found') ||
+ errorLower.includes('is not recognized as an internal or external command')
+ ) {
+ enhancedError = `${errorText}\n\nTip: Make sure the Codex CLI is installed. Run 'npm install -g @openai/codex-cli' to install.`;
+ }
+
+ console.error('[CodexProvider] CLI error event:', { errorText, event });
+ yield { type: 'error', error: enhancedError };
+ continue;
+ }
+
+ if (eventType === CODEX_EVENT_TYPES.turnCompleted) {
+ const resultText = extractText(event.result) || undefined;
+ yield { type: 'result', subtype: 'success', result: resultText };
+ continue;
+ }
+
+ if (!eventType) {
+ const fallbackText = extractText(event);
+ if (fallbackText) {
+ yield {
+ type: 'assistant',
+ message: {
+ role: 'assistant',
+ content: [{ type: 'text', text: fallbackText }],
+ },
+ };
+ }
+ continue;
+ }
+
+ const item = (event.item ?? {}) as Record;
+ const itemType = extractItemType(item);
+
+ if (
+ eventType === CODEX_EVENT_TYPES.itemStarted &&
+ itemType === CODEX_ITEM_TYPES.commandExecution
+ ) {
+ const commandText = extractCommandText(item) || '';
+ const tool = resolveCodexToolCall(commandText);
+ const toolUseId = toolUseTracker.register(event, item);
+ yield {
+ type: 'assistant',
+ message: {
+ role: 'assistant',
+ content: [
+ {
+ type: 'tool_use',
+ name: tool.name,
+ input: tool.input,
+ tool_use_id: toolUseId,
+ },
+ ],
+ },
+ };
+ continue;
+ }
+
+ if (eventType === CODEX_EVENT_TYPES.itemUpdated && itemType === CODEX_ITEM_TYPES.todoList) {
+ const todos = extractCodexTodoItems(item);
+ if (todos) {
+ yield {
+ type: 'assistant',
+ message: {
+ role: 'assistant',
+ content: [
+ {
+ type: 'tool_use',
+ name: getCodexTodoToolName(),
+ input: { todos },
+ },
+ ],
+ },
+ };
+ } else {
+ const todoText = extractText(item) || '';
+ const formatted = todoText ? `Updated TODO list:\n${todoText}` : 'Updated TODO list';
+ yield {
+ type: 'assistant',
+ message: {
+ role: 'assistant',
+ content: [{ type: 'text', text: formatted }],
+ },
+ };
+ }
+ continue;
+ }
+
+ if (eventType === CODEX_EVENT_TYPES.itemCompleted) {
+ if (itemType === CODEX_ITEM_TYPES.reasoning) {
+ const thinkingText = extractText(item) || '';
+ yield {
+ type: 'assistant',
+ message: {
+ role: 'assistant',
+ content: [{ type: 'thinking', thinking: thinkingText }],
+ },
+ };
+ continue;
+ }
+
+ if (itemType === CODEX_ITEM_TYPES.commandExecution) {
+ const commandOutput =
+ extractCommandOutput(item) ?? extractCommandText(item) ?? extractText(item) ?? '';
+ if (commandOutput) {
+ const toolUseId = toolUseTracker.resolve(event, item);
+ const toolResultBlock: {
+ type: 'tool_result';
+ content: string;
+ tool_use_id?: string;
+ } = { type: 'tool_result', content: commandOutput };
+ if (toolUseId) {
+ toolResultBlock.tool_use_id = toolUseId;
+ }
+ yield {
+ type: 'assistant',
+ message: {
+ role: 'assistant',
+ content: [toolResultBlock],
+ },
+ };
+ }
+ continue;
+ }
+
+ const text = extractText(item) || extractText(event);
+ if (text) {
+ yield {
+ type: 'assistant',
+ message: {
+ role: 'assistant',
+ content: [{ type: 'text', text }],
+ },
+ };
+ }
+ }
+ }
+ } catch (error) {
+ const errorInfo = classifyError(error);
+ const userMessage = getUserFriendlyErrorMessage(error);
+ const enhancedMessage = errorInfo.isRateLimit
+ ? `${userMessage}\n\nTip: If you're rate limited, try reducing concurrent tasks or waiting a few minutes.`
+ : userMessage;
+
+ console.error('[CodexProvider] executeQuery() error:', {
+ type: errorInfo.type,
+ message: errorInfo.message,
+ isRateLimit: errorInfo.isRateLimit,
+ retryAfter: errorInfo.retryAfter,
+ stack: error instanceof Error ? error.stack : undefined,
+ });
+
+ yield { type: 'error', error: enhancedMessage };
+ }
+ }
+
+ async detectInstallation(): Promise {
+ const cliPath = await findCodexCliPath();
+ const hasApiKey = Boolean(await resolveOpenAiApiKey());
+ const installed = !!cliPath;
+
+ let version = '';
+ if (installed) {
+ try {
+ const result = await spawnProcess({
+ command: cliPath || CODEX_COMMAND,
+ args: [CODEX_VERSION_FLAG],
+ cwd: process.cwd(),
+ });
+ version = result.stdout.trim();
+ } catch {
+ version = '';
+ }
+ }
+
+ // Determine auth status - always verify with CLI, never assume authenticated
+ const authCheck = await checkCodexAuthentication(cliPath);
+ const authenticated = authCheck.authenticated;
+
+ return {
+ installed,
+ path: cliPath || undefined,
+ version: version || undefined,
+ method: 'cli' as const, // Installation method
+ hasApiKey,
+ authenticated,
+ };
+ }
+
+ getAvailableModels(): ModelDefinition[] {
+ // Return all available Codex/OpenAI models
+ return CODEX_MODELS;
+ }
+
+ /**
+ * Check authentication status for Codex CLI
+ */
+ async checkAuth(): Promise {
+ const cliPath = await findCodexCliPath();
+ const hasApiKey = Boolean(await resolveOpenAiApiKey());
+ const authIndicators = await getCodexAuthIndicators();
+
+ // Check for API key in environment
+ if (hasApiKey) {
+ return { authenticated: true, method: 'api_key' };
+ }
+
+ // Check for OAuth/token from Codex CLI
+ if (authIndicators.hasOAuthToken || authIndicators.hasApiKey) {
+ return { authenticated: true, method: 'oauth' };
+ }
+
+ // CLI is installed but not authenticated via indicators - try CLI command
+ if (cliPath) {
+ try {
+ // Try 'codex login status' first (same as checkCodexAuthentication)
+ const result = await spawnProcess({
+ command: cliPath || CODEX_COMMAND,
+ args: ['login', 'status'],
+ cwd: process.cwd(),
+ env: {
+ ...process.env,
+ TERM: 'dumb',
+ },
+ });
+
+ // Check both stdout and stderr - Codex CLI outputs to stderr
+ const combinedOutput = (result.stdout + result.stderr).toLowerCase();
+ const isLoggedIn = combinedOutput.includes('logged in');
+
+ if (result.exitCode === 0 && isLoggedIn) {
+ return { authenticated: true, method: 'oauth' };
+ }
+ } catch (error) {
+ logger.warn('Error running login status command during auth check:', error);
+ }
+ }
+
+ return { authenticated: false, method: 'none' };
+ }
+
+ /**
+ * Get the detected CLI path (public accessor for status endpoints)
+ */
+ async getCliPath(): Promise {
+ const path = await findCodexCliPath();
+ return path || null;
+ }
+
+ /**
+ * Get the last CLI session ID (for tracking across queries)
+ * This can be used to resume sessions in subsequent requests
+ */
+ getLastSessionId(): string | null {
+ return this._lastSessionId ?? null;
+ }
+
+ /**
+ * Set a session ID to use for CLI session resumption
+ */
+ setSessionId(sessionId: string | null): void {
+ this._lastSessionId = sessionId;
+ }
+
+ private _lastSessionId: string | null = null;
+}
diff --git a/temp_repo/apps/server/src/providers/codex-sdk-client.ts b/temp_repo/apps/server/src/providers/codex-sdk-client.ts
new file mode 100644
index 0000000000000000000000000000000000000000..bc885c7219f14f8ef9fff8dfeb40b2ab78e85622
--- /dev/null
+++ b/temp_repo/apps/server/src/providers/codex-sdk-client.ts
@@ -0,0 +1,222 @@
+/**
+ * Codex SDK client - Executes Codex queries via official @openai/codex-sdk
+ *
+ * Used for programmatic control of Codex from within the application.
+ * Provides cleaner integration than spawning CLI processes.
+ */
+
+import { Codex } from '@openai/codex-sdk';
+import { formatHistoryAsText, classifyError, getUserFriendlyErrorMessage } from '@automaker/utils';
+import { supportsReasoningEffort } from '@automaker/types';
+import type { ExecuteOptions, ProviderMessage } from './types.js';
+
+const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY';
+const SDK_HISTORY_HEADER = 'Current request:\n';
+const DEFAULT_RESPONSE_TEXT = '';
+const SDK_ERROR_DETAILS_LABEL = 'Details:';
+
+type SdkReasoningEffort = 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
+const SDK_REASONING_EFFORTS = new Set(['minimal', 'low', 'medium', 'high', 'xhigh']);
+
+type PromptBlock = {
+ type: string;
+ text?: string;
+ source?: {
+ type?: string;
+ media_type?: string;
+ data?: string;
+ };
+};
+
+function resolveApiKey(): string {
+ const apiKey = process.env[OPENAI_API_KEY_ENV];
+ if (!apiKey) {
+ throw new Error('OPENAI_API_KEY is not set.');
+ }
+ return apiKey;
+}
+
+function normalizePromptBlocks(prompt: ExecuteOptions['prompt']): PromptBlock[] {
+ if (Array.isArray(prompt)) {
+ return prompt as PromptBlock[];
+ }
+ return [{ type: 'text', text: prompt }];
+}
+
+function buildPromptText(options: ExecuteOptions, systemPrompt: string | null): string {
+ const historyText =
+ options.conversationHistory && options.conversationHistory.length > 0
+ ? formatHistoryAsText(options.conversationHistory)
+ : '';
+
+ const promptBlocks = normalizePromptBlocks(options.prompt);
+ const promptTexts: string[] = [];
+
+ for (const block of promptBlocks) {
+ if (block.type === 'text' && typeof block.text === 'string' && block.text.trim()) {
+ promptTexts.push(block.text);
+ }
+ }
+
+ const promptContent = promptTexts.join('\n\n');
+ if (!promptContent.trim()) {
+ throw new Error('Codex SDK prompt is empty.');
+ }
+
+ const parts: string[] = [];
+ if (systemPrompt) {
+ parts.push(`System: ${systemPrompt}`);
+ }
+ if (historyText) {
+ parts.push(historyText);
+ }
+ parts.push(`${SDK_HISTORY_HEADER}${promptContent}`);
+
+ return parts.join('\n\n');
+}
+
+function buildSdkErrorMessage(rawMessage: string, userMessage: string): string {
+ if (!rawMessage) {
+ return userMessage;
+ }
+ if (!userMessage || rawMessage === userMessage) {
+ return rawMessage;
+ }
+ return `${userMessage}\n\n${SDK_ERROR_DETAILS_LABEL} ${rawMessage}`;
+}
+
+/**
+ * Execute a query using the official Codex SDK
+ *
+ * The SDK provides a cleaner interface than spawning CLI processes:
+ * - Handles authentication automatically
+ * - Provides TypeScript types
+ * - Supports thread management and resumption
+ * - Better error handling
+ */
+export async function* executeCodexSdkQuery(
+ options: ExecuteOptions,
+ systemPrompt: string | null
+): AsyncGenerator {
+ try {
+ const apiKey = resolveApiKey();
+ const codex = new Codex({ apiKey });
+
+ // Build thread options with model
+ // The model must be passed to startThread/resumeThread so the SDK
+ // knows which model to use for the conversation. Without this,
+ // the SDK may use a default model that the user doesn't have access to.
+ const threadOptions: {
+ model?: string;
+ modelReasoningEffort?: SdkReasoningEffort;
+ } = {};
+
+ if (options.model) {
+ threadOptions.model = options.model;
+ }
+
+ // Add reasoning effort to thread options if model supports it
+ if (
+ options.reasoningEffort &&
+ options.model &&
+ supportsReasoningEffort(options.model) &&
+ options.reasoningEffort !== 'none' &&
+ SDK_REASONING_EFFORTS.has(options.reasoningEffort)
+ ) {
+ threadOptions.modelReasoningEffort = options.reasoningEffort as SdkReasoningEffort;
+ }
+
+ // Resume existing thread or start new one
+ let thread;
+ if (options.sdkSessionId) {
+ try {
+ thread = codex.resumeThread(options.sdkSessionId, threadOptions);
+ } catch {
+ // If resume fails, start a new thread
+ thread = codex.startThread(threadOptions);
+ }
+ } else {
+ thread = codex.startThread(threadOptions);
+ }
+
+ const promptText = buildPromptText(options, systemPrompt);
+
+ // Build run options
+ const runOptions: {
+ signal?: AbortSignal;
+ } = {
+ signal: options.abortController?.signal,
+ };
+
+ // Run the query
+ const result = await thread.run(promptText, runOptions);
+
+ // Extract response text (from finalResponse property)
+ const outputText = result.finalResponse ?? DEFAULT_RESPONSE_TEXT;
+
+ // Get thread ID (may be null if not populated yet)
+ const threadId = thread.id ?? undefined;
+
+ // Yield assistant message
+ yield {
+ type: 'assistant',
+ session_id: threadId,
+ message: {
+ role: 'assistant',
+ content: [{ type: 'text', text: outputText }],
+ },
+ };
+
+ // Yield result
+ yield {
+ type: 'result',
+ subtype: 'success',
+ session_id: threadId,
+ result: outputText,
+ };
+ } catch (error) {
+ const errorInfo = classifyError(error);
+ const userMessage = getUserFriendlyErrorMessage(error);
+ let combinedMessage = buildSdkErrorMessage(errorInfo.message, userMessage);
+
+ // Enhance error messages with actionable tips for common Codex issues
+ // Normalize inputs to avoid crashes from nullish values
+ const errorLower = (errorInfo?.message ?? '').toLowerCase();
+ const modelLabel = options?.model ?? '';
+
+ if (
+ errorLower.includes('does not exist') ||
+ errorLower.includes('model_not_found') ||
+ errorLower.includes('invalid_model')
+ ) {
+ // Model not found - provide helpful guidance
+ combinedMessage +=
+ `\n\nTip: The model '${modelLabel}' may not be available on your OpenAI plan. ` +
+ `Some models (like gpt-5.3-codex) require a ChatGPT Pro/Plus subscription and OAuth login via 'codex login'. ` +
+ `Try using a different model (e.g., gpt-5.1 or gpt-5.2), or authenticate with 'codex login' instead of an API key.`;
+ } else if (
+ errorLower.includes('stream disconnected') ||
+ errorLower.includes('stream ended') ||
+ errorLower.includes('connection reset') ||
+ errorLower.includes('socket hang up')
+ ) {
+ // Stream disconnection - provide helpful guidance
+ combinedMessage +=
+ `\n\nTip: The connection to OpenAI was interrupted. This can happen due to:\n` +
+ `- Network instability\n` +
+ `- The model not being available on your plan (try 'codex login' for OAuth authentication)\n` +
+ `- Server-side timeouts for long-running requests\n` +
+ `Try again, or switch to a different model.`;
+ }
+
+ console.error('[CodexSDK] executeQuery() error during execution:', {
+ type: errorInfo.type,
+ message: errorInfo.message,
+ model: options.model,
+ isRateLimit: errorInfo.isRateLimit,
+ retryAfter: errorInfo.retryAfter,
+ stack: error instanceof Error ? error.stack : undefined,
+ });
+ yield { type: 'error', error: combinedMessage };
+ }
+}
diff --git a/temp_repo/apps/server/src/providers/codex-tool-mapping.ts b/temp_repo/apps/server/src/providers/codex-tool-mapping.ts
new file mode 100644
index 0000000000000000000000000000000000000000..f951e0f0214aa24e4d170ebff8a42fee77a28b31
--- /dev/null
+++ b/temp_repo/apps/server/src/providers/codex-tool-mapping.ts
@@ -0,0 +1,436 @@
+export type CodexToolResolution = {
+ name: string;
+ input: Record;
+};
+
+export type CodexTodoItem = {
+ content: string;
+ status: 'pending' | 'in_progress' | 'completed';
+ activeForm?: string;
+};
+
+const TOOL_NAME_BASH = 'Bash';
+const TOOL_NAME_READ = 'Read';
+const TOOL_NAME_EDIT = 'Edit';
+const TOOL_NAME_WRITE = 'Write';
+const TOOL_NAME_GREP = 'Grep';
+const TOOL_NAME_GLOB = 'Glob';
+const TOOL_NAME_TODO = 'TodoWrite';
+const TOOL_NAME_DELETE = 'Delete';
+const TOOL_NAME_LS = 'Ls';
+
+const INPUT_KEY_COMMAND = 'command';
+const INPUT_KEY_FILE_PATH = 'file_path';
+const INPUT_KEY_PATTERN = 'pattern';
+
+const SHELL_WRAPPER_PATTERNS = [
+ /^\/bin\/bash\s+-lc\s+["']([\s\S]+)["']$/,
+ /^bash\s+-lc\s+["']([\s\S]+)["']$/,
+ /^\/bin\/sh\s+-lc\s+["']([\s\S]+)["']$/,
+ /^sh\s+-lc\s+["']([\s\S]+)["']$/,
+ /^cmd\.exe\s+\/c\s+["']?([\s\S]+)["']?$/i,
+ /^powershell(?:\.exe)?\s+-Command\s+["']?([\s\S]+)["']?$/i,
+ /^pwsh(?:\.exe)?\s+-Command\s+["']?([\s\S]+)["']?$/i,
+] as const;
+
+const COMMAND_SEPARATOR_PATTERN = /\s*(?:&&|\|\||;)\s*/;
+const SEGMENT_SKIP_PREFIXES = ['cd ', 'export ', 'set ', 'pushd '] as const;
+const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'command']);
+const READ_COMMANDS = new Set(['cat', 'sed', 'head', 'tail', 'less', 'more', 'bat', 'stat', 'wc']);
+const SEARCH_COMMANDS = new Set(['rg', 'grep', 'ag', 'ack']);
+const GLOB_COMMANDS = new Set(['ls', 'find', 'fd', 'tree']);
+const DELETE_COMMANDS = new Set(['rm', 'del', 'erase', 'remove', 'unlink']);
+const LIST_COMMANDS = new Set(['ls', 'dir', 'll', 'la']);
+const WRITE_COMMANDS = new Set(['tee', 'touch', 'mkdir']);
+const APPLY_PATCH_COMMAND = 'apply_patch';
+const APPLY_PATCH_PATTERN = /\bapply_patch\b/;
+const REDIRECTION_TARGET_PATTERN = /(?:>>|>)\s*([^\s]+)/;
+const SED_IN_PLACE_FLAGS = new Set(['-i', '--in-place']);
+const PERL_IN_PLACE_FLAG = /-.*i/;
+const SEARCH_PATTERN_FLAGS = new Set(['-e', '--regexp']);
+const SEARCH_VALUE_FLAGS = new Set([
+ '-g',
+ '--glob',
+ '--iglob',
+ '--type',
+ '--type-add',
+ '--type-clear',
+ '--encoding',
+]);
+const SEARCH_FILE_LIST_FLAGS = new Set(['--files']);
+const TODO_LINE_PATTERN = /^[-*]\s*(?:\[(?[ x~])\]\s*)?(?.+)$/;
+const TODO_STATUS_COMPLETED = 'completed';
+const TODO_STATUS_IN_PROGRESS = 'in_progress';
+const TODO_STATUS_PENDING = 'pending';
+const PATCH_FILE_MARKERS = [
+ '*** Update File: ',
+ '*** Add File: ',
+ '*** Delete File: ',
+ '*** Move to: ',
+] as const;
+
+function stripShellWrapper(command: string): string {
+ const trimmed = command.trim();
+ for (const pattern of SHELL_WRAPPER_PATTERNS) {
+ const match = trimmed.match(pattern);
+ if (match && match[1]) {
+ return unescapeCommand(match[1].trim());
+ }
+ }
+ return trimmed;
+}
+
+function unescapeCommand(command: string): string {
+ return command.replace(/\\(["'])/g, '$1');
+}
+
+function extractPrimarySegment(command: string): string {
+ const segments = command
+ .split(COMMAND_SEPARATOR_PATTERN)
+ .map((segment) => segment.trim())
+ .filter(Boolean);
+
+ for (const segment of segments) {
+ const shouldSkip = SEGMENT_SKIP_PREFIXES.some((prefix) => segment.startsWith(prefix));
+ if (!shouldSkip) {
+ return segment;
+ }
+ }
+
+ return command.trim();
+}
+
+function tokenizeCommand(command: string): string[] {
+ const tokens: string[] = [];
+ let current = '';
+ let inSingleQuote = false;
+ let inDoubleQuote = false;
+ let isEscaped = false;
+
+ for (const char of command) {
+ if (isEscaped) {
+ current += char;
+ isEscaped = false;
+ continue;
+ }
+
+ if (char === '\\') {
+ isEscaped = true;
+ continue;
+ }
+
+ if (char === "'" && !inDoubleQuote) {
+ inSingleQuote = !inSingleQuote;
+ continue;
+ }
+
+ if (char === '"' && !inSingleQuote) {
+ inDoubleQuote = !inDoubleQuote;
+ continue;
+ }
+
+ if (!inSingleQuote && !inDoubleQuote && /\s/.test(char)) {
+ if (current) {
+ tokens.push(current);
+ current = '';
+ }
+ continue;
+ }
+
+ current += char;
+ }
+
+ if (current) {
+ tokens.push(current);
+ }
+
+ return tokens;
+}
+
+function stripWrapperTokens(tokens: string[]): string[] {
+ let index = 0;
+ while (index < tokens.length && WRAPPER_COMMANDS.has(tokens[index].toLowerCase())) {
+ index += 1;
+ }
+ return tokens.slice(index);
+}
+
+function extractFilePathFromTokens(tokens: string[]): string | null {
+ const candidates = tokens.slice(1).filter((token) => token && !token.startsWith('-'));
+ if (candidates.length === 0) return null;
+ return candidates[candidates.length - 1];
+}
+
+function extractSearchPattern(tokens: string[]): string | null {
+ const remaining = tokens.slice(1);
+
+ for (let index = 0; index < remaining.length; index += 1) {
+ const token = remaining[index];
+ if (token === '--') {
+ return remaining[index + 1] ?? null;
+ }
+ if (SEARCH_PATTERN_FLAGS.has(token)) {
+ return remaining[index + 1] ?? null;
+ }
+ if (SEARCH_VALUE_FLAGS.has(token)) {
+ index += 1;
+ continue;
+ }
+ if (token.startsWith('-')) {
+ continue;
+ }
+ return token;
+ }
+
+ return null;
+}
+
+function extractTeeTarget(tokens: string[]): string | null {
+ const teeIndex = tokens.findIndex((token) => token === 'tee');
+ if (teeIndex < 0) return null;
+ const candidate = tokens[teeIndex + 1];
+ return candidate && !candidate.startsWith('-') ? candidate : null;
+}
+
+function extractRedirectionTarget(command: string): string | null {
+ const match = command.match(REDIRECTION_TARGET_PATTERN);
+ return match?.[1] ?? null;
+}
+
+function extractFilePathFromDeleteTokens(tokens: string[]): string | null {
+ // rm file.txt or rm /path/to/file.txt
+ // Skip flags and get the first non-flag argument
+ for (let i = 1; i < tokens.length; i++) {
+ const token = tokens[i];
+ if (token && !token.startsWith('-')) {
+ return token;
+ }
+ }
+ return null;
+}
+
+function hasSedInPlaceFlag(tokens: string[]): boolean {
+ return tokens.some((token) => SED_IN_PLACE_FLAGS.has(token) || token.startsWith('-i'));
+}
+
+function hasPerlInPlaceFlag(tokens: string[]): boolean {
+ return tokens.some((token) => PERL_IN_PLACE_FLAG.test(token));
+}
+
+function extractPatchFilePath(command: string): string | null {
+ for (const marker of PATCH_FILE_MARKERS) {
+ const index = command.indexOf(marker);
+ if (index < 0) continue;
+ const start = index + marker.length;
+ const end = command.indexOf('\n', start);
+ const rawPath = (end === -1 ? command.slice(start) : command.slice(start, end)).trim();
+ if (rawPath) return rawPath;
+ }
+ return null;
+}
+
+function buildInputWithFilePath(filePath: string | null): Record {
+ return filePath ? { [INPUT_KEY_FILE_PATH]: filePath } : {};
+}
+
+function buildInputWithPattern(pattern: string | null): Record {
+ return pattern ? { [INPUT_KEY_PATTERN]: pattern } : {};
+}
+
+export function resolveCodexToolCall(command: string): CodexToolResolution {
+ const normalized = stripShellWrapper(command);
+ const primarySegment = extractPrimarySegment(normalized);
+ const tokens = stripWrapperTokens(tokenizeCommand(primarySegment));
+ const commandToken = tokens[0]?.toLowerCase() ?? '';
+
+ const redirectionTarget = extractRedirectionTarget(primarySegment);
+ if (redirectionTarget) {
+ return {
+ name: TOOL_NAME_WRITE,
+ input: buildInputWithFilePath(redirectionTarget),
+ };
+ }
+
+ if (commandToken === APPLY_PATCH_COMMAND || APPLY_PATCH_PATTERN.test(primarySegment)) {
+ return {
+ name: TOOL_NAME_EDIT,
+ input: buildInputWithFilePath(extractPatchFilePath(primarySegment)),
+ };
+ }
+
+ if (commandToken === 'sed' && hasSedInPlaceFlag(tokens)) {
+ return {
+ name: TOOL_NAME_EDIT,
+ input: buildInputWithFilePath(extractFilePathFromTokens(tokens)),
+ };
+ }
+
+ if (commandToken === 'perl' && hasPerlInPlaceFlag(tokens)) {
+ return {
+ name: TOOL_NAME_EDIT,
+ input: buildInputWithFilePath(extractFilePathFromTokens(tokens)),
+ };
+ }
+
+ if (WRITE_COMMANDS.has(commandToken)) {
+ const filePath =
+ commandToken === 'tee' ? extractTeeTarget(tokens) : extractFilePathFromTokens(tokens);
+ return {
+ name: TOOL_NAME_WRITE,
+ input: buildInputWithFilePath(filePath),
+ };
+ }
+
+ if (SEARCH_COMMANDS.has(commandToken)) {
+ if (tokens.some((token) => SEARCH_FILE_LIST_FLAGS.has(token))) {
+ return {
+ name: TOOL_NAME_GLOB,
+ input: buildInputWithPattern(extractFilePathFromTokens(tokens)),
+ };
+ }
+
+ return {
+ name: TOOL_NAME_GREP,
+ input: buildInputWithPattern(extractSearchPattern(tokens)),
+ };
+ }
+
+ // Handle Delete commands (rm, del, erase, remove, unlink)
+ if (DELETE_COMMANDS.has(commandToken)) {
+ // Skip if -r or -rf flags (recursive delete should go to Bash)
+ if (
+ tokens.some((token) => token === '-r' || token === '-rf' || token === '-f' || token === '-rf')
+ ) {
+ return {
+ name: TOOL_NAME_BASH,
+ input: { [INPUT_KEY_COMMAND]: normalized },
+ };
+ }
+ // Simple file deletion - extract the file path
+ const filePath = extractFilePathFromDeleteTokens(tokens);
+ if (filePath) {
+ return {
+ name: TOOL_NAME_DELETE,
+ input: { path: filePath },
+ };
+ }
+ // Fall back to bash if we can't determine the file path
+ return {
+ name: TOOL_NAME_BASH,
+ input: { [INPUT_KEY_COMMAND]: normalized },
+ };
+ }
+
+ // Handle simple Ls commands (just listing, not find/glob)
+ if (LIST_COMMANDS.has(commandToken)) {
+ const filePath = extractFilePathFromTokens(tokens);
+ return {
+ name: TOOL_NAME_LS,
+ input: { path: filePath || '.' },
+ };
+ }
+
+ if (GLOB_COMMANDS.has(commandToken)) {
+ return {
+ name: TOOL_NAME_GLOB,
+ input: buildInputWithPattern(extractFilePathFromTokens(tokens)),
+ };
+ }
+
+ if (READ_COMMANDS.has(commandToken)) {
+ return {
+ name: TOOL_NAME_READ,
+ input: buildInputWithFilePath(extractFilePathFromTokens(tokens)),
+ };
+ }
+
+ return {
+ name: TOOL_NAME_BASH,
+ input: { [INPUT_KEY_COMMAND]: normalized },
+ };
+}
+
+function parseTodoLines(lines: string[]): CodexTodoItem[] {
+ const todos: CodexTodoItem[] = [];
+
+ for (const line of lines) {
+ const match = line.match(TODO_LINE_PATTERN);
+ if (!match?.groups?.content) continue;
+
+ const statusToken = match.groups.status;
+ const status =
+ statusToken === 'x'
+ ? TODO_STATUS_COMPLETED
+ : statusToken === '~'
+ ? TODO_STATUS_IN_PROGRESS
+ : TODO_STATUS_PENDING;
+
+ todos.push({ content: match.groups.content.trim(), status });
+ }
+
+ return todos;
+}
+
+function extractTodoFromArray(value: unknown[]): CodexTodoItem[] {
+ return value
+ .map((entry) => {
+ if (typeof entry === 'string') {
+ return { content: entry, status: TODO_STATUS_PENDING };
+ }
+ if (entry && typeof entry === 'object') {
+ const record = entry as Record;
+ const content =
+ typeof record.content === 'string'
+ ? record.content
+ : typeof record.text === 'string'
+ ? record.text
+ : typeof record.title === 'string'
+ ? record.title
+ : null;
+ if (!content) return null;
+ const status =
+ record.status === TODO_STATUS_COMPLETED ||
+ record.status === TODO_STATUS_IN_PROGRESS ||
+ record.status === TODO_STATUS_PENDING
+ ? (record.status as CodexTodoItem['status'])
+ : TODO_STATUS_PENDING;
+ const activeForm = typeof record.activeForm === 'string' ? record.activeForm : undefined;
+ return { content, status, activeForm };
+ }
+ return null;
+ })
+ .filter((item): item is CodexTodoItem => Boolean(item));
+}
+
+export function extractCodexTodoItems(item: Record): CodexTodoItem[] | null {
+ const todosValue = item.todos;
+ if (Array.isArray(todosValue)) {
+ const todos = extractTodoFromArray(todosValue);
+ return todos.length > 0 ? todos : null;
+ }
+
+ const itemsValue = item.items;
+ if (Array.isArray(itemsValue)) {
+ const todos = extractTodoFromArray(itemsValue);
+ return todos.length > 0 ? todos : null;
+ }
+
+ const textValue =
+ typeof item.text === 'string'
+ ? item.text
+ : typeof item.content === 'string'
+ ? item.content
+ : null;
+ if (!textValue) return null;
+
+ const lines = textValue
+ .split('\n')
+ .map((line) => line.trim())
+ .filter(Boolean);
+ const todos = parseTodoLines(lines);
+ return todos.length > 0 ? todos : null;
+}
+
+export function getCodexTodoToolName(): string {
+ return TOOL_NAME_TODO;
+}
diff --git a/temp_repo/apps/server/src/providers/copilot-provider.ts b/temp_repo/apps/server/src/providers/copilot-provider.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c5cc3a7e23e10aa19f60cd8cf977c455ab411d18
--- /dev/null
+++ b/temp_repo/apps/server/src/providers/copilot-provider.ts
@@ -0,0 +1,1006 @@
+/**
+ * Copilot Provider - Executes queries using the GitHub Copilot SDK
+ *
+ * Uses the official @github/copilot-sdk for:
+ * - Session management and streaming responses
+ * - GitHub OAuth authentication (via gh CLI)
+ * - Tool call handling and permission management
+ * - Runtime model discovery
+ *
+ * Based on https://github.com/github/copilot-sdk
+ */
+
+import { execSync } from 'child_process';
+import * as fs from 'fs/promises';
+import * as path from 'path';
+import * as os from 'os';
+import { CliProvider, type CliSpawnConfig, type CliErrorInfo } from './cli-provider.js';
+import type {
+ ProviderConfig,
+ ExecuteOptions,
+ ProviderMessage,
+ InstallationStatus,
+ ModelDefinition,
+} from './types.js';
+// Note: validateBareModelId is not used because Copilot's bare model IDs
+// legitimately contain prefixes like claude-, gemini-, gpt-
+import {
+ COPILOT_MODEL_MAP,
+ type CopilotAuthStatus,
+ type CopilotRuntimeModel,
+} from '@automaker/types';
+import { createLogger, isAbortError } from '@automaker/utils';
+import { resolveModelString } from '@automaker/model-resolver';
+import { CopilotClient, type PermissionRequest } from '@github/copilot-sdk';
+import {
+ normalizeTodos,
+ normalizeFilePathInput,
+ normalizeCommandInput,
+ normalizePatternInput,
+} from './tool-normalization.js';
+
+// Create logger for this module
+const logger = createLogger('CopilotProvider');
+
+// Default bare model (without copilot- prefix) for SDK calls
+const DEFAULT_BARE_MODEL = 'claude-sonnet-4.6';
+
+// =============================================================================
+// SDK Event Types (from @github/copilot-sdk)
+// =============================================================================
+
+/**
+ * SDK session event data types
+ */
+interface SdkEvent {
+ type: string;
+ data?: unknown;
+}
+
+interface SdkMessageEvent extends SdkEvent {
+ type: 'assistant.message';
+ data: {
+ content: string;
+ };
+}
+
+// Note: SdkMessageDeltaEvent is not used - we skip delta events to reduce noise
+// The final assistant.message event contains the complete content
+
+interface SdkToolExecutionStartEvent extends SdkEvent {
+ type: 'tool.execution_start';
+ data: {
+ toolName: string;
+ toolCallId: string;
+ input?: Record;
+ };
+}
+
+interface SdkToolExecutionCompleteEvent extends SdkEvent {
+ type: 'tool.execution_complete';
+ data: {
+ toolCallId: string;
+ success: boolean;
+ result?: {
+ content: string;
+ };
+ error?: {
+ message: string;
+ code?: string;
+ };
+ };
+}
+
+interface SdkSessionErrorEvent extends SdkEvent {
+ type: 'session.error';
+ data: {
+ message: string;
+ code?: string;
+ };
+}
+
+// =============================================================================
+// Constants
+// =============================================================================
+
+/**
+ * Prefix for error messages in tool results
+ * Consistent with GeminiProvider's error formatting
+ */
+const TOOL_ERROR_PREFIX = '[ERROR]' as const;
+
+// =============================================================================
+// Error Codes
+// =============================================================================
+
+export enum CopilotErrorCode {
+ NOT_INSTALLED = 'COPILOT_NOT_INSTALLED',
+ NOT_AUTHENTICATED = 'COPILOT_NOT_AUTHENTICATED',
+ RATE_LIMITED = 'COPILOT_RATE_LIMITED',
+ MODEL_UNAVAILABLE = 'COPILOT_MODEL_UNAVAILABLE',
+ NETWORK_ERROR = 'COPILOT_NETWORK_ERROR',
+ PROCESS_CRASHED = 'COPILOT_PROCESS_CRASHED',
+ TIMEOUT = 'COPILOT_TIMEOUT',
+ CLI_ERROR = 'COPILOT_CLI_ERROR',
+ SDK_ERROR = 'COPILOT_SDK_ERROR',
+ UNKNOWN = 'COPILOT_UNKNOWN_ERROR',
+}
+
+export interface CopilotError extends Error {
+ code: CopilotErrorCode;
+ recoverable: boolean;
+ suggestion?: string;
+}
+
+type CopilotSession = Awaited>;
+type CopilotSessionOptions = Parameters[0];
+type ResumableCopilotClient = CopilotClient & {
+ resumeSession?: (sessionId: string, options: CopilotSessionOptions) => Promise;
+};
+
+// =============================================================================
+// Tool Name Normalization
+// =============================================================================
+
+/**
+ * Copilot SDK tool name to standard tool name mapping
+ *
+ * Maps Copilot CLI tool names to our standard tool names for consistent UI display.
+ * Tool names are case-insensitive (normalized to lowercase before lookup).
+ */
+const COPILOT_TOOL_NAME_MAP: Record = {
+ // File operations
+ read_file: 'Read',
+ read: 'Read',
+ view: 'Read', // Copilot uses 'view' for reading files
+ read_many_files: 'Read',
+ write_file: 'Write',
+ write: 'Write',
+ create_file: 'Write',
+ edit_file: 'Edit',
+ edit: 'Edit',
+ replace: 'Edit',
+ patch: 'Edit',
+ // Shell operations
+ run_shell: 'Bash',
+ run_shell_command: 'Bash',
+ shell: 'Bash',
+ bash: 'Bash',
+ execute: 'Bash',
+ terminal: 'Bash',
+ // Search operations
+ search: 'Grep',
+ grep: 'Grep',
+ search_file_content: 'Grep',
+ find_files: 'Glob',
+ glob: 'Glob',
+ list_dir: 'Ls',
+ list_directory: 'Ls',
+ ls: 'Ls',
+ // Web operations
+ web_fetch: 'WebFetch',
+ fetch: 'WebFetch',
+ web_search: 'WebSearch',
+ search_web: 'WebSearch',
+ google_web_search: 'WebSearch',
+ // Todo operations
+ todo_write: 'TodoWrite',
+ write_todos: 'TodoWrite',
+ update_todos: 'TodoWrite',
+ // Planning/intent operations (Copilot-specific)
+ report_intent: 'ReportIntent', // Keep as-is, it's a planning tool
+ think: 'Think',
+ plan: 'Plan',
+};
+
+/**
+ * Normalize Copilot tool names to standard tool names
+ */
+function normalizeCopilotToolName(copilotToolName: string): string {
+ const lowerName = copilotToolName.toLowerCase();
+ return COPILOT_TOOL_NAME_MAP[lowerName] || copilotToolName;
+}
+
+/**
+ * Normalize Copilot tool input parameters to standard format
+ *
+ * Maps Copilot's parameter names to our standard parameter names.
+ * Uses shared utilities from tool-normalization.ts for common normalizations.
+ */
+function normalizeCopilotToolInput(
+ toolName: string,
+ input: Record
+): Record {
+ const normalizedName = normalizeCopilotToolName(toolName);
+
+ // Normalize todo_write / write_todos: ensure proper format
+ if (normalizedName === 'TodoWrite' && Array.isArray(input.todos)) {
+ return { todos: normalizeTodos(input.todos) };
+ }
+
+ // Normalize file path parameters for Read/Write/Edit tools
+ if (normalizedName === 'Read' || normalizedName === 'Write' || normalizedName === 'Edit') {
+ return normalizeFilePathInput(input);
+ }
+
+ // Normalize shell command parameters for Bash tool
+ if (normalizedName === 'Bash') {
+ return normalizeCommandInput(input);
+ }
+
+ // Normalize search parameters for Grep tool
+ if (normalizedName === 'Grep') {
+ return normalizePatternInput(input);
+ }
+
+ return input;
+}
+
+/**
+ * CopilotProvider - Integrates GitHub Copilot SDK as an AI provider
+ *
+ * Features:
+ * - GitHub OAuth authentication
+ * - SDK-based session management
+ * - Runtime model discovery
+ * - Tool call normalization
+ * - Per-execution working directory support
+ */
+export class CopilotProvider extends CliProvider {
+ private runtimeModels: CopilotRuntimeModel[] | null = null;
+
+ constructor(config: ProviderConfig = {}) {
+ super(config);
+ // Trigger CLI detection on construction
+ this.ensureCliDetected();
+ }
+
+ // ==========================================================================
+ // CliProvider Abstract Method Implementations
+ // ==========================================================================
+
+ getName(): string {
+ return 'copilot';
+ }
+
+ getCliName(): string {
+ return 'copilot';
+ }
+
+ getSpawnConfig(): CliSpawnConfig {
+ return {
+ windowsStrategy: 'npx', // Copilot CLI can be run via npx
+ npxPackage: '@github/copilot', // Official GitHub Copilot CLI package
+ commonPaths: {
+ linux: [
+ path.join(os.homedir(), '.local/bin/copilot'),
+ '/usr/local/bin/copilot',
+ path.join(os.homedir(), '.npm-global/bin/copilot'),
+ ],
+ darwin: [
+ path.join(os.homedir(), '.local/bin/copilot'),
+ '/usr/local/bin/copilot',
+ '/opt/homebrew/bin/copilot',
+ path.join(os.homedir(), '.npm-global/bin/copilot'),
+ ],
+ win32: [
+ path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'copilot.cmd'),
+ path.join(os.homedir(), '.npm-global', 'copilot.cmd'),
+ ],
+ },
+ };
+ }
+
+ /**
+ * Extract prompt text from ExecuteOptions
+ *
+ * Note: CopilotProvider does not yet support vision/image inputs.
+ * If non-text content is provided, an error is thrown.
+ */
+ private extractPromptText(options: ExecuteOptions): string {
+ if (typeof options.prompt === 'string') {
+ return options.prompt;
+ } else if (Array.isArray(options.prompt)) {
+ // Check for non-text content (images, etc.) which we don't support yet
+ const hasNonText = options.prompt.some((p) => p.type !== 'text');
+ if (hasNonText) {
+ throw new Error(
+ 'CopilotProvider does not yet support non-text prompt parts (e.g., images). ' +
+ 'Please use text-only prompts or switch to a provider that supports vision.'
+ );
+ }
+ return options.prompt
+ .filter((p) => p.type === 'text' && p.text)
+ .map((p) => p.text)
+ .join('\n');
+ } else {
+ throw new Error('Invalid prompt format');
+ }
+ }
+
+ /**
+ * Not used with SDK approach - kept for interface compatibility
+ */
+ buildCliArgs(_options: ExecuteOptions): string[] {
+ return [];
+ }
+
+ /**
+ * Convert SDK event to AutoMaker ProviderMessage format
+ */
+ normalizeEvent(event: unknown): ProviderMessage | null {
+ const sdkEvent = event as SdkEvent;
+
+ switch (sdkEvent.type) {
+ case 'assistant.message': {
+ const messageEvent = sdkEvent as SdkMessageEvent;
+ return {
+ type: 'assistant',
+ message: {
+ role: 'assistant',
+ content: [{ type: 'text', text: messageEvent.data.content }],
+ },
+ };
+ }
+
+ case 'assistant.message_delta': {
+ // Skip delta events - they create too much noise
+ // The final assistant.message event has the complete content
+ return null;
+ }
+
+ case 'tool.execution_start': {
+ const toolEvent = sdkEvent as SdkToolExecutionStartEvent;
+ const normalizedName = normalizeCopilotToolName(toolEvent.data.toolName);
+ const normalizedInput = toolEvent.data.input
+ ? normalizeCopilotToolInput(toolEvent.data.toolName, toolEvent.data.input)
+ : {};
+
+ return {
+ type: 'assistant',
+ message: {
+ role: 'assistant',
+ content: [
+ {
+ type: 'tool_use',
+ name: normalizedName,
+ tool_use_id: toolEvent.data.toolCallId,
+ input: normalizedInput,
+ },
+ ],
+ },
+ };
+ }
+
+ /**
+ * Tool execution completed event
+ * Handles both successful results and errors from tool executions
+ * Error messages optionally include error codes for better debugging
+ */
+ case 'tool.execution_complete': {
+ const toolResultEvent = sdkEvent as SdkToolExecutionCompleteEvent;
+ const error = toolResultEvent.data.error;
+
+ // Format error message with optional code for better debugging
+ const content = error
+ ? `${TOOL_ERROR_PREFIX} ${error.message}${error.code ? ` (${error.code})` : ''}`
+ : toolResultEvent.data.result?.content || '';
+
+ return {
+ type: 'assistant',
+ message: {
+ role: 'assistant',
+ content: [
+ {
+ type: 'tool_result',
+ tool_use_id: toolResultEvent.data.toolCallId,
+ content,
+ },
+ ],
+ },
+ };
+ }
+
+ case 'session.idle': {
+ logger.debug('Copilot session idle');
+ return {
+ type: 'result',
+ subtype: 'success',
+ };
+ }
+
+ case 'session.error': {
+ const errorEvent = sdkEvent as SdkSessionErrorEvent;
+ const enrichedError =
+ errorEvent.data.message ||
+ (errorEvent.data.code
+ ? `Copilot agent error (code: ${errorEvent.data.code})`
+ : 'Copilot agent error');
+ return {
+ type: 'error',
+ error: enrichedError,
+ };
+ }
+
+ default:
+ logger.debug(`Unknown Copilot SDK event type: ${sdkEvent.type}`);
+ return null;
+ }
+ }
+
+ // ==========================================================================
+ // CliProvider Overrides
+ // ==========================================================================
+
+ /**
+ * Override error mapping for Copilot-specific error codes
+ */
+ protected mapError(stderr: string, exitCode: number | null): CliErrorInfo {
+ const lower = stderr.toLowerCase();
+
+ if (
+ lower.includes('not authenticated') ||
+ lower.includes('please log in') ||
+ lower.includes('unauthorized') ||
+ lower.includes('login required') ||
+ lower.includes('authentication required') ||
+ lower.includes('github login')
+ ) {
+ return {
+ code: CopilotErrorCode.NOT_AUTHENTICATED,
+ message: 'GitHub Copilot is not authenticated',
+ recoverable: true,
+ suggestion: 'Run "gh auth login" or "copilot auth login" to authenticate with GitHub',
+ };
+ }
+
+ if (
+ lower.includes('rate limit') ||
+ lower.includes('too many requests') ||
+ lower.includes('429') ||
+ lower.includes('quota exceeded')
+ ) {
+ return {
+ code: CopilotErrorCode.RATE_LIMITED,
+ message: 'Copilot API rate limit exceeded',
+ recoverable: true,
+ suggestion: 'Wait a few minutes and try again',
+ };
+ }
+
+ if (
+ lower.includes('model not available') ||
+ lower.includes('invalid model') ||
+ lower.includes('unknown model') ||
+ lower.includes('model not found') ||
+ (lower.includes('not found') && lower.includes('404'))
+ ) {
+ return {
+ code: CopilotErrorCode.MODEL_UNAVAILABLE,
+ message: 'Requested model is not available',
+ recoverable: true,
+ suggestion: `Try using "${DEFAULT_BARE_MODEL}" or select a different model`,
+ };
+ }
+
+ if (
+ lower.includes('network') ||
+ lower.includes('connection') ||
+ lower.includes('econnrefused') ||
+ lower.includes('timeout')
+ ) {
+ return {
+ code: CopilotErrorCode.NETWORK_ERROR,
+ message: 'Network connection error',
+ recoverable: true,
+ suggestion: 'Check your internet connection and try again',
+ };
+ }
+
+ if (exitCode === 137 || lower.includes('killed') || lower.includes('sigterm')) {
+ return {
+ code: CopilotErrorCode.PROCESS_CRASHED,
+ message: 'Copilot CLI process was terminated',
+ recoverable: true,
+ suggestion: 'The process may have run out of memory. Try a simpler task.',
+ };
+ }
+
+ return {
+ code: CopilotErrorCode.UNKNOWN,
+ message: stderr || `Copilot CLI exited with code ${exitCode}`,
+ recoverable: false,
+ };
+ }
+
+ /**
+ * Override install instructions for Copilot-specific guidance
+ */
+ protected getInstallInstructions(): string {
+ return 'Install with: npm install -g @github/copilot (or visit https://github.com/github/copilot)';
+ }
+
+ /**
+ * Execute a prompt using Copilot SDK with real-time streaming
+ *
+ * Creates a new CopilotClient for each execution with the correct working directory.
+ * Streams tool execution events in real-time for UI display.
+ */
+ async *executeQuery(options: ExecuteOptions): AsyncGenerator {
+ this.ensureCliDetected();
+
+ // Note: We don't use validateBareModelId here because Copilot's model IDs
+ // legitimately contain prefixes like claude-, gemini-, gpt- which are the
+ // actual model names from the Copilot CLI. We only need to ensure the
+ // copilot- prefix has been stripped by the ProviderFactory.
+ if (options.model?.startsWith('copilot-')) {
+ throw new Error(
+ `[CopilotProvider] Model ID should not have 'copilot-' prefix. Got: '${options.model}'. ` +
+ `The ProviderFactory should strip this prefix before passing to the provider.`
+ );
+ }
+
+ if (!this.cliPath) {
+ throw this.createError(
+ CopilotErrorCode.NOT_INSTALLED,
+ 'Copilot CLI is not installed',
+ true,
+ this.getInstallInstructions()
+ );
+ }
+
+ const promptText = this.extractPromptText(options);
+ // resolveModelString may return dash-separated canonical names (e.g. "claude-sonnet-4-6"),
+ // but the Copilot SDK expects dot-separated version suffixes (e.g. "claude-sonnet-4.6").
+ // Normalize by converting the last dash-separated numeric pair to dot notation.
+ const resolvedModel = resolveModelString(options.model || DEFAULT_BARE_MODEL);
+ const bareModel = resolvedModel.replace(/-(\d+)-(\d+)$/, '-$1.$2');
+ const workingDirectory = options.cwd || process.cwd();
+
+ logger.debug(
+ `CopilotProvider.executeQuery called with model: "${bareModel}", cwd: "${workingDirectory}"`
+ );
+ logger.debug(`Prompt length: ${promptText.length} characters`);
+
+ // Create a client for this execution with the correct working directory
+ const client = new CopilotClient({
+ logLevel: 'warning',
+ autoRestart: false,
+ cwd: workingDirectory,
+ });
+
+ // Use an async queue to bridge callback-based SDK events to async generator
+ const eventQueue: SdkEvent[] = [];
+ let resolveWaiting: (() => void) | null = null;
+ let sessionComplete = false;
+ let sessionError: Error | null = null;
+
+ const pushEvent = (event: SdkEvent) => {
+ eventQueue.push(event);
+ if (resolveWaiting) {
+ resolveWaiting();
+ resolveWaiting = null;
+ }
+ };
+
+ const waitForEvent = (): Promise => {
+ if (eventQueue.length > 0 || sessionComplete) {
+ return Promise.resolve();
+ }
+ return new Promise((resolve) => {
+ resolveWaiting = resolve;
+ });
+ };
+
+ // Declare session outside try so it's accessible in the catch block for cleanup.
+ let session: CopilotSession | undefined;
+
+ try {
+ await client.start();
+ logger.debug(`CopilotClient started with cwd: ${workingDirectory}`);
+
+ const sessionOptions: CopilotSessionOptions = {
+ model: bareModel,
+ streaming: true,
+ // AUTONOMOUS MODE: Auto-approve all permission requests.
+ // AutoMaker is designed for fully autonomous AI agent operation.
+ // Security boundary is provided by Docker containerization (see CLAUDE.md).
+ // User is warned about this at app startup.
+ onPermissionRequest: async (
+ request: PermissionRequest
+ ): Promise<{ kind: 'approved' } | { kind: 'denied-interactively-by-user' }> => {
+ logger.debug(`Permission request: ${request.kind}`);
+ return { kind: 'approved' };
+ },
+ };
+
+ // Resume the previous Copilot session when possible; otherwise create a fresh one.
+ const resumableClient = client as ResumableCopilotClient;
+ let sessionResumed = false;
+ if (options.sdkSessionId && typeof resumableClient.resumeSession === 'function') {
+ try {
+ session = await resumableClient.resumeSession(options.sdkSessionId, sessionOptions);
+ sessionResumed = true;
+ logger.debug(`Resumed Copilot session: ${session.sessionId}`);
+ } catch (resumeError) {
+ logger.warn(
+ `Failed to resume Copilot session "${options.sdkSessionId}", creating a new session: ${resumeError}`
+ );
+ session = await client.createSession(sessionOptions);
+ }
+ } else {
+ session = await client.createSession(sessionOptions);
+ }
+
+ // session is always assigned by this point (both branches above assign it)
+ const activeSession = session!;
+ const sessionId = activeSession.sessionId;
+ logger.debug(`Session ${sessionResumed ? 'resumed' : 'created'}: ${sessionId}`);
+
+ // Set up event handler to push events to queue
+ activeSession.on((event: SdkEvent) => {
+ logger.debug(`SDK event: ${event.type}`);
+
+ if (event.type === 'session.idle') {
+ sessionComplete = true;
+ pushEvent(event);
+ } else if (event.type === 'session.error') {
+ const errorEvent = event as SdkSessionErrorEvent;
+ sessionError = new Error(errorEvent.data.message);
+ sessionComplete = true;
+ pushEvent(event);
+ } else {
+ // Push all other events (tool.execution_start, tool.execution_complete, assistant.message, etc.)
+ pushEvent(event);
+ }
+ });
+
+ // Send the prompt (non-blocking)
+ await activeSession.send({ prompt: promptText });
+
+ // Process events as they arrive
+ while (!sessionComplete || eventQueue.length > 0) {
+ await waitForEvent();
+
+ // Check for errors first (before processing events to avoid race condition)
+ if (sessionError) {
+ await activeSession.destroy();
+ await client.stop();
+ throw sessionError;
+ }
+
+ // Process all queued events
+ while (eventQueue.length > 0) {
+ const event = eventQueue.shift()!;
+ const normalized = this.normalizeEvent(event);
+ if (normalized) {
+ // Add session_id if not present
+ if (!normalized.session_id) {
+ normalized.session_id = sessionId;
+ }
+ yield normalized;
+ }
+ }
+ }
+
+ // Cleanup
+ await activeSession.destroy();
+ await client.stop();
+ logger.debug('CopilotClient stopped successfully');
+ } catch (error) {
+ // Ensure session is destroyed and client is stopped on error to prevent leaks.
+ // The session may have been created/resumed before the error occurred.
+ if (session) {
+ try {
+ await session.destroy();
+ } catch (sessionCleanupError) {
+ logger.debug(`Failed to destroy session during cleanup: ${sessionCleanupError}`);
+ }
+ }
+ try {
+ await client.stop();
+ } catch (cleanupError) {
+ // Log but don't throw cleanup errors - the original error is more important
+ logger.debug(`Failed to stop client during cleanup: ${cleanupError}`);
+ }
+
+ if (isAbortError(error)) {
+ logger.debug('Query aborted');
+ return;
+ }
+
+ // Map errors to CopilotError
+ if (error instanceof Error) {
+ logger.error(`Copilot SDK error: ${error.message}`);
+ const errorInfo = this.mapError(error.message, null);
+ throw this.createError(
+ errorInfo.code as CopilotErrorCode,
+ errorInfo.message,
+ errorInfo.recoverable,
+ errorInfo.suggestion
+ );
+ }
+ throw error;
+ }
+ }
+
+ // ==========================================================================
+ // Copilot-Specific Methods
+ // ==========================================================================
+
+ /**
+ * Create a CopilotError with details
+ */
+ private createError(
+ code: CopilotErrorCode,
+ message: string,
+ recoverable: boolean = false,
+ suggestion?: string
+ ): CopilotError {
+ const error = new Error(message) as CopilotError;
+ error.code = code;
+ error.recoverable = recoverable;
+ error.suggestion = suggestion;
+ error.name = 'CopilotError';
+ return error;
+ }
+
+ /**
+ * Get Copilot CLI version
+ */
+ async getVersion(): Promise {
+ this.ensureCliDetected();
+ if (!this.cliPath) return null;
+
+ try {
+ const result = execSync(`"${this.cliPath}" --version`, {
+ encoding: 'utf8',
+ timeout: 5000,
+ stdio: 'pipe',
+ }).trim();
+ return result;
+ } catch {
+ return null;
+ }
+ }
+
+ /**
+ * Check authentication status
+ *
+ * Uses GitHub CLI (gh) to check Copilot authentication status.
+ * The Copilot CLI relies on gh auth for authentication.
+ */
+ async checkAuth(): Promise {
+ this.ensureCliDetected();
+ if (!this.cliPath) {
+ logger.debug('checkAuth: CLI not found');
+ return { authenticated: false, method: 'none' };
+ }
+
+ logger.debug('checkAuth: Starting credential check');
+
+ // Try to check GitHub CLI authentication status first
+ // The Copilot CLI uses gh auth for authentication
+ try {
+ const ghStatus = execSync('gh auth status --hostname github.com', {
+ encoding: 'utf8',
+ timeout: 10000,
+ stdio: 'pipe',
+ });
+
+ logger.debug(`checkAuth: gh auth status output: ${ghStatus.substring(0, 200)}`);
+
+ // Parse gh auth status output
+ const loggedInMatch = ghStatus.match(/Logged in to github\.com account (\S+)/);
+ if (loggedInMatch) {
+ return {
+ authenticated: true,
+ method: 'oauth',
+ login: loggedInMatch[1],
+ host: 'github.com',
+ };
+ }
+
+ // Check for token auth
+ if (ghStatus.includes('Logged in') || ghStatus.includes('Token:')) {
+ return {
+ authenticated: true,
+ method: 'oauth',
+ host: 'github.com',
+ };
+ }
+ } catch (ghError) {
+ logger.debug(`checkAuth: gh auth status failed: ${ghError}`);
+ }
+
+ // Try Copilot-specific auth check if gh is not available
+ try {
+ const result = execSync(`"${this.cliPath}" auth status`, {
+ encoding: 'utf8',
+ timeout: 10000,
+ stdio: 'pipe',
+ });
+
+ logger.debug(`checkAuth: copilot auth status output: ${result.substring(0, 200)}`);
+
+ if (result.includes('authenticated') || result.includes('logged in')) {
+ return {
+ authenticated: true,
+ method: 'cli',
+ };
+ }
+ } catch (copilotError) {
+ logger.debug(`checkAuth: copilot auth status failed: ${copilotError}`);
+ }
+
+ // Check for GITHUB_TOKEN environment variable
+ if (process.env.GITHUB_TOKEN) {
+ logger.debug('checkAuth: Found GITHUB_TOKEN environment variable');
+ return {
+ authenticated: true,
+ method: 'oauth',
+ statusMessage: 'Using GITHUB_TOKEN environment variable',
+ };
+ }
+
+ // Check for gh config file
+ const ghConfigPath = path.join(os.homedir(), '.config', 'gh', 'hosts.yml');
+ try {
+ await fs.access(ghConfigPath);
+ const content = await fs.readFile(ghConfigPath, 'utf8');
+ if (content.includes('github.com') && content.includes('oauth_token')) {
+ logger.debug('checkAuth: Found gh config with oauth_token');
+ return {
+ authenticated: true,
+ method: 'oauth',
+ host: 'github.com',
+ };
+ }
+ } catch {
+ logger.debug('checkAuth: No gh config found');
+ }
+
+ // No credentials found
+ logger.debug('checkAuth: No valid credentials found');
+ return {
+ authenticated: false,
+ method: 'none',
+ error:
+ 'No authentication configured. Run "gh auth login" or install GitHub Copilot extension.',
+ };
+ }
+
+ /**
+ * Fetch available models from the CLI at runtime
+ */
+ async fetchRuntimeModels(): Promise {
+ this.ensureCliDetected();
+ if (!this.cliPath) {
+ return [];
+ }
+
+ try {
+ // Try to list models using the CLI
+ const result = execSync(`"${this.cliPath}" models list --format json`, {
+ encoding: 'utf8',
+ timeout: 15000,
+ stdio: 'pipe',
+ });
+
+ const models = JSON.parse(result) as CopilotRuntimeModel[];
+ this.runtimeModels = models;
+ logger.debug(`Fetched ${models.length} runtime models from Copilot CLI`);
+ return models;
+ } catch (error) {
+ // Clear cache on failure to avoid returning stale data
+ this.runtimeModels = null;
+ logger.debug(`Failed to fetch runtime models: ${error}`);
+ return [];
+ }
+ }
+
+ /**
+ * Detect installation status (required by BaseProvider)
+ */
+ async detectInstallation(): Promise {
+ const installed = await this.isInstalled();
+ const version = installed ? await this.getVersion() : undefined;
+ const auth = await this.checkAuth();
+
+ return {
+ installed,
+ version: version || undefined,
+ path: this.cliPath || undefined,
+ method: 'cli',
+ authenticated: auth.authenticated,
+ };
+ }
+
+ /**
+ * Get the detected CLI path (public accessor for status endpoints)
+ */
+ getCliPath(): string | null {
+ this.ensureCliDetected();
+ return this.cliPath;
+ }
+
+ /**
+ * Get available Copilot models
+ *
+ * Returns both static model definitions and runtime-discovered models
+ */
+ getAvailableModels(): ModelDefinition[] {
+ // Start with static model definitions - explicitly typed to allow runtime models
+ const staticModels: ModelDefinition[] = Object.entries(COPILOT_MODEL_MAP).map(
+ ([id, config]) => ({
+ id, // Full model ID with copilot- prefix
+ name: config.label,
+ modelString: id.replace('copilot-', ''), // Bare model for CLI
+ provider: 'copilot',
+ description: config.description,
+ supportsTools: config.supportsTools,
+ supportsVision: config.supportsVision,
+ contextWindow: config.contextWindow,
+ })
+ );
+
+ // Add runtime models if available (discovered via CLI)
+ if (this.runtimeModels) {
+ for (const runtimeModel of this.runtimeModels) {
+ // Skip if already in static list
+ const staticId = `copilot-${runtimeModel.id}`;
+ if (staticModels.some((m) => m.id === staticId)) {
+ continue;
+ }
+
+ staticModels.push({
+ id: staticId,
+ name: runtimeModel.name || runtimeModel.id,
+ modelString: runtimeModel.id,
+ provider: 'copilot',
+ description: `Dynamic model: ${runtimeModel.name || runtimeModel.id}`,
+ supportsTools: true,
+ supportsVision: runtimeModel.capabilities?.supportsVision ?? false,
+ contextWindow: runtimeModel.capabilities?.maxInputTokens,
+ });
+ }
+ }
+
+ return staticModels;
+ }
+
+ /**
+ * Check if a feature is supported
+ *
+ * Note: Vision is NOT currently supported - the SDK doesn't handle image inputs yet.
+ * This may change in future versions of the Copilot SDK.
+ */
+ supportsFeature(feature: string): boolean {
+ const supported = ['tools', 'text', 'streaming'];
+ return supported.includes(feature);
+ }
+
+ /**
+ * Check if runtime models have been cached
+ */
+ hasCachedModels(): boolean {
+ return this.runtimeModels !== null && this.runtimeModels.length > 0;
+ }
+
+ /**
+ * Clear the runtime model cache
+ */
+ clearModelCache(): void {
+ this.runtimeModels = null;
+ logger.debug('Cleared Copilot model cache');
+ }
+
+ /**
+ * Refresh models from CLI and return all available models
+ */
+ async refreshModels(): Promise {
+ logger.debug('Refreshing Copilot models from CLI');
+ await this.fetchRuntimeModels();
+ return this.getAvailableModels();
+ }
+}
diff --git a/temp_repo/apps/server/src/providers/cursor-config-manager.ts b/temp_repo/apps/server/src/providers/cursor-config-manager.ts
new file mode 100644
index 0000000000000000000000000000000000000000..7b32ceb95b69e435de56a7b31431776d94e0c991
--- /dev/null
+++ b/temp_repo/apps/server/src/providers/cursor-config-manager.ts
@@ -0,0 +1,197 @@
+/**
+ * Cursor CLI Configuration Manager
+ *
+ * Manages Cursor CLI configuration stored in .automaker/cursor-config.json
+ */
+
+import * as fs from 'fs';
+import * as path from 'path';
+import { getAllCursorModelIds, type CursorCliConfig, type CursorModelId } from '@automaker/types';
+import { createLogger } from '@automaker/utils';
+import { getAutomakerDir } from '@automaker/platform';
+
+// Create logger for this module
+const logger = createLogger('CursorConfigManager');
+
+/**
+ * Manages Cursor CLI configuration
+ * Config location: .automaker/cursor-config.json
+ */
+export class CursorConfigManager {
+ private configPath: string;
+ private config: CursorCliConfig;
+
+ constructor(projectPath: string) {
+ // Use getAutomakerDir for consistent path resolution
+ this.configPath = path.join(getAutomakerDir(projectPath), 'cursor-config.json');
+ this.config = this.loadConfig();
+ }
+
+ /**
+ * Load configuration from disk
+ */
+ private loadConfig(): CursorCliConfig {
+ try {
+ if (fs.existsSync(this.configPath)) {
+ const content = fs.readFileSync(this.configPath, 'utf8');
+ const parsed = JSON.parse(content) as CursorCliConfig;
+ logger.debug(`Loaded config from ${this.configPath}`);
+ return parsed;
+ }
+ } catch (error) {
+ logger.warn('Failed to load config:', error);
+ }
+
+ // Return default config with all available models
+ return {
+ defaultModel: 'cursor-auto',
+ models: getAllCursorModelIds(),
+ };
+ }
+
+ /**
+ * Save configuration to disk
+ */
+ private saveConfig(): void {
+ try {
+ const dir = path.dirname(this.configPath);
+ if (!fs.existsSync(dir)) {
+ fs.mkdirSync(dir, { recursive: true });
+ }
+ fs.writeFileSync(this.configPath, JSON.stringify(this.config, null, 2));
+ logger.debug('Config saved');
+ } catch (error) {
+ logger.error('Failed to save config:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Get the full configuration
+ */
+ getConfig(): CursorCliConfig {
+ return { ...this.config };
+ }
+
+ /**
+ * Get the default model
+ */
+ getDefaultModel(): CursorModelId {
+ return this.config.defaultModel || 'cursor-auto';
+ }
+
+ /**
+ * Set the default model
+ */
+ setDefaultModel(model: CursorModelId): void {
+ this.config.defaultModel = model;
+ this.saveConfig();
+ logger.info(`Default model set to: ${model}`);
+ }
+
+ /**
+ * Get enabled models
+ */
+ getEnabledModels(): CursorModelId[] {
+ return this.config.models || ['cursor-auto'];
+ }
+
+ /**
+ * Set enabled models
+ */
+ setEnabledModels(models: CursorModelId[]): void {
+ this.config.models = models;
+ this.saveConfig();
+ logger.info(`Enabled models updated: ${models.join(', ')}`);
+ }
+
+ /**
+ * Add a model to enabled list
+ */
+ addModel(model: CursorModelId): void {
+ if (!this.config.models) {
+ this.config.models = [];
+ }
+ if (!this.config.models.includes(model)) {
+ this.config.models.push(model);
+ this.saveConfig();
+ logger.info(`Model added: ${model}`);
+ }
+ }
+
+ /**
+ * Remove a model from enabled list
+ */
+ removeModel(model: CursorModelId): void {
+ if (this.config.models) {
+ this.config.models = this.config.models.filter((m) => m !== model);
+ this.saveConfig();
+ logger.info(`Model removed: ${model}`);
+ }
+ }
+
+ /**
+ * Check if a model is enabled
+ */
+ isModelEnabled(model: CursorModelId): boolean {
+ return this.config.models?.includes(model) ?? false;
+ }
+
+ /**
+ * Get MCP server configurations
+ */
+ getMcpServers(): string[] {
+ return this.config.mcpServers || [];
+ }
+
+ /**
+ * Set MCP server configurations
+ */
+ setMcpServers(servers: string[]): void {
+ this.config.mcpServers = servers;
+ this.saveConfig();
+ logger.info(`MCP servers updated: ${servers.join(', ')}`);
+ }
+
+ /**
+ * Get Cursor rules paths
+ */
+ getRules(): string[] {
+ return this.config.rules || [];
+ }
+
+ /**
+ * Set Cursor rules paths
+ */
+ setRules(rules: string[]): void {
+ this.config.rules = rules;
+ this.saveConfig();
+ logger.info(`Rules updated: ${rules.join(', ')}`);
+ }
+
+ /**
+ * Reset configuration to defaults
+ */
+ reset(): void {
+ this.config = {
+ defaultModel: 'cursor-auto',
+ models: getAllCursorModelIds(),
+ };
+ this.saveConfig();
+ logger.info('Config reset to defaults');
+ }
+
+ /**
+ * Check if config file exists
+ */
+ exists(): boolean {
+ return fs.existsSync(this.configPath);
+ }
+
+ /**
+ * Get the config file path
+ */
+ getConfigPath(): string {
+ return this.configPath;
+ }
+}
diff --git a/temp_repo/apps/server/src/providers/cursor-provider.ts b/temp_repo/apps/server/src/providers/cursor-provider.ts
new file mode 100644
index 0000000000000000000000000000000000000000..3903ea648c10625c82d425eb644a58baf1902bb0
--- /dev/null
+++ b/temp_repo/apps/server/src/providers/cursor-provider.ts
@@ -0,0 +1,1258 @@
+/**
+ * Cursor Provider - Executes queries using cursor-agent CLI
+ *
+ * Extends CliProvider with Cursor-specific:
+ * - Event normalization for Cursor's JSONL format
+ * - Text block deduplication (Cursor sends duplicates)
+ * - Session ID tracking
+ * - Versions directory detection
+ *
+ * Spawns the cursor-agent CLI with --output-format stream-json for streaming responses.
+ */
+
+import { execSync } from 'child_process';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import { findCliInWsl, isWslAvailable } from '@automaker/platform';
+import {
+ CliProvider,
+ type CliSpawnConfig,
+ type CliDetectionResult,
+ type CliErrorInfo,
+} from './cli-provider.js';
+import type {
+ ProviderConfig,
+ ExecuteOptions,
+ ProviderMessage,
+ InstallationStatus,
+ ModelDefinition,
+ ContentBlock,
+} from './types.js';
+import { validateBareModelId } from '@automaker/types';
+import { validateApiKey } from '../lib/auth-utils.js';
+import { getEffectivePermissions, detectProfile } from '../services/cursor-config-service.js';
+import {
+ type CursorStreamEvent,
+ type CursorSystemEvent,
+ type CursorAssistantEvent,
+ type CursorToolCallEvent,
+ type CursorResultEvent,
+ type CursorAuthStatus,
+ CURSOR_MODEL_MAP,
+} from '@automaker/types';
+import { createLogger, isAbortError } from '@automaker/utils';
+import { spawnJSONLProcess, execInWsl } from '@automaker/platform';
+
+// Create logger for this module
+const logger = createLogger('CursorProvider');
+
+// =============================================================================
+// Cursor Tool Handler Registry
+// =============================================================================
+
+/**
+ * Tool handler definition for mapping Cursor tool calls to normalized format
+ */
+interface CursorToolHandler {
+ /** The normalized tool name (e.g., 'Read', 'Write') */
+ name: string;
+ /** Extract and normalize input from Cursor's args format */
+ mapInput: (args: TArgs) => unknown;
+ /** Format the result content for display (optional) */
+ formatResult?: (result: TResult, args?: TArgs) => string;
+ /** Format rejected result (optional) */
+ formatRejected?: (reason: string) => string;
+}
+
+/**
+ * Registry of Cursor tool handlers
+ * Each handler knows how to normalize its specific tool call type
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any -- handler registry stores heterogeneous tool type parameters
+const CURSOR_TOOL_HANDLERS: Record> = {
+ readToolCall: {
+ name: 'Read',
+ mapInput: (args: { path: string }) => ({ file_path: args.path }),
+ formatResult: (result: { content: string }) => result.content,
+ },
+
+ writeToolCall: {
+ name: 'Write',
+ mapInput: (args: { path: string; fileText: string }) => ({
+ file_path: args.path,
+ content: args.fileText,
+ }),
+ formatResult: (result: { linesCreated: number; path: string }) =>
+ `Wrote ${result.linesCreated} lines to ${result.path}`,
+ },
+
+ editToolCall: {
+ name: 'Edit',
+ mapInput: (args: { path: string; oldText?: string; newText?: string }) => ({
+ file_path: args.path,
+ old_string: args.oldText,
+ new_string: args.newText,
+ }),
+ formatResult: (_result: unknown, args?: { path: string }) => `Edited file: ${args?.path}`,
+ },
+
+ shellToolCall: {
+ name: 'Bash',
+ mapInput: (args: { command: string }) => ({ command: args.command }),
+ formatResult: (result: { exitCode: number; stdout?: string; stderr?: string }) => {
+ let content = `Exit code: ${result.exitCode}`;
+ if (result.stdout) content += `\n${result.stdout}`;
+ if (result.stderr) content += `\nStderr: ${result.stderr}`;
+ return content;
+ },
+ formatRejected: (reason: string) => `Rejected: ${reason}`,
+ },
+
+ deleteToolCall: {
+ name: 'Delete',
+ mapInput: (args: { path: string }) => ({ file_path: args.path }),
+ formatResult: (_result: unknown, args?: { path: string }) => `Deleted: ${args?.path}`,
+ formatRejected: (reason: string) => `Delete rejected: ${reason}`,
+ },
+
+ grepToolCall: {
+ name: 'Grep',
+ mapInput: (args: { pattern: string; path?: string }) => ({
+ pattern: args.pattern,
+ path: args.path,
+ }),
+ formatResult: (result: { matchedLines: number }) =>
+ `Found ${result.matchedLines} matching lines`,
+ },
+
+ lsToolCall: {
+ name: 'Ls',
+ mapInput: (args: { path: string }) => ({ path: args.path }),
+ formatResult: (result: { childrenFiles: number; childrenDirs: number }) =>
+ `Found ${result.childrenFiles} files, ${result.childrenDirs} directories`,
+ },
+
+ globToolCall: {
+ name: 'Glob',
+ mapInput: (args: { globPattern: string; targetDirectory?: string }) => ({
+ pattern: args.globPattern,
+ path: args.targetDirectory,
+ }),
+ formatResult: (result: { totalFiles: number }) => `Found ${result.totalFiles} matching files`,
+ },
+
+ semSearchToolCall: {
+ name: 'SemanticSearch',
+ mapInput: (args: { query: string; targetDirectories?: string[]; explanation?: string }) => ({
+ query: args.query,
+ targetDirectories: args.targetDirectories,
+ explanation: args.explanation,
+ }),
+ formatResult: (result: { results: string; codeResults?: unknown[] }) => {
+ const resultCount = result.codeResults?.length || 0;
+ return resultCount > 0
+ ? `Found ${resultCount} semantic search result(s)`
+ : result.results || 'No results found';
+ },
+ },
+
+ readLintsToolCall: {
+ name: 'ReadLints',
+ mapInput: (args: { paths: string[] }) => ({ paths: args.paths }),
+ formatResult: (result: { totalDiagnostics: number; totalFiles: number }) =>
+ `Found ${result.totalDiagnostics} diagnostic(s) in ${result.totalFiles} file(s)`,
+ },
+};
+
+/**
+ * Process a Cursor tool call using the handler registry
+ * Returns { toolName, toolInput } or null if tool type is unknown
+ */
+function processCursorToolCall(
+ toolCall: CursorToolCallEvent['tool_call']
+): { toolName: string; toolInput: unknown } | null {
+ // Check each registered handler
+ for (const [key, handler] of Object.entries(CURSOR_TOOL_HANDLERS)) {
+ const toolData = toolCall[key as keyof typeof toolCall] as { args?: unknown } | undefined;
+ if (toolData) {
+ // Skip if args not yet populated (partial streaming event)
+ if (!toolData.args) return null;
+ return {
+ toolName: handler.name,
+ toolInput: handler.mapInput(toolData.args),
+ };
+ }
+ }
+
+ // Handle generic function call (fallback)
+ if (toolCall.function) {
+ let toolInput: unknown;
+ try {
+ toolInput = JSON.parse(toolCall.function.arguments || '{}');
+ } catch {
+ toolInput = { raw: toolCall.function.arguments };
+ }
+ return {
+ toolName: toolCall.function.name,
+ toolInput,
+ };
+ }
+
+ return null;
+}
+
+/**
+ * Format the result content for a completed Cursor tool call
+ */
+function formatCursorToolResult(toolCall: CursorToolCallEvent['tool_call']): string {
+ for (const [key, handler] of Object.entries(CURSOR_TOOL_HANDLERS)) {
+ const toolData = toolCall[key as keyof typeof toolCall] as
+ | {
+ args?: unknown;
+ result?: { success?: unknown; rejected?: { reason: string } };
+ }
+ | undefined;
+
+ if (toolData?.result) {
+ if (toolData.result.success && handler.formatResult) {
+ return handler.formatResult(toolData.result.success, toolData.args);
+ }
+ if (toolData.result.rejected && handler.formatRejected) {
+ return handler.formatRejected(toolData.result.rejected.reason);
+ }
+ }
+ }
+
+ return '';
+}
+
+// =============================================================================
+// Error Codes
+// =============================================================================
+
+/**
+ * Cursor-specific error codes for detailed error handling
+ */
+export enum CursorErrorCode {
+ NOT_INSTALLED = 'CURSOR_NOT_INSTALLED',
+ NOT_AUTHENTICATED = 'CURSOR_NOT_AUTHENTICATED',
+ RATE_LIMITED = 'CURSOR_RATE_LIMITED',
+ MODEL_UNAVAILABLE = 'CURSOR_MODEL_UNAVAILABLE',
+ NETWORK_ERROR = 'CURSOR_NETWORK_ERROR',
+ PROCESS_CRASHED = 'CURSOR_PROCESS_CRASHED',
+ TIMEOUT = 'CURSOR_TIMEOUT',
+ UNKNOWN = 'CURSOR_UNKNOWN_ERROR',
+}
+
+export interface CursorError extends Error {
+ code: CursorErrorCode;
+ recoverable: boolean;
+ suggestion?: string;
+}
+
+/**
+ * CursorProvider - Integrates cursor-agent CLI as an AI provider
+ *
+ * Extends CliProvider with Cursor-specific behavior:
+ * - WSL required on Windows (cursor-agent has no native Windows build)
+ * - Versions directory detection for cursor-agent installations
+ * - Session ID tracking for conversation continuity
+ * - Text block deduplication (Cursor sends duplicate chunks)
+ */
+export class CursorProvider extends CliProvider {
+ /**
+ * Version data directory where cursor-agent stores versions
+ * The install script creates versioned folders like:
+ * ~/.local/share/cursor-agent/versions/2025.12.17-996666f/cursor-agent
+ */
+ private static VERSIONS_DIR = path.join(os.homedir(), '.local/share/cursor-agent/versions');
+
+ constructor(config: ProviderConfig = {}) {
+ super(config);
+ // Trigger CLI detection on construction (eager for Cursor)
+ this.ensureCliDetected();
+ }
+
+ // ==========================================================================
+ // CliProvider Abstract Method Implementations
+ // ==========================================================================
+
+ getName(): string {
+ return 'cursor';
+ }
+
+ getCliName(): string {
+ return 'cursor-agent';
+ }
+
+ getSpawnConfig(): CliSpawnConfig {
+ return {
+ windowsStrategy: 'direct',
+ commonPaths: {
+ linux: [
+ path.join(os.homedir(), '.local/bin/cursor-agent'), // Primary symlink location
+ '/usr/local/bin/cursor-agent',
+ ],
+ darwin: [path.join(os.homedir(), '.local/bin/cursor-agent'), '/usr/local/bin/cursor-agent'],
+ win32: [
+ path.join(
+ process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
+ 'Programs',
+ 'Cursor',
+ 'resources',
+ 'app',
+ 'bin',
+ 'cursor-agent.exe'
+ ),
+ path.join(
+ process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
+ 'Programs',
+ 'Cursor',
+ 'resources',
+ 'app',
+ 'bin',
+ 'cursor-agent.cmd'
+ ),
+ path.join(
+ process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
+ 'Programs',
+ 'Cursor',
+ 'resources',
+ 'app',
+ 'bin',
+ 'cursor.exe'
+ ),
+ path.join(
+ process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
+ 'Programs',
+ 'Cursor',
+ 'cursor.exe'
+ ),
+ path.join(
+ process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
+ 'Programs',
+ 'cursor',
+ 'resources',
+ 'app',
+ 'bin',
+ 'cursor-agent.exe'
+ ),
+ path.join(
+ process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
+ 'Programs',
+ 'cursor',
+ 'resources',
+ 'app',
+ 'bin',
+ 'cursor-agent.cmd'
+ ),
+ path.join(
+ process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
+ 'Programs',
+ 'cursor',
+ 'resources',
+ 'app',
+ 'bin',
+ 'cursor.exe'
+ ),
+ path.join(
+ process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
+ 'Programs',
+ 'cursor',
+ 'cursor.exe'
+ ),
+ path.join(
+ process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
+ 'npm',
+ 'cursor-agent.cmd'
+ ),
+ path.join(
+ process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
+ 'npm',
+ 'cursor.cmd'
+ ),
+ path.join(
+ process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
+ '.npm-global',
+ 'bin',
+ 'cursor-agent.cmd'
+ ),
+ path.join(
+ process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
+ '.npm-global',
+ 'bin',
+ 'cursor.cmd'
+ ),
+ path.join(
+ process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
+ 'pnpm',
+ 'cursor-agent.cmd'
+ ),
+ path.join(
+ process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
+ 'pnpm',
+ 'cursor.cmd'
+ ),
+ ],
+ },
+ };
+ }
+
+ /**
+ * Extract prompt text from ExecuteOptions
+ * Used to pass prompt via stdin instead of CLI args to avoid shell escaping issues
+ */
+ private extractPromptText(options: ExecuteOptions): string {
+ if (typeof options.prompt === 'string') {
+ return options.prompt;
+ } else if (Array.isArray(options.prompt)) {
+ return options.prompt
+ .filter((p) => p.type === 'text' && p.text)
+ .map((p) => p.text)
+ .join('\n');
+ } else {
+ throw new Error('Invalid prompt format');
+ }
+ }
+
+ buildCliArgs(options: ExecuteOptions): string[] {
+ // Model is already bare (no prefix) - validated by executeQuery
+ const model = options.model || 'auto';
+
+ // Build CLI arguments for cursor-agent
+ // NOTE: Prompt is NOT included here - it's passed via stdin to avoid
+ // shell escaping issues when content contains $(), backticks, etc.
+ const cliArgs: string[] = [];
+
+ // If using Cursor IDE (cliPath is 'cursor' not 'cursor-agent'), add 'agent' subcommand
+ if (this.cliPath && !this.cliPath.includes('cursor-agent')) {
+ cliArgs.push('agent');
+ }
+
+ cliArgs.push(
+ '-p', // Print mode (non-interactive)
+ '--output-format',
+ 'stream-json',
+ '--stream-partial-output' // Real-time streaming
+ );
+
+ // In read-only mode, use --mode ask for Q&A style (no tools)
+ // Otherwise, add --force to allow file edits
+ if (options.readOnly) {
+ cliArgs.push('--mode', 'ask');
+ } else {
+ cliArgs.push('--force');
+ }
+
+ // Add model if not auto
+ if (model !== 'auto') {
+ cliArgs.push('--model', model);
+ }
+
+ // Resume an existing chat when a provider session ID is available
+ if (options.sdkSessionId) {
+ cliArgs.push('--resume', options.sdkSessionId);
+ }
+
+ // Use '-' to indicate reading prompt from stdin
+ cliArgs.push('-');
+
+ return cliArgs;
+ }
+
+ /**
+ * Convert Cursor event to AutoMaker ProviderMessage format
+ * Made public as required by CliProvider abstract method
+ */
+ normalizeEvent(event: unknown): ProviderMessage | null {
+ const cursorEvent = event as CursorStreamEvent;
+
+ switch (cursorEvent.type) {
+ case 'system':
+ // System init - we capture session_id but don't yield a message
+ return null;
+
+ case 'user':
+ // User message - already handled by caller
+ return null;
+
+ case 'assistant': {
+ const assistantEvent = cursorEvent as CursorAssistantEvent;
+ return {
+ type: 'assistant',
+ session_id: assistantEvent.session_id,
+ message: {
+ role: 'assistant',
+ content: assistantEvent.message.content.map((c) => ({
+ type: 'text' as const,
+ text: c.text,
+ })),
+ },
+ };
+ }
+
+ case 'tool_call': {
+ const toolEvent = cursorEvent as CursorToolCallEvent;
+ const toolCall = toolEvent.tool_call;
+
+ // Use the tool handler registry to process the tool call
+ const processed = processCursorToolCall(toolCall);
+ if (!processed) {
+ // Log unrecognized tool call structure for debugging
+ const toolCallKeys = Object.keys(toolCall);
+ logger.warn(
+ `[UNHANDLED TOOL_CALL] Unknown tool call structure. Keys: ${toolCallKeys.join(', ')}. ` +
+ `Full tool_call: ${JSON.stringify(toolCall).substring(0, 500)}`
+ );
+ return null;
+ }
+
+ const { toolName, toolInput } = processed;
+
+ // For started events, emit tool_use
+ if (toolEvent.subtype === 'started') {
+ return {
+ type: 'assistant',
+ session_id: toolEvent.session_id,
+ message: {
+ role: 'assistant',
+ content: [
+ {
+ type: 'tool_use',
+ name: toolName,
+ tool_use_id: toolEvent.call_id,
+ input: toolInput,
+ },
+ ],
+ },
+ };
+ }
+
+ // For completed events, emit both tool_use and tool_result
+ if (toolEvent.subtype === 'completed') {
+ const resultContent = formatCursorToolResult(toolCall);
+
+ return {
+ type: 'assistant',
+ session_id: toolEvent.session_id,
+ message: {
+ role: 'assistant',
+ content: [
+ {
+ type: 'tool_use',
+ name: toolName,
+ tool_use_id: toolEvent.call_id,
+ input: toolInput,
+ },
+ {
+ type: 'tool_result',
+ tool_use_id: toolEvent.call_id,
+ content: resultContent,
+ },
+ ],
+ },
+ };
+ }
+
+ return null;
+ }
+
+ case 'result': {
+ const resultEvent = cursorEvent as CursorResultEvent;
+
+ if (resultEvent.is_error) {
+ const errorText = resultEvent.error || resultEvent.result || '';
+ const enrichedError =
+ errorText ||
+ `Cursor agent failed (duration: ${resultEvent.duration_ms}ms, subtype: ${resultEvent.subtype}, session: ${resultEvent.session_id ?? 'none'})`;
+ return {
+ type: 'error',
+ session_id: resultEvent.session_id,
+ error: enrichedError,
+ };
+ }
+
+ return {
+ type: 'result',
+ subtype: 'success',
+ session_id: resultEvent.session_id,
+ result: resultEvent.result,
+ };
+ }
+
+ default:
+ return null;
+ }
+ }
+
+ // ==========================================================================
+ // CliProvider Overrides
+ // ==========================================================================
+
+ /**
+ * Override CLI detection to add Cursor-specific checks:
+ * 1. Versions directory for cursor-agent installations
+ * 2. Cursor IDE with 'cursor agent' subcommand support
+ */
+ protected detectCli(): CliDetectionResult {
+ if (process.platform === 'win32') {
+ const findInPath = (command: string): string | null => {
+ try {
+ const result = execSync(`where ${command}`, {
+ encoding: 'utf8',
+ timeout: 5000,
+ stdio: ['pipe', 'pipe', 'pipe'],
+ windowsHide: true,
+ })
+ .trim()
+ .split(/\r?\n/)[0];
+
+ if (result && fs.existsSync(result)) {
+ return result;
+ }
+ } catch {
+ // Not in PATH
+ }
+
+ return null;
+ };
+
+ const isCursorAgentBinary = (cliPath: string) =>
+ cliPath.toLowerCase().includes('cursor-agent');
+
+ const supportsCursorAgentSubcommand = (cliPath: string) => {
+ try {
+ execSync(`"${cliPath}" agent --version`, {
+ encoding: 'utf8',
+ timeout: 5000,
+ stdio: 'pipe',
+ windowsHide: true,
+ });
+ return true;
+ } catch {
+ return false;
+ }
+ };
+
+ const pathResult = findInPath('cursor-agent') || findInPath('cursor');
+ if (pathResult) {
+ if (isCursorAgentBinary(pathResult) || supportsCursorAgentSubcommand(pathResult)) {
+ return {
+ cliPath: pathResult,
+ useWsl: false,
+ strategy: pathResult.toLowerCase().endsWith('.cmd') ? 'cmd' : 'direct',
+ };
+ }
+ }
+
+ const config = this.getSpawnConfig();
+ for (const candidate of config.commonPaths.win32 || []) {
+ const resolved = candidate;
+ if (!fs.existsSync(resolved)) {
+ continue;
+ }
+ if (isCursorAgentBinary(resolved) || supportsCursorAgentSubcommand(resolved)) {
+ return {
+ cliPath: resolved,
+ useWsl: false,
+ strategy: resolved.toLowerCase().endsWith('.cmd') ? 'cmd' : 'direct',
+ };
+ }
+ }
+
+ const wslLogger = (msg: string) => logger.debug(msg);
+ if (isWslAvailable({ logger: wslLogger })) {
+ const wslResult = findCliInWsl('cursor-agent', { logger: wslLogger });
+ if (wslResult) {
+ logger.debug(
+ `Using cursor-agent via WSL (${wslResult.distribution || 'default'}): ${wslResult.wslPath}`
+ );
+ return {
+ cliPath: 'wsl.exe',
+ useWsl: true,
+ wslCliPath: wslResult.wslPath,
+ wslDistribution: wslResult.distribution,
+ strategy: 'wsl',
+ };
+ }
+ }
+
+ logger.debug('cursor-agent not found on Windows');
+ return { cliPath: null, useWsl: false, strategy: 'direct' };
+ }
+
+ // First try standard detection (PATH, common paths, WSL)
+ const result = super.detectCli();
+ if (result.cliPath) {
+ return result;
+ }
+
+ // Cursor-specific: Check versions directory for any installed version
+ // This handles cases where cursor-agent is installed but not in PATH
+ if (fs.existsSync(CursorProvider.VERSIONS_DIR)) {
+ try {
+ const versions = fs
+ .readdirSync(CursorProvider.VERSIONS_DIR)
+ .filter((v) => !v.startsWith('.'))
+ .sort()
+ .reverse(); // Most recent first
+
+ for (const version of versions) {
+ const versionPath = path.join(CursorProvider.VERSIONS_DIR, version, 'cursor-agent');
+ if (fs.existsSync(versionPath)) {
+ logger.debug(`Found cursor-agent version ${version} at: ${versionPath}`);
+ return {
+ cliPath: versionPath,
+ useWsl: false,
+ strategy: 'native',
+ };
+ }
+ }
+ } catch {
+ // Ignore directory read errors
+ }
+ }
+
+ // If cursor-agent not found, try to find 'cursor' IDE and use 'cursor agent' subcommand
+ // The Cursor IDE includes the agent as a subcommand: cursor agent
+ const cursorPaths = [
+ '/usr/bin/cursor',
+ '/usr/local/bin/cursor',
+ path.join(os.homedir(), '.local/bin/cursor'),
+ '/opt/cursor/cursor',
+ ];
+
+ for (const cursorPath of cursorPaths) {
+ if (fs.existsSync(cursorPath)) {
+ // Verify cursor agent subcommand works
+ try {
+ execSync(`"${cursorPath}" agent --version`, {
+ encoding: 'utf8',
+ timeout: 5000,
+ stdio: 'pipe',
+ });
+ logger.debug(`Using cursor agent via Cursor IDE: ${cursorPath}`);
+ // Return cursor path but we'll use 'cursor agent' subcommand
+ return {
+ cliPath: cursorPath,
+ useWsl: false,
+ strategy: 'native',
+ };
+ } catch {
+ // cursor agent subcommand doesn't work, try next path
+ }
+ }
+ }
+
+ return result;
+ }
+
+ /**
+ * Override error mapping for Cursor-specific error codes
+ */
+ protected mapError(stderr: string, exitCode: number | null): CliErrorInfo {
+ const lower = stderr.toLowerCase();
+
+ if (
+ lower.includes('not authenticated') ||
+ lower.includes('please log in') ||
+ lower.includes('unauthorized')
+ ) {
+ return {
+ code: CursorErrorCode.NOT_AUTHENTICATED,
+ message: 'Cursor CLI is not authenticated',
+ recoverable: true,
+ suggestion: 'Run "cursor-agent login" to authenticate with your browser',
+ };
+ }
+
+ if (
+ lower.includes('rate limit') ||
+ lower.includes('too many requests') ||
+ lower.includes('429')
+ ) {
+ return {
+ code: CursorErrorCode.RATE_LIMITED,
+ message: 'Cursor API rate limit exceeded',
+ recoverable: true,
+ suggestion: 'Wait a few minutes and try again, or upgrade to Cursor Pro',
+ };
+ }
+
+ if (
+ lower.includes('model not available') ||
+ lower.includes('invalid model') ||
+ lower.includes('unknown model')
+ ) {
+ return {
+ code: CursorErrorCode.MODEL_UNAVAILABLE,
+ message: 'Requested model is not available',
+ recoverable: true,
+ suggestion: 'Try using "auto" mode or select a different model',
+ };
+ }
+
+ if (
+ lower.includes('network') ||
+ lower.includes('connection') ||
+ lower.includes('econnrefused') ||
+ lower.includes('timeout')
+ ) {
+ return {
+ code: CursorErrorCode.NETWORK_ERROR,
+ message: 'Network connection error',
+ recoverable: true,
+ suggestion: 'Check your internet connection and try again',
+ };
+ }
+
+ if (exitCode === 137 || lower.includes('killed') || lower.includes('sigterm')) {
+ return {
+ code: CursorErrorCode.PROCESS_CRASHED,
+ message: 'Cursor agent process was terminated',
+ recoverable: true,
+ suggestion: 'The process may have run out of memory. Try a simpler task.',
+ };
+ }
+
+ return {
+ code: CursorErrorCode.UNKNOWN,
+ message: stderr || `Cursor agent exited with code ${exitCode}`,
+ recoverable: false,
+ };
+ }
+
+ /**
+ * Override install instructions for Cursor-specific guidance
+ */
+ protected getInstallInstructions(): string {
+ if (process.platform === 'win32') {
+ return 'cursor-agent requires WSL on Windows. Install WSL, then run in WSL: curl https://cursor.com/install -fsS | bash';
+ }
+ return 'Install with: curl https://cursor.com/install -fsS | bash';
+ }
+
+ /**
+ * Execute a prompt using Cursor CLI with streaming
+ *
+ * Overrides base class to add:
+ * - Session ID tracking from system init events
+ * - Text block deduplication (Cursor sends duplicate chunks)
+ */
+ async *executeQuery(options: ExecuteOptions): AsyncGenerator {
+ this.ensureCliDetected();
+
+ // Validate that model doesn't have a provider prefix (except cursor- which should already be stripped)
+ // AgentService should strip prefixes before passing to providers
+ // Note: Cursor's Gemini models (e.g., "gemini-3-pro") legitimately start with "gemini-"
+ validateBareModelId(options.model, 'CursorProvider', 'cursor');
+
+ if (!this.cliPath) {
+ throw this.createError(
+ CursorErrorCode.NOT_INSTALLED,
+ 'Cursor CLI is not installed',
+ true,
+ this.getInstallInstructions()
+ );
+ }
+
+ // MCP servers are not yet supported by Cursor CLI - log warning but continue
+ if (options.mcpServers && Object.keys(options.mcpServers).length > 0) {
+ const serverCount = Object.keys(options.mcpServers).length;
+ logger.warn(
+ `MCP servers configured (${serverCount}) but not yet supported by Cursor CLI in AutoMaker. ` +
+ `MCP support for Cursor will be added in a future release. ` +
+ `The configured MCP servers will be ignored for this execution.`
+ );
+ }
+
+ // Embed system prompt into user prompt (Cursor CLI doesn't support separate system messages)
+ const effectiveOptions = this.embedSystemPromptIntoPrompt(options);
+
+ // Extract prompt text to pass via stdin (avoids shell escaping issues)
+ const promptText = this.extractPromptText(effectiveOptions);
+
+ const cliArgs = this.buildCliArgs(effectiveOptions);
+ const subprocessOptions = this.buildSubprocessOptions(options, cliArgs);
+
+ // Pass prompt via stdin to avoid shell interpretation of special characters
+ // like $(), backticks, etc. that may appear in file content
+ subprocessOptions.stdinData = promptText;
+
+ let sessionId: string | undefined;
+
+ // Dedup state for Cursor-specific text block handling
+ let lastTextBlock = '';
+ let accumulatedText = '';
+
+ logger.debug(`CursorProvider.executeQuery called with model: "${options.model}"`);
+
+ // Get effective permissions for this project and detect the active profile
+ const effectivePermissions = await getEffectivePermissions(options.cwd || process.cwd());
+ const activeProfile = detectProfile(effectivePermissions);
+ logger.debug(
+ `Active permission profile: ${activeProfile ?? 'none'}, permissions: ${JSON.stringify(effectivePermissions)}`
+ );
+
+ // Debug: log raw events when AUTOMAKER_DEBUG_RAW_OUTPUT is enabled
+ const debugRawEvents =
+ process.env.AUTOMAKER_DEBUG_RAW_OUTPUT === 'true' ||
+ process.env.AUTOMAKER_DEBUG_RAW_OUTPUT === '1';
+
+ try {
+ for await (const rawEvent of spawnJSONLProcess(subprocessOptions)) {
+ const event = rawEvent as CursorStreamEvent;
+
+ // Log raw event for debugging
+ if (debugRawEvents) {
+ const subtype = 'subtype' in event ? (event.subtype as string) : 'none';
+ logger.info(`[RAW EVENT] type=${event.type} subtype=${subtype}`);
+ if (event.type === 'tool_call') {
+ const toolEvent = event as CursorToolCallEvent;
+ const tc = toolEvent.tool_call;
+ const toolTypes =
+ [
+ tc.readToolCall && 'read',
+ tc.writeToolCall && 'write',
+ tc.editToolCall && 'edit',
+ tc.shellToolCall && 'shell',
+ tc.deleteToolCall && 'delete',
+ tc.grepToolCall && 'grep',
+ tc.lsToolCall && 'ls',
+ tc.globToolCall && 'glob',
+ tc.function && `function:${tc.function.name}`,
+ ]
+ .filter(Boolean)
+ .join(',') || 'unknown';
+ logger.info(
+ `[RAW TOOL_CALL] call_id=${toolEvent.call_id} types=[${toolTypes}]` +
+ (tc.shellToolCall ? ` cmd="${tc.shellToolCall.args?.command}"` : '') +
+ (tc.writeToolCall ? ` path="${tc.writeToolCall.args?.path}"` : '')
+ );
+ }
+ }
+
+ // Capture session ID from system init
+ if (event.type === 'system' && (event as CursorSystemEvent).subtype === 'init') {
+ sessionId = event.session_id;
+ logger.debug(`Session started: ${sessionId}`);
+ }
+
+ // Normalize and yield the event
+ const normalized = this.normalizeEvent(event);
+ if (!normalized && debugRawEvents) {
+ logger.info(`[DROPPED EVENT] type=${event.type} - normalizeEvent returned null`);
+ }
+ if (normalized) {
+ // Ensure session_id is always set
+ if (!normalized.session_id && sessionId) {
+ normalized.session_id = sessionId;
+ }
+
+ // Apply Cursor-specific dedup for assistant text messages
+ if (normalized.type === 'assistant' && normalized.message?.content) {
+ const dedupedContent = this.deduplicateTextBlocks(
+ normalized.message.content,
+ lastTextBlock,
+ accumulatedText
+ );
+
+ if (dedupedContent.content.length === 0) {
+ // All blocks were duplicates, skip this message
+ continue;
+ }
+
+ // Update state
+ lastTextBlock = dedupedContent.lastBlock;
+ accumulatedText = dedupedContent.accumulated;
+
+ // Update the message with deduped content
+ normalized.message.content = dedupedContent.content;
+ }
+
+ yield normalized;
+ }
+ }
+ } catch (error) {
+ if (isAbortError(error)) {
+ logger.debug('Query aborted');
+ return;
+ }
+
+ // Map CLI errors to CursorError
+ if (error instanceof Error && 'stderr' in error) {
+ const errorInfo = this.mapError(
+ (error as { stderr?: string }).stderr || error.message,
+ (error as { exitCode?: number | null }).exitCode ?? null
+ );
+ throw this.createError(
+ errorInfo.code as CursorErrorCode,
+ errorInfo.message,
+ errorInfo.recoverable,
+ errorInfo.suggestion
+ );
+ }
+ throw error;
+ }
+ }
+
+ // ==========================================================================
+ // Cursor-Specific Methods
+ // ==========================================================================
+
+ /**
+ * Create a CursorError with details
+ */
+ private createError(
+ code: CursorErrorCode,
+ message: string,
+ recoverable: boolean = false,
+ suggestion?: string
+ ): CursorError {
+ const error = new Error(message) as CursorError;
+ error.code = code;
+ error.recoverable = recoverable;
+ error.suggestion = suggestion;
+ error.name = 'CursorError';
+ return error;
+ }
+
+ /**
+ * Deduplicate text blocks in Cursor assistant messages
+ *
+ * Cursor often sends:
+ * 1. Duplicate consecutive text blocks (same text twice in a row)
+ * 2. A final accumulated block containing ALL previous text
+ *
+ * This method filters out these duplicates to prevent UI stuttering.
+ */
+ private deduplicateTextBlocks(
+ content: ContentBlock[],
+ lastTextBlock: string,
+ accumulatedText: string
+ ): { content: ContentBlock[]; lastBlock: string; accumulated: string } {
+ const filtered: ContentBlock[] = [];
+ let newLastBlock = lastTextBlock;
+ let newAccumulated = accumulatedText;
+
+ for (const block of content) {
+ if (block.type !== 'text' || !block.text) {
+ filtered.push(block);
+ continue;
+ }
+
+ const text = block.text;
+
+ // Skip empty text
+ if (!text.trim()) continue;
+
+ // Skip duplicate consecutive text blocks
+ if (text === newLastBlock) {
+ continue;
+ }
+
+ // Skip final accumulated text block
+ // Cursor sends one large block containing ALL previous text at the end
+ if (newAccumulated.length > 100 && text.length > newAccumulated.length * 0.8) {
+ const normalizedAccum = newAccumulated.replace(/\s+/g, ' ').trim();
+ const normalizedNew = text.replace(/\s+/g, ' ').trim();
+ if (normalizedNew.includes(normalizedAccum.slice(0, 100))) {
+ // This is the final accumulated block, skip it
+ continue;
+ }
+ }
+
+ // This is a valid new text block
+ newLastBlock = text;
+ newAccumulated += text;
+ filtered.push(block);
+ }
+
+ return {
+ content: filtered,
+ lastBlock: newLastBlock,
+ accumulated: newAccumulated,
+ };
+ }
+
+ /**
+ * Get Cursor CLI version
+ */
+ async getVersion(): Promise {
+ this.ensureCliDetected();
+ if (!this.cliPath) return null;
+
+ try {
+ if (this.useWsl && this.wslCliPath) {
+ const result = execInWsl(`${this.wslCliPath} --version`, {
+ timeout: 5000,
+ distribution: this.wslDistribution,
+ });
+ return result;
+ }
+
+ // If using Cursor IDE, use 'cursor agent --version'
+ const versionCmd = this.cliPath.includes('cursor-agent')
+ ? `"${this.cliPath}" --version`
+ : `"${this.cliPath}" agent --version`;
+
+ const result = execSync(versionCmd, {
+ encoding: 'utf8',
+ timeout: 5000,
+ stdio: 'pipe',
+ }).trim();
+ return result;
+ } catch {
+ return null;
+ }
+ }
+
+ /**
+ * Check authentication status
+ */
+ async checkAuth(): Promise {
+ this.ensureCliDetected();
+ if (!this.cliPath) {
+ return { authenticated: false, method: 'none' };
+ }
+
+ // Check for API key in environment with validation
+ if (process.env.CURSOR_API_KEY) {
+ const validation = validateApiKey(process.env.CURSOR_API_KEY, 'cursor');
+ if (!validation.isValid) {
+ logger.warn('Cursor API key validation failed:', validation.error);
+ return { authenticated: false, method: 'api_key', error: validation.error };
+ }
+ return { authenticated: true, method: 'api_key' };
+ }
+
+ // For WSL mode, check credentials inside WSL
+ if (this.useWsl && this.wslCliPath) {
+ const wslOpts = { timeout: 5000, distribution: this.wslDistribution };
+
+ // Check for credentials file inside WSL
+ const wslCredPaths = [
+ '$HOME/.cursor/credentials.json',
+ '$HOME/.config/cursor/credentials.json',
+ ];
+
+ for (const credPath of wslCredPaths) {
+ const content = execInWsl(`sh -c "cat ${credPath} 2>/dev/null || echo ''"`, wslOpts);
+ if (content && content.trim()) {
+ try {
+ const creds = JSON.parse(content);
+ if (creds.accessToken || creds.token) {
+ return { authenticated: true, method: 'login', hasCredentialsFile: true };
+ }
+ } catch {
+ // Invalid credentials file
+ }
+ }
+ }
+
+ // Try running --version to check if CLI works
+ const versionResult = execInWsl(`${this.wslCliPath} --version`, {
+ timeout: 10000,
+ distribution: this.wslDistribution,
+ });
+ if (versionResult) {
+ return { authenticated: true, method: 'login' };
+ }
+
+ return { authenticated: false, method: 'none' };
+ }
+
+ // Native mode (Linux/macOS) - check local credentials
+ const credentialPaths = [
+ path.join(os.homedir(), '.cursor', 'credentials.json'),
+ path.join(os.homedir(), '.config', 'cursor', 'credentials.json'),
+ ];
+
+ for (const credPath of credentialPaths) {
+ if (fs.existsSync(credPath)) {
+ try {
+ const content = fs.readFileSync(credPath, 'utf8');
+ const creds = JSON.parse(content);
+ if (creds.accessToken || creds.token) {
+ return { authenticated: true, method: 'login', hasCredentialsFile: true };
+ }
+ } catch {
+ // Invalid credentials file
+ }
+ }
+ }
+
+ // Try running a simple command to check auth
+ try {
+ execSync(`"${this.cliPath}" --version`, {
+ encoding: 'utf8',
+ timeout: 10000,
+ env: { ...process.env },
+ });
+ return { authenticated: true, method: 'login' };
+ } catch (error: unknown) {
+ const execError = error as { stderr?: string };
+ if (execError.stderr?.includes('not authenticated') || execError.stderr?.includes('log in')) {
+ return { authenticated: false, method: 'none' };
+ }
+ }
+
+ return { authenticated: false, method: 'none' };
+ }
+
+ /**
+ * Detect installation status (required by BaseProvider)
+ */
+ async detectInstallation(): Promise {
+ const installed = await this.isInstalled();
+ const version = installed ? await this.getVersion() : undefined;
+ const auth = await this.checkAuth();
+
+ // Determine the display path - for WSL, show the WSL path with distribution
+ const displayPath =
+ this.useWsl && this.wslCliPath
+ ? `(WSL${this.wslDistribution ? `:${this.wslDistribution}` : ''}) ${this.wslCliPath}`
+ : this.cliPath || undefined;
+
+ return {
+ installed,
+ version: version || undefined,
+ path: displayPath,
+ method: this.useWsl ? 'wsl' : 'cli',
+ hasApiKey: !!process.env.CURSOR_API_KEY,
+ authenticated: auth.authenticated,
+ };
+ }
+
+ /**
+ * Get the detected CLI path (public accessor for status endpoints)
+ */
+ getCliPath(): string | null {
+ this.ensureCliDetected();
+ return this.cliPath;
+ }
+
+ /**
+ * Get available Cursor models
+ */
+ getAvailableModels(): ModelDefinition[] {
+ return Object.entries(CURSOR_MODEL_MAP).map(([id, config]) => ({
+ id: `cursor-${id}`,
+ name: config.label,
+ modelString: id,
+ provider: 'cursor',
+ description: config.description,
+ supportsTools: true,
+ supportsVision: config.supportsVision,
+ }));
+ }
+
+ /**
+ * Check if a feature is supported
+ */
+ supportsFeature(feature: string): boolean {
+ const supported = ['tools', 'text', 'streaming'];
+ return supported.includes(feature);
+ }
+}
diff --git a/temp_repo/apps/server/src/providers/gemini-provider.ts b/temp_repo/apps/server/src/providers/gemini-provider.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9723de45a00ba5037163cd0315f9f31335b3a518
--- /dev/null
+++ b/temp_repo/apps/server/src/providers/gemini-provider.ts
@@ -0,0 +1,900 @@
+/**
+ * Gemini Provider - Executes queries using the Gemini CLI
+ *
+ * Extends CliProvider with Gemini-specific:
+ * - Event normalization for Gemini's JSONL streaming format
+ * - Google account and API key authentication support
+ * - Thinking level configuration
+ *
+ * Based on https://github.com/google-gemini/gemini-cli
+ */
+
+import { execSync } from 'child_process';
+import * as fs from 'fs/promises';
+import * as path from 'path';
+import * as os from 'os';
+import { CliProvider, type CliSpawnConfig, type CliErrorInfo } from './cli-provider.js';
+import type {
+ ProviderConfig,
+ ExecuteOptions,
+ ProviderMessage,
+ InstallationStatus,
+ ModelDefinition,
+} from './types.js';
+import { validateBareModelId } from '@automaker/types';
+import { GEMINI_MODEL_MAP, type GeminiAuthStatus } from '@automaker/types';
+import { createLogger, isAbortError } from '@automaker/utils';
+import { spawnJSONLProcess, type SubprocessOptions } from '@automaker/platform';
+import { normalizeTodos } from './tool-normalization.js';
+
+// Create logger for this module
+const logger = createLogger('GeminiProvider');
+
+// =============================================================================
+// Gemini Stream Event Types
+// =============================================================================
+
+/**
+ * Base event structure from Gemini CLI --output-format stream-json
+ *
+ * Actual CLI output format:
+ * {"type":"init","timestamp":"...","session_id":"...","model":"..."}
+ * {"type":"message","timestamp":"...","role":"user","content":"..."}
+ * {"type":"message","timestamp":"...","role":"assistant","content":"...","delta":true}
+ * {"type":"tool_use","timestamp":"...","tool_name":"...","tool_id":"...","parameters":{...}}
+ * {"type":"tool_result","timestamp":"...","tool_id":"...","status":"success","output":"..."}
+ * {"type":"result","timestamp":"...","status":"success","stats":{...}}
+ */
+interface GeminiStreamEvent {
+ type: 'init' | 'message' | 'tool_use' | 'tool_result' | 'result' | 'error';
+ timestamp?: string;
+ session_id?: string;
+}
+
+interface GeminiInitEvent extends GeminiStreamEvent {
+ type: 'init';
+ session_id: string;
+ model: string;
+}
+
+interface GeminiMessageEvent extends GeminiStreamEvent {
+ type: 'message';
+ role: 'user' | 'assistant';
+ content: string;
+ delta?: boolean;
+ session_id?: string;
+}
+
+interface GeminiToolUseEvent extends GeminiStreamEvent {
+ type: 'tool_use';
+ tool_id: string;
+ tool_name: string;
+ parameters: Record;
+ session_id?: string;
+}
+
+interface GeminiToolResultEvent extends GeminiStreamEvent {
+ type: 'tool_result';
+ tool_id: string;
+ status: 'success' | 'error';
+ output: string;
+ session_id?: string;
+}
+
+interface GeminiResultEvent extends GeminiStreamEvent {
+ type: 'result';
+ status: 'success' | 'error';
+ stats?: {
+ total_tokens?: number;
+ input_tokens?: number;
+ output_tokens?: number;
+ cached?: number;
+ input?: number;
+ duration_ms?: number;
+ tool_calls?: number;
+ };
+ error?: string;
+ session_id?: string;
+}
+
+// =============================================================================
+// Error Codes
+// =============================================================================
+
+export enum GeminiErrorCode {
+ NOT_INSTALLED = 'GEMINI_NOT_INSTALLED',
+ NOT_AUTHENTICATED = 'GEMINI_NOT_AUTHENTICATED',
+ RATE_LIMITED = 'GEMINI_RATE_LIMITED',
+ MODEL_UNAVAILABLE = 'GEMINI_MODEL_UNAVAILABLE',
+ NETWORK_ERROR = 'GEMINI_NETWORK_ERROR',
+ PROCESS_CRASHED = 'GEMINI_PROCESS_CRASHED',
+ TIMEOUT = 'GEMINI_TIMEOUT',
+ UNKNOWN = 'GEMINI_UNKNOWN_ERROR',
+}
+
+export interface GeminiError extends Error {
+ code: GeminiErrorCode;
+ recoverable: boolean;
+ suggestion?: string;
+}
+
+// =============================================================================
+// Tool Name Normalization
+// =============================================================================
+
+/**
+ * Gemini CLI tool name to standard tool name mapping
+ * This allows the UI to properly categorize and display Gemini tool calls
+ */
+const GEMINI_TOOL_NAME_MAP: Record = {
+ write_todos: 'TodoWrite',
+ read_file: 'Read',
+ read_many_files: 'Read',
+ replace: 'Edit',
+ write_file: 'Write',
+ run_shell_command: 'Bash',
+ search_file_content: 'Grep',
+ glob: 'Glob',
+ list_directory: 'Ls',
+ web_fetch: 'WebFetch',
+ google_web_search: 'WebSearch',
+};
+
+/**
+ * Normalize Gemini tool names to standard tool names
+ */
+function normalizeGeminiToolName(geminiToolName: string): string {
+ return GEMINI_TOOL_NAME_MAP[geminiToolName] || geminiToolName;
+}
+
+/**
+ * Normalize Gemini tool input parameters to standard format
+ *
+ * Uses shared normalizeTodos utility for consistent todo normalization.
+ *
+ * Gemini `write_todos` format:
+ * {"todos": [{"description": "Task text", "status": "pending|in_progress|completed|cancelled"}]}
+ *
+ * Claude `TodoWrite` format:
+ * {"todos": [{"content": "Task text", "status": "pending|in_progress|completed", "activeForm": "..."}]}
+ */
+function normalizeGeminiToolInput(
+ toolName: string,
+ input: Record
+): Record {
+ // Normalize write_todos using shared utility
+ if (toolName === 'write_todos' && Array.isArray(input.todos)) {
+ return { todos: normalizeTodos(input.todos) };
+ }
+ return input;
+}
+
+/**
+ * GeminiProvider - Integrates Gemini CLI as an AI provider
+ *
+ * Features:
+ * - Google account OAuth login support
+ * - API key authentication (GEMINI_API_KEY)
+ * - Vertex AI support
+ * - Thinking level configuration
+ * - Streaming JSON output
+ */
+export class GeminiProvider extends CliProvider {
+ constructor(config: ProviderConfig = {}) {
+ super(config);
+ // Trigger CLI detection on construction
+ this.ensureCliDetected();
+ }
+
+ // ==========================================================================
+ // CliProvider Abstract Method Implementations
+ // ==========================================================================
+
+ getName(): string {
+ return 'gemini';
+ }
+
+ getCliName(): string {
+ return 'gemini';
+ }
+
+ getSpawnConfig(): CliSpawnConfig {
+ return {
+ windowsStrategy: 'npx', // Gemini CLI can be run via npx
+ npxPackage: '@google/gemini-cli', // Official Google Gemini CLI package
+ commonPaths: {
+ linux: [
+ path.join(os.homedir(), '.local/bin/gemini'),
+ '/usr/local/bin/gemini',
+ path.join(os.homedir(), '.npm-global/bin/gemini'),
+ ],
+ darwin: [
+ path.join(os.homedir(), '.local/bin/gemini'),
+ '/usr/local/bin/gemini',
+ '/opt/homebrew/bin/gemini',
+ path.join(os.homedir(), '.npm-global/bin/gemini'),
+ ],
+ win32: [
+ path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'gemini.cmd'),
+ path.join(os.homedir(), '.npm-global', 'gemini.cmd'),
+ ],
+ },
+ };
+ }
+
+ /**
+ * Extract prompt text from ExecuteOptions
+ */
+ private extractPromptText(options: ExecuteOptions): string {
+ if (typeof options.prompt === 'string') {
+ return options.prompt;
+ } else if (Array.isArray(options.prompt)) {
+ return options.prompt
+ .filter((p) => p.type === 'text' && p.text)
+ .map((p) => p.text)
+ .join('\n');
+ } else {
+ throw new Error('Invalid prompt format');
+ }
+ }
+
+ buildCliArgs(options: ExecuteOptions): string[] {
+ // Model comes in stripped of provider prefix (e.g., '2.5-flash' from 'gemini-2.5-flash')
+ // We need to add 'gemini-' back since it's part of the actual CLI model name
+ const bareModel = options.model || '2.5-flash';
+ const cliArgs: string[] = [];
+
+ // Streaming JSON output format for real-time updates
+ cliArgs.push('--output-format', 'stream-json');
+
+ // Model selection - Gemini CLI expects full model names like "gemini-2.5-flash"
+ // Unlike Cursor CLI where 'cursor-' is just a routing prefix, for Gemini CLI
+ // the 'gemini-' is part of the actual model name Google expects
+ if (bareModel && bareModel !== 'auto') {
+ // Add gemini- prefix if not already present (handles edge cases)
+ const cliModel = bareModel.startsWith('gemini-') ? bareModel : `gemini-${bareModel}`;
+ cliArgs.push('--model', cliModel);
+ }
+
+ // Disable sandbox mode for faster execution (sandbox adds overhead)
+ cliArgs.push('--sandbox', 'false');
+
+ // YOLO mode for automatic approval (required for non-interactive use)
+ // Use explicit approval-mode for clearer semantics
+ cliArgs.push('--approval-mode', 'yolo');
+
+ // Force headless (non-interactive) mode with --prompt flag.
+ // The actual prompt content is passed via stdin (see buildSubprocessOptions()),
+ // but we MUST include -p to trigger headless mode. Without it, Gemini CLI
+ // starts in interactive mode which adds significant startup overhead
+ // (interactive REPL setup, extra context loading, etc.).
+ // Per Gemini CLI docs: stdin content is "appended to" the -p value.
+ cliArgs.push('--prompt', '');
+
+ // Explicitly include the working directory in allowed workspace directories
+ // This ensures Gemini CLI allows file operations in the project directory,
+ // even if it has a different workspace cached from a previous session
+ if (options.cwd) {
+ cliArgs.push('--include-directories', options.cwd);
+ }
+
+ // Resume an existing Gemini session when one is available
+ if (options.sdkSessionId) {
+ cliArgs.push('--resume', options.sdkSessionId);
+ }
+
+ // Note: Gemini CLI doesn't have a --thinking-level flag.
+ // Thinking capabilities are determined by the model selection (e.g., gemini-2.5-pro).
+ // The model handles thinking internally based on the task complexity.
+
+ return cliArgs;
+ }
+
+ /**
+ * Convert Gemini event to AutoMaker ProviderMessage format
+ */
+ normalizeEvent(event: unknown): ProviderMessage | null {
+ const geminiEvent = event as GeminiStreamEvent;
+
+ switch (geminiEvent.type) {
+ case 'init': {
+ // Init event - capture session but don't yield a message
+ const initEvent = geminiEvent as GeminiInitEvent;
+ logger.debug(
+ `Gemini init event: session=${initEvent.session_id}, model=${initEvent.model}`
+ );
+ return null;
+ }
+
+ case 'message': {
+ const messageEvent = geminiEvent as GeminiMessageEvent;
+
+ // Skip user messages - already handled by caller
+ if (messageEvent.role === 'user') {
+ return null;
+ }
+
+ // Handle assistant messages
+ if (messageEvent.role === 'assistant') {
+ return {
+ type: 'assistant',
+ session_id: messageEvent.session_id,
+ message: {
+ role: 'assistant',
+ content: [{ type: 'text', text: messageEvent.content }],
+ },
+ };
+ }
+
+ return null;
+ }
+
+ case 'tool_use': {
+ const toolEvent = geminiEvent as GeminiToolUseEvent;
+ const normalizedName = normalizeGeminiToolName(toolEvent.tool_name);
+ const normalizedInput = normalizeGeminiToolInput(
+ toolEvent.tool_name,
+ toolEvent.parameters as Record
+ );
+
+ return {
+ type: 'assistant',
+ session_id: toolEvent.session_id,
+ message: {
+ role: 'assistant',
+ content: [
+ {
+ type: 'tool_use',
+ name: normalizedName,
+ tool_use_id: toolEvent.tool_id,
+ input: normalizedInput,
+ },
+ ],
+ },
+ };
+ }
+
+ case 'tool_result': {
+ const toolResultEvent = geminiEvent as GeminiToolResultEvent;
+ // If tool result is an error, prefix with error indicator
+ const content =
+ toolResultEvent.status === 'error'
+ ? `[ERROR] ${toolResultEvent.output}`
+ : toolResultEvent.output;
+ return {
+ type: 'assistant',
+ session_id: toolResultEvent.session_id,
+ message: {
+ role: 'assistant',
+ content: [
+ {
+ type: 'tool_result',
+ tool_use_id: toolResultEvent.tool_id,
+ content,
+ },
+ ],
+ },
+ };
+ }
+
+ case 'result': {
+ const resultEvent = geminiEvent as GeminiResultEvent;
+
+ if (resultEvent.status === 'error') {
+ const enrichedError =
+ resultEvent.error ||
+ `Gemini agent failed (duration: ${resultEvent.stats?.duration_ms ?? 'unknown'}ms, session: ${resultEvent.session_id ?? 'none'})`;
+ return {
+ type: 'error',
+ session_id: resultEvent.session_id,
+ error: enrichedError,
+ };
+ }
+
+ // Success result - include stats for logging
+ logger.debug(
+ `Gemini result: status=${resultEvent.status}, tokens=${resultEvent.stats?.total_tokens}`
+ );
+ return {
+ type: 'result',
+ subtype: 'success',
+ session_id: resultEvent.session_id,
+ };
+ }
+
+ case 'error': {
+ const errorEvent = geminiEvent as GeminiResultEvent;
+ const enrichedError =
+ errorEvent.error || `Gemini agent failed (session: ${errorEvent.session_id ?? 'none'})`;
+ return {
+ type: 'error',
+ session_id: errorEvent.session_id,
+ error: enrichedError,
+ };
+ }
+
+ default:
+ logger.debug(`Unknown Gemini event type: ${geminiEvent.type}`);
+ return null;
+ }
+ }
+
+ // ==========================================================================
+ // CliProvider Overrides
+ // ==========================================================================
+
+ /**
+ * Build subprocess options with stdin data for prompt and speed-optimized env vars.
+ *
+ * Passes the prompt via stdin instead of --prompt CLI arg to:
+ * - Avoid shell argument size limits with large prompts (system prompt + context)
+ * - Avoid shell escaping issues with special characters in prompts
+ * - Match the pattern used by Cursor, OpenCode, and Codex providers
+ *
+ * Also injects environment variables to reduce Gemini CLI startup overhead:
+ * - GEMINI_TELEMETRY_ENABLED=false: Disables OpenTelemetry collection
+ */
+ protected buildSubprocessOptions(options: ExecuteOptions, cliArgs: string[]): SubprocessOptions {
+ const subprocessOptions = super.buildSubprocessOptions(options, cliArgs);
+
+ // Pass prompt via stdin to avoid shell interpretation of special characters
+ // and shell argument size limits with large system prompts + context files
+ subprocessOptions.stdinData = this.extractPromptText(options);
+
+ // Disable telemetry to reduce startup overhead
+ if (subprocessOptions.env) {
+ subprocessOptions.env['GEMINI_TELEMETRY_ENABLED'] = 'false';
+ }
+
+ return subprocessOptions;
+ }
+
+ /**
+ * Override error mapping for Gemini-specific error codes
+ */
+ protected mapError(stderr: string, exitCode: number | null): CliErrorInfo {
+ const lower = stderr.toLowerCase();
+
+ if (
+ lower.includes('not authenticated') ||
+ lower.includes('please log in') ||
+ lower.includes('unauthorized') ||
+ lower.includes('login required') ||
+ lower.includes('error authenticating') ||
+ lower.includes('loadcodeassist') ||
+ (lower.includes('econnrefused') && lower.includes('8888'))
+ ) {
+ return {
+ code: GeminiErrorCode.NOT_AUTHENTICATED,
+ message: 'Gemini CLI is not authenticated',
+ recoverable: true,
+ suggestion:
+ 'Run "gemini" interactively to log in, or set GEMINI_API_KEY environment variable',
+ };
+ }
+
+ if (
+ lower.includes('rate limit') ||
+ lower.includes('too many requests') ||
+ lower.includes('429') ||
+ lower.includes('quota exceeded')
+ ) {
+ return {
+ code: GeminiErrorCode.RATE_LIMITED,
+ message: 'Gemini API rate limit exceeded',
+ recoverable: true,
+ suggestion: 'Wait a few minutes and try again. Free tier: 60 req/min, 1000 req/day',
+ };
+ }
+
+ if (
+ lower.includes('model not available') ||
+ lower.includes('invalid model') ||
+ lower.includes('unknown model') ||
+ lower.includes('modelnotfounderror') ||
+ lower.includes('model not found') ||
+ (lower.includes('not found') && lower.includes('404'))
+ ) {
+ return {
+ code: GeminiErrorCode.MODEL_UNAVAILABLE,
+ message: 'Requested model is not available',
+ recoverable: true,
+ suggestion: 'Try using "gemini-2.5-flash" or select a different model',
+ };
+ }
+
+ if (
+ lower.includes('network') ||
+ lower.includes('connection') ||
+ lower.includes('econnrefused') ||
+ lower.includes('timeout')
+ ) {
+ return {
+ code: GeminiErrorCode.NETWORK_ERROR,
+ message: 'Network connection error',
+ recoverable: true,
+ suggestion: 'Check your internet connection and try again',
+ };
+ }
+
+ if (exitCode === 137 || lower.includes('killed') || lower.includes('sigterm')) {
+ return {
+ code: GeminiErrorCode.PROCESS_CRASHED,
+ message: 'Gemini CLI process was terminated',
+ recoverable: true,
+ suggestion: 'The process may have run out of memory. Try a simpler task.',
+ };
+ }
+
+ return {
+ code: GeminiErrorCode.UNKNOWN,
+ message: stderr || `Gemini CLI exited with code ${exitCode}`,
+ recoverable: false,
+ };
+ }
+
+ /**
+ * Override install instructions for Gemini-specific guidance
+ */
+ protected getInstallInstructions(): string {
+ return 'Install with: npm install -g @google/gemini-cli (or visit https://github.com/google-gemini/gemini-cli)';
+ }
+
+ /**
+ * Execute a prompt using Gemini CLI with streaming
+ */
+ async *executeQuery(options: ExecuteOptions): AsyncGenerator {
+ this.ensureCliDetected();
+
+ // Validate that model doesn't have a provider prefix (except gemini- which should already be stripped)
+ validateBareModelId(options.model, 'GeminiProvider', 'gemini');
+
+ if (!this.cliPath) {
+ throw this.createError(
+ GeminiErrorCode.NOT_INSTALLED,
+ 'Gemini CLI is not installed',
+ true,
+ this.getInstallInstructions()
+ );
+ }
+
+ // Ensure .geminiignore exists in the working directory to prevent Gemini CLI
+ // from scanning .git and node_modules directories during startup. This reduces
+ // startup time significantly (reported: 35s → 11s) by skipping large directories
+ // that Gemini CLI would otherwise traverse for context discovery.
+ await this.ensureGeminiIgnore(options.cwd || process.cwd());
+
+ // Embed system prompt into the user prompt so Gemini CLI receives
+ // project context (CLAUDE.md, CODE_QUALITY.md, etc.) that would
+ // otherwise be silently dropped since Gemini CLI has no --system-prompt flag.
+ const effectiveOptions = this.embedSystemPromptIntoPrompt(options);
+
+ // Build CLI args for headless execution.
+ const cliArgs = this.buildCliArgs(effectiveOptions);
+
+ const subprocessOptions = this.buildSubprocessOptions(effectiveOptions, cliArgs);
+
+ let sessionId: string | undefined;
+
+ logger.debug(`GeminiProvider.executeQuery called with model: "${options.model}"`);
+
+ try {
+ for await (const rawEvent of spawnJSONLProcess(subprocessOptions)) {
+ const event = rawEvent as GeminiStreamEvent;
+
+ // Capture session ID from init event
+ if (event.type === 'init') {
+ const initEvent = event as GeminiInitEvent;
+ sessionId = initEvent.session_id;
+ logger.debug(`Session started: ${sessionId}, model: ${initEvent.model}`);
+ }
+
+ // Normalize and yield the event
+ const normalized = this.normalizeEvent(event);
+ if (normalized) {
+ if (!normalized.session_id && sessionId) {
+ normalized.session_id = sessionId;
+ }
+ yield normalized;
+ }
+ }
+ } catch (error) {
+ if (isAbortError(error)) {
+ logger.debug('Query aborted');
+ return;
+ }
+
+ // Map CLI errors to GeminiError
+ if (error instanceof Error && 'stderr' in error) {
+ const errorInfo = this.mapError(
+ (error as { stderr?: string }).stderr || error.message,
+ (error as { exitCode?: number | null }).exitCode ?? null
+ );
+ throw this.createError(
+ errorInfo.code as GeminiErrorCode,
+ errorInfo.message,
+ errorInfo.recoverable,
+ errorInfo.suggestion
+ );
+ }
+ throw error;
+ }
+ }
+
+ // ==========================================================================
+ // Gemini-Specific Methods
+ // ==========================================================================
+
+ /**
+ * Ensure a .geminiignore file exists in the working directory.
+ *
+ * Gemini CLI scans the working directory for context discovery during startup.
+ * Excluding .git and node_modules dramatically reduces startup time by preventing
+ * traversal of large directories (reported improvement: 35s → 11s).
+ *
+ * Only creates the file if it doesn't already exist to avoid overwriting user config.
+ */
+ private async ensureGeminiIgnore(cwd: string): Promise {
+ const ignorePath = path.join(cwd, '.geminiignore');
+ const content = [
+ '# Auto-generated by Automaker to speed up Gemini CLI startup',
+ '# Prevents Gemini CLI from scanning large directories during context discovery',
+ '.git',
+ 'node_modules',
+ 'dist',
+ 'build',
+ '.next',
+ '.nuxt',
+ 'coverage',
+ '.automaker',
+ '.worktrees',
+ '.vscode',
+ '.idea',
+ '*.lock',
+ '',
+ ].join('\n');
+ try {
+ // Use 'wx' flag for atomic creation - fails if file exists (EEXIST)
+ await fs.writeFile(ignorePath, content, { encoding: 'utf-8', flag: 'wx' });
+ logger.debug(`Created .geminiignore at ${ignorePath}`);
+ } catch (writeError) {
+ // EEXIST means file already exists - that's fine, preserve user's file
+ if ((writeError as NodeJS.ErrnoException).code === 'EEXIST') {
+ logger.debug(`.geminiignore already exists at ${ignorePath}, preserving existing file`);
+ return;
+ }
+ // Non-fatal: startup will just be slower without the ignore file
+ logger.debug(`Failed to create .geminiignore: ${writeError}`);
+ }
+ }
+
+ /**
+ * Create a GeminiError with details
+ */
+ private createError(
+ code: GeminiErrorCode,
+ message: string,
+ recoverable: boolean = false,
+ suggestion?: string
+ ): GeminiError {
+ const error = new Error(message) as GeminiError;
+ error.code = code;
+ error.recoverable = recoverable;
+ error.suggestion = suggestion;
+ error.name = 'GeminiError';
+ return error;
+ }
+
+ /**
+ * Get Gemini CLI version
+ */
+ async getVersion(): Promise {
+ this.ensureCliDetected();
+ if (!this.cliPath) return null;
+
+ try {
+ const result = execSync(`"${this.cliPath}" --version`, {
+ encoding: 'utf8',
+ timeout: 5000,
+ stdio: 'pipe',
+ }).trim();
+ return result;
+ } catch {
+ return null;
+ }
+ }
+
+ /**
+ * Check authentication status
+ *
+ * Uses a fast credential check approach:
+ * 1. Check for GEMINI_API_KEY environment variable
+ * 2. Check for Google Cloud credentials
+ * 3. Check for Gemini settings file with stored credentials
+ * 4. Quick CLI auth test with --help (fast, doesn't make API calls)
+ */
+ async checkAuth(): Promise {
+ this.ensureCliDetected();
+ if (!this.cliPath) {
+ logger.debug('checkAuth: CLI not found');
+ return { authenticated: false, method: 'none' };
+ }
+
+ logger.debug('checkAuth: Starting credential check');
+
+ // Determine the likely auth method based on environment
+ const hasApiKey = !!process.env.GEMINI_API_KEY;
+ const hasEnvApiKey = hasApiKey;
+ const hasVertexAi = !!(
+ process.env.GOOGLE_APPLICATION_CREDENTIALS || process.env.GOOGLE_CLOUD_PROJECT
+ );
+
+ logger.debug(`checkAuth: hasApiKey=${hasApiKey}, hasVertexAi=${hasVertexAi}`);
+
+ // Check for Gemini credentials file (~/.gemini/settings.json)
+ const geminiConfigDir = path.join(os.homedir(), '.gemini');
+ const settingsPath = path.join(geminiConfigDir, 'settings.json');
+ let hasCredentialsFile = false;
+ let authType: string | null = null;
+
+ try {
+ await fs.access(settingsPath);
+ logger.debug(`checkAuth: Found settings file at ${settingsPath}`);
+ try {
+ const content = await fs.readFile(settingsPath, 'utf8');
+ const settings = JSON.parse(content);
+
+ // Auth config is at security.auth.selectedType (e.g., "oauth-personal", "oauth-adc", "api-key")
+ const selectedType = settings?.security?.auth?.selectedType;
+ if (selectedType) {
+ hasCredentialsFile = true;
+ authType = selectedType;
+ logger.debug(`checkAuth: Settings file has auth config, selectedType=${selectedType}`);
+ } else {
+ logger.debug(`checkAuth: Settings file found but no auth type configured`);
+ }
+ } catch (e) {
+ logger.debug(`checkAuth: Failed to parse settings file: ${e}`);
+ }
+ } catch {
+ logger.debug('checkAuth: No settings file found');
+ }
+
+ // If we have an API key, we're authenticated
+ if (hasApiKey) {
+ logger.debug('checkAuth: Using API key authentication');
+ return {
+ authenticated: true,
+ method: 'api_key',
+ hasApiKey,
+ hasEnvApiKey,
+ hasCredentialsFile,
+ };
+ }
+
+ // If we have Vertex AI credentials, we're authenticated
+ if (hasVertexAi) {
+ logger.debug('checkAuth: Using Vertex AI authentication');
+ return {
+ authenticated: true,
+ method: 'vertex_ai',
+ hasApiKey,
+ hasEnvApiKey,
+ hasCredentialsFile,
+ };
+ }
+
+ // Check if settings file indicates configured authentication
+ if (hasCredentialsFile && authType) {
+ // OAuth types: "oauth-personal", "oauth-adc"
+ // API key type: "api-key"
+ // Code assist: "code-assist" (requires IDE integration)
+ if (authType.startsWith('oauth')) {
+ logger.debug(`checkAuth: OAuth authentication configured (${authType})`);
+ return {
+ authenticated: true,
+ method: 'google_login',
+ hasApiKey,
+ hasEnvApiKey,
+ hasCredentialsFile,
+ };
+ }
+
+ if (authType === 'api-key') {
+ logger.debug('checkAuth: API key authentication configured in settings');
+ return {
+ authenticated: true,
+ method: 'api_key',
+ hasApiKey,
+ hasEnvApiKey,
+ hasCredentialsFile,
+ };
+ }
+
+ if (authType === 'code-assist' || authType === 'codeassist') {
+ logger.debug('checkAuth: Code Assist auth configured but requires local server');
+ return {
+ authenticated: false,
+ method: 'google_login',
+ hasApiKey,
+ hasEnvApiKey,
+ hasCredentialsFile,
+ error:
+ 'Code Assist authentication requires IDE integration. Please use "gemini" CLI to log in with a different method, or set GEMINI_API_KEY.',
+ };
+ }
+
+ // Unknown auth type but something is configured
+ logger.debug(`checkAuth: Unknown auth type configured: ${authType}`);
+ return {
+ authenticated: true,
+ method: 'google_login',
+ hasApiKey,
+ hasEnvApiKey,
+ hasCredentialsFile,
+ };
+ }
+
+ // No credentials found
+ logger.debug('checkAuth: No valid credentials found');
+ return {
+ authenticated: false,
+ method: 'none',
+ hasApiKey,
+ hasEnvApiKey,
+ hasCredentialsFile,
+ error:
+ 'No authentication configured. Run "gemini" interactively to log in, or set GEMINI_API_KEY.',
+ };
+ }
+
+ /**
+ * Detect installation status (required by BaseProvider)
+ */
+ async detectInstallation(): Promise {
+ const installed = await this.isInstalled();
+ const version = installed ? await this.getVersion() : undefined;
+ const auth = await this.checkAuth();
+
+ return {
+ installed,
+ version: version || undefined,
+ path: this.cliPath || undefined,
+ method: 'cli',
+ hasApiKey: !!process.env.GEMINI_API_KEY,
+ authenticated: auth.authenticated,
+ };
+ }
+
+ /**
+ * Get the detected CLI path (public accessor for status endpoints)
+ */
+ getCliPath(): string | null {
+ this.ensureCliDetected();
+ return this.cliPath;
+ }
+
+ /**
+ * Get available Gemini models
+ */
+ getAvailableModels(): ModelDefinition[] {
+ return Object.entries(GEMINI_MODEL_MAP).map(([id, config]) => ({
+ id, // Full model ID with gemini- prefix (e.g., 'gemini-2.5-flash')
+ name: config.label,
+ modelString: id, // Same as id - CLI uses the full model name
+ provider: 'gemini',
+ description: config.description,
+ supportsTools: true,
+ supportsVision: config.supportsVision,
+ contextWindow: config.contextWindow,
+ }));
+ }
+
+ /**
+ * Check if a feature is supported
+ */
+ supportsFeature(feature: string): boolean {
+ const supported = ['tools', 'text', 'streaming', 'vision', 'thinking'];
+ return supported.includes(feature);
+ }
+}
diff --git a/temp_repo/apps/server/src/providers/index.ts b/temp_repo/apps/server/src/providers/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0cb79470157bb42403896edc7f346f6194479d12
--- /dev/null
+++ b/temp_repo/apps/server/src/providers/index.ts
@@ -0,0 +1,56 @@
+/**
+ * Provider exports
+ */
+
+// Base providers
+export { BaseProvider } from './base-provider.js';
+export {
+ CliProvider,
+ type SpawnStrategy,
+ type CliSpawnConfig,
+ type CliErrorInfo,
+} from './cli-provider.js';
+export type {
+ ProviderConfig,
+ ExecuteOptions,
+ ProviderMessage,
+ InstallationStatus,
+ ModelDefinition,
+ AgentDefinition,
+ ReasoningEffort,
+ SystemPromptPreset,
+ ConversationMessage,
+ ContentBlock,
+ ValidationResult,
+ McpServerConfig,
+ McpStdioServerConfig,
+ McpSSEServerConfig,
+ McpHttpServerConfig,
+} from './types.js';
+
+// Claude provider
+export { ClaudeProvider } from './claude-provider.js';
+
+// Cursor provider
+export { CursorProvider, CursorErrorCode, CursorError } from './cursor-provider.js';
+export { CursorConfigManager } from './cursor-config-manager.js';
+
+// OpenCode provider
+export { OpencodeProvider } from './opencode-provider.js';
+
+// Gemini provider
+export { GeminiProvider, GeminiErrorCode } from './gemini-provider.js';
+
+// Copilot provider (GitHub Copilot SDK)
+export { CopilotProvider, CopilotErrorCode } from './copilot-provider.js';
+
+// Provider factory
+export { ProviderFactory } from './provider-factory.js';
+
+// Simple query service - unified interface for basic AI queries
+export { simpleQuery, streamingQuery } from './simple-query-service.js';
+export type {
+ SimpleQueryOptions,
+ SimpleQueryResult,
+ StreamingQueryOptions,
+} from './simple-query-service.js';
diff --git a/temp_repo/apps/server/src/providers/mock-provider.ts b/temp_repo/apps/server/src/providers/mock-provider.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2880fecef9cb538f1bd2c34f28cf182cb62dbcbc
--- /dev/null
+++ b/temp_repo/apps/server/src/providers/mock-provider.ts
@@ -0,0 +1,53 @@
+/**
+ * Mock Provider - No-op AI provider for E2E and CI testing
+ *
+ * When AUTOMAKER_MOCK_AGENT=true, the server uses this provider instead of
+ * real backends (Claude, Codex, etc.) so tests never call external APIs.
+ */
+
+import type { ExecuteOptions } from '@automaker/types';
+import { BaseProvider } from './base-provider.js';
+import type { ProviderMessage, InstallationStatus, ModelDefinition } from './types.js';
+
+const MOCK_TEXT = 'Mock agent output for testing.';
+
+export class MockProvider extends BaseProvider {
+ getName(): string {
+ return 'mock';
+ }
+
+ async *executeQuery(_options: ExecuteOptions): AsyncGenerator {
+ yield {
+ type: 'assistant',
+ message: {
+ role: 'assistant',
+ content: [{ type: 'text', text: MOCK_TEXT }],
+ },
+ };
+ yield {
+ type: 'result',
+ subtype: 'success',
+ };
+ }
+
+ async detectInstallation(): Promise {
+ return {
+ installed: true,
+ method: 'sdk',
+ hasApiKey: true,
+ authenticated: true,
+ };
+ }
+
+ getAvailableModels(): ModelDefinition[] {
+ return [
+ {
+ id: 'mock-model',
+ name: 'Mock Model',
+ modelString: 'mock-model',
+ provider: 'mock',
+ description: 'Mock model for testing',
+ },
+ ];
+ }
+}
diff --git a/temp_repo/apps/server/src/providers/opencode-provider.ts b/temp_repo/apps/server/src/providers/opencode-provider.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c38e339968408c8ce578e220ab490bda59102c5e
--- /dev/null
+++ b/temp_repo/apps/server/src/providers/opencode-provider.ts
@@ -0,0 +1,1537 @@
+/**
+ * OpenCode Provider - Executes queries using opencode CLI
+ *
+ * Extends CliProvider with OpenCode-specific configuration:
+ * - Event normalization for OpenCode's stream-json format
+ * - Dynamic model discovery via `opencode models` CLI command
+ * - NPX-based Windows execution strategy
+ * - Platform-specific npm global installation paths
+ *
+ * Spawns the opencode CLI with --output-format stream-json for streaming responses.
+ */
+
+import * as path from 'path';
+import * as os from 'os';
+import { execFile } from 'child_process';
+import { promisify } from 'util';
+import { CliProvider, type CliSpawnConfig } from './cli-provider.js';
+
+const execFileAsync = promisify(execFile);
+import type {
+ ProviderConfig,
+ ExecuteOptions,
+ ProviderMessage,
+ ModelDefinition,
+ InstallationStatus,
+ ContentBlock,
+} from '@automaker/types';
+import { type SubprocessOptions, getOpenCodeAuthIndicators } from '@automaker/platform';
+import { createLogger } from '@automaker/utils';
+
+// Create logger for OpenCode operations
+const opencodeLogger = createLogger('OpencodeProvider');
+
+// =============================================================================
+// OpenCode Auth Types
+// =============================================================================
+
+export interface OpenCodeAuthStatus {
+ authenticated: boolean;
+ method: 'api_key' | 'oauth' | 'none';
+ hasOAuthToken?: boolean;
+ hasApiKey?: boolean;
+}
+
+// =============================================================================
+// OpenCode Dynamic Model Types
+// =============================================================================
+
+/**
+ * Model information from `opencode models` CLI output
+ */
+export interface OpenCodeModelInfo {
+ /** Full model ID (e.g., "copilot/claude-sonnet-4-5") */
+ id: string;
+ /** Provider name (e.g., "copilot", "anthropic", "openai") */
+ provider: string;
+ /** Model name without provider prefix */
+ name: string;
+ /** Display name for UI */
+ displayName?: string;
+}
+
+/**
+ * Provider information from `opencode auth list` CLI output
+ */
+export interface OpenCodeProviderInfo {
+ /** Provider ID (e.g., "copilot", "anthropic") */
+ id: string;
+ /** Human-readable name */
+ name: string;
+ /** Whether the provider is authenticated */
+ authenticated: boolean;
+ /** Authentication method if authenticated */
+ authMethod?: 'oauth' | 'api_key';
+}
+
+/** Cache duration for dynamic model fetching (5 minutes) */
+const MODEL_CACHE_DURATION_MS = 5 * 60 * 1000;
+const OPENCODE_MODEL_ID_SEPARATOR = '/';
+const OPENCODE_MODEL_ID_PATTERN = /^[a-z0-9.-]+\/\S+$/;
+const OPENCODE_PROVIDER_PATTERN = /^[a-z0-9.-]+$/;
+const OPENCODE_MODEL_NAME_PATTERN = /^[a-zA-Z0-9._:/-]+$/;
+
+// =============================================================================
+// OpenCode Stream Event Types
+// =============================================================================
+
+/**
+ * Part object within OpenCode events
+ */
+interface OpenCodePart {
+ id?: string;
+ sessionID?: string;
+ messageID?: string;
+ type: string;
+ text?: string;
+ reason?: string;
+ error?: string;
+ name?: string;
+ args?: unknown;
+ call_id?: string;
+ output?: string;
+ tokens?: {
+ input?: number;
+ output?: number;
+ reasoning?: number;
+ };
+}
+
+/**
+ * Base interface for all OpenCode stream events
+ * Format: {"type":"event_type","timestamp":...,"sessionID":"...","part":{...}}
+ */
+interface OpenCodeBaseEvent {
+ /** Event type identifier (step_start, text, step_finish, tool_call, etc.) */
+ type: string;
+ /** Unix timestamp */
+ timestamp?: number;
+ /** Session identifier */
+ sessionID?: string;
+ /** Event details */
+ part?: OpenCodePart;
+}
+
+/**
+ * Text event - Text output from the model
+ */
+export interface OpenCodeTextEvent extends OpenCodeBaseEvent {
+ type: 'text';
+ part: OpenCodePart & { type: 'text'; text: string };
+}
+
+/**
+ * Step start event - Begins an agentic loop iteration
+ */
+export interface OpenCodeStepStartEvent extends OpenCodeBaseEvent {
+ type: 'step_start';
+ part: OpenCodePart & { type: 'step-start' };
+}
+
+/**
+ * Step finish event - Completes an agentic loop iteration
+ */
+export interface OpenCodeStepFinishEvent extends OpenCodeBaseEvent {
+ type: 'step_finish';
+ part: OpenCodePart & { type: 'step-finish'; reason?: string };
+}
+
+/**
+ * Tool call event - Request to execute a tool
+ */
+export interface OpenCodeToolCallEvent extends OpenCodeBaseEvent {
+ type: 'tool_call';
+ part: OpenCodePart & { type: 'tool-call'; name: string; args?: unknown };
+}
+
+/**
+ * Tool result event - Output from a tool execution
+ */
+export interface OpenCodeToolResultEvent extends OpenCodeBaseEvent {
+ type: 'tool_result';
+ part: OpenCodePart & { type: 'tool-result'; output: string };
+}
+
+/**
+ * Error details object in error events
+ */
+interface OpenCodeErrorDetails {
+ name?: string;
+ message?: string;
+ data?: {
+ message?: string;
+ statusCode?: number;
+ isRetryable?: boolean;
+ };
+}
+
+/**
+ * Error event - An error occurred
+ */
+export interface OpenCodeErrorEvent extends OpenCodeBaseEvent {
+ type: 'error';
+ part?: OpenCodePart & { error: string };
+ error?: string | OpenCodeErrorDetails;
+}
+
+/**
+ * Tool error event - A tool execution failed
+ */
+export interface OpenCodeToolErrorEvent extends OpenCodeBaseEvent {
+ type: 'tool_error';
+ part?: OpenCodePart & { error: string };
+}
+
+/**
+ * Tool use event - The actual format emitted by OpenCode CLI when a tool is invoked.
+ * Contains the tool name, call ID, and the complete state (input, output, status).
+ * Note: OpenCode CLI emits 'tool_use' (not 'tool_call') as the event type.
+ */
+export interface OpenCodeToolUseEvent extends OpenCodeBaseEvent {
+ type: 'tool_use';
+ part: OpenCodePart & {
+ type: 'tool';
+ callID?: string;
+ tool?: string;
+ state?: {
+ status?: string;
+ input?: unknown;
+ output?: string;
+ title?: string;
+ metadata?: unknown;
+ time?: { start: number; end: number };
+ };
+ };
+}
+
+/**
+ * Union type of all OpenCode stream events
+ */
+export type OpenCodeStreamEvent =
+ | OpenCodeTextEvent
+ | OpenCodeStepStartEvent
+ | OpenCodeStepFinishEvent
+ | OpenCodeToolCallEvent
+ | OpenCodeToolUseEvent
+ | OpenCodeToolResultEvent
+ | OpenCodeErrorEvent
+ | OpenCodeToolErrorEvent;
+
+// =============================================================================
+// Tool Use ID Generation
+// =============================================================================
+
+/** Counter for generating unique tool use IDs when call_id is not provided */
+let toolUseIdCounter = 0;
+
+/**
+ * Generate a unique tool use ID for tool calls without explicit IDs
+ */
+function generateToolUseId(): string {
+ toolUseIdCounter += 1;
+ return `opencode-tool-${toolUseIdCounter}`;
+}
+
+/**
+ * Reset the tool use ID counter (useful for testing)
+ */
+export function resetToolUseIdCounter(): void {
+ toolUseIdCounter = 0;
+}
+
+// =============================================================================
+// Provider Implementation
+// =============================================================================
+
+/**
+ * OpencodeProvider - Integrates opencode CLI as an AI provider
+ *
+ * OpenCode is an npm-distributed CLI tool that provides access to
+ * multiple AI model providers through a unified interface.
+ *
+ * Supports dynamic model discovery via `opencode models` CLI command,
+ * enabling access to 75+ providers including GitHub Copilot, Google,
+ * Anthropic, OpenAI, and more based on user authentication.
+ */
+export class OpencodeProvider extends CliProvider {
+ // ==========================================================================
+ // Dynamic Model Cache
+ // ==========================================================================
+
+ /** Cached model definitions */
+ private cachedModels: ModelDefinition[] | null = null;
+
+ /** Timestamp when cache expires */
+ private modelsCacheExpiry: number = 0;
+
+ /** Cached authenticated providers */
+ private cachedProviders: OpenCodeProviderInfo[] | null = null;
+
+ /** Whether model refresh is in progress */
+ private isRefreshing: boolean = false;
+
+ /** Promise that resolves when current refresh completes */
+ private refreshPromise: Promise | null = null;
+
+ constructor(config: ProviderConfig = {}) {
+ super(config);
+ }
+
+ // ==========================================================================
+ // CliProvider Abstract Method Implementations
+ // ==========================================================================
+
+ getName(): string {
+ return 'opencode';
+ }
+
+ getCliName(): string {
+ return 'opencode';
+ }
+
+ getSpawnConfig(): CliSpawnConfig {
+ return {
+ windowsStrategy: 'npx',
+ npxPackage: 'opencode-ai@latest',
+ commonPaths: {
+ linux: [
+ path.join(os.homedir(), '.opencode/bin/opencode'),
+ path.join(os.homedir(), '.npm-global/bin/opencode'),
+ '/usr/local/bin/opencode',
+ '/usr/bin/opencode',
+ path.join(os.homedir(), '.local/bin/opencode'),
+ ],
+ darwin: [
+ path.join(os.homedir(), '.opencode/bin/opencode'),
+ path.join(os.homedir(), '.npm-global/bin/opencode'),
+ '/usr/local/bin/opencode',
+ '/opt/homebrew/bin/opencode',
+ path.join(os.homedir(), '.local/bin/opencode'),
+ ],
+ win32: [
+ path.join(os.homedir(), '.opencode', 'bin', 'opencode.exe'),
+ path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'opencode.cmd'),
+ path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'opencode'),
+ path.join(process.env.APPDATA || '', 'npm', 'opencode.cmd'),
+ ],
+ },
+ };
+ }
+
+ /**
+ * Build CLI arguments for the `opencode run` command
+ *
+ * Arguments built:
+ * - 'run' subcommand for executing queries
+ * - '--format', 'json' for JSONL streaming output
+ * - '--model', '' for model selection (if specified)
+ * - '--session', '' for continuing an existing session (if sdkSessionId is set)
+ *
+ * The prompt is passed via stdin (piped) to avoid shell escaping issues.
+ * OpenCode CLI automatically reads from stdin when input is piped.
+ *
+ * @param options - Execution options containing model, cwd, etc.
+ * @returns Array of CLI arguments for opencode run
+ */
+ buildCliArgs(options: ExecuteOptions): string[] {
+ const args: string[] = ['run'];
+
+ // Add JSON output format for JSONL parsing (not 'stream-json')
+ args.push('--format', 'json');
+
+ // Handle session resumption for conversation continuity.
+ // The opencode CLI supports `--session ` to continue an existing session.
+ // The sdkSessionId is captured from the sessionID field in previous stream events
+ // and persisted by AgentService for use in follow-up messages.
+ if (options.sdkSessionId) {
+ args.push('--session', options.sdkSessionId);
+ }
+
+ // Handle model selection
+ // Convert canonical prefix format (opencode-xxx) to CLI slash format (opencode/xxx)
+ // OpenCode CLI expects provider/model format (e.g., 'opencode/big-model')
+ if (options.model) {
+ // Strip opencode- prefix if present, then ensure slash format
+ const model = options.model.startsWith('opencode-')
+ ? options.model.slice('opencode-'.length)
+ : options.model;
+
+ // If model has slash, it's already provider/model format; otherwise prepend opencode/
+ const cliModel = model.includes('/') ? model : `opencode/${model}`;
+
+ args.push('--model', cliModel);
+ }
+
+ // Note: OpenCode reads from stdin automatically when input is piped
+ // No '-' argument needed
+
+ return args;
+ }
+
+ // ==========================================================================
+ // Prompt Handling
+ // ==========================================================================
+
+ /**
+ * Extract prompt text from ExecuteOptions for passing via stdin
+ *
+ * Handles both string prompts and array-based prompts with content blocks.
+ * For array prompts with images, extracts only text content (images would
+ * need separate handling via file paths if OpenCode supports them).
+ *
+ * @param options - Execution options containing the prompt
+ * @returns Plain text prompt string
+ */
+ private extractPromptText(options: ExecuteOptions): string {
+ if (typeof options.prompt === 'string') {
+ return options.prompt;
+ }
+
+ // Array-based prompt - extract text content
+ if (Array.isArray(options.prompt)) {
+ return options.prompt
+ .filter((block) => block.type === 'text' && block.text)
+ .map((block) => block.text)
+ .join('\n');
+ }
+
+ throw new Error('Invalid prompt format: expected string or content block array');
+ }
+
+ /**
+ * Build subprocess options with stdin data for prompt
+ *
+ * Extends the base class method to add stdinData containing the prompt.
+ * This allows passing prompts via stdin instead of CLI arguments,
+ * avoiding shell escaping issues with special characters.
+ *
+ * @param options - Execution options
+ * @param cliArgs - CLI arguments from buildCliArgs
+ * @returns SubprocessOptions with stdinData set
+ */
+ protected buildSubprocessOptions(options: ExecuteOptions, cliArgs: string[]): SubprocessOptions {
+ const subprocessOptions = super.buildSubprocessOptions(options, cliArgs);
+
+ // Pass prompt via stdin to avoid shell interpretation of special characters
+ // like $(), backticks, quotes, etc. that may appear in prompts or file content
+ subprocessOptions.stdinData = this.extractPromptText(options);
+
+ return subprocessOptions;
+ }
+
+ /**
+ * Check if an error message indicates a session-not-found condition.
+ *
+ * Centralizes the pattern matching for session errors to avoid duplication.
+ * Strips ANSI escape codes first since opencode CLI uses colored stderr output
+ * (e.g. "\x1b[91m\x1b[1mError: \x1b[0mSession not found").
+ *
+ * IMPORTANT: Patterns must be specific enough to avoid false positives.
+ * Generic patterns like "notfounderror" or "resource not found" match
+ * non-session errors (e.g. "ProviderModelNotFoundError") which would
+ * trigger unnecessary retries that fail identically, producing confusing
+ * error messages like "OpenCode session could not be created".
+ *
+ * @param errorText - Raw error text (may contain ANSI codes)
+ * @returns true if the error indicates the session was not found
+ */
+ private static isSessionNotFoundError(errorText: string): boolean {
+ const cleaned = OpencodeProvider.stripAnsiCodes(errorText).toLowerCase();
+
+ // Explicit session-related phrases — high confidence
+ if (
+ cleaned.includes('session not found') ||
+ cleaned.includes('session does not exist') ||
+ cleaned.includes('invalid session') ||
+ cleaned.includes('session expired') ||
+ cleaned.includes('no such session')
+ ) {
+ return true;
+ }
+
+ // Generic "NotFoundError" / "resource not found" are only session errors
+ // when the message also references a session path or session ID.
+ // Without this guard, errors like "ProviderModelNotFoundError" or
+ // "Resource not found: /path/to/config.json" would false-positive.
+ if (cleaned.includes('notfounderror') || cleaned.includes('resource not found')) {
+ return cleaned.includes('/session/') || /\bsession\b/.test(cleaned);
+ }
+
+ return false;
+ }
+
+ /**
+ * Strip ANSI escape codes from a string.
+ *
+ * The OpenCode CLI uses colored stderr output (e.g. "\x1b[91m\x1b[1mError: \x1b[0m").
+ * These escape codes render as garbled text like "[91m[1mError: [0m" in the UI
+ * when passed through as-is. This utility removes them so error messages are
+ * clean and human-readable.
+ */
+ private static stripAnsiCodes(text: string): string {
+ return text.replace(/\x1b\[[0-9;]*m/g, '');
+ }
+
+ /**
+ * Clean a CLI error message for display.
+ *
+ * Strips ANSI escape codes AND removes the redundant "Error: " prefix that
+ * the OpenCode CLI prepends to error messages in its colored stderr output
+ * (e.g. "\x1b[91m\x1b[1mError: \x1b[0mSession not found" → "Session not found").
+ *
+ * Without this, consumers that wrap the message in their own "Error: " prefix
+ * (like AgentService or AgentExecutor) produce garbled double-prefixed output:
+ * "Error: Error: Session not found".
+ */
+ private static cleanErrorMessage(text: string): string {
+ let cleaned = OpencodeProvider.stripAnsiCodes(text).trim();
+ // Remove leading "Error: " prefix (case-insensitive) if present.
+ // The CLI formats errors as: \x1b[91m\x1b[1mError: \x1b[0m
+ // After ANSI stripping this becomes: "Error: "
+ cleaned = cleaned.replace(/^Error:\s*/i, '').trim();
+ return cleaned || text;
+ }
+
+ /**
+ * Execute a query with automatic session resumption fallback.
+ *
+ * When a sdkSessionId is provided, the CLI receives `--session `.
+ * If the session no longer exists on disk the CLI will fail with a
+ * "NotFoundError" / "Resource not found" / "Session not found" error.
+ *
+ * The opencode CLI writes this to **stderr** and exits non-zero.
+ * `spawnJSONLProcess` collects stderr and **yields** it as
+ * `{ type: 'error', error: }` — it is NOT thrown.
+ * After `normalizeEvent`, the error becomes a yielded `ProviderMessage`
+ * with `type: 'error'`. A simple try/catch therefore cannot intercept it.
+ *
+ * This override iterates the parent stream, intercepts yielded error
+ * messages that match the session-not-found pattern, and retries the
+ * entire query WITHOUT the `--session` flag so a fresh session is started.
+ *
+ * Session-not-found retry is ONLY attempted when `sdkSessionId` is set.
+ * Without the `--session` flag the CLI always creates a fresh session, so
+ * retrying without it would be identical to the first attempt and would
+ * fail the same way — producing a confusing "session could not be created"
+ * message for what is actually a different error (model not found, auth
+ * failure, etc.).
+ *
+ * All error messages (session or not) are cleaned of ANSI codes and the
+ * CLI's redundant "Error: " prefix before being yielded to consumers.
+ *
+ * After a successful retry, the consumer (AgentService) will receive a new
+ * session_id from the fresh stream events, which it persists to metadata —
+ * replacing the stale sdkSessionId and preventing repeated failures.
+ */
+ async *executeQuery(options: ExecuteOptions): AsyncGenerator {
+ // When no sdkSessionId is set, there is nothing to "retry without" — just
+ // stream normally and clean error messages as they pass through.
+ if (!options.sdkSessionId) {
+ for await (const msg of super.executeQuery(options)) {
+ // Clean error messages so consumers don't get ANSI or double "Error:" prefix
+ if (msg.type === 'error' && msg.error && typeof msg.error === 'string') {
+ msg.error = OpencodeProvider.cleanErrorMessage(msg.error);
+ }
+ yield msg;
+ }
+ return;
+ }
+
+ // sdkSessionId IS set — the CLI will receive `--session `.
+ // If that session no longer exists, intercept the error and retry fresh.
+ //
+ // To avoid buffering the entire stream in memory for long-lived sessions,
+ // we only buffer an initial window of messages until we observe a healthy
+ // (non-error) message. Once a healthy message is seen, we flush the buffer
+ // and switch to direct passthrough, while still watching for session errors
+ // via isSessionNotFoundError on any subsequent error messages.
+ const buffered: ProviderMessage[] = [];
+ let sessionError = false;
+ let seenHealthyMessage = false;
+
+ try {
+ for await (const msg of super.executeQuery(options)) {
+ if (msg.type === 'error') {
+ const errorText = msg.error || '';
+ if (OpencodeProvider.isSessionNotFoundError(errorText)) {
+ sessionError = true;
+ opencodeLogger.info(
+ `OpenCode session error detected (session "${options.sdkSessionId}") ` +
+ `— retrying without --session to start fresh`
+ );
+ break; // stop consuming the failed stream
+ }
+
+ // Non-session error — clean it
+ if (msg.error && typeof msg.error === 'string') {
+ msg.error = OpencodeProvider.cleanErrorMessage(msg.error);
+ }
+ } else {
+ // A non-error message is a healthy signal — stop buffering after this
+ seenHealthyMessage = true;
+ }
+
+ if (seenHealthyMessage && buffered.length > 0) {
+ // Flush the pre-healthy buffer first, then switch to passthrough
+ for (const bufferedMsg of buffered) {
+ yield bufferedMsg;
+ }
+ buffered.length = 0;
+ }
+
+ if (seenHealthyMessage) {
+ // Passthrough mode — yield directly without buffering
+ yield msg;
+ } else {
+ // Still in initial window — buffer until we see a healthy message
+ buffered.push(msg);
+ }
+ }
+ } catch (error) {
+ // Also handle thrown exceptions (e.g. from mapError in cli-provider)
+ const errMsg = error instanceof Error ? error.message : String(error);
+ if (OpencodeProvider.isSessionNotFoundError(errMsg)) {
+ sessionError = true;
+ opencodeLogger.info(
+ `OpenCode session error detected (thrown, session "${options.sdkSessionId}") ` +
+ `— retrying without --session to start fresh`
+ );
+ } else {
+ throw error;
+ }
+ }
+
+ if (sessionError) {
+ // Retry the entire query without the stale session ID.
+ const retryOptions = { ...options, sdkSessionId: undefined };
+ opencodeLogger.info('Retrying OpenCode query without --session flag...');
+
+ // Stream the retry directly to the consumer.
+ // If the retry also fails, it's a genuine error (not session-related)
+ // and should be surfaced as-is rather than masked with a misleading
+ // "session could not be created" message.
+ for await (const retryMsg of super.executeQuery(retryOptions)) {
+ if (retryMsg.type === 'error' && retryMsg.error && typeof retryMsg.error === 'string') {
+ retryMsg.error = OpencodeProvider.cleanErrorMessage(retryMsg.error);
+ }
+ yield retryMsg;
+ }
+ } else if (buffered.length > 0) {
+ // No session error and still have buffered messages (stream ended before
+ // any healthy message was observed) — flush them to the consumer
+ for (const msg of buffered) {
+ yield msg;
+ }
+ }
+ // If seenHealthyMessage is true, all messages have already been yielded
+ // directly in passthrough mode — nothing left to flush.
+ }
+
+ /**
+ * Normalize a raw CLI event to ProviderMessage format
+ *
+ * Maps OpenCode event types to the standard ProviderMessage structure:
+ * - text -> type: 'assistant', content with type: 'text'
+ * - step_start -> null (informational, no message needed)
+ * - step_finish with reason 'stop'/'end_turn' -> type: 'result', subtype: 'success'
+ * - step_finish with reason 'tool-calls' -> null (intermediate step, not final)
+ * - step_finish with error -> type: 'error'
+ * - tool_use -> type: 'assistant', content with type: 'tool_use' (OpenCode CLI format)
+ * - tool_call -> type: 'assistant', content with type: 'tool_use' (legacy format)
+ * - tool_result -> type: 'assistant', content with type: 'tool_result'
+ * - error -> type: 'error'
+ *
+ * @param event - Raw event from OpenCode CLI JSONL output
+ * @returns Normalized ProviderMessage or null to skip the event
+ */
+ normalizeEvent(event: unknown): ProviderMessage | null {
+ if (!event || typeof event !== 'object') {
+ return null;
+ }
+
+ const openCodeEvent = event as OpenCodeStreamEvent;
+
+ switch (openCodeEvent.type) {
+ case 'text': {
+ const textEvent = openCodeEvent as OpenCodeTextEvent;
+
+ // Skip empty text
+ if (!textEvent.part?.text) {
+ return null;
+ }
+
+ const content: ContentBlock[] = [
+ {
+ type: 'text',
+ text: textEvent.part.text,
+ },
+ ];
+
+ return {
+ type: 'assistant',
+ session_id: textEvent.sessionID,
+ message: {
+ role: 'assistant',
+ content,
+ },
+ };
+ }
+
+ case 'step_start': {
+ // Step start is informational - no message needed
+ return null;
+ }
+
+ case 'step_finish': {
+ const finishEvent = openCodeEvent as OpenCodeStepFinishEvent;
+
+ // Check if the step failed - either by error property or reason='error'
+ if (finishEvent.part?.error) {
+ return {
+ type: 'error',
+ session_id: finishEvent.sessionID,
+ error: OpencodeProvider.cleanErrorMessage(finishEvent.part.error),
+ };
+ }
+
+ // Check if reason indicates error (even without explicit error text)
+ if (finishEvent.part?.reason === 'error') {
+ return {
+ type: 'error',
+ session_id: finishEvent.sessionID,
+ error: OpencodeProvider.cleanErrorMessage('Step execution failed'),
+ };
+ }
+
+ // Intermediate step completion (reason: 'tool-calls') — the agent loop
+ // is continuing because the model requested tool calls. Skip these so
+ // consumers don't mistake them for final results.
+ if (finishEvent.part?.reason === 'tool-calls') {
+ return null;
+ }
+
+ // Only treat an explicit allowlist of reasons as true success.
+ // Reasons like 'length' (context-window truncation) or 'content-filter'
+ // indicate the model stopped abnormally and must not be surfaced as
+ // successful completions.
+ const SUCCESS_REASONS = new Set(['stop', 'end_turn']);
+ const reason = finishEvent.part?.reason;
+
+ if (reason === undefined || SUCCESS_REASONS.has(reason)) {
+ // Final completion (reason: 'stop', 'end_turn', or unset)
+ return {
+ type: 'result',
+ subtype: 'success',
+ session_id: finishEvent.sessionID,
+ result: (finishEvent.part as OpenCodePart & { result?: string })?.result,
+ };
+ }
+
+ // Non-success, non-tool-calls reason (e.g. 'length', 'content-filter')
+ return {
+ type: 'result',
+ subtype: 'error',
+ session_id: finishEvent.sessionID,
+ error: `Step finished with non-success reason: ${reason}`,
+ result: (finishEvent.part as OpenCodePart & { result?: string })?.result,
+ };
+ }
+
+ case 'tool_error': {
+ const toolErrorEvent = openCodeEvent as OpenCodeBaseEvent;
+
+ // Extract error message from part.error and clean ANSI codes
+ const errorMessage = OpencodeProvider.cleanErrorMessage(
+ toolErrorEvent.part?.error || 'Tool execution failed'
+ );
+
+ return {
+ type: 'error',
+ session_id: toolErrorEvent.sessionID,
+ error: errorMessage,
+ };
+ }
+
+ // OpenCode CLI emits 'tool_use' events (not 'tool_call') when the model invokes a tool.
+ // The event format includes the tool name, call ID, and state with input/output.
+ // Handle both 'tool_use' (actual CLI format) and 'tool_call' (legacy/alternative) for robustness.
+ case 'tool_use': {
+ const toolUseEvent = openCodeEvent as OpenCodeToolUseEvent;
+ const part = toolUseEvent.part;
+
+ // Generate a tool use ID if not provided
+ const toolUseId = part?.callID || part?.call_id || generateToolUseId();
+ const toolName = part?.tool || part?.name || 'unknown';
+
+ const content: ContentBlock[] = [
+ {
+ type: 'tool_use',
+ name: toolName,
+ tool_use_id: toolUseId,
+ input: part?.state?.input || part?.args,
+ },
+ ];
+
+ // If the tool has already completed (state.status === 'completed'), also emit the result
+ if (part?.state?.status === 'completed' && part?.state?.output) {
+ content.push({
+ type: 'tool_result',
+ tool_use_id: toolUseId,
+ content: part.state.output,
+ });
+ }
+
+ return {
+ type: 'assistant',
+ session_id: toolUseEvent.sessionID,
+ message: {
+ role: 'assistant',
+ content,
+ },
+ };
+ }
+
+ case 'tool_call': {
+ const toolEvent = openCodeEvent as OpenCodeToolCallEvent;
+
+ // Generate a tool use ID if not provided
+ const toolUseId = toolEvent.part?.call_id || generateToolUseId();
+
+ const content: ContentBlock[] = [
+ {
+ type: 'tool_use',
+ name: toolEvent.part?.name || 'unknown',
+ tool_use_id: toolUseId,
+ input: toolEvent.part?.args,
+ },
+ ];
+
+ return {
+ type: 'assistant',
+ session_id: toolEvent.sessionID,
+ message: {
+ role: 'assistant',
+ content,
+ },
+ };
+ }
+
+ case 'tool_result': {
+ const resultEvent = openCodeEvent as OpenCodeToolResultEvent;
+
+ const content: ContentBlock[] = [
+ {
+ type: 'tool_result',
+ tool_use_id: resultEvent.part?.call_id,
+ content: resultEvent.part?.output || '',
+ },
+ ];
+
+ return {
+ type: 'assistant',
+ session_id: resultEvent.sessionID,
+ message: {
+ role: 'assistant',
+ content,
+ },
+ };
+ }
+
+ case 'error': {
+ const errorEvent = openCodeEvent as OpenCodeErrorEvent;
+
+ // Extract error message from various formats
+ let errorMessage = 'Unknown error';
+ if (errorEvent.error) {
+ if (typeof errorEvent.error === 'string') {
+ errorMessage = errorEvent.error;
+ } else {
+ // Error is an object with name/data structure
+ errorMessage =
+ errorEvent.error.data?.message ||
+ errorEvent.error.message ||
+ errorEvent.error.name ||
+ 'Unknown error';
+ }
+ } else if (errorEvent.part?.error) {
+ errorMessage = errorEvent.part.error;
+ }
+
+ // Clean error messages: strip ANSI escape codes AND the redundant "Error: "
+ // prefix the CLI adds. The OpenCode CLI outputs colored stderr like:
+ // \x1b[91m\x1b[1mError: \x1b[0mSession not found
+ // Without cleaning, consumers that wrap in their own "Error: " prefix
+ // produce "Error: Error: Session not found".
+ errorMessage = OpencodeProvider.cleanErrorMessage(errorMessage);
+
+ return {
+ type: 'error',
+ session_id: errorEvent.sessionID,
+ error: errorMessage,
+ };
+ }
+
+ default: {
+ // Unknown event type - skip it
+ return null;
+ }
+ }
+ }
+
+ // ==========================================================================
+ // Model Configuration
+ // ==========================================================================
+
+ /**
+ * Get available models for OpenCode
+ *
+ * Returns cached models if available and not expired.
+ * Falls back to default models if cache is empty or CLI is unavailable.
+ *
+ * Use `refreshModels()` to force a fresh fetch from the CLI.
+ */
+ getAvailableModels(): ModelDefinition[] {
+ // Return cached models if available and not expired
+ if (this.cachedModels && Date.now() < this.modelsCacheExpiry) {
+ return this.cachedModels;
+ }
+
+ // Return cached models even if expired (better than nothing)
+ if (this.cachedModels) {
+ // Trigger background refresh
+ this.refreshModels().catch((err) => {
+ opencodeLogger.debug(`Background model refresh failed: ${err}`);
+ });
+ return this.cachedModels;
+ }
+
+ // Return default models while cache is empty
+ return this.getDefaultModels();
+ }
+
+ /**
+ * Get default hardcoded models (fallback when CLI is unavailable)
+ */
+ private getDefaultModels(): ModelDefinition[] {
+ return [
+ // OpenCode Free Tier Models
+ {
+ id: 'opencode/big-pickle',
+ name: 'Big Pickle (Free)',
+ modelString: 'opencode/big-pickle',
+ provider: 'opencode',
+ description: 'OpenCode free tier model - great for general coding',
+ supportsTools: true,
+ supportsVision: false,
+ tier: 'basic',
+ default: true,
+ },
+ {
+ id: 'opencode/glm-5-free',
+ name: 'GLM 5 Free',
+ modelString: 'opencode/glm-5-free',
+ provider: 'opencode',
+ description: 'OpenCode free tier GLM model',
+ supportsTools: true,
+ supportsVision: false,
+ tier: 'basic',
+ },
+ {
+ id: 'opencode/gpt-5-nano',
+ name: 'GPT-5 Nano (Free)',
+ modelString: 'opencode/gpt-5-nano',
+ provider: 'opencode',
+ description: 'Fast and lightweight free tier model',
+ supportsTools: true,
+ supportsVision: false,
+ tier: 'basic',
+ },
+ {
+ id: 'opencode/kimi-k2.5-free',
+ name: 'Kimi K2.5 Free',
+ modelString: 'opencode/kimi-k2.5-free',
+ provider: 'opencode',
+ description: 'OpenCode free tier Kimi model for coding',
+ supportsTools: true,
+ supportsVision: false,
+ tier: 'basic',
+ },
+ {
+ id: 'opencode/minimax-m2.5-free',
+ name: 'MiniMax M2.5 Free',
+ modelString: 'opencode/minimax-m2.5-free',
+ provider: 'opencode',
+ description: 'OpenCode free tier MiniMax model',
+ supportsTools: true,
+ supportsVision: false,
+ tier: 'basic',
+ },
+ ];
+ }
+
+ // ==========================================================================
+ // Dynamic Model Discovery
+ // ==========================================================================
+
+ /**
+ * Refresh models from OpenCode CLI
+ *
+ * Fetches available models using `opencode models` command and updates cache.
+ * Returns the updated model definitions.
+ */
+ async refreshModels(): Promise {
+ // If refresh is in progress, wait for existing promise instead of busy-waiting
+ if (this.isRefreshing && this.refreshPromise) {
+ opencodeLogger.debug('Model refresh already in progress, waiting for completion...');
+ return this.refreshPromise;
+ }
+
+ this.isRefreshing = true;
+ opencodeLogger.debug('Starting model refresh from OpenCode CLI');
+
+ this.refreshPromise = this.doRefreshModels();
+ try {
+ return await this.refreshPromise;
+ } finally {
+ this.refreshPromise = null;
+ this.isRefreshing = false;
+ }
+ }
+
+ /**
+ * Internal method that performs the actual model refresh
+ */
+ private async doRefreshModels(): Promise {
+ try {
+ const models = await this.fetchModelsFromCli();
+
+ if (models.length > 0) {
+ this.cachedModels = models;
+ this.modelsCacheExpiry = Date.now() + MODEL_CACHE_DURATION_MS;
+ opencodeLogger.debug(`Cached ${models.length} models from OpenCode CLI`);
+ } else {
+ // Keep existing cache if fetch returned nothing
+ opencodeLogger.debug('No models returned from CLI, keeping existing cache');
+ }
+
+ return this.cachedModels || this.getDefaultModels();
+ } catch (error) {
+ opencodeLogger.debug(`Model refresh failed: ${error}`);
+ // Return existing cache or defaults on error
+ return this.cachedModels || this.getDefaultModels();
+ }
+ }
+
+ /**
+ * Fetch models from OpenCode CLI using `opencode models` command
+ *
+ * Uses async execFile to avoid blocking the event loop.
+ */
+ private async fetchModelsFromCli(): Promise {
+ this.ensureCliDetected();
+
+ if (!this.cliPath) {
+ opencodeLogger.debug('OpenCode CLI not available for model fetch');
+ return [];
+ }
+
+ try {
+ let command: string;
+ let args: string[];
+
+ if (this.detectedStrategy === 'npx') {
+ // NPX strategy: execute npx with opencode-ai package
+ command = process.platform === 'win32' ? 'npx.cmd' : 'npx';
+ args = ['opencode-ai@latest', 'models'];
+ opencodeLogger.debug(`Executing: ${command} ${args.join(' ')}`);
+ } else if (this.useWsl && this.wslCliPath) {
+ // WSL strategy: execute via wsl.exe
+ command = 'wsl.exe';
+ args = this.wslDistribution
+ ? ['-d', this.wslDistribution, this.wslCliPath, 'models']
+ : [this.wslCliPath, 'models'];
+ opencodeLogger.debug(`Executing: ${command} ${args.join(' ')}`);
+ } else {
+ // Direct CLI execution
+ command = this.cliPath;
+ args = ['models'];
+ opencodeLogger.debug(`Executing: ${command} ${args.join(' ')}`);
+ }
+
+ const { stdout } = await execFileAsync(command, args, {
+ encoding: 'utf-8',
+ timeout: 30000,
+ windowsHide: true,
+ // Use shell on Windows for .cmd files
+ shell: process.platform === 'win32' && command.endsWith('.cmd'),
+ });
+
+ opencodeLogger.debug(
+ `Models output (${stdout.length} chars): ${stdout.substring(0, 200)}...`
+ );
+ return this.parseModelsOutput(stdout);
+ } catch (error) {
+ opencodeLogger.error(`Failed to fetch models from CLI: ${error}`);
+ return [];
+ }
+ }
+
+ /**
+ * Parse the output of `opencode models` command
+ *
+ * OpenCode CLI output format (one model per line):
+ * opencode/big-pickle
+ * opencode/glm-5-free
+ * anthropic/claude-3-5-haiku-20241022
+ * github-copilot/claude-3.5-sonnet
+ * ...
+ */
+ private parseModelsOutput(output: string): ModelDefinition[] {
+ // Parse line-based format (one model ID per line)
+ const lines = output.split('\n');
+ const models: ModelDefinition[] = [];
+
+ // Regex to validate "provider/model-name" format
+ // Provider: lowercase letters, numbers, dots, hyphens
+ // Model name: non-whitespace (supports nested paths like openrouter/anthropic/claude)
+ const modelIdRegex = OPENCODE_MODEL_ID_PATTERN;
+
+ for (const line of lines) {
+ // Remove ANSI escape codes if any
+ const cleanLine = line.replace(/\x1b\[[0-9;]*m/g, '').trim();
+
+ // Skip empty lines
+ if (!cleanLine) continue;
+
+ // Validate format using regex for robustness
+ if (modelIdRegex.test(cleanLine)) {
+ const separatorIndex = cleanLine.indexOf(OPENCODE_MODEL_ID_SEPARATOR);
+ if (separatorIndex <= 0 || separatorIndex === cleanLine.length - 1) {
+ continue;
+ }
+
+ const provider = cleanLine.slice(0, separatorIndex);
+ const name = cleanLine.slice(separatorIndex + 1);
+
+ if (!OPENCODE_PROVIDER_PATTERN.test(provider) || !OPENCODE_MODEL_NAME_PATTERN.test(name)) {
+ continue;
+ }
+
+ models.push(
+ this.modelInfoToDefinition({
+ id: cleanLine,
+ provider,
+ name,
+ })
+ );
+ }
+ }
+
+ opencodeLogger.debug(`Parsed ${models.length} models from CLI output`);
+ return models;
+ }
+
+ /**
+ * Convert OpenCodeModelInfo to ModelDefinition
+ */
+ private modelInfoToDefinition(model: OpenCodeModelInfo): ModelDefinition {
+ const displayName = model.displayName || this.formatModelDisplayName(model);
+ const tier = this.inferModelTier(model.id);
+
+ return {
+ id: model.id,
+ name: displayName,
+ modelString: model.id,
+ provider: model.provider, // Use the actual provider (github-copilot, google, etc.)
+ description: `${model.name} via ${this.formatProviderName(model.provider)}`,
+ supportsTools: true,
+ supportsVision: this.modelSupportsVision(model.id),
+ tier,
+ // Mark Claude Sonnet as default if available
+ default: model.id.includes('claude-sonnet-4'),
+ };
+ }
+
+ /**
+ * Format provider name for display
+ */
+ private formatProviderName(provider: string): string {
+ const providerNames: Record = {
+ 'github-copilot': 'GitHub Copilot',
+ google: 'Google AI',
+ openai: 'OpenAI',
+ anthropic: 'Anthropic',
+ openrouter: 'OpenRouter',
+ opencode: 'OpenCode',
+ ollama: 'Ollama',
+ lmstudio: 'LM Studio',
+ azure: 'Azure OpenAI',
+ xai: 'xAI',
+ deepseek: 'DeepSeek',
+ };
+ return (
+ providerNames[provider] ||
+ provider.charAt(0).toUpperCase() + provider.slice(1).replace(/-/g, ' ')
+ );
+ }
+
+ /**
+ * Format a display name for a model
+ */
+ private formatModelDisplayName(model: OpenCodeModelInfo): string {
+ // Extract the last path segment for nested model IDs
+ // e.g., "arcee-ai/trinity-large-preview:free" → "trinity-large-preview:free"
+ let rawName = model.name;
+ if (rawName.includes('/')) {
+ rawName = rawName.split('/').pop()!;
+ }
+
+ // Strip tier/pricing suffixes like ":free", ":extended"
+ const colonIdx = rawName.indexOf(':');
+ let suffix = '';
+ if (colonIdx !== -1) {
+ const tierPart = rawName.slice(colonIdx + 1);
+ if (/^(free|extended|beta|preview)$/i.test(tierPart)) {
+ suffix = ` (${tierPart.charAt(0).toUpperCase() + tierPart.slice(1)})`;
+ }
+ rawName = rawName.slice(0, colonIdx);
+ }
+
+ // Capitalize and format the model name
+ const formattedName = rawName
+ .split('-')
+ .map((part) => {
+ // Handle version numbers like "4-5" -> "4.5"
+ if (/^\d+$/.test(part)) {
+ return part;
+ }
+ return part.charAt(0).toUpperCase() + part.slice(1);
+ })
+ .join(' ')
+ .replace(/(\d)\s+(\d)/g, '$1.$2'); // "4 5" -> "4.5"
+
+ // Format provider name
+ const providerNames: Record = {
+ copilot: 'GitHub Copilot',
+ anthropic: 'Anthropic',
+ openai: 'OpenAI',
+ google: 'Google',
+ 'amazon-bedrock': 'AWS Bedrock',
+ bedrock: 'AWS Bedrock',
+ openrouter: 'OpenRouter',
+ opencode: 'OpenCode',
+ azure: 'Azure',
+ ollama: 'Ollama',
+ lmstudio: 'LM Studio',
+ };
+
+ const providerDisplay = providerNames[model.provider] || model.provider;
+ return `${formattedName}${suffix} (${providerDisplay})`;
+ }
+
+ /**
+ * Infer model tier based on model ID
+ */
+ private inferModelTier(modelId: string): 'basic' | 'standard' | 'premium' {
+ const lowerModelId = modelId.toLowerCase();
+
+ // Premium tier: flagship models
+ if (
+ lowerModelId.includes('opus') ||
+ lowerModelId.includes('gpt-5') ||
+ lowerModelId.includes('o3') ||
+ lowerModelId.includes('o4') ||
+ lowerModelId.includes('gemini-2') ||
+ lowerModelId.includes('deepseek-r1')
+ ) {
+ return 'premium';
+ }
+
+ // Basic tier: free or lightweight models
+ if (
+ lowerModelId.includes('free') ||
+ lowerModelId.includes('nano') ||
+ lowerModelId.includes('mini') ||
+ lowerModelId.includes('haiku') ||
+ lowerModelId.includes('flash')
+ ) {
+ return 'basic';
+ }
+
+ // Standard tier: everything else
+ return 'standard';
+ }
+
+ /**
+ * Check if a model supports vision based on model ID
+ */
+ private modelSupportsVision(modelId: string): boolean {
+ const lowerModelId = modelId.toLowerCase();
+
+ // Models known to support vision
+ const visionModels = ['claude', 'gpt-4', 'gpt-5', 'gemini', 'nova', 'llama-3', 'llama-4'];
+
+ return visionModels.some((vm) => lowerModelId.includes(vm));
+ }
+
+ /**
+ * Fetch authenticated providers from OpenCode CLI
+ *
+ * Runs `opencode auth list` to get the list of authenticated providers.
+ * Uses async execFile to avoid blocking the event loop.
+ */
+ async fetchAuthenticatedProviders(): Promise {
+ this.ensureCliDetected();
+
+ if (!this.cliPath) {
+ opencodeLogger.debug('OpenCode CLI not available for provider fetch');
+ return [];
+ }
+
+ try {
+ let command: string;
+ let args: string[];
+
+ if (this.detectedStrategy === 'npx') {
+ // NPX strategy
+ command = process.platform === 'win32' ? 'npx.cmd' : 'npx';
+ args = ['opencode-ai@latest', 'auth', 'list'];
+ opencodeLogger.debug(`Executing: ${command} ${args.join(' ')}`);
+ } else if (this.useWsl && this.wslCliPath) {
+ // WSL strategy
+ command = 'wsl.exe';
+ args = this.wslDistribution
+ ? ['-d', this.wslDistribution, this.wslCliPath, 'auth', 'list']
+ : [this.wslCliPath, 'auth', 'list'];
+ opencodeLogger.debug(`Executing: ${command} ${args.join(' ')}`);
+ } else {
+ // Direct CLI execution
+ command = this.cliPath;
+ args = ['auth', 'list'];
+ opencodeLogger.debug(`Executing: ${command} ${args.join(' ')}`);
+ }
+
+ const { stdout } = await execFileAsync(command, args, {
+ encoding: 'utf-8',
+ timeout: 15000,
+ windowsHide: true,
+ // Use shell on Windows for .cmd files
+ shell: process.platform === 'win32' && command.endsWith('.cmd'),
+ });
+
+ opencodeLogger.debug(
+ `Auth list output (${stdout.length} chars): ${stdout.substring(0, 200)}...`
+ );
+ const providers = this.parseProvidersOutput(stdout);
+ this.cachedProviders = providers;
+ return providers;
+ } catch (error) {
+ opencodeLogger.error(`Failed to fetch providers from CLI: ${error}`);
+ return this.cachedProviders || [];
+ }
+ }
+
+ /**
+ * Parse the output of `opencode auth list` command
+ *
+ * OpenCode CLI output format:
+ * ┌ Credentials ~/.local/share/opencode/auth.json
+ * │
+ * ● Anthropic oauth
+ * │
+ * ● GitHub Copilot oauth
+ * │
+ * └ 4 credentials
+ *
+ * Each line with ● contains: provider name and auth method (oauth/api)
+ */
+ private parseProvidersOutput(output: string): OpenCodeProviderInfo[] {
+ const lines = output.split('\n');
+ const providers: OpenCodeProviderInfo[] = [];
+
+ // Provider name to ID mapping
+ const providerIdMap: Record = {
+ anthropic: 'anthropic',
+ 'github copilot': 'github-copilot',
+ copilot: 'github-copilot',
+ google: 'google',
+ openai: 'openai',
+ openrouter: 'openrouter',
+ azure: 'azure',
+ bedrock: 'amazon-bedrock',
+ 'amazon bedrock': 'amazon-bedrock',
+ ollama: 'ollama',
+ 'lm studio': 'lmstudio',
+ lmstudio: 'lmstudio',
+ opencode: 'opencode',
+ 'z.ai coding plan': 'zai-coding-plan',
+ 'z.ai': 'z-ai',
+ };
+
+ for (const line of lines) {
+ // Look for lines with ● which indicate authenticated providers
+ // Format: "● Provider Name auth_method"
+ if (line.includes('●')) {
+ // Remove ANSI escape codes and the ● symbol
+ const cleanLine = line
+ .replace(/\x1b\[[0-9;]*m/g, '') // Remove ANSI codes
+ .replace(/●/g, '') // Remove ● symbol
+ .trim();
+
+ if (!cleanLine) continue;
+
+ // Parse "Provider Name auth_method" format
+ // Auth method is the last word (oauth, api, etc.)
+ const parts = cleanLine.split(/\s+/);
+ if (parts.length >= 2) {
+ const authMethod = parts[parts.length - 1].toLowerCase();
+ const providerName = parts.slice(0, -1).join(' ');
+
+ // Determine auth method type
+ let authMethodType: 'oauth' | 'api_key' | undefined;
+ if (authMethod === 'oauth') {
+ authMethodType = 'oauth';
+ } else if (authMethod === 'api' || authMethod === 'api_key') {
+ authMethodType = 'api_key';
+ }
+
+ // Get provider ID from name
+ const providerNameLower = providerName.toLowerCase();
+ const providerId =
+ providerIdMap[providerNameLower] || providerNameLower.replace(/\s+/g, '-');
+
+ providers.push({
+ id: providerId,
+ name: providerName,
+ authenticated: true, // If it's listed with ●, it's authenticated
+ authMethod: authMethodType,
+ });
+ }
+ }
+ }
+
+ opencodeLogger.debug(`Parsed ${providers.length} providers from auth list`);
+ return providers;
+ }
+
+ /**
+ * Get cached authenticated providers
+ */
+ getCachedProviders(): OpenCodeProviderInfo[] | null {
+ return this.cachedProviders;
+ }
+
+ /**
+ * Clear the model cache, forcing a refresh on next access
+ */
+ clearModelCache(): void {
+ this.cachedModels = null;
+ this.modelsCacheExpiry = 0;
+ this.cachedProviders = null;
+ opencodeLogger.debug('Model cache cleared');
+ }
+
+ /**
+ * Check if we have cached models (not just defaults)
+ */
+ hasCachedModels(): boolean {
+ return this.cachedModels !== null && this.cachedModels.length > 0;
+ }
+
+ // ==========================================================================
+ // Feature Support
+ // ==========================================================================
+
+ /**
+ * Check if a feature is supported by OpenCode
+ *
+ * Supported features:
+ * - tools: Function calling / tool use
+ * - text: Text generation
+ * - vision: Image understanding
+ */
+ supportsFeature(feature: string): boolean {
+ const supportedFeatures = ['tools', 'text', 'vision'];
+ return supportedFeatures.includes(feature);
+ }
+
+ // ==========================================================================
+ // Authentication
+ // ==========================================================================
+
+ /**
+ * Check authentication status for OpenCode CLI
+ *
+ * Checks for authentication via:
+ * - OAuth token in auth file
+ * - API key in auth file
+ */
+ async checkAuth(): Promise {
+ const authIndicators = await getOpenCodeAuthIndicators();
+
+ // Check for OAuth token
+ if (authIndicators.hasOAuthToken) {
+ return {
+ authenticated: true,
+ method: 'oauth',
+ hasOAuthToken: true,
+ hasApiKey: authIndicators.hasApiKey,
+ };
+ }
+
+ // Check for API key
+ if (authIndicators.hasApiKey) {
+ return {
+ authenticated: true,
+ method: 'api_key',
+ hasOAuthToken: false,
+ hasApiKey: true,
+ };
+ }
+
+ return {
+ authenticated: false,
+ method: 'none',
+ hasOAuthToken: false,
+ hasApiKey: false,
+ };
+ }
+
+ // ==========================================================================
+ // Installation Detection
+ // ==========================================================================
+
+ /**
+ * Detect OpenCode installation status
+ *
+ * Checks if the opencode CLI is available either through:
+ * - Direct installation (npm global)
+ * - NPX (fallback on Windows)
+ * Also checks authentication status.
+ */
+ async detectInstallation(): Promise {
+ this.ensureCliDetected();
+
+ const installed = await this.isInstalled();
+ const auth = await this.checkAuth();
+
+ return {
+ installed,
+ path: this.cliPath || undefined,
+ method: this.detectedStrategy === 'npx' ? 'npm' : 'cli',
+ authenticated: auth.authenticated,
+ hasApiKey: auth.hasApiKey,
+ hasOAuthToken: auth.hasOAuthToken,
+ };
+ }
+}
diff --git a/temp_repo/apps/server/src/providers/provider-factory.ts b/temp_repo/apps/server/src/providers/provider-factory.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2b5535084a26100c35a7ceeb5eb6daa6a6d8c8a6
--- /dev/null
+++ b/temp_repo/apps/server/src/providers/provider-factory.ts
@@ -0,0 +1,350 @@
+/**
+ * Provider Factory - Routes model IDs to the appropriate provider
+ *
+ * Uses a registry pattern for dynamic provider registration.
+ * Providers register themselves on import, making it easy to add new providers.
+ */
+
+import { BaseProvider } from './base-provider.js';
+import type { InstallationStatus, ModelDefinition } from './types.js';
+import {
+ isCursorModel,
+ isCodexModel,
+ isOpencodeModel,
+ isGeminiModel,
+ isCopilotModel,
+ type ModelProvider,
+} from '@automaker/types';
+import * as fs from 'fs';
+import * as path from 'path';
+
+const DISCONNECTED_MARKERS: Record = {
+ claude: '.claude-disconnected',
+ codex: '.codex-disconnected',
+ cursor: '.cursor-disconnected',
+ opencode: '.opencode-disconnected',
+ gemini: '.gemini-disconnected',
+ copilot: '.copilot-disconnected',
+};
+
+/**
+ * Check if a provider CLI is disconnected from the app
+ */
+export function isProviderDisconnected(providerName: string): boolean {
+ const markerFile = DISCONNECTED_MARKERS[providerName.toLowerCase()];
+ if (!markerFile) return false;
+
+ const markerPath = path.join(process.cwd(), '.automaker', markerFile);
+ return fs.existsSync(markerPath);
+}
+
+/**
+ * Provider registration entry
+ */
+interface ProviderRegistration {
+ /** Factory function to create provider instance */
+ factory: () => BaseProvider;
+ /** Aliases for this provider (e.g., 'anthropic' for 'claude') */
+ aliases?: string[];
+ /** Function to check if this provider can handle a model ID */
+ canHandleModel?: (modelId: string) => boolean;
+ /** Priority for model matching (higher = checked first) */
+ priority?: number;
+}
+
+/**
+ * Provider registry - stores registered providers
+ */
+const providerRegistry = new Map();
+
+/**
+ * Register a provider with the factory
+ *
+ * @param name Provider name (e.g., 'claude', 'cursor')
+ * @param registration Provider registration config
+ */
+export function registerProvider(name: string, registration: ProviderRegistration): void {
+ providerRegistry.set(name.toLowerCase(), registration);
+}
+
+/** Cached mock provider instance when AUTOMAKER_MOCK_AGENT is set (E2E/CI). */
+let mockProviderInstance: BaseProvider | null = null;
+
+function getMockProvider(): BaseProvider {
+ if (!mockProviderInstance) {
+ mockProviderInstance = new MockProvider();
+ }
+ return mockProviderInstance;
+}
+
+export class ProviderFactory {
+ /**
+ * Determine which provider to use for a given model
+ *
+ * @param model Model identifier
+ * @returns Provider name (ModelProvider type)
+ */
+ static getProviderNameForModel(model: string): ModelProvider {
+ if (process.env.AUTOMAKER_MOCK_AGENT === 'true') {
+ return 'claude' as ModelProvider; // Name only; getProviderForModel returns MockProvider
+ }
+ const lowerModel = model.toLowerCase();
+
+ // Get all registered providers sorted by priority (descending)
+ const registrations = Array.from(providerRegistry.entries()).sort(
+ ([, a], [, b]) => (b.priority ?? 0) - (a.priority ?? 0)
+ );
+
+ // Check each provider's canHandleModel function
+ for (const [name, reg] of registrations) {
+ if (reg.canHandleModel?.(lowerModel)) {
+ return name as ModelProvider;
+ }
+ }
+
+ // Fallback: Check for explicit prefixes
+ for (const [name] of registrations) {
+ if (lowerModel.startsWith(`${name}-`)) {
+ return name as ModelProvider;
+ }
+ }
+
+ // Default to claude (first registered provider or claude)
+ return 'claude';
+ }
+
+ /**
+ * Get the appropriate provider for a given model ID
+ *
+ * @param modelId Model identifier (e.g., "claude-opus-4-6", "cursor-gpt-4o", "cursor-auto")
+ * @param options Optional settings
+ * @param options.throwOnDisconnected Throw error if provider is disconnected (default: true)
+ * @returns Provider instance for the model
+ * @throws Error if provider is disconnected and throwOnDisconnected is true
+ */
+ static getProviderForModel(
+ modelId: string,
+ options: { throwOnDisconnected?: boolean } = {}
+ ): BaseProvider {
+ if (process.env.AUTOMAKER_MOCK_AGENT === 'true') {
+ return getMockProvider();
+ }
+ const { throwOnDisconnected = true } = options;
+ const providerName = this.getProviderForModelName(modelId);
+
+ // Check if provider is disconnected
+ if (throwOnDisconnected && isProviderDisconnected(providerName)) {
+ throw new Error(
+ `${providerName.charAt(0).toUpperCase() + providerName.slice(1)} CLI is disconnected from the app. ` +
+ `Please go to Settings > Providers and click "Sign In" to reconnect.`
+ );
+ }
+
+ const provider = this.getProviderByName(providerName);
+
+ if (!provider) {
+ // Fallback to claude if provider not found
+ const claudeReg = providerRegistry.get('claude');
+ if (claudeReg) {
+ return claudeReg.factory();
+ }
+ throw new Error(`No provider found for model: ${modelId}`);
+ }
+
+ return provider;
+ }
+
+ /**
+ * Get the provider name for a given model ID (without creating provider instance)
+ */
+ static getProviderForModelName(modelId: string): string {
+ if (process.env.AUTOMAKER_MOCK_AGENT === 'true') {
+ return 'claude';
+ }
+ const lowerModel = modelId.toLowerCase();
+
+ // Get all registered providers sorted by priority (descending)
+ const registrations = Array.from(providerRegistry.entries()).sort(
+ ([, a], [, b]) => (b.priority ?? 0) - (a.priority ?? 0)
+ );
+
+ // Check each provider's canHandleModel function
+ for (const [name, reg] of registrations) {
+ if (reg.canHandleModel?.(lowerModel)) {
+ return name;
+ }
+ }
+
+ // Fallback: Check for explicit prefixes
+ for (const [name] of registrations) {
+ if (lowerModel.startsWith(`${name}-`)) {
+ return name;
+ }
+ }
+
+ // Default to claude (first registered provider or claude)
+ return 'claude';
+ }
+
+ /**
+ * Get all available providers
+ */
+ static getAllProviders(): BaseProvider[] {
+ return Array.from(providerRegistry.values()).map((reg) => reg.factory());
+ }
+
+ /**
+ * Check installation status for all providers
+ *
+ * @returns Map of provider name to installation status
+ */
+ static async checkAllProviders(): Promise> {
+ const statuses: Record = {};
+
+ for (const [name, reg] of providerRegistry.entries()) {
+ const provider = reg.factory();
+ const status = await provider.detectInstallation();
+ statuses[name] = status;
+ }
+
+ return statuses;
+ }
+
+ /**
+ * Get provider by name (for direct access if needed)
+ *
+ * @param name Provider name (e.g., "claude", "cursor") or alias (e.g., "anthropic")
+ * @returns Provider instance or null if not found
+ */
+ static getProviderByName(name: string): BaseProvider | null {
+ const lowerName = name.toLowerCase();
+
+ // Direct lookup
+ const directReg = providerRegistry.get(lowerName);
+ if (directReg) {
+ return directReg.factory();
+ }
+
+ // Check aliases
+ for (const [, reg] of providerRegistry.entries()) {
+ if (reg.aliases?.includes(lowerName)) {
+ return reg.factory();
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Get all available models from all providers
+ */
+ static getAllAvailableModels(): ModelDefinition[] {
+ const providers = this.getAllProviders();
+ return providers.flatMap((p) => p.getAvailableModels());
+ }
+
+ /**
+ * Get list of registered provider names
+ */
+ static getRegisteredProviderNames(): string[] {
+ return Array.from(providerRegistry.keys());
+ }
+
+ /**
+ * Check if a specific model supports vision/image input
+ *
+ * @param modelId Model identifier
+ * @returns Whether the model supports vision (defaults to true if model not found)
+ */
+ static modelSupportsVision(modelId: string): boolean {
+ const provider = this.getProviderForModel(modelId);
+ const models = provider.getAvailableModels();
+
+ // Find the model in the available models list
+ for (const model of models) {
+ if (
+ model.id === modelId ||
+ model.modelString === modelId ||
+ model.id.endsWith(`-${modelId}`) ||
+ model.modelString.endsWith(`-${modelId}`) ||
+ model.modelString === modelId.replace(/^(claude|cursor|codex|gemini)-/, '') ||
+ model.modelString === modelId.replace(/-(claude|cursor|codex|gemini)$/, '')
+ ) {
+ return model.supportsVision ?? true;
+ }
+ }
+
+ // Also try exact match with model string from provider's model map
+ for (const model of models) {
+ if (model.modelString === modelId || model.id === modelId) {
+ return model.supportsVision ?? true;
+ }
+ }
+
+ // Default to true (Claude SDK supports vision by default)
+ return true;
+ }
+}
+
+// =============================================================================
+// Provider Registrations
+// =============================================================================
+
+// Import providers for registration side-effects
+import { MockProvider } from './mock-provider.js';
+import { ClaudeProvider } from './claude-provider.js';
+import { CursorProvider } from './cursor-provider.js';
+import { CodexProvider } from './codex-provider.js';
+import { OpencodeProvider } from './opencode-provider.js';
+import { GeminiProvider } from './gemini-provider.js';
+import { CopilotProvider } from './copilot-provider.js';
+
+// Register Claude provider
+registerProvider('claude', {
+ factory: () => new ClaudeProvider(),
+ aliases: ['anthropic'],
+ canHandleModel: (model: string) => {
+ return (
+ model.startsWith('claude-') || ['opus', 'sonnet', 'haiku'].some((n) => model.includes(n))
+ );
+ },
+ priority: 0, // Default priority
+});
+
+// Register Cursor provider
+registerProvider('cursor', {
+ factory: () => new CursorProvider(),
+ canHandleModel: (model: string) => isCursorModel(model),
+ priority: 10, // Higher priority - check Cursor models first
+});
+
+// Register Codex provider
+registerProvider('codex', {
+ factory: () => new CodexProvider(),
+ aliases: ['openai'],
+ canHandleModel: (model: string) => isCodexModel(model),
+ priority: 5, // Medium priority - check after Cursor but before Claude
+});
+
+// Register OpenCode provider
+registerProvider('opencode', {
+ factory: () => new OpencodeProvider(),
+ canHandleModel: (model: string) => isOpencodeModel(model),
+ priority: 3, // Between codex (5) and claude (0)
+});
+
+// Register Gemini provider
+registerProvider('gemini', {
+ factory: () => new GeminiProvider(),
+ aliases: ['google'],
+ canHandleModel: (model: string) => isGeminiModel(model),
+ priority: 4, // Between opencode (3) and codex (5)
+});
+
+// Register Copilot provider (GitHub Copilot SDK)
+registerProvider('copilot', {
+ factory: () => new CopilotProvider(),
+ aliases: ['github-copilot', 'github'],
+ canHandleModel: (model: string) => isCopilotModel(model),
+ priority: 6, // High priority - check before Codex since both can handle GPT models
+});
diff --git a/temp_repo/apps/server/src/providers/simple-query-service.ts b/temp_repo/apps/server/src/providers/simple-query-service.ts
new file mode 100644
index 0000000000000000000000000000000000000000..5ebe4db974f91423d875d107e161bb3c20fb381c
--- /dev/null
+++ b/temp_repo/apps/server/src/providers/simple-query-service.ts
@@ -0,0 +1,273 @@
+/**
+ * Simple Query Service - Simplified interface for basic AI queries
+ *
+ * Use this for routes that need simple text responses without
+ * complex event handling. This service abstracts away the provider
+ * selection and streaming details, providing a clean interface
+ * for common query patterns.
+ *
+ * Benefits:
+ * - No direct SDK imports needed in route files
+ * - Consistent provider routing based on model
+ * - Automatic text extraction from streaming responses
+ * - Structured output support for JSON schema responses
+ * - Eliminates duplicate extractTextFromStream() functions
+ */
+
+import { ProviderFactory } from './provider-factory.js';
+import type {
+ ThinkingLevel,
+ ReasoningEffort,
+ ClaudeApiProfile,
+ ClaudeCompatibleProvider,
+ Credentials,
+} from '@automaker/types';
+import { stripProviderPrefix } from '@automaker/types';
+
+/**
+ * Options for simple query execution
+ */
+export interface SimpleQueryOptions {
+ /** The prompt to send to the AI (can be text or multi-part content) */
+ prompt: string | Array<{ type: string; text?: string; source?: object }>;
+ /** Model to use (with or without provider prefix) */
+ model?: string;
+ /** Working directory for the query */
+ cwd: string;
+ /** System prompt (combined with user prompt for some providers) */
+ systemPrompt?: string;
+ /** Maximum turns for agentic operations (default: 1) */
+ maxTurns?: number;
+ /** Tools to allow (default: [] for simple queries) */
+ allowedTools?: string[];
+ /** Abort controller for cancellation */
+ abortController?: AbortController;
+ /** Structured output format for JSON responses */
+ outputFormat?: {
+ type: 'json_schema';
+ schema: Record;
+ };
+ /** Thinking level for Claude models */
+ thinkingLevel?: ThinkingLevel;
+ /** Reasoning effort for Codex/OpenAI models */
+ reasoningEffort?: ReasoningEffort;
+ /** If true, runs in read-only mode (no file writes) */
+ readOnly?: boolean;
+ /** Setting sources for CLAUDE.md loading */
+ settingSources?: Array<'user' | 'project' | 'local'>;
+ /**
+ * Active Claude API profile for alternative endpoint configuration
+ * @deprecated Use claudeCompatibleProvider instead
+ */
+ claudeApiProfile?: ClaudeApiProfile;
+ /**
+ * Claude-compatible provider for alternative endpoint configuration.
+ * Takes precedence over claudeApiProfile if both are set.
+ */
+ claudeCompatibleProvider?: ClaudeCompatibleProvider;
+ /** Credentials for resolving 'credentials' apiKeySource in Claude API profiles/providers */
+ credentials?: Credentials;
+}
+
+/**
+ * Result from a simple query
+ */
+export interface SimpleQueryResult {
+ /** The accumulated text response */
+ text: string;
+ /** Structured output if outputFormat was specified and provider supports it */
+ structured_output?: Record;
+}
+
+/**
+ * Options for streaming query execution
+ */
+export interface StreamingQueryOptions extends SimpleQueryOptions {
+ /** Callback for each text chunk received */
+ onText?: (text: string) => void;
+ /** Callback for tool use events */
+ onToolUse?: (tool: string, input: unknown) => void;
+ /** Callback for thinking blocks (if available) */
+ onThinking?: (thinking: string) => void;
+}
+
+/**
+ * Default model to use when none specified
+ */
+const DEFAULT_MODEL = 'claude-sonnet-4-6';
+
+/**
+ * Execute a simple query and return the text result
+ *
+ * Use this for simple, non-streaming queries where you just need
+ * the final text response. For more complex use cases with progress
+ * callbacks, use streamingQuery() instead.
+ *
+ * @example
+ * ```typescript
+ * const result = await simpleQuery({
+ * prompt: 'Generate a title for: user authentication',
+ * cwd: process.cwd(),
+ * systemPrompt: 'You are a title generator...',
+ * maxTurns: 1,
+ * allowedTools: [],
+ * });
+ * console.log(result.text); // "Add user authentication"
+ * ```
+ */
+export async function simpleQuery(options: SimpleQueryOptions): Promise {
+ const model = options.model || DEFAULT_MODEL;
+ const provider = ProviderFactory.getProviderForModel(model);
+ const bareModel = stripProviderPrefix(model);
+
+ let responseText = '';
+ let structuredOutput: Record | undefined;
+
+ // Build provider options
+ const providerOptions = {
+ prompt: options.prompt,
+ model: bareModel,
+ originalModel: model,
+ cwd: options.cwd,
+ systemPrompt: options.systemPrompt,
+ maxTurns: options.maxTurns ?? 1,
+ allowedTools: options.allowedTools ?? [],
+ abortController: options.abortController,
+ outputFormat: options.outputFormat,
+ thinkingLevel: options.thinkingLevel,
+ reasoningEffort: options.reasoningEffort,
+ readOnly: options.readOnly,
+ settingSources: options.settingSources,
+ claudeApiProfile: options.claudeApiProfile, // Legacy: Pass active Claude API profile for alternative endpoint configuration
+ claudeCompatibleProvider: options.claudeCompatibleProvider, // New: Pass Claude-compatible provider (takes precedence)
+ credentials: options.credentials, // Pass credentials for resolving 'credentials' apiKeySource
+ };
+
+ for await (const msg of provider.executeQuery(providerOptions)) {
+ // Handle error messages
+ if (msg.type === 'error') {
+ const errorMessage = msg.error || 'Provider returned an error';
+ throw new Error(errorMessage);
+ }
+
+ // Extract text from assistant messages
+ if (msg.type === 'assistant' && msg.message?.content) {
+ for (const block of msg.message.content) {
+ if (block.type === 'text' && block.text) {
+ responseText += block.text;
+ }
+ }
+ }
+
+ // Handle result messages
+ if (msg.type === 'result') {
+ if (msg.subtype === 'success') {
+ // Use result text if longer than accumulated text
+ if (msg.result && msg.result.length > responseText.length) {
+ responseText = msg.result;
+ }
+ // Capture structured output if present
+ if (msg.structured_output) {
+ structuredOutput = msg.structured_output;
+ }
+ } else if (msg.subtype === 'error_max_turns') {
+ // Max turns reached - return what we have
+ break;
+ } else if (msg.subtype === 'error_max_structured_output_retries') {
+ throw new Error('Could not produce valid structured output after retries');
+ }
+ }
+ }
+
+ return { text: responseText, structured_output: structuredOutput };
+}
+
+/**
+ * Execute a streaming query with event callbacks
+ *
+ * Use this for queries where you need real-time progress updates,
+ * such as when displaying streaming output to a user.
+ *
+ * @example
+ * ```typescript
+ * const result = await streamingQuery({
+ * prompt: 'Analyze this project and suggest improvements',
+ * cwd: '/path/to/project',
+ * maxTurns: 250,
+ * allowedTools: ['Read', 'Glob', 'Grep'],
+ * onText: (text) => emitProgress(text),
+ * onToolUse: (tool, input) => emitToolUse(tool, input),
+ * });
+ * ```
+ */
+export async function streamingQuery(options: StreamingQueryOptions): Promise {
+ const model = options.model || DEFAULT_MODEL;
+ const provider = ProviderFactory.getProviderForModel(model);
+ const bareModel = stripProviderPrefix(model);
+
+ let responseText = '';
+ let structuredOutput: Record | undefined;
+
+ // Build provider options
+ const providerOptions = {
+ prompt: options.prompt,
+ model: bareModel,
+ originalModel: model,
+ cwd: options.cwd,
+ systemPrompt: options.systemPrompt,
+ maxTurns: options.maxTurns ?? 250,
+ allowedTools: options.allowedTools ?? ['Read', 'Glob', 'Grep'],
+ abortController: options.abortController,
+ outputFormat: options.outputFormat,
+ thinkingLevel: options.thinkingLevel,
+ reasoningEffort: options.reasoningEffort,
+ readOnly: options.readOnly,
+ settingSources: options.settingSources,
+ claudeApiProfile: options.claudeApiProfile, // Legacy: Pass active Claude API profile for alternative endpoint configuration
+ claudeCompatibleProvider: options.claudeCompatibleProvider, // New: Pass Claude-compatible provider (takes precedence)
+ credentials: options.credentials, // Pass credentials for resolving 'credentials' apiKeySource
+ };
+
+ for await (const msg of provider.executeQuery(providerOptions)) {
+ // Handle error messages
+ if (msg.type === 'error') {
+ const errorMessage = msg.error || 'Provider returned an error';
+ throw new Error(errorMessage);
+ }
+
+ // Extract content from assistant messages
+ if (msg.type === 'assistant' && msg.message?.content) {
+ for (const block of msg.message.content) {
+ if (block.type === 'text' && block.text) {
+ responseText += block.text;
+ options.onText?.(block.text);
+ } else if (block.type === 'tool_use' && block.name) {
+ options.onToolUse?.(block.name, block.input);
+ } else if (block.type === 'thinking' && block.thinking) {
+ options.onThinking?.(block.thinking);
+ }
+ }
+ }
+
+ // Handle result messages
+ if (msg.type === 'result') {
+ if (msg.subtype === 'success') {
+ // Use result text if longer than accumulated text
+ if (msg.result && msg.result.length > responseText.length) {
+ responseText = msg.result;
+ }
+ // Capture structured output if present
+ if (msg.structured_output) {
+ structuredOutput = msg.structured_output;
+ }
+ } else if (msg.subtype === 'error_max_turns') {
+ // Max turns reached - return what we have
+ break;
+ } else if (msg.subtype === 'error_max_structured_output_retries') {
+ throw new Error('Could not produce valid structured output after retries');
+ }
+ }
+ }
+
+ return { text: responseText, structured_output: structuredOutput };
+}
diff --git a/temp_repo/apps/server/src/providers/tool-normalization.ts b/temp_repo/apps/server/src/providers/tool-normalization.ts
new file mode 100644
index 0000000000000000000000000000000000000000..27442a7a22cd919d601b31167173a37e7793e4f7
--- /dev/null
+++ b/temp_repo/apps/server/src/providers/tool-normalization.ts
@@ -0,0 +1,112 @@
+/**
+ * Shared tool normalization utilities for AI providers
+ *
+ * These utilities help normalize tool inputs from various AI providers
+ * to the standard format expected by the application.
+ */
+
+/**
+ * Valid todo status values in the standard format
+ */
+type TodoStatus = 'pending' | 'in_progress' | 'completed';
+
+/**
+ * Set of valid status values for validation
+ */
+const VALID_STATUSES = new Set(['pending', 'in_progress', 'completed']);
+
+/**
+ * Todo item from various AI providers (Gemini, Copilot, etc.)
+ */
+interface ProviderTodo {
+ description?: string;
+ content?: string;
+ status?: string;
+}
+
+/**
+ * Standard todo format used by the application
+ */
+interface NormalizedTodo {
+ content: string;
+ status: TodoStatus;
+ activeForm: string;
+}
+
+/**
+ * Normalize a provider status value to a valid TodoStatus
+ */
+function normalizeStatus(status: string | undefined): TodoStatus {
+ if (!status) return 'pending';
+ if (status === 'cancelled' || status === 'canceled') return 'completed';
+ if (VALID_STATUSES.has(status as TodoStatus)) return status as TodoStatus;
+ return 'pending';
+}
+
+/**
+ * Normalize todos array from provider format to standard format
+ *
+ * Handles different formats from providers:
+ * - Gemini: { description, status } with 'cancelled' as possible status
+ * - Copilot: { content/description, status } with 'cancelled' as possible status
+ *
+ * Output format (Claude/Standard):
+ * - { content, status, activeForm } where status is 'pending'|'in_progress'|'completed'
+ */
+export function normalizeTodos(todos: ProviderTodo[] | null | undefined): NormalizedTodo[] {
+ if (!todos) return [];
+ return todos.map((todo) => ({
+ content: todo.content || todo.description || '',
+ status: normalizeStatus(todo.status),
+ // Use content/description as activeForm since providers may not have it
+ activeForm: todo.content || todo.description || '',
+ }));
+}
+
+/**
+ * Normalize file path parameters from various provider formats
+ *
+ * Different providers use different parameter names for file paths:
+ * - path, file, filename, filePath -> file_path
+ */
+export function normalizeFilePathInput(input: Record): Record {
+ const normalized = { ...input };
+ if (!normalized.file_path) {
+ if (input.path) normalized.file_path = input.path;
+ else if (input.file) normalized.file_path = input.file;
+ else if (input.filename) normalized.file_path = input.filename;
+ else if (input.filePath) normalized.file_path = input.filePath;
+ }
+ return normalized;
+}
+
+/**
+ * Normalize shell command parameters from various provider formats
+ *
+ * Different providers use different parameter names for commands:
+ * - cmd, script -> command
+ */
+export function normalizeCommandInput(input: Record): Record {
+ const normalized = { ...input };
+ if (!normalized.command) {
+ if (input.cmd) normalized.command = input.cmd;
+ else if (input.script) normalized.command = input.script;
+ }
+ return normalized;
+}
+
+/**
+ * Normalize search pattern parameters from various provider formats
+ *
+ * Different providers use different parameter names for search patterns:
+ * - query, search, regex -> pattern
+ */
+export function normalizePatternInput(input: Record): Record {
+ const normalized = { ...input };
+ if (!normalized.pattern) {
+ if (input.query) normalized.pattern = input.query;
+ else if (input.search) normalized.pattern = input.search;
+ else if (input.regex) normalized.pattern = input.regex;
+ }
+ return normalized;
+}
diff --git a/temp_repo/apps/server/src/providers/types.ts b/temp_repo/apps/server/src/providers/types.ts
new file mode 100644
index 0000000000000000000000000000000000000000..5d439091bbeb7130ae8d74814cc3feec97dec082
--- /dev/null
+++ b/temp_repo/apps/server/src/providers/types.ts
@@ -0,0 +1,25 @@
+/**
+ * Shared types for AI model providers
+ *
+ * Re-exports types from @automaker/types for consistency across the codebase.
+ * All provider types are defined in @automaker/types to avoid duplication.
+ */
+
+// Re-export all provider types from @automaker/types
+export type {
+ ProviderConfig,
+ ConversationMessage,
+ ExecuteOptions,
+ McpServerConfig,
+ McpStdioServerConfig,
+ McpSSEServerConfig,
+ McpHttpServerConfig,
+ ContentBlock,
+ ProviderMessage,
+ InstallationStatus,
+ ValidationResult,
+ ModelDefinition,
+ AgentDefinition,
+ ReasoningEffort,
+ SystemPromptPreset,
+} from '@automaker/types';
diff --git a/temp_repo/apps/server/src/routes/agent/common.ts b/temp_repo/apps/server/src/routes/agent/common.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9b24a76a7cd0861b4f94c8e1666dfe8ff4bb9513
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/agent/common.ts
@@ -0,0 +1,12 @@
+/**
+ * Common utilities for agent routes
+ */
+
+import { createLogger } from '@automaker/utils';
+import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
+
+const logger = createLogger('Agent');
+
+// Re-export shared utilities
+export { getErrorMessageShared as getErrorMessage };
+export const logError = createLogError(logger);
diff --git a/temp_repo/apps/server/src/routes/agent/index.ts b/temp_repo/apps/server/src/routes/agent/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4e27c2ecc0aefc057c3a7ed72977e7a68aeab24d
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/agent/index.ts
@@ -0,0 +1,45 @@
+/**
+ * Agent routes - HTTP API for Claude agent interactions
+ */
+
+import { Router } from 'express';
+import { AgentService } from '../../services/agent-service.js';
+import type { EventEmitter } from '../../lib/events.js';
+import { validatePathParams } from '../../middleware/validate-paths.js';
+import { createStartHandler } from './routes/start.js';
+import { createSendHandler } from './routes/send.js';
+import { createHistoryHandler } from './routes/history.js';
+import { createStopHandler } from './routes/stop.js';
+import { createClearHandler } from './routes/clear.js';
+import { createModelHandler } from './routes/model.js';
+import { createQueueAddHandler } from './routes/queue-add.js';
+import { createQueueListHandler } from './routes/queue-list.js';
+import { createQueueRemoveHandler } from './routes/queue-remove.js';
+import { createQueueClearHandler } from './routes/queue-clear.js';
+
+export function createAgentRoutes(agentService: AgentService, _events: EventEmitter): Router {
+ const router = Router();
+
+ router.post('/start', validatePathParams('workingDirectory?'), createStartHandler(agentService));
+ router.post(
+ '/send',
+ validatePathParams('workingDirectory?', 'imagePaths[]'),
+ createSendHandler(agentService)
+ );
+ router.post('/history', createHistoryHandler(agentService));
+ router.post('/stop', createStopHandler(agentService));
+ router.post('/clear', createClearHandler(agentService));
+ router.post('/model', createModelHandler(agentService));
+
+ // Queue routes
+ router.post(
+ '/queue/add',
+ validatePathParams('imagePaths[]'),
+ createQueueAddHandler(agentService)
+ );
+ router.post('/queue/list', createQueueListHandler(agentService));
+ router.post('/queue/remove', createQueueRemoveHandler(agentService));
+ router.post('/queue/clear', createQueueClearHandler(agentService));
+
+ return router;
+}
diff --git a/temp_repo/apps/server/src/routes/agent/routes/clear.ts b/temp_repo/apps/server/src/routes/agent/routes/clear.ts
new file mode 100644
index 0000000000000000000000000000000000000000..3ee605b60e16b47b183dbb95db46e63fa2cd2b76
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/agent/routes/clear.ts
@@ -0,0 +1,26 @@
+/**
+ * POST /clear endpoint - Clear conversation
+ */
+
+import type { Request, Response } from 'express';
+import { AgentService } from '../../../services/agent-service.js';
+import { getErrorMessage, logError } from '../common.js';
+
+export function createClearHandler(agentService: AgentService) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { sessionId } = req.body as { sessionId: string };
+
+ if (!sessionId) {
+ res.status(400).json({ success: false, error: 'sessionId is required' });
+ return;
+ }
+
+ const result = await agentService.clearSession(sessionId);
+ res.json(result);
+ } catch (error) {
+ logError(error, 'Clear session failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/agent/routes/history.ts b/temp_repo/apps/server/src/routes/agent/routes/history.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e11578d71deb3d872a94967ba402f4527b52483b
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/agent/routes/history.ts
@@ -0,0 +1,26 @@
+/**
+ * POST /history endpoint - Get conversation history
+ */
+
+import type { Request, Response } from 'express';
+import { AgentService } from '../../../services/agent-service.js';
+import { getErrorMessage, logError } from '../common.js';
+
+export function createHistoryHandler(agentService: AgentService) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { sessionId } = req.body as { sessionId: string };
+
+ if (!sessionId) {
+ res.status(400).json({ success: false, error: 'sessionId is required' });
+ return;
+ }
+
+ const result = await agentService.getHistory(sessionId);
+ res.json(result);
+ } catch (error) {
+ logError(error, 'Get history failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/agent/routes/model.ts b/temp_repo/apps/server/src/routes/agent/routes/model.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8e1a1dddfba882adae4e29c9f180332568ead6f0
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/agent/routes/model.ts
@@ -0,0 +1,29 @@
+/**
+ * POST /model endpoint - Set session model
+ */
+
+import type { Request, Response } from 'express';
+import { AgentService } from '../../../services/agent-service.js';
+import { getErrorMessage, logError } from '../common.js';
+
+export function createModelHandler(agentService: AgentService) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { sessionId, model } = req.body as {
+ sessionId: string;
+ model: string;
+ };
+
+ if (!sessionId || !model) {
+ res.status(400).json({ success: false, error: 'sessionId and model are required' });
+ return;
+ }
+
+ const result = await agentService.setSessionModel(sessionId, model);
+ res.json({ success: result });
+ } catch (error) {
+ logError(error, 'Set session model failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/agent/routes/queue-add.ts b/temp_repo/apps/server/src/routes/agent/routes/queue-add.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e5b8a875ad8a993f41a5154b04a3eb396fd8723a
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/agent/routes/queue-add.ts
@@ -0,0 +1,41 @@
+/**
+ * POST /queue/add endpoint - Add a prompt to the queue
+ */
+
+import type { Request, Response } from 'express';
+import type { ThinkingLevel } from '@automaker/types';
+import { AgentService } from '../../../services/agent-service.js';
+import { getErrorMessage, logError } from '../common.js';
+
+export function createQueueAddHandler(agentService: AgentService) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { sessionId, message, imagePaths, model, thinkingLevel } = req.body as {
+ sessionId: string;
+ message: string;
+ imagePaths?: string[];
+ model?: string;
+ thinkingLevel?: ThinkingLevel;
+ };
+
+ if (!sessionId || !message) {
+ res.status(400).json({
+ success: false,
+ error: 'sessionId and message are required',
+ });
+ return;
+ }
+
+ const result = await agentService.addToQueue(sessionId, {
+ message,
+ imagePaths,
+ model,
+ thinkingLevel,
+ });
+ res.json(result);
+ } catch (error) {
+ logError(error, 'Add to queue failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/agent/routes/queue-clear.ts b/temp_repo/apps/server/src/routes/agent/routes/queue-clear.ts
new file mode 100644
index 0000000000000000000000000000000000000000..34969eab660acc0b6f6f472c9af58861c609dd14
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/agent/routes/queue-clear.ts
@@ -0,0 +1,29 @@
+/**
+ * POST /queue/clear endpoint - Clear all prompts from the queue
+ */
+
+import type { Request, Response } from 'express';
+import { AgentService } from '../../../services/agent-service.js';
+import { getErrorMessage, logError } from '../common.js';
+
+export function createQueueClearHandler(agentService: AgentService) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { sessionId } = req.body as { sessionId: string };
+
+ if (!sessionId) {
+ res.status(400).json({
+ success: false,
+ error: 'sessionId is required',
+ });
+ return;
+ }
+
+ const result = await agentService.clearQueue(sessionId);
+ res.json(result);
+ } catch (error) {
+ logError(error, 'Clear queue failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/agent/routes/queue-list.ts b/temp_repo/apps/server/src/routes/agent/routes/queue-list.ts
new file mode 100644
index 0000000000000000000000000000000000000000..7299e871693e73ce42d0e8a853392c152d80be5e
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/agent/routes/queue-list.ts
@@ -0,0 +1,29 @@
+/**
+ * POST /queue/list endpoint - List queued prompts
+ */
+
+import type { Request, Response } from 'express';
+import { AgentService } from '../../../services/agent-service.js';
+import { getErrorMessage, logError } from '../common.js';
+
+export function createQueueListHandler(agentService: AgentService) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { sessionId } = req.body as { sessionId: string };
+
+ if (!sessionId) {
+ res.status(400).json({
+ success: false,
+ error: 'sessionId is required',
+ });
+ return;
+ }
+
+ const result = await agentService.getQueue(sessionId);
+ res.json(result);
+ } catch (error) {
+ logError(error, 'List queue failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/agent/routes/queue-remove.ts b/temp_repo/apps/server/src/routes/agent/routes/queue-remove.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b2ed43d832fa31481d17d8455a367579d57ef45b
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/agent/routes/queue-remove.ts
@@ -0,0 +1,32 @@
+/**
+ * POST /queue/remove endpoint - Remove a prompt from the queue
+ */
+
+import type { Request, Response } from 'express';
+import { AgentService } from '../../../services/agent-service.js';
+import { getErrorMessage, logError } from '../common.js';
+
+export function createQueueRemoveHandler(agentService: AgentService) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { sessionId, promptId } = req.body as {
+ sessionId: string;
+ promptId: string;
+ };
+
+ if (!sessionId || !promptId) {
+ res.status(400).json({
+ success: false,
+ error: 'sessionId and promptId are required',
+ });
+ return;
+ }
+
+ const result = await agentService.removeFromQueue(sessionId, promptId);
+ res.json(result);
+ } catch (error) {
+ logError(error, 'Remove from queue failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/agent/routes/send.ts b/temp_repo/apps/server/src/routes/agent/routes/send.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4f6e527cc32c2b9cb5c754009cdad15445fd31a2
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/agent/routes/send.ts
@@ -0,0 +1,78 @@
+/**
+ * POST /send endpoint - Send a message
+ */
+
+import type { Request, Response } from 'express';
+import type { ThinkingLevel } from '@automaker/types';
+import { AgentService } from '../../../services/agent-service.js';
+import { createLogger } from '@automaker/utils';
+import { getErrorMessage, logError } from '../common.js';
+const logger = createLogger('Agent');
+
+export function createSendHandler(agentService: AgentService) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { sessionId, message, workingDirectory, imagePaths, model, thinkingLevel } =
+ req.body as {
+ sessionId: string;
+ message: string;
+ workingDirectory?: string;
+ imagePaths?: string[];
+ model?: string;
+ thinkingLevel?: ThinkingLevel;
+ };
+
+ logger.debug('Received request:', {
+ sessionId,
+ messageLength: message?.length,
+ workingDirectory,
+ imageCount: imagePaths?.length || 0,
+ model,
+ thinkingLevel,
+ });
+
+ if (!sessionId || !message) {
+ logger.warn('Validation failed - missing sessionId or message');
+ res.status(400).json({
+ success: false,
+ error: 'sessionId and message are required',
+ });
+ return;
+ }
+
+ logger.debug('Validation passed, calling agentService.sendMessage()');
+
+ // Start the message processing (don't await - it streams via WebSocket)
+ agentService
+ .sendMessage({
+ sessionId,
+ message,
+ workingDirectory,
+ imagePaths,
+ model,
+ thinkingLevel,
+ })
+ .catch((error) => {
+ const errorMsg = (error as Error).message || 'Unknown error';
+ logger.error(`Background error in sendMessage() for session ${sessionId}:`, errorMsg);
+
+ // Emit error via WebSocket so the UI is notified even though
+ // the HTTP response already returned 200. This is critical for
+ // session-not-found errors where sendMessage() throws before it
+ // can emit its own error event (no in-memory session to emit from).
+ agentService.emitSessionError(sessionId, errorMsg);
+
+ logError(error, 'Send message failed (background)');
+ });
+
+ logger.debug('Returning immediate response to client');
+
+ // Return immediately - responses come via WebSocket
+ res.json({ success: true, message: 'Message sent' });
+ } catch (error) {
+ logger.error('Synchronous error:', error);
+ logError(error, 'Send message failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/agent/routes/start.ts b/temp_repo/apps/server/src/routes/agent/routes/start.ts
new file mode 100644
index 0000000000000000000000000000000000000000..dd9b7e4194134aba14dab100d5627e84f71e0fb8
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/agent/routes/start.ts
@@ -0,0 +1,35 @@
+/**
+ * POST /start endpoint - Start a conversation
+ */
+
+import type { Request, Response } from 'express';
+import { AgentService } from '../../../services/agent-service.js';
+import { createLogger } from '@automaker/utils';
+import { getErrorMessage, logError } from '../common.js';
+const _logger = createLogger('Agent');
+
+export function createStartHandler(agentService: AgentService) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { sessionId, workingDirectory } = req.body as {
+ sessionId: string;
+ workingDirectory?: string;
+ };
+
+ if (!sessionId) {
+ res.status(400).json({ success: false, error: 'sessionId is required' });
+ return;
+ }
+
+ const result = await agentService.startConversation({
+ sessionId,
+ workingDirectory,
+ });
+
+ res.json(result);
+ } catch (error) {
+ logError(error, 'Start conversation failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/agent/routes/stop.ts b/temp_repo/apps/server/src/routes/agent/routes/stop.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c5a5fe014b1e043b7d09d9f23d8c70c2fac13a17
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/agent/routes/stop.ts
@@ -0,0 +1,26 @@
+/**
+ * POST /stop endpoint - Stop execution
+ */
+
+import type { Request, Response } from 'express';
+import { AgentService } from '../../../services/agent-service.js';
+import { getErrorMessage, logError } from '../common.js';
+
+export function createStopHandler(agentService: AgentService) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { sessionId } = req.body as { sessionId: string };
+
+ if (!sessionId) {
+ res.status(400).json({ success: false, error: 'sessionId is required' });
+ return;
+ }
+
+ const result = await agentService.stopExecution(sessionId);
+ res.json(result);
+ } catch (error) {
+ logError(error, 'Stop execution failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/app-spec/common.ts b/temp_repo/apps/server/src/routes/app-spec/common.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0731a7ddd71b5fd87d0a0500a8c463fb4bc132cc
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/app-spec/common.ts
@@ -0,0 +1,140 @@
+/**
+ * Common utilities and state management for spec regeneration
+ */
+
+import { createLogger } from '@automaker/utils';
+
+const logger = createLogger('SpecRegeneration');
+
+// Types for running generation
+export type GenerationType = 'spec_regeneration' | 'feature_generation' | 'sync';
+
+interface RunningGeneration {
+ isRunning: boolean;
+ type: GenerationType;
+ startedAt: string;
+}
+
+// Shared state for tracking generation status - scoped by project path
+const runningProjects = new Map();
+const abortControllers = new Map();
+
+/**
+ * Get the running state for a specific project
+ */
+export function getSpecRegenerationStatus(projectPath?: string): {
+ isRunning: boolean;
+ currentAbortController: AbortController | null;
+ projectPath?: string;
+ type?: GenerationType;
+ startedAt?: string;
+} {
+ if (projectPath) {
+ const generation = runningProjects.get(projectPath);
+ return {
+ isRunning: generation?.isRunning || false,
+ currentAbortController: abortControllers.get(projectPath) || null,
+ projectPath,
+ type: generation?.type,
+ startedAt: generation?.startedAt,
+ };
+ }
+ // Fallback: check if any project is running (for backward compatibility)
+ const isAnyRunning = Array.from(runningProjects.values()).some((g) => g.isRunning);
+ return { isRunning: isAnyRunning, currentAbortController: null };
+}
+
+/**
+ * Get the project path that is currently running (if any)
+ */
+export function getRunningProjectPath(): string | null {
+ for (const [path, running] of runningProjects.entries()) {
+ if (running) return path;
+ }
+ return null;
+}
+
+/**
+ * Set the running state and abort controller for a specific project
+ */
+export function setRunningState(
+ projectPath: string,
+ running: boolean,
+ controller: AbortController | null = null,
+ type: GenerationType = 'spec_regeneration'
+): void {
+ if (running) {
+ runningProjects.set(projectPath, {
+ isRunning: true,
+ type,
+ startedAt: new Date().toISOString(),
+ });
+ if (controller) {
+ abortControllers.set(projectPath, controller);
+ }
+ } else {
+ runningProjects.delete(projectPath);
+ abortControllers.delete(projectPath);
+ }
+}
+
+/**
+ * Get all running spec/feature generations for the running agents view
+ */
+export function getAllRunningGenerations(): Array<{
+ projectPath: string;
+ type: GenerationType;
+ startedAt: string;
+}> {
+ const results: Array<{
+ projectPath: string;
+ type: GenerationType;
+ startedAt: string;
+ }> = [];
+
+ for (const [projectPath, generation] of runningProjects.entries()) {
+ if (generation.isRunning) {
+ results.push({
+ projectPath,
+ type: generation.type,
+ startedAt: generation.startedAt,
+ });
+ }
+ }
+
+ return results;
+}
+
+/**
+ * Helper to log authentication status
+ */
+export function logAuthStatus(context: string): void {
+ const hasApiKey = !!process.env.ANTHROPIC_API_KEY;
+
+ logger.info(`${context} - Auth Status:`);
+ logger.info(
+ ` ANTHROPIC_API_KEY: ${
+ hasApiKey ? 'SET (' + process.env.ANTHROPIC_API_KEY?.substring(0, 20) + '...)' : 'NOT SET'
+ }`
+ );
+
+ if (!hasApiKey) {
+ logger.warn('⚠️ WARNING: No authentication configured! SDK will fail.');
+ }
+}
+
+/**
+ * Log error details consistently
+ */
+export function logError(error: unknown, context: string): void {
+ logger.error(`❌ ${context}:`);
+ logger.error('Error name:', (error as Error)?.name);
+ logger.error('Error message:', (error as Error)?.message);
+ logger.error('Error stack:', (error as Error)?.stack);
+ logger.error('Full error object:', JSON.stringify(error, Object.getOwnPropertyNames(error), 2));
+}
+
+import { getErrorMessage as getErrorMessageShared } from '../common.js';
+
+// Re-export shared utility
+export { getErrorMessageShared as getErrorMessage };
diff --git a/temp_repo/apps/server/src/routes/app-spec/generate-features-from-spec.ts b/temp_repo/apps/server/src/routes/app-spec/generate-features-from-spec.ts
new file mode 100644
index 0000000000000000000000000000000000000000..562484714cc775be0401d13a12fdf1d1c2d16dcb
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/app-spec/generate-features-from-spec.ts
@@ -0,0 +1,329 @@
+/**
+ * Generate features from existing app_spec.txt
+ *
+ * Model is configurable via phaseModels.featureGenerationModel in settings
+ * (defaults to Sonnet for balanced speed and quality).
+ */
+
+import * as secureFs from '../../lib/secure-fs.js';
+import type { EventEmitter } from '../../lib/events.js';
+import { createLogger } from '@automaker/utils';
+import { DEFAULT_PHASE_MODELS, supportsStructuredOutput, isCodexModel } from '@automaker/types';
+import { resolvePhaseModel } from '@automaker/model-resolver';
+import { streamingQuery } from '../../providers/simple-query-service.js';
+import { parseAndCreateFeatures } from './parse-and-create-features.js';
+import { extractJsonWithArray } from '../../lib/json-extractor.js';
+import { getAppSpecPath } from '@automaker/platform';
+import type { SettingsService } from '../../services/settings-service.js';
+import {
+ getAutoLoadClaudeMdSetting,
+ getPromptCustomization,
+ getPhaseModelWithOverrides,
+} from '../../lib/settings-helpers.js';
+import { FeatureLoader } from '../../services/feature-loader.js';
+
+const logger = createLogger('SpecRegeneration');
+
+const DEFAULT_MAX_FEATURES = 50;
+
+/**
+ * Timeout for Codex models when generating features (5 minutes).
+ * Codex models are slower and need more time to generate 50+ features.
+ */
+const _CODEX_FEATURE_GENERATION_TIMEOUT_MS = 300000; // 5 minutes
+
+/**
+ * Type for extracted features JSON response
+ */
+interface FeaturesExtractionResult {
+ features: Array<{
+ id: string;
+ category?: string;
+ title: string;
+ description: string;
+ priority?: number;
+ complexity?: 'simple' | 'moderate' | 'complex';
+ dependencies?: string[];
+ }>;
+}
+
+/**
+ * JSON schema for features output format (Claude/Codex structured output)
+ */
+const featuresOutputSchema = {
+ type: 'object',
+ properties: {
+ features: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ id: { type: 'string', description: 'Unique feature identifier (kebab-case)' },
+ category: { type: 'string', description: 'Feature category' },
+ title: { type: 'string', description: 'Short, descriptive title' },
+ description: { type: 'string', description: 'Detailed feature description' },
+ priority: {
+ type: 'number',
+ description: 'Priority level: 1 (highest) to 5 (lowest)',
+ },
+ complexity: {
+ type: 'string',
+ enum: ['simple', 'moderate', 'complex'],
+ description: 'Implementation complexity',
+ },
+ dependencies: {
+ type: 'array',
+ items: { type: 'string' },
+ description: 'IDs of features this depends on',
+ },
+ },
+ required: ['id', 'title', 'description'],
+ },
+ },
+ },
+ required: ['features'],
+} as const;
+
+export async function generateFeaturesFromSpec(
+ projectPath: string,
+ events: EventEmitter,
+ abortController: AbortController,
+ maxFeatures?: number,
+ settingsService?: SettingsService
+): Promise {
+ const featureCount = maxFeatures ?? DEFAULT_MAX_FEATURES;
+ logger.debug('========== generateFeaturesFromSpec() started ==========');
+ logger.debug('projectPath:', projectPath);
+ logger.debug('maxFeatures:', featureCount);
+
+ // Read existing spec from .automaker directory
+ const specPath = getAppSpecPath(projectPath);
+ let spec: string;
+
+ logger.debug('Reading spec from:', specPath);
+
+ try {
+ spec = (await secureFs.readFile(specPath, 'utf-8')) as string;
+ logger.info(`Spec loaded successfully (${spec.length} chars)`);
+ logger.info(`Spec preview (first 500 chars): ${spec.substring(0, 500)}`);
+ logger.info(`Spec preview (last 500 chars): ${spec.substring(spec.length - 500)}`);
+ } catch (readError) {
+ logger.error('❌ Failed to read spec file:', readError);
+ events.emit('spec-regeneration:event', {
+ type: 'spec_regeneration_error',
+ error: 'No project spec found. Generate spec first.',
+ projectPath: projectPath,
+ });
+ return;
+ }
+
+ // Get customized prompts from settings
+ const prompts = await getPromptCustomization(settingsService, '[FeatureGeneration]');
+
+ // Load existing features to prevent duplicates
+ const featureLoader = new FeatureLoader();
+ const existingFeatures = await featureLoader.getAll(projectPath);
+
+ logger.info(`Found ${existingFeatures.length} existing features to exclude from generation`);
+
+ // Build existing features context for the prompt
+ let existingFeaturesContext = '';
+ if (existingFeatures.length > 0) {
+ const featuresList = existingFeatures
+ .map(
+ (f) =>
+ `- "${f.title}" (ID: ${f.id}): ${f.description?.substring(0, 100) || 'No description'}`
+ )
+ .join('\n');
+ existingFeaturesContext = `
+
+## EXISTING FEATURES (DO NOT REGENERATE THESE)
+
+The following ${existingFeatures.length} features already exist in the project. You MUST NOT generate features that duplicate or overlap with these:
+
+${featuresList}
+
+CRITICAL INSTRUCTIONS:
+- DO NOT generate any features with the same or similar titles as the existing features listed above
+- DO NOT generate features that cover the same functionality as existing features
+- ONLY generate NEW features that are not yet in the system
+- If a feature from the roadmap already exists, skip it entirely
+- Generate unique feature IDs that do not conflict with existing IDs: ${existingFeatures.map((f) => f.id).join(', ')}
+`;
+ }
+
+ const prompt = `Based on this project specification:
+
+${spec}
+${existingFeaturesContext}
+${prompts.appSpec.generateFeaturesFromSpecPrompt}
+
+Generate ${featureCount} NEW features that build on each other logically. Remember: ONLY generate features that DO NOT already exist.`;
+
+ logger.info('========== PROMPT BEING SENT ==========');
+ logger.info(`Prompt length: ${prompt.length} chars`);
+ logger.info(`Prompt preview (first 1000 chars):\n${prompt.substring(0, 1000)}`);
+ logger.info('========== END PROMPT PREVIEW ==========');
+
+ events.emit('spec-regeneration:event', {
+ type: 'spec_regeneration_progress',
+ content: 'Analyzing spec and generating features...\n',
+ projectPath: projectPath,
+ });
+
+ // Load autoLoadClaudeMd setting
+ const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
+ projectPath,
+ settingsService,
+ '[FeatureGeneration]'
+ );
+
+ // Get model from phase settings with provider info
+ const {
+ phaseModel: phaseModelEntry,
+ provider,
+ credentials,
+ } = settingsService
+ ? await getPhaseModelWithOverrides(
+ 'featureGenerationModel',
+ settingsService,
+ projectPath,
+ '[FeatureGeneration]'
+ )
+ : {
+ phaseModel: DEFAULT_PHASE_MODELS.featureGenerationModel,
+ provider: undefined,
+ credentials: undefined,
+ };
+ const { model, thinkingLevel, reasoningEffort } = resolvePhaseModel(phaseModelEntry);
+
+ logger.info('Using model:', model, provider ? `via provider: ${provider.name}` : 'direct API');
+
+ // Codex models need extended timeout for generating many features.
+ // Use 'xhigh' reasoning effort to get 5-minute timeout (300s base * 1.0x = 300s).
+ // The Codex provider has a special 5-minute base timeout for feature generation.
+ const isCodex = isCodexModel(model);
+ const effectiveReasoningEffort = isCodex ? 'xhigh' : reasoningEffort;
+
+ if (isCodex) {
+ logger.info('Codex model detected - using extended timeout (5 minutes for feature generation)');
+ }
+ if (effectiveReasoningEffort) {
+ logger.info('Reasoning effort:', effectiveReasoningEffort);
+ }
+
+ // Determine if we should use structured output based on model type
+ const useStructuredOutput = supportsStructuredOutput(model);
+ logger.info(
+ `Structured output mode: ${useStructuredOutput ? 'enabled (Claude/Codex)' : 'disabled (using JSON instructions)'}`
+ );
+
+ // Build the final prompt - for non-Claude/Codex models, include explicit JSON instructions
+ let finalPrompt = prompt;
+ if (!useStructuredOutput) {
+ finalPrompt = `${prompt}
+
+CRITICAL INSTRUCTIONS:
+1. DO NOT write any files. Return the JSON in your response only.
+2. After analyzing the spec, respond with ONLY a JSON object - no explanations, no markdown, just raw JSON.
+3. The JSON must have this exact structure:
+{
+ "features": [
+ {
+ "id": "unique-feature-id",
+ "category": "Category Name",
+ "title": "Short Feature Title",
+ "description": "Detailed description of the feature",
+ "priority": 1,
+ "complexity": "simple|moderate|complex",
+ "dependencies": ["other-feature-id"]
+ }
+ ]
+}
+
+4. Feature IDs must be unique, lowercase, kebab-case (e.g., "user-authentication", "data-export")
+5. Priority ranges from 1 (highest) to 5 (lowest)
+6. Complexity must be one of: "simple", "moderate", "complex"
+7. Dependencies is an array of feature IDs that must be completed first (can be empty)
+
+Your entire response should be valid JSON starting with { and ending with }. No text before or after.`;
+ }
+
+ // Use streamingQuery with event callbacks
+ const result = await streamingQuery({
+ prompt: finalPrompt,
+ model,
+ cwd: projectPath,
+ maxTurns: 250,
+ allowedTools: ['Read', 'Glob', 'Grep'],
+ abortController,
+ thinkingLevel,
+ reasoningEffort: effectiveReasoningEffort, // Extended timeout for Codex models
+ readOnly: true, // Feature generation only reads code, doesn't write
+ settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
+ claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
+ credentials, // Pass credentials for resolving 'credentials' apiKeySource
+ outputFormat: useStructuredOutput
+ ? {
+ type: 'json_schema',
+ schema: featuresOutputSchema,
+ }
+ : undefined,
+ onText: (text) => {
+ logger.debug(`Feature text block received (${text.length} chars)`);
+ events.emit('spec-regeneration:event', {
+ type: 'spec_regeneration_progress',
+ content: text,
+ projectPath: projectPath,
+ });
+ },
+ });
+
+ // Get response content - prefer structured output if available
+ let contentForParsing: string;
+
+ if (result.structured_output) {
+ // Use structured output from Claude/Codex models
+ logger.info('✅ Received structured output from model');
+ contentForParsing = JSON.stringify(result.structured_output);
+ logger.debug('Structured output:', contentForParsing);
+ } else {
+ // Use text response (for non-Claude/Codex models or fallback)
+ // Pre-extract JSON to handle conversational text that may surround the JSON response
+ // This follows the same pattern used in generate-spec.ts and validate-issue.ts
+ const rawText = result.text;
+ logger.info(`Feature stream complete.`);
+ logger.info(`Feature response length: ${rawText.length} chars`);
+ logger.info('========== FULL RESPONSE TEXT ==========');
+ logger.info(rawText);
+ logger.info('========== END RESPONSE TEXT ==========');
+
+ // Pre-extract JSON from response - handles conversational text around the JSON
+ const extracted = extractJsonWithArray(rawText, 'features', {
+ logger,
+ });
+ if (extracted) {
+ contentForParsing = JSON.stringify(extracted);
+ logger.info('✅ Pre-extracted JSON from text response');
+ } else {
+ // If pre-extraction fails, we know the next step will also fail.
+ // Throw an error here to avoid redundant parsing and make the failure point clearer.
+ logger.error(
+ '❌ Could not extract features JSON from model response. Full response text was:\n' +
+ rawText
+ );
+ const errorMessage =
+ 'Failed to parse features from model response: No valid JSON with a "features" array found.';
+ events.emit('spec-regeneration:event', {
+ type: 'spec_regeneration_error',
+ error: errorMessage,
+ projectPath: projectPath,
+ });
+ throw new Error(errorMessage);
+ }
+ }
+
+ await parseAndCreateFeatures(projectPath, contentForParsing, events, settingsService);
+
+ logger.debug('========== generateFeaturesFromSpec() completed ==========');
+}
diff --git a/temp_repo/apps/server/src/routes/app-spec/generate-spec.ts b/temp_repo/apps/server/src/routes/app-spec/generate-spec.ts
new file mode 100644
index 0000000000000000000000000000000000000000..bd47e9ea4ead3641e7faa505ad684fd97f92971a
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/app-spec/generate-spec.ts
@@ -0,0 +1,317 @@
+/**
+ * Generate app_spec.txt from project overview
+ *
+ * Model is configurable via phaseModels.specGenerationModel in settings
+ * (defaults to Opus for high-quality specification generation).
+ */
+
+import * as secureFs from '../../lib/secure-fs.js';
+import type { EventEmitter } from '../../lib/events.js';
+import { specOutputSchema, specToXml, type SpecOutput } from '../../lib/app-spec-format.js';
+import { createLogger } from '@automaker/utils';
+import { DEFAULT_PHASE_MODELS, supportsStructuredOutput } from '@automaker/types';
+import { resolvePhaseModel } from '@automaker/model-resolver';
+import { extractJson } from '../../lib/json-extractor.js';
+import { streamingQuery } from '../../providers/simple-query-service.js';
+import { generateFeaturesFromSpec } from './generate-features-from-spec.js';
+import { ensureAutomakerDir, getAppSpecPath } from '@automaker/platform';
+import type { SettingsService } from '../../services/settings-service.js';
+import {
+ getAutoLoadClaudeMdSetting,
+ getPromptCustomization,
+ getPhaseModelWithOverrides,
+} from '../../lib/settings-helpers.js';
+
+const logger = createLogger('SpecRegeneration');
+
+export async function generateSpec(
+ projectPath: string,
+ projectOverview: string,
+ events: EventEmitter,
+ abortController: AbortController,
+ generateFeatures?: boolean,
+ analyzeProject?: boolean,
+ maxFeatures?: number,
+ settingsService?: SettingsService
+): Promise {
+ logger.info('========== generateSpec() started ==========');
+ logger.info('projectPath:', projectPath);
+ logger.info('projectOverview length:', `${projectOverview.length} chars`);
+ logger.info('projectOverview preview:', projectOverview.substring(0, 300));
+ logger.info('generateFeatures:', generateFeatures);
+ logger.info('analyzeProject:', analyzeProject);
+ logger.info('maxFeatures:', maxFeatures);
+
+ // Get customized prompts from settings
+ const prompts = await getPromptCustomization(settingsService, '[SpecRegeneration]');
+
+ // Build the prompt based on whether we should analyze the project
+ let analysisInstructions = '';
+ let techStackDefaults = '';
+
+ if (analyzeProject !== false) {
+ // Default to true - analyze the project
+ analysisInstructions = `Based on this overview, analyze the project directory (if it exists) using the Read, Glob, and Grep tools to understand:
+- Existing technologies and frameworks
+- Project structure and architecture
+- Current features and capabilities
+- Code patterns and conventions`;
+ } else {
+ // Use default tech stack
+ techStackDefaults = `Default Technology Stack:
+- Framework: TanStack Start (React-based full-stack framework)
+- Database: PostgreSQL with Drizzle ORM
+- UI Components: shadcn/ui
+- Styling: Tailwind CSS
+- Frontend: React
+
+Use these technologies as the foundation for the specification.`;
+ }
+
+ const prompt = `${prompts.appSpec.generateSpecSystemPrompt}
+
+Project Overview:
+${projectOverview}
+
+${techStackDefaults}
+
+${analysisInstructions}
+
+${prompts.appSpec.structuredSpecInstructions}`;
+
+ logger.info('========== PROMPT BEING SENT ==========');
+ logger.info(`Prompt length: ${prompt.length} chars`);
+ logger.info(`Prompt preview (first 500 chars):\n${prompt.substring(0, 500)}`);
+ logger.info('========== END PROMPT PREVIEW ==========');
+
+ events.emit('spec-regeneration:event', {
+ type: 'spec_progress',
+ content: 'Starting spec generation...\n',
+ });
+
+ // Load autoLoadClaudeMd setting
+ const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
+ projectPath,
+ settingsService,
+ '[SpecRegeneration]'
+ );
+
+ // Get model from phase settings with provider info
+ const {
+ phaseModel: phaseModelEntry,
+ provider,
+ credentials,
+ } = settingsService
+ ? await getPhaseModelWithOverrides(
+ 'specGenerationModel',
+ settingsService,
+ projectPath,
+ '[SpecRegeneration]'
+ )
+ : {
+ phaseModel: DEFAULT_PHASE_MODELS.specGenerationModel,
+ provider: undefined,
+ credentials: undefined,
+ };
+ const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
+
+ logger.info('Using model:', model, provider ? `via provider: ${provider.name}` : 'direct API');
+
+ let responseText = '';
+ let structuredOutput: SpecOutput | null = null;
+
+ // Determine if we should use structured output based on model type
+ const useStructuredOutput = supportsStructuredOutput(model);
+ logger.info(
+ `Structured output mode: ${useStructuredOutput ? 'enabled (Claude/Codex)' : 'disabled (using JSON instructions)'}`
+ );
+
+ // Build the final prompt - for non-Claude/Codex models, include JSON schema instructions
+ let finalPrompt = prompt;
+ if (!useStructuredOutput) {
+ finalPrompt = `${prompt}
+
+CRITICAL INSTRUCTIONS:
+1. DO NOT write any files. DO NOT create any files like "project_specification.json".
+2. After analyzing the project, respond with ONLY a JSON object - no explanations, no markdown, just raw JSON.
+3. The JSON must match this exact schema:
+
+${JSON.stringify(specOutputSchema, null, 2)}
+
+Your entire response should be valid JSON starting with { and ending with }. No text before or after.`;
+ }
+
+ // Use streamingQuery with event callbacks
+ const result = await streamingQuery({
+ prompt: finalPrompt,
+ model,
+ cwd: projectPath,
+ maxTurns: 250,
+ allowedTools: ['Read', 'Glob', 'Grep'],
+ abortController,
+ thinkingLevel,
+ readOnly: true, // Spec generation only reads code, we write the spec ourselves
+ settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
+ claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
+ credentials, // Pass credentials for resolving 'credentials' apiKeySource
+ outputFormat: useStructuredOutput
+ ? {
+ type: 'json_schema',
+ schema: specOutputSchema,
+ }
+ : undefined,
+ onText: (text) => {
+ responseText += text;
+ logger.info(
+ `Text block received (${text.length} chars), total now: ${responseText.length} chars`
+ );
+ events.emit('spec-regeneration:event', {
+ type: 'spec_regeneration_progress',
+ content: text,
+ projectPath: projectPath,
+ });
+ },
+ onToolUse: (tool, input) => {
+ logger.info('Tool use:', tool);
+ events.emit('spec-regeneration:event', {
+ type: 'spec_tool',
+ tool,
+ input,
+ });
+ },
+ });
+
+ // Get structured output if available
+ if (result.structured_output) {
+ structuredOutput = result.structured_output as unknown as SpecOutput;
+ logger.info('✅ Received structured output');
+ logger.debug('Structured output:', JSON.stringify(structuredOutput, null, 2));
+ } else if (!useStructuredOutput && responseText) {
+ // For non-Claude providers, parse JSON from response text
+ structuredOutput = extractJson(responseText, { logger });
+ }
+
+ logger.info(`Stream iteration complete.`);
+ logger.info(`Response text length: ${responseText.length} chars`);
+
+ // Determine XML content to save
+ let xmlContent: string;
+
+ if (structuredOutput) {
+ // Use structured output - convert JSON to XML
+ logger.info('✅ Using structured output for XML generation');
+ xmlContent = specToXml(structuredOutput);
+ logger.info(`Generated XML from structured output: ${xmlContent.length} chars`);
+ } else {
+ // Fallback: Extract XML content from response text
+ // Claude might include conversational text before/after
+ // See: https://github.com/AutoMaker-Org/automaker/issues/149
+ logger.warn('⚠️ No structured output, falling back to text parsing');
+ logger.info('========== FINAL RESPONSE TEXT ==========');
+ logger.info(responseText || '(empty)');
+ logger.info('========== END RESPONSE TEXT ==========');
+
+ if (!responseText || responseText.trim().length === 0) {
+ throw new Error('No response text and no structured output - cannot generate spec');
+ }
+
+ const xmlStart = responseText.indexOf('');
+ const xmlEnd = responseText.lastIndexOf('');
+
+ if (xmlStart !== -1 && xmlEnd !== -1) {
+ // Extract just the XML content, discarding any conversational text before/after
+ xmlContent = responseText.substring(xmlStart, xmlEnd + ''.length);
+ logger.info(`Extracted XML content: ${xmlContent.length} chars (from position ${xmlStart})`);
+ } else {
+ // No XML found, try JSON extraction
+ logger.warn('⚠️ No XML tags found, attempting JSON extraction...');
+ const extractedJson = extractJson(responseText, { logger });
+
+ if (
+ extractedJson &&
+ typeof extractedJson.project_name === 'string' &&
+ typeof extractedJson.overview === 'string' &&
+ Array.isArray(extractedJson.technology_stack) &&
+ Array.isArray(extractedJson.core_capabilities) &&
+ Array.isArray(extractedJson.implemented_features)
+ ) {
+ logger.info('✅ Successfully extracted JSON from response text');
+ xmlContent = specToXml(extractedJson);
+ logger.info(`✅ Converted extracted JSON to XML: ${xmlContent.length} chars`);
+ } else {
+ // Neither XML nor valid JSON found
+ logger.error('❌ Response does not contain valid XML or JSON structure');
+ logger.error(
+ 'This typically happens when structured output failed and the agent produced conversational text instead of structured output'
+ );
+ throw new Error(
+ 'Failed to generate spec: No valid XML or JSON structure found in response. ' +
+ 'The response contained conversational text but no tags or valid JSON. ' +
+ 'Please try again.'
+ );
+ }
+ }
+ }
+
+ // Save spec to .automaker directory
+ await ensureAutomakerDir(projectPath);
+ const specPath = getAppSpecPath(projectPath);
+
+ logger.info('Saving spec to:', specPath);
+ logger.info(`Content to save (${xmlContent.length} chars)`);
+
+ await secureFs.writeFile(specPath, xmlContent);
+
+ // Verify the file was written
+ const savedContent = await secureFs.readFile(specPath, 'utf-8');
+ logger.info(`Verified saved file: ${savedContent.length} chars`);
+ if (savedContent.length === 0) {
+ logger.error('❌ File was saved but is empty!');
+ }
+
+ logger.info('Spec saved successfully');
+
+ // Emit spec completion event
+ if (generateFeatures) {
+ // If features will be generated, emit intermediate completion
+ events.emit('spec-regeneration:event', {
+ type: 'spec_regeneration_progress',
+ content: '[Phase: spec_complete] Spec created! Generating features...\n',
+ projectPath: projectPath,
+ });
+ } else {
+ // If no features, emit final completion
+ events.emit('spec-regeneration:event', {
+ type: 'spec_regeneration_complete',
+ message: 'Spec regeneration complete!',
+ projectPath: projectPath,
+ });
+ }
+
+ // If generate features was requested, generate them from the spec
+ if (generateFeatures) {
+ logger.info('Starting feature generation from spec...');
+ // Create a new abort controller for feature generation
+ const featureAbortController = new AbortController();
+ try {
+ await generateFeaturesFromSpec(
+ projectPath,
+ events,
+ featureAbortController,
+ maxFeatures,
+ settingsService
+ );
+ // Final completion will be emitted by generateFeaturesFromSpec -> parseAndCreateFeatures
+ } catch (featureError) {
+ logger.error('Feature generation failed:', featureError);
+ // Don't throw - spec generation succeeded, feature generation is optional
+ events.emit('spec-regeneration:event', {
+ type: 'spec_regeneration_error',
+ error: (featureError as Error).message || 'Feature generation failed',
+ projectPath: projectPath,
+ });
+ }
+ }
+
+ logger.debug('========== generateSpec() completed ==========');
+}
diff --git a/temp_repo/apps/server/src/routes/app-spec/index.ts b/temp_repo/apps/server/src/routes/app-spec/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..79b8e44de8193c747fe59b73a0bbb4b93390598b
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/app-spec/index.ts
@@ -0,0 +1,29 @@
+/**
+ * Spec Regeneration routes - HTTP API for AI-powered spec generation
+ */
+
+import { Router } from 'express';
+import type { EventEmitter } from '../../lib/events.js';
+import { createCreateHandler } from './routes/create.js';
+import { createGenerateHandler } from './routes/generate.js';
+import { createGenerateFeaturesHandler } from './routes/generate-features.js';
+import { createSyncHandler } from './routes/sync.js';
+import { createStopHandler } from './routes/stop.js';
+import { createStatusHandler } from './routes/status.js';
+import type { SettingsService } from '../../services/settings-service.js';
+
+export function createSpecRegenerationRoutes(
+ events: EventEmitter,
+ settingsService?: SettingsService
+): Router {
+ const router = Router();
+
+ router.post('/create', createCreateHandler(events));
+ router.post('/generate', createGenerateHandler(events, settingsService));
+ router.post('/generate-features', createGenerateFeaturesHandler(events, settingsService));
+ router.post('/sync', createSyncHandler(events, settingsService));
+ router.post('/stop', createStopHandler());
+ router.get('/status', createStatusHandler());
+
+ return router;
+}
diff --git a/temp_repo/apps/server/src/routes/app-spec/parse-and-create-features.ts b/temp_repo/apps/server/src/routes/app-spec/parse-and-create-features.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b7a474d29d7b8d60cc2c29d1f1736d91274bfaa6
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/app-spec/parse-and-create-features.ts
@@ -0,0 +1,156 @@
+/**
+ * Parse agent response and create feature files
+ */
+
+import path from 'path';
+import * as secureFs from '../../lib/secure-fs.js';
+import type { EventEmitter } from '../../lib/events.js';
+import { createLogger, atomicWriteJson, DEFAULT_BACKUP_COUNT } from '@automaker/utils';
+import { getFeaturesDir } from '@automaker/platform';
+import { extractJsonWithArray } from '../../lib/json-extractor.js';
+import { getNotificationService } from '../../services/notification-service.js';
+import type { SettingsService } from '../../services/settings-service.js';
+import { resolvePhaseModel } from '@automaker/model-resolver';
+
+const logger = createLogger('SpecRegeneration');
+
+export async function parseAndCreateFeatures(
+ projectPath: string,
+ content: string,
+ events: EventEmitter,
+ settingsService?: SettingsService
+): Promise {
+ logger.info('========== parseAndCreateFeatures() started ==========');
+ logger.info(`Content length: ${content.length} chars`);
+ logger.info('========== CONTENT RECEIVED FOR PARSING ==========');
+ logger.info(content);
+ logger.info('========== END CONTENT ==========');
+
+ // Load default model and planning settings from settingsService
+ let defaultModel: string | undefined;
+ let defaultPlanningMode: string = 'skip';
+ let defaultRequirePlanApproval = false;
+
+ if (settingsService) {
+ try {
+ const globalSettings = await settingsService.getGlobalSettings();
+ const projectSettings = await settingsService.getProjectSettings(projectPath);
+
+ const defaultModelEntry =
+ projectSettings.defaultFeatureModel ?? globalSettings.defaultFeatureModel;
+ if (defaultModelEntry) {
+ const resolved = resolvePhaseModel(defaultModelEntry);
+ defaultModel = resolved.model;
+ }
+
+ defaultPlanningMode = globalSettings.defaultPlanningMode ?? 'skip';
+ defaultRequirePlanApproval = globalSettings.defaultRequirePlanApproval ?? false;
+
+ logger.info(
+ `[parseAndCreateFeatures] Using defaults: model=${defaultModel ?? 'none'}, planningMode=${defaultPlanningMode}, requirePlanApproval=${defaultRequirePlanApproval}`
+ );
+ } catch (settingsError) {
+ logger.warn(
+ '[parseAndCreateFeatures] Failed to load settings, using defaults:',
+ settingsError
+ );
+ }
+ }
+
+ try {
+ // Extract JSON from response using shared utility
+ logger.info('Extracting JSON from response using extractJsonWithArray...');
+
+ interface FeaturesResponse {
+ features: Array<{
+ id: string;
+ category?: string;
+ title: string;
+ description: string;
+ priority?: number;
+ complexity?: string;
+ dependencies?: string[];
+ }>;
+ }
+
+ const parsed = extractJsonWithArray(content, 'features', { logger });
+
+ if (!parsed || !parsed.features) {
+ logger.error('❌ No valid JSON with "features" array found in response');
+ logger.error('Full content received:');
+ logger.error(content);
+ throw new Error('No valid JSON found in response');
+ }
+
+ logger.info(`Parsed ${parsed.features?.length || 0} features`);
+ logger.info('Parsed features:', JSON.stringify(parsed.features, null, 2));
+
+ const featuresDir = getFeaturesDir(projectPath);
+ await secureFs.mkdir(featuresDir, { recursive: true });
+
+ const createdFeatures: Array<{ id: string; title: string }> = [];
+
+ for (const feature of parsed.features) {
+ logger.debug('Creating feature:', feature.id);
+ const featureDir = path.join(featuresDir, feature.id);
+ await secureFs.mkdir(featureDir, { recursive: true });
+
+ const featureData: Record = {
+ id: feature.id,
+ category: feature.category || 'Uncategorized',
+ title: feature.title,
+ description: feature.description,
+ status: 'backlog', // Features go to backlog - user must manually start them
+ priority: feature.priority || 2,
+ complexity: feature.complexity || 'moderate',
+ dependencies: feature.dependencies || [],
+ planningMode: defaultPlanningMode,
+ requirePlanApproval:
+ defaultPlanningMode === 'skip' || defaultPlanningMode === 'lite'
+ ? false
+ : defaultRequirePlanApproval,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ };
+
+ // Apply default model if available from settings
+ if (defaultModel) {
+ featureData.model = defaultModel;
+ }
+
+ // Use atomic write with backup support for crash protection
+ await atomicWriteJson(path.join(featureDir, 'feature.json'), featureData, {
+ backupCount: DEFAULT_BACKUP_COUNT,
+ });
+
+ createdFeatures.push({ id: feature.id, title: feature.title });
+ }
+
+ logger.info(`✓ Created ${createdFeatures.length} features successfully`);
+
+ events.emit('spec-regeneration:event', {
+ type: 'spec_regeneration_complete',
+ message: `Spec regeneration complete! Created ${createdFeatures.length} features.`,
+ projectPath: projectPath,
+ });
+
+ // Create notification for spec generation completion
+ const notificationService = getNotificationService();
+ await notificationService.createNotification({
+ type: 'spec_regeneration_complete',
+ title: 'Spec Generation Complete',
+ message: `Created ${createdFeatures.length} features from the project specification.`,
+ projectPath: projectPath,
+ });
+ } catch (error) {
+ logger.error('❌ parseAndCreateFeatures() failed:');
+ logger.error('Error:', error);
+ events.emit('spec-regeneration:event', {
+ type: 'spec_regeneration_error',
+ error: (error as Error).message,
+ projectPath: projectPath,
+ });
+ }
+
+ logger.debug('========== parseAndCreateFeatures() completed ==========');
+}
diff --git a/temp_repo/apps/server/src/routes/app-spec/routes/create.ts b/temp_repo/apps/server/src/routes/app-spec/routes/create.ts
new file mode 100644
index 0000000000000000000000000000000000000000..31836867ee29efdfdac0f44caf5a5dd36930860c
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/app-spec/routes/create.ts
@@ -0,0 +1,93 @@
+/**
+ * POST /create endpoint - Create project spec from overview
+ */
+
+import type { Request, Response } from 'express';
+import type { EventEmitter } from '../../../lib/events.js';
+import { createLogger } from '@automaker/utils';
+import {
+ getSpecRegenerationStatus,
+ setRunningState,
+ logAuthStatus,
+ logError,
+ getErrorMessage,
+} from '../common.js';
+import { generateSpec } from '../generate-spec.js';
+
+const logger = createLogger('SpecRegeneration');
+
+export function createCreateHandler(events: EventEmitter) {
+ return async (req: Request, res: Response): Promise => {
+ logger.info('========== /create endpoint called ==========');
+ logger.debug('Request body:', JSON.stringify(req.body, null, 2));
+
+ try {
+ const { projectPath, projectOverview, generateFeatures, analyzeProject, maxFeatures } =
+ req.body as {
+ projectPath: string;
+ projectOverview: string;
+ generateFeatures?: boolean;
+ analyzeProject?: boolean;
+ maxFeatures?: number;
+ };
+
+ logger.debug('Parsed params:');
+ logger.debug(' projectPath:', projectPath);
+ logger.debug(' projectOverview length:', `${projectOverview?.length || 0} chars`);
+ logger.debug(' generateFeatures:', generateFeatures);
+ logger.debug(' analyzeProject:', analyzeProject);
+ logger.debug(' maxFeatures:', maxFeatures);
+
+ if (!projectPath || !projectOverview) {
+ logger.error('Missing required parameters');
+ res.status(400).json({
+ success: false,
+ error: 'projectPath and projectOverview required',
+ });
+ return;
+ }
+
+ const { isRunning } = getSpecRegenerationStatus(projectPath);
+ if (isRunning) {
+ logger.warn('Generation already running for project:', projectPath);
+ res.json({ success: false, error: 'Spec generation already running for this project' });
+ return;
+ }
+
+ logAuthStatus('Before starting generation');
+
+ const abortController = new AbortController();
+ setRunningState(projectPath, true, abortController);
+ logger.info('Starting background generation task...');
+
+ // Start generation in background
+ generateSpec(
+ projectPath,
+ projectOverview,
+ events,
+ abortController,
+ generateFeatures,
+ analyzeProject,
+ maxFeatures
+ )
+ .catch((error) => {
+ logError(error, 'Generation failed with error');
+ events.emit('spec-regeneration:event', {
+ type: 'spec_regeneration_error',
+ error: getErrorMessage(error),
+ projectPath: projectPath,
+ });
+ })
+ .finally(() => {
+ logger.info('Generation task finished (success or error)');
+ setRunningState(projectPath, false, null);
+ });
+
+ logger.info('Returning success response (generation running in background)');
+ res.json({ success: true });
+ } catch (error) {
+ logError(error, 'Create spec route handler failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/app-spec/routes/generate-features.ts b/temp_repo/apps/server/src/routes/app-spec/routes/generate-features.ts
new file mode 100644
index 0000000000000000000000000000000000000000..670652ea9915275f5976c5d27047afeb83085dff
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/app-spec/routes/generate-features.ts
@@ -0,0 +1,76 @@
+/**
+ * POST /generate-features endpoint - Generate features from existing spec
+ */
+
+import type { Request, Response } from 'express';
+import type { EventEmitter } from '../../../lib/events.js';
+import { createLogger } from '@automaker/utils';
+import {
+ getSpecRegenerationStatus,
+ setRunningState,
+ logAuthStatus,
+ logError,
+ getErrorMessage,
+} from '../common.js';
+import { generateFeaturesFromSpec } from '../generate-features-from-spec.js';
+import type { SettingsService } from '../../../services/settings-service.js';
+
+const logger = createLogger('SpecRegeneration');
+
+export function createGenerateFeaturesHandler(
+ events: EventEmitter,
+ settingsService?: SettingsService
+) {
+ return async (req: Request, res: Response): Promise => {
+ logger.info('========== /generate-features endpoint called ==========');
+ logger.debug('Request body:', JSON.stringify(req.body, null, 2));
+
+ try {
+ const { projectPath, maxFeatures } = req.body as {
+ projectPath: string;
+ maxFeatures?: number;
+ };
+
+ logger.debug('projectPath:', projectPath);
+ logger.debug('maxFeatures:', maxFeatures);
+
+ if (!projectPath) {
+ logger.error('Missing projectPath parameter');
+ res.status(400).json({ success: false, error: 'projectPath required' });
+ return;
+ }
+
+ const { isRunning } = getSpecRegenerationStatus(projectPath);
+ if (isRunning) {
+ logger.warn('Generation already running for project:', projectPath);
+ res.json({ success: false, error: 'Generation already running for this project' });
+ return;
+ }
+
+ logAuthStatus('Before starting feature generation');
+
+ const abortController = new AbortController();
+ setRunningState(projectPath, true, abortController, 'feature_generation');
+ logger.info('Starting background feature generation task...');
+
+ generateFeaturesFromSpec(projectPath, events, abortController, maxFeatures, settingsService)
+ .catch((error) => {
+ logError(error, 'Feature generation failed with error');
+ events.emit('spec-regeneration:event', {
+ type: 'features_error',
+ error: getErrorMessage(error),
+ });
+ })
+ .finally(() => {
+ logger.info('Feature generation task finished (success or error)');
+ setRunningState(projectPath, false, null);
+ });
+
+ logger.info('Returning success response (generation running in background)');
+ res.json({ success: true });
+ } catch (error) {
+ logError(error, 'Generate features route handler failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/app-spec/routes/generate.ts b/temp_repo/apps/server/src/routes/app-spec/routes/generate.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ffc792aea80156819c726c395cd97d94839670aa
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/app-spec/routes/generate.ts
@@ -0,0 +1,94 @@
+/**
+ * POST /generate endpoint - Generate spec from project definition
+ */
+
+import type { Request, Response } from 'express';
+import type { EventEmitter } from '../../../lib/events.js';
+import { createLogger } from '@automaker/utils';
+import {
+ getSpecRegenerationStatus,
+ setRunningState,
+ logAuthStatus,
+ logError,
+ getErrorMessage,
+} from '../common.js';
+import { generateSpec } from '../generate-spec.js';
+import type { SettingsService } from '../../../services/settings-service.js';
+
+const logger = createLogger('SpecRegeneration');
+
+export function createGenerateHandler(events: EventEmitter, settingsService?: SettingsService) {
+ return async (req: Request, res: Response): Promise => {
+ logger.info('========== /generate endpoint called ==========');
+ logger.debug('Request body:', JSON.stringify(req.body, null, 2));
+
+ try {
+ const { projectPath, projectDefinition, generateFeatures, analyzeProject, maxFeatures } =
+ req.body as {
+ projectPath: string;
+ projectDefinition: string;
+ generateFeatures?: boolean;
+ analyzeProject?: boolean;
+ maxFeatures?: number;
+ };
+
+ logger.debug('Parsed params:');
+ logger.debug(' projectPath:', projectPath);
+ logger.debug(' projectDefinition length:', `${projectDefinition?.length || 0} chars`);
+ logger.debug(' generateFeatures:', generateFeatures);
+ logger.debug(' analyzeProject:', analyzeProject);
+ logger.debug(' maxFeatures:', maxFeatures);
+
+ if (!projectPath || !projectDefinition) {
+ logger.error('Missing required parameters');
+ res.status(400).json({
+ success: false,
+ error: 'projectPath and projectDefinition required',
+ });
+ return;
+ }
+
+ const { isRunning } = getSpecRegenerationStatus(projectPath);
+ if (isRunning) {
+ logger.warn('Generation already running for project:', projectPath);
+ res.json({ success: false, error: 'Spec generation already running for this project' });
+ return;
+ }
+
+ logAuthStatus('Before starting generation');
+
+ const abortController = new AbortController();
+ setRunningState(projectPath, true, abortController);
+ logger.info('Starting background generation task...');
+
+ generateSpec(
+ projectPath,
+ projectDefinition,
+ events,
+ abortController,
+ generateFeatures,
+ analyzeProject,
+ maxFeatures,
+ settingsService
+ )
+ .catch((error) => {
+ logError(error, 'Generation failed with error');
+ events.emit('spec-regeneration:event', {
+ type: 'spec_regeneration_error',
+ error: getErrorMessage(error),
+ projectPath: projectPath,
+ });
+ })
+ .finally(() => {
+ logger.info('Generation task finished (success or error)');
+ setRunningState(projectPath, false, null);
+ });
+
+ logger.info('Returning success response (generation running in background)');
+ res.json({ success: true });
+ } catch (error) {
+ logError(error, 'Generate spec route handler failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/app-spec/routes/status.ts b/temp_repo/apps/server/src/routes/app-spec/routes/status.ts
new file mode 100644
index 0000000000000000000000000000000000000000..34caea32607f694b42e5b3de86324989a8bc4202
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/app-spec/routes/status.ts
@@ -0,0 +1,18 @@
+/**
+ * GET /status endpoint - Get generation status
+ */
+
+import type { Request, Response } from 'express';
+import { getSpecRegenerationStatus, getErrorMessage } from '../common.js';
+
+export function createStatusHandler() {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const projectPath = req.query.projectPath as string | undefined;
+ const { isRunning } = getSpecRegenerationStatus(projectPath);
+ res.json({ success: true, isRunning, projectPath });
+ } catch (error) {
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/app-spec/routes/stop.ts b/temp_repo/apps/server/src/routes/app-spec/routes/stop.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2a7b0aab3e11a530ad3e5a62753faedba126b901
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/app-spec/routes/stop.ts
@@ -0,0 +1,24 @@
+/**
+ * POST /stop endpoint - Stop generation
+ */
+
+import type { Request, Response } from 'express';
+import { getSpecRegenerationStatus, setRunningState, getErrorMessage } from '../common.js';
+
+export function createStopHandler() {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { projectPath } = req.body as { projectPath?: string };
+ const { currentAbortController } = getSpecRegenerationStatus(projectPath);
+ if (currentAbortController) {
+ currentAbortController.abort();
+ }
+ if (projectPath) {
+ setRunningState(projectPath, false, null);
+ }
+ res.json({ success: true });
+ } catch (error) {
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/app-spec/routes/sync.ts b/temp_repo/apps/server/src/routes/app-spec/routes/sync.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c6c34e6868ac72d31b46ba6acb18fc50813920fb
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/app-spec/routes/sync.ts
@@ -0,0 +1,76 @@
+/**
+ * POST /sync endpoint - Sync spec with codebase and features
+ */
+
+import type { Request, Response } from 'express';
+import type { EventEmitter } from '../../../lib/events.js';
+import { createLogger } from '@automaker/utils';
+import {
+ getSpecRegenerationStatus,
+ setRunningState,
+ logAuthStatus,
+ logError,
+ getErrorMessage,
+} from '../common.js';
+import { syncSpec } from '../sync-spec.js';
+import type { SettingsService } from '../../../services/settings-service.js';
+
+const logger = createLogger('SpecSync');
+
+export function createSyncHandler(events: EventEmitter, settingsService?: SettingsService) {
+ return async (req: Request, res: Response): Promise => {
+ logger.info('========== /sync endpoint called ==========');
+ logger.debug('Request body:', JSON.stringify(req.body, null, 2));
+
+ try {
+ const { projectPath } = req.body as {
+ projectPath: string;
+ };
+
+ logger.debug('projectPath:', projectPath);
+
+ if (!projectPath) {
+ logger.error('Missing projectPath parameter');
+ res.status(400).json({ success: false, error: 'projectPath required' });
+ return;
+ }
+
+ const { isRunning } = getSpecRegenerationStatus(projectPath);
+ if (isRunning) {
+ logger.warn('Generation/sync already running for project:', projectPath);
+ res.json({ success: false, error: 'Operation already running for this project' });
+ return;
+ }
+
+ logAuthStatus('Before starting spec sync');
+
+ const abortController = new AbortController();
+ setRunningState(projectPath, true, abortController, 'sync');
+ logger.info('Starting background spec sync task...');
+
+ syncSpec(projectPath, events, abortController, settingsService)
+ .then((result) => {
+ logger.info('Spec sync completed successfully');
+ logger.info('Result:', JSON.stringify(result, null, 2));
+ })
+ .catch((error) => {
+ logError(error, 'Spec sync failed with error');
+ events.emit('spec-regeneration:event', {
+ type: 'spec_regeneration_error',
+ error: getErrorMessage(error),
+ projectPath,
+ });
+ })
+ .finally(() => {
+ logger.info('Spec sync task finished (success or error)');
+ setRunningState(projectPath, false, null);
+ });
+
+ logger.info('Returning success response (sync running in background)');
+ res.json({ success: true });
+ } catch (error) {
+ logError(error, 'Sync route handler failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/app-spec/sync-spec.ts b/temp_repo/apps/server/src/routes/app-spec/sync-spec.ts
new file mode 100644
index 0000000000000000000000000000000000000000..53bdc91a353679108bb65e6ef6fdb54c22248c63
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/app-spec/sync-spec.ts
@@ -0,0 +1,389 @@
+/**
+ * Sync spec with current codebase and feature state
+ *
+ * Updates the spec file based on:
+ * - Completed Automaker features
+ * - Code analysis for tech stack and implementations
+ * - Roadmap phase status updates
+ */
+
+import * as secureFs from '../../lib/secure-fs.js';
+import type { EventEmitter } from '../../lib/events.js';
+import { createLogger } from '@automaker/utils';
+import { DEFAULT_PHASE_MODELS, supportsStructuredOutput } from '@automaker/types';
+import { resolvePhaseModel } from '@automaker/model-resolver';
+import { streamingQuery } from '../../providers/simple-query-service.js';
+import { extractJson } from '../../lib/json-extractor.js';
+import { getAppSpecPath } from '@automaker/platform';
+import type { SettingsService } from '../../services/settings-service.js';
+import {
+ getAutoLoadClaudeMdSetting,
+ getPhaseModelWithOverrides,
+} from '../../lib/settings-helpers.js';
+import { FeatureLoader } from '../../services/feature-loader.js';
+import {
+ extractImplementedFeatures,
+ extractTechnologyStack,
+ extractRoadmapPhases,
+ updateImplementedFeaturesSection,
+ updateTechnologyStack,
+ updateRoadmapPhaseStatus,
+ type ImplementedFeature,
+} from '../../lib/xml-extractor.js';
+import { getNotificationService } from '../../services/notification-service.js';
+
+const logger = createLogger('SpecSync');
+
+/**
+ * Type for extracted tech stack JSON response
+ */
+interface TechStackExtractionResult {
+ technologies: string[];
+}
+
+/**
+ * JSON schema for tech stack analysis output (Claude/Codex structured output)
+ */
+const techStackOutputSchema = {
+ type: 'object',
+ properties: {
+ technologies: {
+ type: 'array',
+ items: { type: 'string' },
+ description: 'List of technologies detected in the project',
+ },
+ },
+ required: ['technologies'],
+} as const;
+
+/**
+ * Result of a sync operation
+ */
+export interface SyncResult {
+ techStackUpdates: {
+ added: string[];
+ removed: string[];
+ };
+ implementedFeaturesUpdates: {
+ addedFromFeatures: string[];
+ removed: string[];
+ };
+ roadmapUpdates: Array<{ phaseName: string; newStatus: string }>;
+ summary: string;
+}
+
+/**
+ * Sync the spec with current codebase and feature state
+ */
+export async function syncSpec(
+ projectPath: string,
+ events: EventEmitter,
+ abortController: AbortController,
+ settingsService?: SettingsService
+): Promise {
+ logger.info('========== syncSpec() started ==========');
+ logger.info('projectPath:', projectPath);
+
+ const result: SyncResult = {
+ techStackUpdates: { added: [], removed: [] },
+ implementedFeaturesUpdates: { addedFromFeatures: [], removed: [] },
+ roadmapUpdates: [],
+ summary: '',
+ };
+
+ // Read existing spec
+ const specPath = getAppSpecPath(projectPath);
+ let specContent: string;
+
+ try {
+ specContent = (await secureFs.readFile(specPath, 'utf-8')) as string;
+ logger.info(`Spec loaded successfully (${specContent.length} chars)`);
+ } catch (readError) {
+ logger.error('Failed to read spec file:', readError);
+ events.emit('spec-regeneration:event', {
+ type: 'spec_regeneration_error',
+ error: 'No project spec found. Create or regenerate spec first.',
+ projectPath,
+ });
+ throw new Error('No project spec found');
+ }
+
+ events.emit('spec-regeneration:event', {
+ type: 'spec_regeneration_progress',
+ content: '[Phase: sync] Starting spec sync...\n',
+ projectPath,
+ });
+
+ // Extract current state from spec
+ const currentImplementedFeatures = extractImplementedFeatures(specContent);
+ const currentTechStack = extractTechnologyStack(specContent);
+ const currentRoadmapPhases = extractRoadmapPhases(specContent);
+
+ logger.info(`Current spec has ${currentImplementedFeatures.length} implemented features`);
+ logger.info(`Current spec has ${currentTechStack.length} technologies`);
+ logger.info(`Current spec has ${currentRoadmapPhases.length} roadmap phases`);
+
+ // Load completed Automaker features
+ const featureLoader = new FeatureLoader();
+ const allFeatures = await featureLoader.getAll(projectPath);
+ const completedFeatures = allFeatures.filter(
+ (f) => f.status === 'completed' || f.status === 'verified'
+ );
+
+ logger.info(`Found ${completedFeatures.length} completed/verified features in Automaker`);
+
+ events.emit('spec-regeneration:event', {
+ type: 'spec_regeneration_progress',
+ content: `Found ${completedFeatures.length} completed features to sync...\n`,
+ projectPath,
+ });
+
+ // Build new implemented features list from completed Automaker features
+ const newImplementedFeatures: ImplementedFeature[] = [];
+ const existingNames = new Set(currentImplementedFeatures.map((f) => f.name.toLowerCase()));
+
+ for (const feature of completedFeatures) {
+ const name = feature.title || `Feature: ${feature.id}`;
+ if (!existingNames.has(name.toLowerCase())) {
+ newImplementedFeatures.push({
+ name,
+ description: feature.description || '',
+ });
+ result.implementedFeaturesUpdates.addedFromFeatures.push(name);
+ }
+ }
+
+ // Merge: keep existing + add new from completed features
+ const mergedFeatures = [...currentImplementedFeatures, ...newImplementedFeatures];
+
+ // Update spec with merged features
+ if (result.implementedFeaturesUpdates.addedFromFeatures.length > 0) {
+ specContent = updateImplementedFeaturesSection(specContent, mergedFeatures);
+ logger.info(
+ `Added ${result.implementedFeaturesUpdates.addedFromFeatures.length} features to spec`
+ );
+ }
+
+ // Analyze codebase for tech stack updates using AI
+ events.emit('spec-regeneration:event', {
+ type: 'spec_regeneration_progress',
+ content: 'Analyzing codebase for technology updates...\n',
+ projectPath,
+ });
+
+ const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
+ projectPath,
+ settingsService,
+ '[SpecSync]'
+ );
+
+ // Get model from phase settings with provider info
+ const {
+ phaseModel: phaseModelEntry,
+ provider,
+ credentials,
+ } = settingsService
+ ? await getPhaseModelWithOverrides(
+ 'specGenerationModel',
+ settingsService,
+ projectPath,
+ '[SpecSync]'
+ )
+ : {
+ phaseModel: DEFAULT_PHASE_MODELS.specGenerationModel,
+ provider: undefined,
+ credentials: undefined,
+ };
+ const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
+
+ logger.info('Using model:', model, provider ? `via provider: ${provider.name}` : 'direct API');
+
+ // Determine if we should use structured output based on model type
+ const useStructuredOutput = supportsStructuredOutput(model);
+ logger.info(
+ `Structured output mode: ${useStructuredOutput ? 'enabled (Claude/Codex)' : 'disabled (using JSON instructions)'}`
+ );
+
+ // Use AI to analyze tech stack
+ let techAnalysisPrompt = `Analyze this project and return ONLY a JSON object with the current technology stack.
+
+Current known technologies: ${currentTechStack.join(', ')}
+
+Look at package.json, config files, and source code to identify:
+- Frameworks (React, Vue, Express, etc.)
+- Languages (TypeScript, JavaScript, Python, etc.)
+- Build tools (Vite, Webpack, etc.)
+- Databases (PostgreSQL, MongoDB, etc.)
+- Key libraries and tools
+
+Return ONLY this JSON format, no other text:
+{
+ "technologies": ["Technology 1", "Technology 2", ...]
+}`;
+
+ // Add explicit JSON instructions for non-Claude/Codex models
+ if (!useStructuredOutput) {
+ techAnalysisPrompt = `${techAnalysisPrompt}
+
+CRITICAL INSTRUCTIONS:
+1. DO NOT write any files. Return the JSON in your response only.
+2. Your entire response should be valid JSON starting with { and ending with }.
+3. No explanations, no markdown, no text before or after the JSON.`;
+ }
+
+ try {
+ const techResult = await streamingQuery({
+ prompt: techAnalysisPrompt,
+ model,
+ cwd: projectPath,
+ maxTurns: 10,
+ allowedTools: ['Read', 'Glob', 'Grep'],
+ abortController,
+ thinkingLevel,
+ readOnly: true,
+ settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
+ claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
+ credentials, // Pass credentials for resolving 'credentials' apiKeySource
+ outputFormat: useStructuredOutput
+ ? {
+ type: 'json_schema',
+ schema: techStackOutputSchema,
+ }
+ : undefined,
+ onText: (text) => {
+ logger.debug(`Tech analysis text: ${text.substring(0, 100)}`);
+ },
+ });
+
+ // Parse tech stack from response - prefer structured output if available
+ let parsedTechnologies: string[] | null = null;
+
+ if (techResult.structured_output) {
+ // Use structured output from Claude/Codex models
+ const structured = techResult.structured_output as unknown as TechStackExtractionResult;
+ if (Array.isArray(structured.technologies)) {
+ parsedTechnologies = structured.technologies;
+ logger.info('✅ Received structured output for tech analysis');
+ }
+ } else {
+ // Fall back to text parsing for non-Claude/Codex models
+ const extracted = extractJson(techResult.text, {
+ logger,
+ requiredKey: 'technologies',
+ requireArray: true,
+ });
+ if (extracted && Array.isArray(extracted.technologies)) {
+ parsedTechnologies = extracted.technologies;
+ logger.info('✅ Extracted tech stack from text response');
+ } else {
+ logger.warn('⚠️ Failed to extract tech stack JSON from response');
+ }
+ }
+
+ if (parsedTechnologies) {
+ const newTechStack = parsedTechnologies;
+
+ // Calculate differences
+ const currentSet = new Set(currentTechStack.map((t) => t.toLowerCase()));
+ const newSet = new Set(newTechStack.map((t) => t.toLowerCase()));
+
+ for (const tech of newTechStack) {
+ if (!currentSet.has(tech.toLowerCase())) {
+ result.techStackUpdates.added.push(tech);
+ }
+ }
+
+ for (const tech of currentTechStack) {
+ if (!newSet.has(tech.toLowerCase())) {
+ result.techStackUpdates.removed.push(tech);
+ }
+ }
+
+ // Update spec with new tech stack if there are changes
+ if (result.techStackUpdates.added.length > 0 || result.techStackUpdates.removed.length > 0) {
+ specContent = updateTechnologyStack(specContent, newTechStack);
+ logger.info(
+ `Updated tech stack: +${result.techStackUpdates.added.length}, -${result.techStackUpdates.removed.length}`
+ );
+ }
+ }
+ } catch (error) {
+ logger.warn('Failed to analyze tech stack:', error);
+ // Continue with other sync operations
+ }
+
+ // Update roadmap phase statuses based on completed features
+ events.emit('spec-regeneration:event', {
+ type: 'spec_regeneration_progress',
+ content: 'Checking roadmap phase statuses...\n',
+ projectPath,
+ });
+
+ // For each phase, check if all its features are completed
+ // This is a heuristic - we check if the phase name appears in any feature titles/descriptions
+ for (const phase of currentRoadmapPhases) {
+ if (phase.status === 'completed') continue; // Already completed
+
+ // Check if this phase should be marked as completed
+ // A phase is considered complete if we have completed features that mention it
+ const phaseNameLower = phase.name.toLowerCase();
+ const relatedCompletedFeatures = completedFeatures.filter(
+ (f) =>
+ f.title?.toLowerCase().includes(phaseNameLower) ||
+ f.description?.toLowerCase().includes(phaseNameLower) ||
+ f.category?.toLowerCase().includes(phaseNameLower)
+ );
+
+ // If we have related completed features and the phase is still pending/in_progress,
+ // update it to in_progress or completed based on feature count
+ if (relatedCompletedFeatures.length > 0 && phase.status !== 'completed') {
+ const newStatus = 'in_progress';
+ specContent = updateRoadmapPhaseStatus(specContent, phase.name, newStatus);
+ result.roadmapUpdates.push({ phaseName: phase.name, newStatus });
+ logger.info(`Updated phase "${phase.name}" to ${newStatus}`);
+ }
+ }
+
+ // Save updated spec
+ await secureFs.writeFile(specPath, specContent, 'utf-8');
+ logger.info('Spec saved successfully');
+
+ // Build summary
+ const summaryParts: string[] = [];
+ if (result.implementedFeaturesUpdates.addedFromFeatures.length > 0) {
+ summaryParts.push(
+ `Added ${result.implementedFeaturesUpdates.addedFromFeatures.length} implemented features`
+ );
+ }
+ if (result.techStackUpdates.added.length > 0) {
+ summaryParts.push(`Added ${result.techStackUpdates.added.length} technologies`);
+ }
+ if (result.techStackUpdates.removed.length > 0) {
+ summaryParts.push(`Removed ${result.techStackUpdates.removed.length} technologies`);
+ }
+ if (result.roadmapUpdates.length > 0) {
+ summaryParts.push(`Updated ${result.roadmapUpdates.length} roadmap phases`);
+ }
+
+ result.summary = summaryParts.length > 0 ? summaryParts.join(', ') : 'Spec is already up to date';
+
+ // Create notification
+ const notificationService = getNotificationService();
+ await notificationService.createNotification({
+ type: 'spec_regeneration_complete',
+ title: 'Spec Sync Complete',
+ message: result.summary,
+ projectPath,
+ });
+
+ events.emit('spec-regeneration:event', {
+ type: 'spec_regeneration_complete',
+ message: `Spec sync complete! ${result.summary}`,
+ projectPath,
+ });
+
+ logger.info('========== syncSpec() completed ==========');
+ logger.info('Summary:', result.summary);
+
+ return result;
+}
diff --git a/temp_repo/apps/server/src/routes/auth/index.ts b/temp_repo/apps/server/src/routes/auth/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..558065c4c92faa4b98c47a585c8fa01a4da863bb
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/auth/index.ts
@@ -0,0 +1,266 @@
+/**
+ * Auth routes - Login, logout, and status endpoints
+ *
+ * Security model:
+ * - Web mode: User enters API key (shown on server console) to get HTTP-only session cookie
+ * - Electron mode: Uses X-API-Key header (handled automatically via IPC)
+ *
+ * The session cookie is:
+ * - HTTP-only: JavaScript cannot read it (protects against XSS)
+ * - SameSite=Strict: Only sent for same-site requests (protects against CSRF)
+ *
+ * Mounted at /api/auth in the main server (BEFORE auth middleware).
+ */
+
+import { Router } from 'express';
+import type { Request } from 'express';
+import {
+ validateApiKey,
+ createSession,
+ invalidateSession,
+ getSessionCookieOptions,
+ getSessionCookieName,
+ isRequestAuthenticated,
+ createWsConnectionToken,
+} from '../../lib/auth.js';
+
+// Rate limiting configuration
+const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute window
+const RATE_LIMIT_MAX_ATTEMPTS = 5; // Max 5 attempts per window
+
+// Check if we're in test mode - disable rate limiting for E2E tests
+const isTestMode = process.env.AUTOMAKER_MOCK_AGENT === 'true';
+
+// In-memory rate limit tracking (resets on server restart)
+const loginAttempts = new Map();
+
+// Clean up old rate limit entries periodically (every 5 minutes)
+setInterval(
+ () => {
+ const now = Date.now();
+ loginAttempts.forEach((data, ip) => {
+ if (now - data.windowStart > RATE_LIMIT_WINDOW_MS * 2) {
+ loginAttempts.delete(ip);
+ }
+ });
+ },
+ 5 * 60 * 1000
+);
+
+/**
+ * Get client IP address from request
+ * Handles X-Forwarded-For header for reverse proxy setups
+ */
+function getClientIp(req: Request): string {
+ const forwarded = req.headers['x-forwarded-for'];
+ if (forwarded) {
+ // X-Forwarded-For can be a comma-separated list; take the first (original client)
+ const forwardedIp = Array.isArray(forwarded) ? forwarded[0] : forwarded.split(',')[0];
+ return forwardedIp.trim();
+ }
+ return req.ip || req.socket.remoteAddress || 'unknown';
+}
+
+/**
+ * Check if an IP is rate limited
+ * Returns { limited: boolean, retryAfter?: number }
+ */
+function checkRateLimit(ip: string): { limited: boolean; retryAfter?: number } {
+ const now = Date.now();
+ const attempt = loginAttempts.get(ip);
+
+ if (!attempt) {
+ return { limited: false };
+ }
+
+ // Check if window has expired
+ if (now - attempt.windowStart > RATE_LIMIT_WINDOW_MS) {
+ loginAttempts.delete(ip);
+ return { limited: false };
+ }
+
+ // Check if over limit
+ if (attempt.count >= RATE_LIMIT_MAX_ATTEMPTS) {
+ const retryAfter = Math.ceil((RATE_LIMIT_WINDOW_MS - (now - attempt.windowStart)) / 1000);
+ return { limited: true, retryAfter };
+ }
+
+ return { limited: false };
+}
+
+/**
+ * Record a login attempt for rate limiting
+ */
+function recordLoginAttempt(ip: string): void {
+ const now = Date.now();
+ const attempt = loginAttempts.get(ip);
+
+ if (!attempt || now - attempt.windowStart > RATE_LIMIT_WINDOW_MS) {
+ // Start new window
+ loginAttempts.set(ip, { count: 1, windowStart: now });
+ } else {
+ // Increment existing window
+ attempt.count++;
+ }
+}
+
+/**
+ * Create auth routes
+ *
+ * @returns Express Router with auth endpoints
+ */
+export function createAuthRoutes(): Router {
+ const router = Router();
+
+ /**
+ * GET /api/auth/status
+ *
+ * Returns whether the current request is authenticated.
+ * Used by the UI to determine if login is needed.
+ *
+ * If AUTOMAKER_AUTO_LOGIN=true is set, automatically creates a session
+ * for unauthenticated requests (useful for development).
+ */
+ router.get('/status', async (req, res) => {
+ let authenticated = isRequestAuthenticated(req);
+
+ // Auto-login for development: create session automatically if enabled
+ // Only works in non-production environments as a safeguard
+ if (
+ !authenticated &&
+ process.env.AUTOMAKER_AUTO_LOGIN === 'true' &&
+ process.env.NODE_ENV !== 'production'
+ ) {
+ const sessionToken = await createSession();
+ const cookieOptions = getSessionCookieOptions();
+ const cookieName = getSessionCookieName();
+ res.cookie(cookieName, sessionToken, cookieOptions);
+ authenticated = true;
+ }
+
+ res.json({
+ success: true,
+ authenticated,
+ required: true,
+ });
+ });
+
+ /**
+ * POST /api/auth/login
+ *
+ * Validates the API key and sets a session cookie.
+ * Body: { apiKey: string }
+ *
+ * Rate limited to 5 attempts per minute per IP to prevent brute force attacks.
+ */
+ router.post('/login', async (req, res) => {
+ const clientIp = getClientIp(req);
+
+ // Skip rate limiting in test mode to allow parallel E2E tests
+ if (!isTestMode) {
+ // Check rate limit before processing
+ const rateLimit = checkRateLimit(clientIp);
+ if (rateLimit.limited) {
+ res.status(429).json({
+ success: false,
+ error: 'Too many login attempts. Please try again later.',
+ retryAfter: rateLimit.retryAfter,
+ });
+ return;
+ }
+ }
+
+ const { apiKey } = req.body as { apiKey?: string };
+
+ if (!apiKey) {
+ res.status(400).json({
+ success: false,
+ error: 'API key is required.',
+ });
+ return;
+ }
+
+ // Record this attempt (only for actual API key validation attempts, skip in test mode)
+ if (!isTestMode) {
+ recordLoginAttempt(clientIp);
+ }
+
+ if (!validateApiKey(apiKey)) {
+ res.status(401).json({
+ success: false,
+ error: 'Invalid API key.',
+ });
+ return;
+ }
+
+ // Create session and set cookie
+ const sessionToken = await createSession();
+ const cookieOptions = getSessionCookieOptions();
+ const cookieName = getSessionCookieName();
+
+ res.cookie(cookieName, sessionToken, cookieOptions);
+ res.json({
+ success: true,
+ message: 'Logged in successfully.',
+ // Return token for explicit header-based auth (works around cross-origin cookie issues)
+ token: sessionToken,
+ });
+ });
+
+ /**
+ * GET /api/auth/token
+ *
+ * Generates a short-lived WebSocket connection token if the user has a valid session.
+ * This token is used for initial WebSocket handshake authentication and expires in 5 minutes.
+ * The token is NOT the session cookie value - it's a separate, short-lived token.
+ */
+ router.get('/token', (req, res) => {
+ // Validate the session is still valid (via cookie, API key, or session token header)
+ if (!isRequestAuthenticated(req)) {
+ res.status(401).json({
+ success: false,
+ error: 'Authentication required.',
+ });
+ return;
+ }
+
+ // Generate a new short-lived WebSocket connection token
+ const wsToken = createWsConnectionToken();
+
+ res.json({
+ success: true,
+ token: wsToken,
+ expiresIn: 300, // 5 minutes in seconds
+ });
+ });
+
+ /**
+ * POST /api/auth/logout
+ *
+ * Clears the session cookie and invalidates the session.
+ */
+ router.post('/logout', async (req, res) => {
+ const cookieName = getSessionCookieName();
+ const sessionToken = req.cookies?.[cookieName] as string | undefined;
+
+ if (sessionToken) {
+ await invalidateSession(sessionToken);
+ }
+
+ // Clear the cookie by setting it to empty with immediate expiration
+ // Using res.cookie() with maxAge: 0 is more reliable than clearCookie()
+ // in cross-origin development environments
+ res.cookie(cookieName, '', {
+ ...getSessionCookieOptions(),
+ maxAge: 0,
+ expires: new Date(0),
+ });
+
+ res.json({
+ success: true,
+ message: 'Logged out successfully.',
+ });
+ });
+
+ return router;
+}
diff --git a/temp_repo/apps/server/src/routes/auto-mode/common.ts b/temp_repo/apps/server/src/routes/auto-mode/common.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8fe9c3ab2dfba6bbc2d45838f4a4cc98f790ff83
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/auto-mode/common.ts
@@ -0,0 +1,12 @@
+/**
+ * Common utilities for auto-mode routes
+ */
+
+import { createLogger } from '@automaker/utils';
+import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
+
+const logger = createLogger('AutoMode');
+
+// Re-export shared utilities
+export { getErrorMessageShared as getErrorMessage };
+export const logError = createLogError(logger);
diff --git a/temp_repo/apps/server/src/routes/auto-mode/index.ts b/temp_repo/apps/server/src/routes/auto-mode/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..016447d739e9831866544bd7b2fbbb158229e803
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/auto-mode/index.ts
@@ -0,0 +1,92 @@
+/**
+ * Auto Mode routes - HTTP API for autonomous feature implementation
+ *
+ * Uses AutoModeServiceCompat which provides the old interface while
+ * delegating to GlobalAutoModeService and per-project facades.
+ */
+
+import { Router } from 'express';
+import type { AutoModeServiceCompat } from '../../services/auto-mode/index.js';
+import { validatePathParams } from '../../middleware/validate-paths.js';
+import { createStopFeatureHandler } from './routes/stop-feature.js';
+import { createStatusHandler } from './routes/status.js';
+import { createRunFeatureHandler } from './routes/run-feature.js';
+import { createStartHandler } from './routes/start.js';
+import { createStopHandler } from './routes/stop.js';
+import { createVerifyFeatureHandler } from './routes/verify-feature.js';
+import { createResumeFeatureHandler } from './routes/resume-feature.js';
+import { createContextExistsHandler } from './routes/context-exists.js';
+import { createAnalyzeProjectHandler } from './routes/analyze-project.js';
+import { createFollowUpFeatureHandler } from './routes/follow-up-feature.js';
+import { createCommitFeatureHandler } from './routes/commit-feature.js';
+import { createApprovePlanHandler } from './routes/approve-plan.js';
+import { createResumeInterruptedHandler } from './routes/resume-interrupted.js';
+import { createReconcileHandler } from './routes/reconcile.js';
+
+/**
+ * Create auto-mode routes.
+ *
+ * @param autoModeService - AutoModeServiceCompat instance
+ */
+export function createAutoModeRoutes(autoModeService: AutoModeServiceCompat): Router {
+ const router = Router();
+
+ // Auto loop control routes
+ router.post('/start', validatePathParams('projectPath'), createStartHandler(autoModeService));
+ router.post('/stop', validatePathParams('projectPath'), createStopHandler(autoModeService));
+
+ router.post('/stop-feature', createStopFeatureHandler(autoModeService));
+ router.post('/status', validatePathParams('projectPath?'), createStatusHandler(autoModeService));
+ router.post(
+ '/run-feature',
+ validatePathParams('projectPath'),
+ createRunFeatureHandler(autoModeService)
+ );
+ router.post(
+ '/verify-feature',
+ validatePathParams('projectPath'),
+ createVerifyFeatureHandler(autoModeService)
+ );
+ router.post(
+ '/resume-feature',
+ validatePathParams('projectPath'),
+ createResumeFeatureHandler(autoModeService)
+ );
+ router.post(
+ '/context-exists',
+ validatePathParams('projectPath'),
+ createContextExistsHandler(autoModeService)
+ );
+ router.post(
+ '/analyze-project',
+ validatePathParams('projectPath'),
+ createAnalyzeProjectHandler(autoModeService)
+ );
+ router.post(
+ '/follow-up-feature',
+ validatePathParams('projectPath', 'imagePaths[]'),
+ createFollowUpFeatureHandler(autoModeService)
+ );
+ router.post(
+ '/commit-feature',
+ validatePathParams('projectPath', 'worktreePath?'),
+ createCommitFeatureHandler(autoModeService)
+ );
+ router.post(
+ '/approve-plan',
+ validatePathParams('projectPath'),
+ createApprovePlanHandler(autoModeService)
+ );
+ router.post(
+ '/resume-interrupted',
+ validatePathParams('projectPath'),
+ createResumeInterruptedHandler(autoModeService)
+ );
+ router.post(
+ '/reconcile',
+ validatePathParams('projectPath'),
+ createReconcileHandler(autoModeService)
+ );
+
+ return router;
+}
diff --git a/temp_repo/apps/server/src/routes/auto-mode/routes/analyze-project.ts b/temp_repo/apps/server/src/routes/auto-mode/routes/analyze-project.ts
new file mode 100644
index 0000000000000000000000000000000000000000..cae70c3689b338d54aa73de7fa6262a13d5d7fb0
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/auto-mode/routes/analyze-project.ts
@@ -0,0 +1,34 @@
+/**
+ * POST /analyze-project endpoint - Analyze project
+ */
+
+import type { Request, Response } from 'express';
+import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
+import { createLogger } from '@automaker/utils';
+import { getErrorMessage, logError } from '../common.js';
+
+const logger = createLogger('AutoMode');
+
+export function createAnalyzeProjectHandler(autoModeService: AutoModeServiceCompat) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { projectPath } = req.body as { projectPath: string };
+
+ if (!projectPath) {
+ res.status(400).json({ success: false, error: 'projectPath is required' });
+ return;
+ }
+
+ // Kick off analysis in the background; attach a rejection handler so
+ // unhandled-promise warnings don't surface and errors are at least logged.
+ // Synchronous throws (e.g. "not implemented") still propagate here.
+ const analysisPromise = autoModeService.analyzeProject(projectPath);
+ analysisPromise.catch((err) => logError(err, 'Background analyzeProject failed'));
+
+ res.json({ success: true, message: 'Project analysis started' });
+ } catch (error) {
+ logError(error, 'Analyze project failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/auto-mode/routes/approve-plan.ts b/temp_repo/apps/server/src/routes/auto-mode/routes/approve-plan.ts
new file mode 100644
index 0000000000000000000000000000000000000000..14673e31bba81d2f11135679b75634f924a88285
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/auto-mode/routes/approve-plan.ts
@@ -0,0 +1,86 @@
+/**
+ * POST /approve-plan endpoint - Approve or reject a generated plan/spec
+ */
+
+import type { Request, Response } from 'express';
+import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
+import { createLogger } from '@automaker/utils';
+import { getErrorMessage, logError } from '../common.js';
+
+const logger = createLogger('AutoMode');
+
+export function createApprovePlanHandler(autoModeService: AutoModeServiceCompat) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { featureId, approved, editedPlan, feedback, projectPath } = req.body as {
+ featureId: string;
+ approved: boolean;
+ editedPlan?: string;
+ feedback?: string;
+ projectPath: string;
+ };
+
+ if (!featureId) {
+ res.status(400).json({
+ success: false,
+ error: 'featureId is required',
+ });
+ return;
+ }
+
+ if (typeof approved !== 'boolean') {
+ res.status(400).json({
+ success: false,
+ error: 'approved must be a boolean',
+ });
+ return;
+ }
+
+ if (!projectPath) {
+ res.status(400).json({
+ success: false,
+ error: 'projectPath is required',
+ });
+ return;
+ }
+
+ // Note: We no longer check hasPendingApproval here because resolvePlanApproval
+ // can handle recovery when pending approval is not in Map but feature has planSpec.status='generated'
+ // This supports cases where the server restarted while waiting for approval
+
+ logger.info(
+ `[AutoMode] Plan ${approved ? 'approved' : 'rejected'} for feature ${featureId}${
+ editedPlan ? ' (with edits)' : ''
+ }${feedback ? ` - Feedback: ${feedback}` : ''}`
+ );
+
+ // Resolve the pending approval (with recovery support)
+ const result = await autoModeService.resolvePlanApproval(
+ projectPath,
+ featureId,
+ approved,
+ editedPlan,
+ feedback
+ );
+
+ if (!result.success) {
+ res.status(500).json({
+ success: false,
+ error: result.error,
+ });
+ return;
+ }
+
+ res.json({
+ success: true,
+ approved,
+ message: approved
+ ? 'Plan approved - implementation will continue'
+ : 'Plan rejected - feature execution stopped',
+ });
+ } catch (error) {
+ logError(error, 'Approve plan failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/auto-mode/routes/commit-feature.ts b/temp_repo/apps/server/src/routes/auto-mode/routes/commit-feature.ts
new file mode 100644
index 0000000000000000000000000000000000000000..16b9000d2ec6e52139c0dab429b97895c21b3b79
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/auto-mode/routes/commit-feature.ts
@@ -0,0 +1,33 @@
+/**
+ * POST /commit-feature endpoint - Commit feature changes
+ */
+
+import type { Request, Response } from 'express';
+import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
+import { getErrorMessage, logError } from '../common.js';
+
+export function createCommitFeatureHandler(autoModeService: AutoModeServiceCompat) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { projectPath, featureId, worktreePath } = req.body as {
+ projectPath: string;
+ featureId: string;
+ worktreePath?: string;
+ };
+
+ if (!projectPath || !featureId) {
+ res.status(400).json({
+ success: false,
+ error: 'projectPath and featureId are required',
+ });
+ return;
+ }
+
+ const commitHash = await autoModeService.commitFeature(projectPath, featureId, worktreePath);
+ res.json({ success: true, commitHash });
+ } catch (error) {
+ logError(error, 'Commit feature failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/auto-mode/routes/context-exists.ts b/temp_repo/apps/server/src/routes/auto-mode/routes/context-exists.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8c85c2abf758b56cf4ce139eef92ed6437fbd4fb
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/auto-mode/routes/context-exists.ts
@@ -0,0 +1,32 @@
+/**
+ * POST /context-exists endpoint - Check if context exists for a feature
+ */
+
+import type { Request, Response } from 'express';
+import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
+import { getErrorMessage, logError } from '../common.js';
+
+export function createContextExistsHandler(autoModeService: AutoModeServiceCompat) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { projectPath, featureId } = req.body as {
+ projectPath: string;
+ featureId: string;
+ };
+
+ if (!projectPath || !featureId) {
+ res.status(400).json({
+ success: false,
+ error: 'projectPath and featureId are required',
+ });
+ return;
+ }
+
+ const exists = await autoModeService.contextExists(projectPath, featureId);
+ res.json({ success: true, exists });
+ } catch (error) {
+ logError(error, 'Check context exists failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/auto-mode/routes/follow-up-feature.ts b/temp_repo/apps/server/src/routes/auto-mode/routes/follow-up-feature.ts
new file mode 100644
index 0000000000000000000000000000000000000000..312edcdea8661db0e54364b2f50a1d302c0cf326
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/auto-mode/routes/follow-up-feature.ts
@@ -0,0 +1,47 @@
+/**
+ * POST /follow-up-feature endpoint - Follow up on a feature
+ */
+
+import type { Request, Response } from 'express';
+import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
+import { createLogger } from '@automaker/utils';
+import { getErrorMessage, logError } from '../common.js';
+
+const logger = createLogger('AutoMode');
+
+export function createFollowUpFeatureHandler(autoModeService: AutoModeServiceCompat) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { projectPath, featureId, prompt, imagePaths, useWorktrees } = req.body as {
+ projectPath: string;
+ featureId: string;
+ prompt: string;
+ imagePaths?: string[];
+ useWorktrees?: boolean;
+ };
+
+ if (!projectPath || !featureId || !prompt) {
+ res.status(400).json({
+ success: false,
+ error: 'projectPath, featureId, and prompt are required',
+ });
+ return;
+ }
+
+ // Start follow-up in background
+ // followUpFeature derives workDir from feature.branchName
+ // Default to false to match run-feature/resume-feature behavior.
+ // Worktrees should only be used when explicitly enabled by the user.
+ autoModeService
+ .followUpFeature(projectPath, featureId, prompt, imagePaths, useWorktrees ?? false)
+ .catch((error) => {
+ logger.error(`[AutoMode] Follow up feature ${featureId} error:`, error);
+ });
+
+ res.json({ success: true });
+ } catch (error) {
+ logError(error, 'Follow up feature failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/auto-mode/routes/reconcile.ts b/temp_repo/apps/server/src/routes/auto-mode/routes/reconcile.ts
new file mode 100644
index 0000000000000000000000000000000000000000..96109051ab151461ea4f1ed07cb3bb342c3d41ad
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/auto-mode/routes/reconcile.ts
@@ -0,0 +1,53 @@
+/**
+ * Reconcile Feature States Handler
+ *
+ * On-demand endpoint to reconcile all feature states for a project.
+ * Resets features stuck in transient states (in_progress, interrupted, pipeline_*)
+ * back to resting states (ready/backlog) and emits events to update the UI.
+ *
+ * This is useful when:
+ * - The UI reconnects after a server restart
+ * - A client detects stale feature states
+ * - An admin wants to force-reset stuck features
+ */
+
+import type { Request, Response } from 'express';
+import { createLogger } from '@automaker/utils';
+import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
+
+const logger = createLogger('ReconcileFeatures');
+
+interface ReconcileRequest {
+ projectPath: string;
+}
+
+export function createReconcileHandler(autoModeService: AutoModeServiceCompat) {
+ return async (req: Request, res: Response): Promise => {
+ const { projectPath } = req.body as ReconcileRequest;
+
+ if (!projectPath) {
+ res.status(400).json({ error: 'Project path is required' });
+ return;
+ }
+
+ logger.info(`Reconciling feature states for ${projectPath}`);
+
+ try {
+ const reconciledCount = await autoModeService.reconcileFeatureStates(projectPath);
+
+ res.json({
+ success: true,
+ reconciledCount,
+ message:
+ reconciledCount > 0
+ ? `Reconciled ${reconciledCount} feature(s)`
+ : 'No features needed reconciliation',
+ });
+ } catch (error) {
+ logger.error('Error reconciling feature states:', error);
+ res.status(500).json({
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/auto-mode/routes/resume-feature.ts b/temp_repo/apps/server/src/routes/auto-mode/routes/resume-feature.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d9f5de32baa751ac3b8416990109c59384419564
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/auto-mode/routes/resume-feature.ts
@@ -0,0 +1,43 @@
+/**
+ * POST /resume-feature endpoint - Resume a feature
+ */
+
+import type { Request, Response } from 'express';
+import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
+import { createLogger } from '@automaker/utils';
+import { getErrorMessage, logError } from '../common.js';
+
+const logger = createLogger('AutoMode');
+
+export function createResumeFeatureHandler(autoModeService: AutoModeServiceCompat) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { projectPath, featureId, useWorktrees } = req.body as {
+ projectPath: string;
+ featureId: string;
+ useWorktrees?: boolean;
+ };
+
+ if (!projectPath || !featureId) {
+ res.status(400).json({
+ success: false,
+ error: 'projectPath and featureId are required',
+ });
+ return;
+ }
+
+ // Start resume in background
+ // Default to false - worktrees should only be used when explicitly enabled
+ autoModeService
+ .resumeFeature(projectPath, featureId, useWorktrees ?? false)
+ .catch((error) => {
+ logger.error(`Resume feature ${featureId} error:`, error);
+ });
+
+ res.json({ success: true });
+ } catch (error) {
+ logError(error, 'Resume feature failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/auto-mode/routes/resume-interrupted.ts b/temp_repo/apps/server/src/routes/auto-mode/routes/resume-interrupted.ts
new file mode 100644
index 0000000000000000000000000000000000000000..314bc067f8704c7218e4068164dada9979055c80
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/auto-mode/routes/resume-interrupted.ts
@@ -0,0 +1,43 @@
+/**
+ * Resume Interrupted Features Handler
+ *
+ * Checks for features that were interrupted (in pipeline steps or in_progress)
+ * when the server was restarted and resumes them.
+ */
+
+import type { Request, Response } from 'express';
+import { createLogger } from '@automaker/utils';
+import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
+
+const logger = createLogger('ResumeInterrupted');
+
+interface ResumeInterruptedRequest {
+ projectPath: string;
+}
+
+export function createResumeInterruptedHandler(autoModeService: AutoModeServiceCompat) {
+ return async (req: Request, res: Response): Promise => {
+ const { projectPath } = req.body as ResumeInterruptedRequest;
+
+ if (!projectPath) {
+ res.status(400).json({ error: 'Project path is required' });
+ return;
+ }
+
+ logger.info(`Checking for interrupted features in ${projectPath}`);
+
+ try {
+ await autoModeService.resumeInterruptedFeatures(projectPath);
+
+ res.json({
+ success: true,
+ message: 'Resume check completed',
+ });
+ } catch (error) {
+ logger.error('Error resuming interrupted features:', error);
+ res.status(500).json({
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/auto-mode/routes/run-feature.ts b/temp_repo/apps/server/src/routes/auto-mode/routes/run-feature.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a61a406415ba72d569a7310657405e87b227c0cb
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/auto-mode/routes/run-feature.ts
@@ -0,0 +1,47 @@
+/**
+ * POST /run-feature endpoint - Run a single feature
+ */
+
+import type { Request, Response } from 'express';
+import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
+import { createLogger } from '@automaker/utils';
+import { getErrorMessage, logError } from '../common.js';
+
+const logger = createLogger('AutoMode');
+
+export function createRunFeatureHandler(autoModeService: AutoModeServiceCompat) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { projectPath, featureId, useWorktrees } = req.body as {
+ projectPath: string;
+ featureId: string;
+ useWorktrees?: boolean;
+ };
+
+ if (!projectPath || !featureId) {
+ res.status(400).json({
+ success: false,
+ error: 'projectPath and featureId are required',
+ });
+ return;
+ }
+
+ // Note: No concurrency limit check here. Manual feature starts always run
+ // immediately and bypass the concurrency limit. Their presence IS counted
+ // by the auto-loop coordinator when deciding whether to dispatch new auto-mode tasks.
+
+ // Start execution in background
+ // executeFeature derives workDir from feature.branchName
+ autoModeService
+ .executeFeature(projectPath, featureId, useWorktrees ?? false, false)
+ .catch((error) => {
+ logger.error(`Feature ${featureId} error:`, error);
+ });
+
+ res.json({ success: true });
+ } catch (error) {
+ logError(error, 'Run feature failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/auto-mode/routes/start.ts b/temp_repo/apps/server/src/routes/auto-mode/routes/start.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c8cc8bff47b7ac8434011223cd0a212cd2249af6
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/auto-mode/routes/start.ts
@@ -0,0 +1,67 @@
+/**
+ * POST /start endpoint - Start auto mode loop for a project
+ */
+
+import type { Request, Response } from 'express';
+import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
+import { createLogger } from '@automaker/utils';
+import { getErrorMessage, logError } from '../common.js';
+
+const logger = createLogger('AutoMode');
+
+export function createStartHandler(autoModeService: AutoModeServiceCompat) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { projectPath, branchName, maxConcurrency } = req.body as {
+ projectPath: string;
+ branchName?: string | null;
+ maxConcurrency?: number;
+ };
+
+ if (!projectPath) {
+ res.status(400).json({
+ success: false,
+ error: 'projectPath is required',
+ });
+ return;
+ }
+
+ // Normalize branchName: undefined becomes null
+ const normalizedBranchName = branchName ?? null;
+ const worktreeDesc = normalizedBranchName
+ ? `worktree ${normalizedBranchName}`
+ : 'main worktree';
+
+ // Check if already running
+ if (autoModeService.isAutoLoopRunningForProject(projectPath, normalizedBranchName)) {
+ res.json({
+ success: true,
+ message: `Auto mode is already running for ${worktreeDesc}`,
+ alreadyRunning: true,
+ branchName: normalizedBranchName,
+ });
+ return;
+ }
+
+ // Start the auto loop for this project/worktree
+ const resolvedMaxConcurrency = await autoModeService.startAutoLoopForProject(
+ projectPath,
+ normalizedBranchName,
+ maxConcurrency
+ );
+
+ logger.info(
+ `Started auto loop for ${worktreeDesc} in project: ${projectPath} with maxConcurrency: ${resolvedMaxConcurrency}`
+ );
+
+ res.json({
+ success: true,
+ message: `Auto mode started with max ${resolvedMaxConcurrency} concurrent features`,
+ branchName: normalizedBranchName,
+ });
+ } catch (error) {
+ logError(error, 'Start auto mode failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/auto-mode/routes/status.ts b/temp_repo/apps/server/src/routes/auto-mode/routes/status.ts
new file mode 100644
index 0000000000000000000000000000000000000000..765ff73a61000a4ffd4d54127b47bf480b720f08
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/auto-mode/routes/status.ts
@@ -0,0 +1,60 @@
+/**
+ * POST /status endpoint - Get auto mode status
+ *
+ * If projectPath is provided, returns per-project status including autoloop state.
+ * If no projectPath, returns global status for backward compatibility.
+ */
+
+import type { Request, Response } from 'express';
+import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
+import { getErrorMessage, logError } from '../common.js';
+
+/**
+ * Create status handler.
+ */
+export function createStatusHandler(autoModeService: AutoModeServiceCompat) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { projectPath, branchName } = req.body as {
+ projectPath?: string;
+ branchName?: string | null;
+ };
+
+ // If projectPath is provided, return per-project/worktree status
+ if (projectPath) {
+ // Normalize branchName: undefined becomes null
+ const normalizedBranchName = branchName ?? null;
+
+ const projectStatus = await autoModeService.getStatusForProject(
+ projectPath,
+ normalizedBranchName
+ );
+ res.json({
+ success: true,
+ isRunning: projectStatus.runningCount > 0,
+ isAutoLoopRunning: projectStatus.isAutoLoopRunning,
+ runningFeatures: projectStatus.runningFeatures,
+ runningCount: projectStatus.runningCount,
+ maxConcurrency: projectStatus.maxConcurrency,
+ projectPath,
+ branchName: normalizedBranchName,
+ });
+ return;
+ }
+
+ // Global status for backward compatibility
+ const status = autoModeService.getStatus();
+ const activeProjects = autoModeService.getActiveAutoLoopProjects();
+ const activeWorktrees = autoModeService.getActiveAutoLoopWorktrees();
+ res.json({
+ success: true,
+ ...status,
+ activeAutoLoopProjects: activeProjects,
+ activeAutoLoopWorktrees: activeWorktrees,
+ });
+ } catch (error) {
+ logError(error, 'Get status failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/auto-mode/routes/stop-feature.ts b/temp_repo/apps/server/src/routes/auto-mode/routes/stop-feature.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2e3e69eb6c4065fa51c2a5d3c2588f30a04abc52
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/auto-mode/routes/stop-feature.ts
@@ -0,0 +1,26 @@
+/**
+ * POST /stop-feature endpoint - Stop a specific feature
+ */
+
+import type { Request, Response } from 'express';
+import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
+import { getErrorMessage, logError } from '../common.js';
+
+export function createStopFeatureHandler(autoModeService: AutoModeServiceCompat) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { featureId } = req.body as { featureId: string };
+
+ if (!featureId) {
+ res.status(400).json({ success: false, error: 'featureId is required' });
+ return;
+ }
+
+ const stopped = await autoModeService.stopFeature(featureId);
+ res.json({ success: true, stopped });
+ } catch (error) {
+ logError(error, 'Stop feature failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/auto-mode/routes/stop.ts b/temp_repo/apps/server/src/routes/auto-mode/routes/stop.ts
new file mode 100644
index 0000000000000000000000000000000000000000..224b0daf47248e08cb05392faeb7d27c42587444
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/auto-mode/routes/stop.ts
@@ -0,0 +1,66 @@
+/**
+ * POST /stop endpoint - Stop auto mode loop for a project
+ */
+
+import type { Request, Response } from 'express';
+import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
+import { createLogger } from '@automaker/utils';
+import { getErrorMessage, logError } from '../common.js';
+
+const logger = createLogger('AutoMode');
+
+export function createStopHandler(autoModeService: AutoModeServiceCompat) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { projectPath, branchName } = req.body as {
+ projectPath: string;
+ branchName?: string | null;
+ };
+
+ if (!projectPath) {
+ res.status(400).json({
+ success: false,
+ error: 'projectPath is required',
+ });
+ return;
+ }
+
+ // Normalize branchName: undefined becomes null
+ const normalizedBranchName = branchName ?? null;
+ const worktreeDesc = normalizedBranchName
+ ? `worktree ${normalizedBranchName}`
+ : 'main worktree';
+
+ // Check if running
+ if (!autoModeService.isAutoLoopRunningForProject(projectPath, normalizedBranchName)) {
+ res.json({
+ success: true,
+ message: `Auto mode is not running for ${worktreeDesc}`,
+ wasRunning: false,
+ branchName: normalizedBranchName,
+ });
+ return;
+ }
+
+ // Stop the auto loop for this project/worktree
+ const runningCount = await autoModeService.stopAutoLoopForProject(
+ projectPath,
+ normalizedBranchName
+ );
+
+ logger.info(
+ `Stopped auto loop for ${worktreeDesc} in project: ${projectPath}, ${runningCount} features still running`
+ );
+
+ res.json({
+ success: true,
+ message: 'Auto mode stopped',
+ runningFeaturesCount: runningCount,
+ branchName: normalizedBranchName,
+ });
+ } catch (error) {
+ logError(error, 'Stop auto mode failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/auto-mode/routes/verify-feature.ts b/temp_repo/apps/server/src/routes/auto-mode/routes/verify-feature.ts
new file mode 100644
index 0000000000000000000000000000000000000000..7c036812a1b9c6b218c75529a07a46fb52e33ddb
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/auto-mode/routes/verify-feature.ts
@@ -0,0 +1,32 @@
+/**
+ * POST /verify-feature endpoint - Verify a feature
+ */
+
+import type { Request, Response } from 'express';
+import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
+import { getErrorMessage, logError } from '../common.js';
+
+export function createVerifyFeatureHandler(autoModeService: AutoModeServiceCompat) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { projectPath, featureId } = req.body as {
+ projectPath: string;
+ featureId: string;
+ };
+
+ if (!projectPath || !featureId) {
+ res.status(400).json({
+ success: false,
+ error: 'projectPath and featureId are required',
+ });
+ return;
+ }
+
+ const passes = await autoModeService.verifyFeature(projectPath, featureId);
+ res.json({ success: true, passes });
+ } catch (error) {
+ logError(error, 'Verify feature failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/backlog-plan/common.ts b/temp_repo/apps/server/src/routes/backlog-plan/common.ts
new file mode 100644
index 0000000000000000000000000000000000000000..27993e95ee7de0945467e3fe9187fad780224f1c
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/backlog-plan/common.ts
@@ -0,0 +1,174 @@
+/**
+ * Common utilities for backlog plan routes
+ */
+
+import { createLogger } from '@automaker/utils';
+import { ensureAutomakerDir, getAutomakerDir } from '@automaker/platform';
+import * as secureFs from '../../lib/secure-fs.js';
+import path from 'path';
+import type { BacklogPlanResult } from '@automaker/types';
+
+const logger = createLogger('BacklogPlan');
+
+// State for tracking running generation
+let isRunning = false;
+let currentAbortController: AbortController | null = null;
+let runningDetails: {
+ projectPath: string;
+ prompt: string;
+ model?: string;
+ startedAt: string;
+} | null = null;
+
+const BACKLOG_PLAN_FILENAME = 'backlog-plan.json';
+
+export interface StoredBacklogPlan {
+ savedAt: string;
+ prompt: string;
+ model?: string;
+ result: BacklogPlanResult;
+}
+
+export function getBacklogPlanStatus(): { isRunning: boolean } {
+ return { isRunning };
+}
+
+export function setRunningState(running: boolean, abortController?: AbortController | null): void {
+ isRunning = running;
+ if (!running) {
+ runningDetails = null;
+ }
+ if (abortController !== undefined) {
+ currentAbortController = abortController;
+ }
+}
+
+export function setRunningDetails(
+ details: {
+ projectPath: string;
+ prompt: string;
+ model?: string;
+ startedAt: string;
+ } | null
+): void {
+ runningDetails = details;
+}
+
+export function getRunningDetails(): {
+ projectPath: string;
+ prompt: string;
+ model?: string;
+ startedAt: string;
+} | null {
+ return runningDetails;
+}
+
+function getBacklogPlanPath(projectPath: string): string {
+ return path.join(getAutomakerDir(projectPath), BACKLOG_PLAN_FILENAME);
+}
+
+export async function saveBacklogPlan(projectPath: string, plan: StoredBacklogPlan): Promise {
+ await ensureAutomakerDir(projectPath);
+ const filePath = getBacklogPlanPath(projectPath);
+ await secureFs.writeFile(filePath, JSON.stringify(plan, null, 2), 'utf-8');
+}
+
+export async function loadBacklogPlan(projectPath: string): Promise {
+ try {
+ const filePath = getBacklogPlanPath(projectPath);
+ const raw = await secureFs.readFile(filePath, 'utf-8');
+ const parsed = JSON.parse(raw as string) as StoredBacklogPlan;
+ if (!Array.isArray(parsed?.result?.changes)) {
+ return null;
+ }
+ return parsed;
+ } catch {
+ return null;
+ }
+}
+
+export async function clearBacklogPlan(projectPath: string): Promise {
+ try {
+ const filePath = getBacklogPlanPath(projectPath);
+ await secureFs.unlink(filePath);
+ } catch {
+ // ignore missing file
+ }
+}
+
+export function getAbortController(): AbortController | null {
+ return currentAbortController;
+}
+
+/**
+ * Map SDK/CLI errors to user-friendly messages
+ */
+export function mapBacklogPlanError(rawMessage: string): string {
+ // Claude Code spawn failures
+ if (
+ rawMessage.includes('Failed to spawn Claude Code process') ||
+ rawMessage.includes('spawn node ENOENT') ||
+ rawMessage.includes('Claude Code executable not found') ||
+ rawMessage.includes('Claude Code native binary not found')
+ ) {
+ return 'Claude CLI could not be launched. Make sure the Claude CLI is installed and available in PATH, or check that Node.js is correctly installed. Try running "which claude" or "claude --version" in your terminal to verify.';
+ }
+
+ // Claude Code process crash - extract exit code for diagnostics
+ if (rawMessage.includes('Claude Code process exited')) {
+ const exitCodeMatch = rawMessage.match(/exited with code (\d+)/);
+ const exitCode = exitCodeMatch ? exitCodeMatch[1] : 'unknown';
+ logger.error(`[BacklogPlan] Claude process exit code: ${exitCode}`);
+ return `Claude exited unexpectedly (exit code: ${exitCode}). This is usually a transient issue. Try again. If it keeps happening, re-run \`claude login\` or update your API key in Setup.`;
+ }
+
+ // Claude Code process killed by signal
+ if (rawMessage.includes('Claude Code process terminated by signal')) {
+ const signalMatch = rawMessage.match(/terminated by signal (\w+)/);
+ const signal = signalMatch ? signalMatch[1] : 'unknown';
+ logger.error(`[BacklogPlan] Claude process terminated by signal: ${signal}`);
+ return `Claude was terminated by signal ${signal}. This may indicate a resource issue. Try again.`;
+ }
+
+ // Rate limiting
+ if (rawMessage.toLowerCase().includes('rate limit') || rawMessage.includes('429')) {
+ return 'Rate limited. Please wait a moment and try again.';
+ }
+
+ // Network errors
+ if (
+ rawMessage.toLowerCase().includes('network') ||
+ rawMessage.toLowerCase().includes('econnrefused') ||
+ rawMessage.toLowerCase().includes('timeout')
+ ) {
+ return 'Network error. Check your internet connection and try again.';
+ }
+
+ // Authentication errors
+ if (
+ rawMessage.toLowerCase().includes('not authenticated') ||
+ rawMessage.toLowerCase().includes('unauthorized') ||
+ rawMessage.includes('401')
+ ) {
+ return 'Authentication failed. Please check your API key or run `claude login` to authenticate.';
+ }
+
+ // Return original message for unknown errors
+ return rawMessage;
+}
+
+export function getErrorMessage(error: unknown): string {
+ let rawMessage: string;
+ if (error instanceof Error) {
+ rawMessage = error.message;
+ } else {
+ rawMessage = String(error);
+ }
+ return mapBacklogPlanError(rawMessage);
+}
+
+export function logError(error: unknown, context: string): void {
+ logger.error(`[BacklogPlan] ${context}:`, getErrorMessage(error));
+}
+
+export { logger };
diff --git a/temp_repo/apps/server/src/routes/backlog-plan/generate-plan.ts b/temp_repo/apps/server/src/routes/backlog-plan/generate-plan.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4a1dc95afaea17ed3fa8f968ceb5f2eab71c000b
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/backlog-plan/generate-plan.ts
@@ -0,0 +1,491 @@
+/**
+ * Generate backlog plan using Claude AI
+ *
+ * Model is configurable via phaseModels.backlogPlanningModel in settings
+ * (defaults to Sonnet). Can be overridden per-call via model parameter.
+ *
+ * Includes automatic retry for transient CLI failures (e.g., "Claude Code
+ * process exited unexpectedly") to improve reliability.
+ */
+
+import type { EventEmitter } from '../../lib/events.js';
+import type { Feature, BacklogPlanResult } from '@automaker/types';
+import {
+ DEFAULT_PHASE_MODELS,
+ isCursorModel,
+ stripProviderPrefix,
+ type ThinkingLevel,
+ type SystemPromptPreset,
+} from '@automaker/types';
+import { resolvePhaseModel } from '@automaker/model-resolver';
+import { getCurrentBranch } from '@automaker/git-utils';
+import { FeatureLoader } from '../../services/feature-loader.js';
+import { ProviderFactory } from '../../providers/provider-factory.js';
+import { extractJsonWithArray } from '../../lib/json-extractor.js';
+import {
+ logger,
+ setRunningState,
+ setRunningDetails,
+ getErrorMessage,
+ saveBacklogPlan,
+} from './common.js';
+import type { SettingsService } from '../../services/settings-service.js';
+import {
+ getAutoLoadClaudeMdSetting,
+ getUseClaudeCodeSystemPromptSetting,
+ getPromptCustomization,
+ getPhaseModelWithOverrides,
+ getProviderByModelId,
+} from '../../lib/settings-helpers.js';
+
+/** Maximum number of retry attempts for transient CLI failures */
+const MAX_RETRIES = 2;
+/** Delay between retries in milliseconds */
+const RETRY_DELAY_MS = 2000;
+
+/**
+ * Check if an error is retryable (transient CLI process failure)
+ */
+function isRetryableError(error: unknown): boolean {
+ const message = error instanceof Error ? error.message : String(error);
+ return (
+ message.includes('Claude Code process exited') ||
+ message.includes('Claude Code process terminated by signal')
+ );
+}
+
+const featureLoader = new FeatureLoader();
+
+/**
+ * Format features for the AI prompt
+ */
+function formatFeaturesForPrompt(features: Feature[]): string {
+ if (features.length === 0) {
+ return 'No features in backlog yet.';
+ }
+
+ return features
+ .map((f) => {
+ const deps = f.dependencies?.length ? `Dependencies: [${f.dependencies.join(', ')}]` : '';
+ const priority = f.priority !== undefined ? `Priority: ${f.priority}` : '';
+ return `- ID: ${f.id}
+ Title: ${f.title || 'Untitled'}
+ Description: ${f.description}
+ Category: ${f.category}
+ Status: ${f.status || 'backlog'}
+ ${priority}
+ ${deps}`.trim();
+ })
+ .join('\n\n');
+}
+
+/**
+ * Parse the AI response into a BacklogPlanResult
+ */
+function parsePlanResponse(response: string): BacklogPlanResult {
+ // Use shared JSON extraction utility for robust parsing
+ // extractJsonWithArray validates that 'changes' exists AND is an array
+ const parsed = extractJsonWithArray(response, 'changes', {
+ logger,
+ });
+
+ if (parsed) {
+ return parsed;
+ }
+
+ // If parsing fails, log details and return an empty result
+ logger.warn('[BacklogPlan] Failed to parse AI response as JSON');
+ logger.warn('[BacklogPlan] Response text length:', response.length);
+ logger.warn('[BacklogPlan] Response preview:', response.slice(0, 500));
+ if (response.length === 0) {
+ logger.error('[BacklogPlan] Response text is EMPTY! No content was extracted from stream.');
+ }
+ return {
+ changes: [],
+ summary: 'Failed to parse AI response',
+ dependencyUpdates: [],
+ };
+}
+
+/**
+ * Try to parse a valid plan response without fallback behavior.
+ * Returns null if parsing fails.
+ */
+function tryParsePlanResponse(response: string): BacklogPlanResult | null {
+ if (!response || response.trim().length === 0) {
+ return null;
+ }
+ return extractJsonWithArray(response, 'changes', { logger });
+}
+
+/**
+ * Choose the most reliable response text between streamed assistant chunks
+ * and provider final result payload.
+ */
+function selectBestResponseText(accumulatedText: string, providerResultText: string): string {
+ const hasAccumulated = accumulatedText.trim().length > 0;
+ const hasProviderResult = providerResultText.trim().length > 0;
+
+ if (!hasProviderResult) {
+ return accumulatedText;
+ }
+ if (!hasAccumulated) {
+ return providerResultText;
+ }
+
+ const accumulatedParsed = tryParsePlanResponse(accumulatedText);
+ const providerParsed = tryParsePlanResponse(providerResultText);
+
+ if (providerParsed && !accumulatedParsed) {
+ logger.info('[BacklogPlan] Using provider result (parseable JSON)');
+ return providerResultText;
+ }
+ if (accumulatedParsed && !providerParsed) {
+ logger.info('[BacklogPlan] Keeping accumulated text (parseable JSON)');
+ return accumulatedText;
+ }
+
+ if (providerResultText.length > accumulatedText.length) {
+ logger.info('[BacklogPlan] Using provider result (longer content)');
+ return providerResultText;
+ }
+
+ logger.info('[BacklogPlan] Keeping accumulated text (longer content)');
+ return accumulatedText;
+}
+
+/**
+ * Generate a backlog modification plan based on user prompt
+ */
+export async function generateBacklogPlan(
+ projectPath: string,
+ prompt: string,
+ events: EventEmitter,
+ abortController: AbortController,
+ settingsService?: SettingsService,
+ model?: string,
+ branchName?: string
+): Promise {
+ try {
+ // Load current features
+ const allFeatures = await featureLoader.getAll(projectPath);
+
+ // Filter features by branch if specified (worktree-scoped backlog)
+ let features: Feature[];
+ if (branchName) {
+ // Determine the primary branch so unassigned features show for the main worktree
+ let primaryBranch: string | null = null;
+ try {
+ primaryBranch = await getCurrentBranch(projectPath);
+ } catch {
+ // If git fails, fall back to 'main' so unassigned features are visible
+ // when branchName matches a common default branch name
+ primaryBranch = 'main';
+ }
+ const isMainBranch = branchName === primaryBranch;
+
+ features = allFeatures.filter((f) => {
+ if (!f.branchName) {
+ // Unassigned features belong to the main/primary worktree
+ return isMainBranch;
+ }
+ return f.branchName === branchName;
+ });
+ logger.info(
+ `[BacklogPlan] Filtered to ${features.length}/${allFeatures.length} features for branch: ${branchName}`
+ );
+ } else {
+ features = allFeatures;
+ }
+
+ events.emit('backlog-plan:event', {
+ type: 'backlog_plan_progress',
+ content: `Loaded ${features.length} features from backlog`,
+ });
+
+ // Load prompts from settings
+ const prompts = await getPromptCustomization(settingsService, '[BacklogPlan]');
+
+ // Build the system prompt
+ const systemPrompt = prompts.backlogPlan.systemPrompt;
+
+ // Build the user prompt from template
+ const currentFeatures = formatFeaturesForPrompt(features);
+ const userPrompt = prompts.backlogPlan.userPromptTemplate
+ .replace('{{currentFeatures}}', currentFeatures)
+ .replace('{{userRequest}}', prompt);
+
+ events.emit('backlog-plan:event', {
+ type: 'backlog_plan_progress',
+ content: 'Generating plan with AI...',
+ });
+
+ // Get the model to use from settings or provided override with provider info
+ let effectiveModel = model;
+ let thinkingLevel: ThinkingLevel | undefined;
+ let claudeCompatibleProvider: import('@automaker/types').ClaudeCompatibleProvider | undefined;
+ let credentials: import('@automaker/types').Credentials | undefined;
+
+ if (effectiveModel) {
+ // Use explicit override - resolve model alias and get credentials
+ const resolved = resolvePhaseModel({ model: effectiveModel });
+ effectiveModel = resolved.model;
+ thinkingLevel = resolved.thinkingLevel;
+ credentials = await settingsService?.getCredentials();
+ // Resolve Claude-compatible provider when client sends a model (e.g. MiniMax, GLM)
+ if (settingsService) {
+ const providerResult = await getProviderByModelId(
+ effectiveModel,
+ settingsService,
+ '[BacklogPlan]'
+ );
+ if (providerResult.provider) {
+ claudeCompatibleProvider = providerResult.provider;
+ if (providerResult.credentials) {
+ credentials = providerResult.credentials;
+ }
+ }
+ // Fallback: use phase settings provider if model lookup found nothing (e.g. model
+ // string format differs from provider's model id, but backlog planning phase has providerId).
+ if (!claudeCompatibleProvider) {
+ const phaseResult = await getPhaseModelWithOverrides(
+ 'backlogPlanningModel',
+ settingsService,
+ projectPath,
+ '[BacklogPlan]'
+ );
+ const phaseResolved = resolvePhaseModel(phaseResult.phaseModel);
+ if (phaseResult.provider && phaseResolved.model === effectiveModel) {
+ claudeCompatibleProvider = phaseResult.provider;
+ credentials = phaseResult.credentials ?? credentials;
+ }
+ }
+ }
+ } else if (settingsService) {
+ // Use settings-based model with provider info
+ const phaseResult = await getPhaseModelWithOverrides(
+ 'backlogPlanningModel',
+ settingsService,
+ projectPath,
+ '[BacklogPlan]'
+ );
+ const resolved = resolvePhaseModel(phaseResult.phaseModel);
+ effectiveModel = resolved.model;
+ thinkingLevel = resolved.thinkingLevel;
+ claudeCompatibleProvider = phaseResult.provider;
+ credentials = phaseResult.credentials;
+ } else {
+ // Fallback to defaults
+ const resolved = resolvePhaseModel(DEFAULT_PHASE_MODELS.backlogPlanningModel);
+ effectiveModel = resolved.model;
+ thinkingLevel = resolved.thinkingLevel;
+ }
+ logger.info(
+ '[BacklogPlan] Using model:',
+ effectiveModel,
+ claudeCompatibleProvider ? `via provider: ${claudeCompatibleProvider.name}` : 'direct API'
+ );
+
+ const provider = ProviderFactory.getProviderForModel(effectiveModel);
+ // Strip provider prefix - providers expect bare model IDs
+ const bareModel = stripProviderPrefix(effectiveModel);
+
+ // Get autoLoadClaudeMd and useClaudeCodeSystemPrompt settings
+ const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
+ projectPath,
+ settingsService,
+ '[BacklogPlan]'
+ );
+ const useClaudeCodeSystemPrompt = await getUseClaudeCodeSystemPromptSetting(
+ projectPath,
+ settingsService,
+ '[BacklogPlan]'
+ );
+
+ // For Cursor models, we need to combine prompts with explicit instructions
+ // because Cursor doesn't support systemPrompt separation like Claude SDK
+ let finalPrompt = userPrompt;
+ let finalSystemPrompt: string | SystemPromptPreset | undefined = systemPrompt;
+ let finalSettingSources: Array<'user' | 'project' | 'local'> | undefined;
+
+ if (isCursorModel(effectiveModel)) {
+ logger.info('[BacklogPlan] Using Cursor model - adding explicit no-file-write instructions');
+ finalPrompt = `${systemPrompt}
+
+CRITICAL INSTRUCTIONS:
+1. DO NOT write any files. Return the JSON in your response only.
+2. DO NOT use Write, Edit, or any file modification tools.
+3. Respond with ONLY a JSON object - no explanations, no markdown, just raw JSON.
+4. Your entire response should be valid JSON starting with { and ending with }.
+5. No text before or after the JSON object.
+
+${userPrompt}`;
+ finalSystemPrompt = undefined; // System prompt is now embedded in the user prompt
+ } else if (claudeCompatibleProvider) {
+ // Claude-compatible providers (MiniMax, GLM, etc.) use a plain API; do not use
+ // the claude_code preset (which is for Claude CLI/subprocess and can break the request).
+ finalSystemPrompt = systemPrompt;
+ } else if (useClaudeCodeSystemPrompt) {
+ // Use claude_code preset for native Claude so the SDK subprocess
+ // authenticates via CLI OAuth or API key the same way all other SDK calls do.
+ finalSystemPrompt = {
+ type: 'preset',
+ preset: 'claude_code',
+ append: systemPrompt,
+ };
+ }
+ // Include settingSources when autoLoadClaudeMd is enabled
+ if (autoLoadClaudeMd) {
+ finalSettingSources = ['user', 'project'];
+ }
+
+ // Execute the query with retry logic for transient CLI failures
+ const queryOptions = {
+ prompt: finalPrompt,
+ model: bareModel,
+ cwd: projectPath,
+ systemPrompt: finalSystemPrompt,
+ maxTurns: 1,
+ tools: [] as string[], // Disable all built-in tools - plan generation only needs text output
+ abortController,
+ settingSources: finalSettingSources,
+ thinkingLevel, // Pass thinking level for extended thinking
+ claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
+ credentials, // Pass credentials for resolving 'credentials' apiKeySource
+ };
+
+ let responseText = '';
+ let bestResponseText = ''; // Preserve best response across all retry attempts
+ let recoveredResult: BacklogPlanResult | null = null;
+ let lastError: unknown = null;
+
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
+ if (abortController.signal.aborted) {
+ throw new Error('Generation aborted');
+ }
+
+ if (attempt > 0) {
+ logger.info(
+ `[BacklogPlan] Retry attempt ${attempt}/${MAX_RETRIES} after transient failure`
+ );
+ events.emit('backlog-plan:event', {
+ type: 'backlog_plan_progress',
+ content: `Retrying... (attempt ${attempt + 1}/${MAX_RETRIES + 1})`,
+ });
+ await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
+ }
+
+ let accumulatedText = '';
+ let providerResultText = '';
+
+ try {
+ const stream = provider.executeQuery(queryOptions);
+
+ for await (const msg of stream) {
+ if (abortController.signal.aborted) {
+ throw new Error('Generation aborted');
+ }
+
+ if (msg.type === 'assistant') {
+ if (msg.message?.content) {
+ for (const block of msg.message.content) {
+ if (block.type === 'text') {
+ accumulatedText += block.text;
+ }
+ }
+ }
+ } else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
+ providerResultText = msg.result;
+ logger.info(
+ '[BacklogPlan] Received result from provider, length:',
+ providerResultText.length
+ );
+ logger.info('[BacklogPlan] Accumulated response length:', accumulatedText.length);
+ }
+ }
+
+ responseText = selectBestResponseText(accumulatedText, providerResultText);
+
+ // If we got here, the stream completed successfully
+ lastError = null;
+ break;
+ } catch (error) {
+ lastError = error;
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ responseText = selectBestResponseText(accumulatedText, providerResultText);
+
+ // Preserve the best response text across all attempts so that if a retry
+ // crashes immediately (empty response), we can still recover from an earlier attempt
+ bestResponseText = selectBestResponseText(bestResponseText, responseText);
+
+ // Claude SDK can occasionally exit non-zero after emitting a complete response.
+ // If we already have valid JSON, recover instead of failing the entire planning flow.
+ if (isRetryableError(error)) {
+ const parsed = tryParsePlanResponse(bestResponseText);
+ if (parsed) {
+ logger.warn(
+ '[BacklogPlan] Recovered from transient CLI exit using accumulated valid response'
+ );
+ recoveredResult = parsed;
+ lastError = null;
+ break;
+ }
+
+ // On final retryable failure, degrade gracefully if we have text from any attempt.
+ if (attempt >= MAX_RETRIES && bestResponseText.trim().length > 0) {
+ logger.warn(
+ '[BacklogPlan] Final retryable CLI failure with non-empty response, attempting fallback parse'
+ );
+ recoveredResult = parsePlanResponse(bestResponseText);
+ lastError = null;
+ break;
+ }
+ }
+
+ // Only retry on transient CLI failures, not on user aborts or other errors
+ if (!isRetryableError(error) || attempt >= MAX_RETRIES) {
+ throw error;
+ }
+
+ logger.warn(
+ `[BacklogPlan] Transient CLI failure (attempt ${attempt + 1}/${MAX_RETRIES + 1}): ${errorMessage}`
+ );
+ }
+ }
+
+ // If we exhausted retries, throw the last error
+ if (lastError) {
+ throw lastError;
+ }
+
+ // Parse the response
+ const result = recoveredResult ?? parsePlanResponse(responseText);
+
+ await saveBacklogPlan(projectPath, {
+ savedAt: new Date().toISOString(),
+ prompt,
+ model: effectiveModel,
+ result,
+ });
+
+ events.emit('backlog-plan:event', {
+ type: 'backlog_plan_complete',
+ result,
+ });
+
+ return result;
+ } catch (error) {
+ const errorMessage = getErrorMessage(error);
+ logger.error('[BacklogPlan] Generation failed:', errorMessage);
+
+ events.emit('backlog-plan:event', {
+ type: 'backlog_plan_error',
+ error: errorMessage,
+ });
+
+ throw error;
+ } finally {
+ setRunningState(false, null);
+ setRunningDetails(null);
+ }
+}
diff --git a/temp_repo/apps/server/src/routes/backlog-plan/index.ts b/temp_repo/apps/server/src/routes/backlog-plan/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d1b7e424d98d077eaca27d864886c0dd7714d248
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/backlog-plan/index.ts
@@ -0,0 +1,32 @@
+/**
+ * Backlog Plan routes - HTTP API for AI-assisted backlog modification
+ */
+
+import { Router } from 'express';
+import type { EventEmitter } from '../../lib/events.js';
+import { validatePathParams } from '../../middleware/validate-paths.js';
+import { createGenerateHandler } from './routes/generate.js';
+import { createStopHandler } from './routes/stop.js';
+import { createStatusHandler } from './routes/status.js';
+import { createApplyHandler } from './routes/apply.js';
+import { createClearHandler } from './routes/clear.js';
+import type { SettingsService } from '../../services/settings-service.js';
+
+export function createBacklogPlanRoutes(
+ events: EventEmitter,
+ settingsService?: SettingsService
+): Router {
+ const router = Router();
+
+ router.post(
+ '/generate',
+ validatePathParams('projectPath'),
+ createGenerateHandler(events, settingsService)
+ );
+ router.post('/stop', createStopHandler());
+ router.get('/status', validatePathParams('projectPath'), createStatusHandler());
+ router.post('/apply', validatePathParams('projectPath'), createApplyHandler(settingsService));
+ router.post('/clear', validatePathParams('projectPath'), createClearHandler());
+
+ return router;
+}
diff --git a/temp_repo/apps/server/src/routes/backlog-plan/routes/apply.ts b/temp_repo/apps/server/src/routes/backlog-plan/routes/apply.ts
new file mode 100644
index 0000000000000000000000000000000000000000..6efd1658016e407cebd7fbf2cdf8c0f032f88cea
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/backlog-plan/routes/apply.ts
@@ -0,0 +1,213 @@
+/**
+ * POST /apply endpoint - Apply a backlog plan
+ */
+
+import type { Request, Response } from 'express';
+import { resolvePhaseModel } from '@automaker/model-resolver';
+import type { BacklogPlanResult, PhaseModelEntry, PlanningMode } from '@automaker/types';
+import { FeatureLoader } from '../../../services/feature-loader.js';
+import type { SettingsService } from '../../../services/settings-service.js';
+import { clearBacklogPlan, getErrorMessage, logError, logger } from '../common.js';
+
+const featureLoader = new FeatureLoader();
+
+function normalizePhaseModelEntry(
+ entry: PhaseModelEntry | string | undefined | null
+): PhaseModelEntry | undefined {
+ if (!entry) return undefined;
+ if (typeof entry === 'string') return { model: entry };
+ return entry;
+}
+
+export function createApplyHandler(settingsService?: SettingsService) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const {
+ projectPath,
+ plan,
+ branchName: rawBranchName,
+ } = req.body as {
+ projectPath: string;
+ plan: BacklogPlanResult;
+ branchName?: string;
+ };
+
+ // Validate branchName: must be undefined or a non-empty trimmed string
+ const branchName =
+ typeof rawBranchName === 'string' && rawBranchName.trim().length > 0
+ ? rawBranchName.trim()
+ : undefined;
+
+ if (!projectPath) {
+ res.status(400).json({ success: false, error: 'projectPath required' });
+ return;
+ }
+
+ if (!plan || !plan.changes) {
+ res.status(400).json({ success: false, error: 'plan with changes required' });
+ return;
+ }
+
+ let defaultPlanningMode: PlanningMode = 'skip';
+ let defaultRequirePlanApproval = false;
+ let defaultModelEntry: PhaseModelEntry | undefined;
+
+ if (settingsService) {
+ const globalSettings = await settingsService.getGlobalSettings();
+ const projectSettings = await settingsService.getProjectSettings(projectPath);
+
+ defaultPlanningMode = globalSettings.defaultPlanningMode ?? 'skip';
+ defaultRequirePlanApproval = globalSettings.defaultRequirePlanApproval ?? false;
+ defaultModelEntry = normalizePhaseModelEntry(
+ projectSettings.defaultFeatureModel ?? globalSettings.defaultFeatureModel
+ );
+ }
+
+ const resolvedDefaultModel = resolvePhaseModel(defaultModelEntry);
+
+ const appliedChanges: string[] = [];
+
+ // Load current features for dependency validation
+ const allFeatures = await featureLoader.getAll(projectPath);
+ const featureMap = new Map(allFeatures.map((f) => [f.id, f]));
+
+ // Process changes in order: deletes first, then adds, then updates
+ // This ensures we can remove dependencies before they cause issues
+
+ // 1. First pass: Handle deletes
+ const deletions = plan.changes.filter((c) => c.type === 'delete');
+ for (const change of deletions) {
+ if (!change.featureId) continue;
+
+ try {
+ // Before deleting, update any features that depend on this one
+ for (const feature of allFeatures) {
+ if (feature.dependencies?.includes(change.featureId)) {
+ const newDeps = feature.dependencies.filter((d) => d !== change.featureId);
+ await featureLoader.update(projectPath, feature.id, { dependencies: newDeps });
+ // Mutate the in-memory feature object so subsequent deletions use the updated
+ // dependency list and don't reintroduce already-removed dependency IDs.
+ feature.dependencies = newDeps;
+ logger.info(
+ `[BacklogPlan] Removed dependency ${change.featureId} from ${feature.id}`
+ );
+ }
+ }
+
+ // Now delete the feature
+ const deleted = await featureLoader.delete(projectPath, change.featureId);
+ if (deleted) {
+ appliedChanges.push(`deleted:${change.featureId}`);
+ featureMap.delete(change.featureId);
+ logger.info(`[BacklogPlan] Deleted feature ${change.featureId}`);
+ }
+ } catch (error) {
+ logger.error(
+ `[BacklogPlan] Failed to delete ${change.featureId}:`,
+ getErrorMessage(error)
+ );
+ }
+ }
+
+ // 2. Second pass: Handle adds
+ const additions = plan.changes.filter((c) => c.type === 'add');
+ for (const change of additions) {
+ if (!change.feature) continue;
+
+ try {
+ const effectivePlanningMode = change.feature.planningMode ?? defaultPlanningMode;
+ const effectiveRequirePlanApproval =
+ effectivePlanningMode === 'skip' || effectivePlanningMode === 'lite'
+ ? false
+ : (change.feature.requirePlanApproval ?? defaultRequirePlanApproval);
+
+ // Create the new feature - use the AI-generated ID if provided
+ const newFeature = await featureLoader.create(projectPath, {
+ id: change.feature.id, // Use descriptive ID from AI if provided
+ title: change.feature.title,
+ description: change.feature.description || '',
+ category: change.feature.category || 'Uncategorized',
+ dependencies: change.feature.dependencies,
+ priority: change.feature.priority,
+ status: 'backlog',
+ model: change.feature.model ?? resolvedDefaultModel.model,
+ thinkingLevel: change.feature.thinkingLevel ?? resolvedDefaultModel.thinkingLevel,
+ reasoningEffort: change.feature.reasoningEffort ?? resolvedDefaultModel.reasoningEffort,
+ providerId: change.feature.providerId ?? resolvedDefaultModel.providerId,
+ planningMode: effectivePlanningMode,
+ requirePlanApproval: effectiveRequirePlanApproval,
+ branchName,
+ });
+
+ appliedChanges.push(`added:${newFeature.id}`);
+ featureMap.set(newFeature.id, newFeature);
+ logger.info(`[BacklogPlan] Created feature ${newFeature.id}: ${newFeature.title}`);
+ } catch (error) {
+ logger.error(`[BacklogPlan] Failed to add feature:`, getErrorMessage(error));
+ }
+ }
+
+ // 3. Third pass: Handle updates
+ const updates = plan.changes.filter((c) => c.type === 'update');
+ for (const change of updates) {
+ if (!change.featureId || !change.feature) continue;
+
+ try {
+ const updated = await featureLoader.update(projectPath, change.featureId, change.feature);
+ appliedChanges.push(`updated:${change.featureId}`);
+ featureMap.set(change.featureId, updated);
+ logger.info(`[BacklogPlan] Updated feature ${change.featureId}`);
+ } catch (error) {
+ logger.error(
+ `[BacklogPlan] Failed to update ${change.featureId}:`,
+ getErrorMessage(error)
+ );
+ }
+ }
+
+ // 4. Apply dependency updates from the plan
+ if (plan.dependencyUpdates) {
+ for (const depUpdate of plan.dependencyUpdates) {
+ try {
+ const feature = featureMap.get(depUpdate.featureId);
+ if (feature) {
+ const currentDeps = feature.dependencies || [];
+ const newDeps = currentDeps
+ .filter((d) => !depUpdate.removedDependencies.includes(d))
+ .concat(depUpdate.addedDependencies.filter((d) => !currentDeps.includes(d)));
+
+ await featureLoader.update(projectPath, depUpdate.featureId, {
+ dependencies: newDeps,
+ });
+ logger.info(`[BacklogPlan] Updated dependencies for ${depUpdate.featureId}`);
+ }
+ } catch (error) {
+ logger.error(
+ `[BacklogPlan] Failed to update dependencies for ${depUpdate.featureId}:`,
+ getErrorMessage(error)
+ );
+ }
+ }
+ }
+
+ // Clear the plan before responding
+ try {
+ await clearBacklogPlan(projectPath);
+ } catch (error) {
+ logger.warn(
+ `[BacklogPlan] Failed to clear backlog plan after apply:`,
+ getErrorMessage(error)
+ );
+ // Don't throw - operation succeeded, just cleanup failed
+ }
+
+ res.json({
+ success: true,
+ appliedChanges,
+ });
+ } catch (error) {
+ logError(error, 'Apply backlog plan failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/backlog-plan/routes/clear.ts b/temp_repo/apps/server/src/routes/backlog-plan/routes/clear.ts
new file mode 100644
index 0000000000000000000000000000000000000000..855dc5075804491a95c3535ed353f80d99912da3
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/backlog-plan/routes/clear.ts
@@ -0,0 +1,25 @@
+/**
+ * POST /clear endpoint - Clear saved backlog plan
+ */
+
+import type { Request, Response } from 'express';
+import { clearBacklogPlan, getErrorMessage, logError } from '../common.js';
+
+export function createClearHandler() {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { projectPath } = req.body as { projectPath: string };
+
+ if (!projectPath) {
+ res.status(400).json({ success: false, error: 'projectPath required' });
+ return;
+ }
+
+ await clearBacklogPlan(projectPath);
+ res.json({ success: true });
+ } catch (error) {
+ logError(error, 'Clear backlog plan failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/backlog-plan/routes/generate.ts b/temp_repo/apps/server/src/routes/backlog-plan/routes/generate.ts
new file mode 100644
index 0000000000000000000000000000000000000000..befe96e8b727032833194b3abec1a9aa7b7e258d
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/backlog-plan/routes/generate.ts
@@ -0,0 +1,77 @@
+/**
+ * POST /generate endpoint - Generate a backlog plan
+ */
+
+import type { Request, Response } from 'express';
+import type { EventEmitter } from '../../../lib/events.js';
+import {
+ getBacklogPlanStatus,
+ setRunningState,
+ setRunningDetails,
+ getErrorMessage,
+ logError,
+} from '../common.js';
+import { generateBacklogPlan } from '../generate-plan.js';
+import type { SettingsService } from '../../../services/settings-service.js';
+
+export function createGenerateHandler(events: EventEmitter, settingsService?: SettingsService) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { projectPath, prompt, model, branchName } = req.body as {
+ projectPath: string;
+ prompt: string;
+ model?: string;
+ branchName?: string;
+ };
+
+ if (!projectPath) {
+ res.status(400).json({ success: false, error: 'projectPath required' });
+ return;
+ }
+
+ if (!prompt) {
+ res.status(400).json({ success: false, error: 'prompt required' });
+ return;
+ }
+
+ const { isRunning } = getBacklogPlanStatus();
+ if (isRunning) {
+ res.json({
+ success: false,
+ error: 'Backlog plan generation is already running',
+ });
+ return;
+ }
+
+ const abortController = new AbortController();
+ setRunningState(true, abortController);
+ setRunningDetails({
+ projectPath,
+ prompt,
+ model,
+ startedAt: new Date().toISOString(),
+ });
+
+ // Start generation in background
+ // Note: generateBacklogPlan handles its own error event emission
+ // and state cleanup in its finally block, so we only log here
+ generateBacklogPlan(
+ projectPath,
+ prompt,
+ events,
+ abortController,
+ settingsService,
+ model,
+ branchName
+ ).catch((error) => {
+ // Just log - error event already emitted by generateBacklogPlan
+ logError(error, 'Generate backlog plan failed (background)');
+ });
+
+ res.json({ success: true });
+ } catch (error) {
+ logError(error, 'Generate backlog plan failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/backlog-plan/routes/status.ts b/temp_repo/apps/server/src/routes/backlog-plan/routes/status.ts
new file mode 100644
index 0000000000000000000000000000000000000000..5f20f1e2e7b8ef9786339166923ce7b896883bdc
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/backlog-plan/routes/status.ts
@@ -0,0 +1,20 @@
+/**
+ * GET /status endpoint - Get backlog plan generation status
+ */
+
+import type { Request, Response } from 'express';
+import { getBacklogPlanStatus, loadBacklogPlan, getErrorMessage, logError } from '../common.js';
+
+export function createStatusHandler() {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const status = getBacklogPlanStatus();
+ const projectPath = typeof req.query.projectPath === 'string' ? req.query.projectPath : '';
+ const savedPlan = projectPath ? await loadBacklogPlan(projectPath) : null;
+ res.json({ success: true, ...status, savedPlan });
+ } catch (error) {
+ logError(error, 'Get backlog plan status failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/backlog-plan/routes/stop.ts b/temp_repo/apps/server/src/routes/backlog-plan/routes/stop.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d969f1b1ee6f86173892709b590b55ef0edab852
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/backlog-plan/routes/stop.ts
@@ -0,0 +1,29 @@
+/**
+ * POST /stop endpoint - Stop the current backlog plan generation
+ */
+
+import type { Request, Response } from 'express';
+import {
+ getAbortController,
+ setRunningState,
+ setRunningDetails,
+ getErrorMessage,
+ logError,
+} from '../common.js';
+
+export function createStopHandler() {
+ return async (_req: Request, res: Response): Promise => {
+ try {
+ const abortController = getAbortController();
+ if (abortController) {
+ abortController.abort();
+ setRunningState(false, null);
+ setRunningDetails(null);
+ }
+ res.json({ success: true });
+ } catch (error) {
+ logError(error, 'Stop backlog plan failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/claude/index.ts b/temp_repo/apps/server/src/routes/claude/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ec35ca1baffdf9868c6b10e1961c7d5c9ae006c0
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/claude/index.ts
@@ -0,0 +1,57 @@
+import { Router, Request, Response } from 'express';
+import { ClaudeUsageService } from '../../services/claude-usage-service.js';
+import { createLogger } from '@automaker/utils';
+
+const logger = createLogger('Claude');
+
+export function createClaudeRoutes(service: ClaudeUsageService): Router {
+ const router = Router();
+
+ // Get current usage (fetches from Claude CLI)
+ router.get('/usage', async (req: Request, res: Response) => {
+ try {
+ // Check if Claude CLI is available first
+ const isAvailable = await service.isAvailable();
+ if (!isAvailable) {
+ // IMPORTANT: This endpoint is behind Automaker session auth already.
+ // Use a 200 + error payload for Claude CLI issues so the UI doesn't
+ // interpret it as an invalid Automaker session (401/403 triggers logout).
+ res.status(200).json({
+ error: 'Claude CLI not found',
+ message: "Please install Claude Code CLI and run 'claude login' to authenticate",
+ });
+ return;
+ }
+
+ const usage = await service.fetchUsageData();
+ res.json(usage);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Unknown error';
+
+ if (message.includes('Authentication required') || message.includes('token_expired')) {
+ // Do NOT use 401/403 here: that status code is reserved for Automaker session auth.
+ res.status(200).json({
+ error: 'Authentication required',
+ message: "Please run 'claude login' to authenticate",
+ });
+ } else if (message.includes('TRUST_PROMPT_PENDING')) {
+ // Trust prompt appeared but couldn't be auto-approved
+ res.status(200).json({
+ error: 'Trust prompt pending',
+ message:
+ 'Claude CLI needs folder permission. Please run "claude" in your terminal and approve access.',
+ });
+ } else if (message.includes('timed out')) {
+ res.status(200).json({
+ error: 'Command timed out',
+ message: 'The Claude CLI took too long to respond',
+ });
+ } else {
+ logger.error('Error fetching usage:', error);
+ res.status(500).json({ error: message });
+ }
+ }
+ });
+
+ return router;
+}
diff --git a/temp_repo/apps/server/src/routes/claude/types.ts b/temp_repo/apps/server/src/routes/claude/types.ts
new file mode 100644
index 0000000000000000000000000000000000000000..bd8927462d2cfcc852eb06bb37040cb62e883aeb
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/claude/types.ts
@@ -0,0 +1,35 @@
+/**
+ * Claude Usage types for CLI-based usage tracking
+ */
+
+export type ClaudeUsage = {
+ sessionTokensUsed: number;
+ sessionLimit: number;
+ sessionPercentage: number;
+ sessionResetTime: string; // ISO date string
+ sessionResetText: string; // Raw text like "Resets 10:59am (Asia/Dubai)"
+
+ weeklyTokensUsed: number;
+ weeklyLimit: number;
+ weeklyPercentage: number;
+ weeklyResetTime: string; // ISO date string
+ weeklyResetText: string; // Raw text like "Resets Dec 22 at 7:59pm (Asia/Dubai)"
+
+ sonnetWeeklyTokensUsed: number;
+ sonnetWeeklyPercentage: number;
+ sonnetResetText: string; // Raw text like "Resets Dec 27 at 9:59am (Asia/Dubai)"
+
+ costUsed: number | null;
+ costLimit: number | null;
+ costCurrency: string | null;
+
+ lastUpdated: string; // ISO date string
+ userTimezone: string;
+};
+
+export type ClaudeStatus = {
+ indicator: {
+ color: 'green' | 'yellow' | 'orange' | 'red' | 'gray';
+ };
+ description: string;
+};
diff --git a/temp_repo/apps/server/src/routes/codex/index.ts b/temp_repo/apps/server/src/routes/codex/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..005a81bc4109ae86f12f9dc0ef6c08d6e8a34298
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/codex/index.ts
@@ -0,0 +1,90 @@
+import { Router, Request, Response } from 'express';
+import { CodexUsageService } from '../../services/codex-usage-service.js';
+import { CodexModelCacheService } from '../../services/codex-model-cache-service.js';
+import { createLogger } from '@automaker/utils';
+
+const logger = createLogger('Codex');
+
+export function createCodexRoutes(
+ usageService: CodexUsageService,
+ modelCacheService: CodexModelCacheService
+): Router {
+ const router = Router();
+
+ // Get current usage (attempts to fetch from Codex CLI)
+ router.get('/usage', async (_req: Request, res: Response) => {
+ try {
+ // Check if Codex CLI is available first
+ const isAvailable = await usageService.isAvailable();
+ if (!isAvailable) {
+ // IMPORTANT: This endpoint is behind Automaker session auth already.
+ // Use a 200 + error payload for Codex CLI issues so the UI doesn't
+ // interpret it as an invalid Automaker session (401/403 triggers logout).
+ res.status(200).json({
+ error: 'Codex CLI not found',
+ message: "Please install Codex CLI and run 'codex login' to authenticate",
+ });
+ return;
+ }
+
+ const usage = await usageService.fetchUsageData();
+ res.json(usage);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Unknown error';
+
+ if (message.includes('not authenticated') || message.includes('login')) {
+ // Do NOT use 401/403 here: that status code is reserved for Automaker session auth.
+ res.status(200).json({
+ error: 'Authentication required',
+ message: "Please run 'codex login' to authenticate",
+ });
+ } else if (message.includes('not available') || message.includes('does not provide')) {
+ // This is the expected case - Codex doesn't provide usage stats
+ res.status(200).json({
+ error: 'Usage statistics not available',
+ message: message,
+ });
+ } else if (message.includes('timed out')) {
+ res.status(200).json({
+ error: 'Command timed out',
+ message: 'The Codex CLI took too long to respond',
+ });
+ } else {
+ logger.error('Error fetching usage:', error);
+ res.status(500).json({ error: message });
+ }
+ }
+ });
+
+ // Get available Codex models (cached)
+ router.get('/models', async (req: Request, res: Response) => {
+ try {
+ const forceRefresh = req.query.refresh === 'true';
+ const { models, cachedAt } = await modelCacheService.getModelsWithMetadata(forceRefresh);
+
+ if (models.length === 0) {
+ res.status(503).json({
+ success: false,
+ error: 'Codex CLI not available or not authenticated',
+ message: "Please install Codex CLI and run 'codex login' to authenticate",
+ });
+ return;
+ }
+
+ res.json({
+ success: true,
+ models,
+ cachedAt,
+ });
+ } catch (error) {
+ logger.error('Error fetching models:', error);
+ const message = error instanceof Error ? error.message : 'Unknown error';
+ res.status(500).json({
+ success: false,
+ error: message,
+ });
+ }
+ });
+
+ return router;
+}
diff --git a/temp_repo/apps/server/src/routes/common.ts b/temp_repo/apps/server/src/routes/common.ts
new file mode 100644
index 0000000000000000000000000000000000000000..14589ffd81ffce765cbda775c5adcca09f8c4ee4
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/common.ts
@@ -0,0 +1,38 @@
+/**
+ * Common utilities shared across all route modules
+ */
+
+import { createLogger } from '@automaker/utils';
+
+// Re-export git utilities from shared package
+export {
+ BINARY_EXTENSIONS,
+ GIT_STATUS_MAP,
+ type FileStatus,
+ isGitRepo,
+ parseGitStatus,
+ generateSyntheticDiffForNewFile,
+ appendUntrackedFileDiffs,
+ listAllFilesInDirectory,
+ generateDiffsForNonGitDirectory,
+ getGitRepositoryDiffs,
+} from '@automaker/git-utils';
+
+type Logger = ReturnType;
+
+/**
+ * Get error message from error object
+ */
+export function getErrorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : 'Unknown error';
+}
+
+/**
+ * Create a logError function for a specific logger
+ * This ensures consistent error logging format across all routes
+ */
+export function createLogError(logger: Logger) {
+ return (error: unknown, context: string): void => {
+ logger.error(`❌ ${context}:`, error);
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/context/index.ts b/temp_repo/apps/server/src/routes/context/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..3f49f1c178f511ceaea3bf16860956b473facf5b
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/context/index.ts
@@ -0,0 +1,26 @@
+/**
+ * Context routes - HTTP API for context file operations
+ *
+ * Provides endpoints for managing context files including
+ * AI-powered image description generation.
+ */
+
+import { Router } from 'express';
+import { createDescribeImageHandler } from './routes/describe-image.js';
+import { createDescribeFileHandler } from './routes/describe-file.js';
+import type { SettingsService } from '../../services/settings-service.js';
+
+/**
+ * Create the context router
+ *
+ * @param settingsService - Optional settings service for loading autoLoadClaudeMd setting
+ * @returns Express router with context endpoints
+ */
+export function createContextRoutes(settingsService?: SettingsService): Router {
+ const router = Router();
+
+ router.post('/describe-image', createDescribeImageHandler(settingsService));
+ router.post('/describe-file', createDescribeFileHandler(settingsService));
+
+ return router;
+}
diff --git a/temp_repo/apps/server/src/routes/context/routes/describe-file.ts b/temp_repo/apps/server/src/routes/context/routes/describe-file.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a59dfb7437c9d9fd5a41185b30c6dfa87d73c62b
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/context/routes/describe-file.ts
@@ -0,0 +1,220 @@
+/**
+ * POST /context/describe-file endpoint - Generate description for a text file
+ *
+ * Uses AI to analyze a text file and generate a concise description
+ * suitable for context file metadata. Model is configurable via
+ * phaseModels.fileDescriptionModel in settings (defaults to Haiku).
+ *
+ * SECURITY: This endpoint validates file paths against ALLOWED_ROOT_DIRECTORY
+ * and reads file content directly (not via Claude's Read tool) to prevent
+ * arbitrary file reads and prompt injection attacks.
+ */
+
+import type { Request, Response } from 'express';
+import { createLogger } from '@automaker/utils';
+import { PathNotAllowedError } from '@automaker/platform';
+import { resolvePhaseModel } from '@automaker/model-resolver';
+import { simpleQuery } from '../../../providers/simple-query-service.js';
+import * as secureFs from '../../../lib/secure-fs.js';
+import * as path from 'path';
+import type { SettingsService } from '../../../services/settings-service.js';
+import {
+ getAutoLoadClaudeMdSetting,
+ getPromptCustomization,
+ getPhaseModelWithOverrides,
+} from '../../../lib/settings-helpers.js';
+
+const logger = createLogger('DescribeFile');
+
+/**
+ * Request body for the describe-file endpoint
+ */
+interface DescribeFileRequestBody {
+ /** Path to the file */
+ filePath: string;
+}
+
+/**
+ * Success response from the describe-file endpoint
+ */
+interface DescribeFileSuccessResponse {
+ success: true;
+ description: string;
+}
+
+/**
+ * Error response from the describe-file endpoint
+ */
+interface DescribeFileErrorResponse {
+ success: false;
+ error: string;
+}
+
+/**
+ * Create the describe-file request handler
+ *
+ * @param settingsService - Optional settings service for loading autoLoadClaudeMd setting
+ * @returns Express request handler for file description
+ */
+export function createDescribeFileHandler(
+ settingsService?: SettingsService
+): (req: Request, res: Response) => Promise {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { filePath } = req.body as DescribeFileRequestBody;
+
+ // Validate required fields
+ if (!filePath || typeof filePath !== 'string') {
+ const response: DescribeFileErrorResponse = {
+ success: false,
+ error: 'filePath is required and must be a string',
+ };
+ res.status(400).json(response);
+ return;
+ }
+
+ logger.info(`Starting description generation for: ${filePath}`);
+
+ // Resolve the path for logging and cwd derivation
+ const resolvedPath = secureFs.resolvePath(filePath);
+
+ // Read file content using secureFs (validates path against ALLOWED_ROOT_DIRECTORY)
+ // This prevents arbitrary file reads (e.g., /etc/passwd, ~/.ssh/id_rsa)
+ // and prompt injection attacks where malicious filePath values could inject instructions
+ let fileContent: string;
+ try {
+ const content = await secureFs.readFile(resolvedPath, 'utf-8');
+ fileContent = typeof content === 'string' ? content : content.toString('utf-8');
+ } catch (readError) {
+ // Path not allowed - return 403 Forbidden
+ if (readError instanceof PathNotAllowedError) {
+ logger.warn(`Path not allowed: ${filePath}`);
+ const response: DescribeFileErrorResponse = {
+ success: false,
+ error: 'File path is not within the allowed directory',
+ };
+ res.status(403).json(response);
+ return;
+ }
+
+ // File not found
+ if (
+ readError !== null &&
+ typeof readError === 'object' &&
+ 'code' in readError &&
+ readError.code === 'ENOENT'
+ ) {
+ logger.warn(`File not found: ${resolvedPath}`);
+ const response: DescribeFileErrorResponse = {
+ success: false,
+ error: `File not found: ${filePath}`,
+ };
+ res.status(404).json(response);
+ return;
+ }
+
+ const errorMessage = readError instanceof Error ? readError.message : 'Unknown error';
+ logger.error(`Failed to read file: ${errorMessage}`);
+ const response: DescribeFileErrorResponse = {
+ success: false,
+ error: `Failed to read file: ${errorMessage}`,
+ };
+ res.status(500).json(response);
+ return;
+ }
+
+ // Truncate very large files to avoid token limits
+ const MAX_CONTENT_LENGTH = 50000;
+ const truncated = fileContent.length > MAX_CONTENT_LENGTH;
+ const contentToAnalyze = truncated
+ ? fileContent.substring(0, MAX_CONTENT_LENGTH)
+ : fileContent;
+
+ // Get the filename for context
+ const fileName = path.basename(resolvedPath);
+
+ // Get customized prompts from settings
+ const prompts = await getPromptCustomization(settingsService, '[DescribeFile]');
+
+ // Build prompt with file content passed as structured data
+ // The file content is included directly, not via tool invocation
+ const prompt = `${prompts.contextDescription.describeFilePrompt}
+
+File: ${fileName}${truncated ? ' (truncated)' : ''}
+
+--- FILE CONTENT ---
+${contentToAnalyze}`;
+
+ // Use the file's directory as the working directory
+ const cwd = path.dirname(resolvedPath);
+
+ // Load autoLoadClaudeMd setting
+ const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
+ cwd,
+ settingsService,
+ '[DescribeFile]'
+ );
+
+ // Get model from phase settings with provider info
+ const {
+ phaseModel: phaseModelEntry,
+ provider,
+ credentials,
+ } = await getPhaseModelWithOverrides(
+ 'fileDescriptionModel',
+ settingsService,
+ cwd,
+ '[DescribeFile]'
+ );
+ const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
+
+ logger.info(
+ `Resolved model: ${model}, thinkingLevel: ${thinkingLevel}`,
+ provider ? `via provider: ${provider.name}` : 'direct API'
+ );
+
+ // Use simpleQuery - provider abstraction handles routing to correct provider
+ const result = await simpleQuery({
+ prompt,
+ model,
+ cwd,
+ maxTurns: 1,
+ allowedTools: [],
+ thinkingLevel,
+ readOnly: true, // File description only reads, doesn't write
+ settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
+ claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
+ credentials, // Pass credentials for resolving 'credentials' apiKeySource
+ });
+
+ const description = result.text;
+
+ if (!description || description.trim().length === 0) {
+ logger.warn('Received empty response from Claude');
+ const response: DescribeFileErrorResponse = {
+ success: false,
+ error: 'Failed to generate description - empty response',
+ };
+ res.status(500).json(response);
+ return;
+ }
+
+ logger.info(`Description generated, length: ${description.length} chars`);
+
+ const response: DescribeFileSuccessResponse = {
+ success: true,
+ description: description.trim(),
+ };
+ res.json(response);
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
+ logger.error('File description failed:', errorMessage);
+
+ const response: DescribeFileErrorResponse = {
+ success: false,
+ error: errorMessage,
+ };
+ res.status(500).json(response);
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/context/routes/describe-image.ts b/temp_repo/apps/server/src/routes/context/routes/describe-image.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1b645d5d0e2aeb3161f8321c43a1d76f83e05020
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/context/routes/describe-image.ts
@@ -0,0 +1,423 @@
+/**
+ * POST /context/describe-image endpoint - Generate description for an image
+ *
+ * Uses AI to analyze an image and generate a concise description
+ * suitable for context file metadata. Model is configurable via
+ * phaseModels.imageDescriptionModel in settings (defaults to Haiku).
+ *
+ * IMPORTANT:
+ * The agent runner (chat/auto-mode) sends images as multi-part content blocks (base64 image blocks),
+ * not by asking Claude to use the Read tool to open files. This endpoint now mirrors that approach
+ * so it doesn't depend on Claude's filesystem tool access or working directory restrictions.
+ */
+
+import type { Request, Response } from 'express';
+import { createLogger, readImageAsBase64 } from '@automaker/utils';
+import { isCursorModel } from '@automaker/types';
+import { resolvePhaseModel } from '@automaker/model-resolver';
+import { simpleQuery } from '../../../providers/simple-query-service.js';
+import * as secureFs from '../../../lib/secure-fs.js';
+import * as path from 'path';
+import type { SettingsService } from '../../../services/settings-service.js';
+import {
+ getAutoLoadClaudeMdSetting,
+ getPromptCustomization,
+ getPhaseModelWithOverrides,
+} from '../../../lib/settings-helpers.js';
+
+const logger = createLogger('DescribeImage');
+
+/**
+ * Allowlist of safe headers to log
+ * All other headers are excluded to prevent leaking sensitive values
+ */
+const SAFE_HEADERS_ALLOWLIST = new Set([
+ 'content-type',
+ 'accept',
+ 'user-agent',
+ 'host',
+ 'referer',
+ 'content-length',
+ 'origin',
+ 'x-request-id',
+]);
+
+/**
+ * Filter request headers to only include safe, non-sensitive values
+ */
+function filterSafeHeaders(headers: Record): Record {
+ const filtered: Record = {};
+ for (const [key, value] of Object.entries(headers)) {
+ if (SAFE_HEADERS_ALLOWLIST.has(key.toLowerCase())) {
+ filtered[key] = value;
+ }
+ }
+ return filtered;
+}
+
+/**
+ * Find the actual file path, handling Unicode character variations.
+ * macOS screenshots use U+202F (NARROW NO-BREAK SPACE) before AM/PM,
+ * but this may be transmitted as a regular space through the API.
+ */
+function findActualFilePath(requestedPath: string): string | null {
+ // First, try the exact path
+ if (secureFs.existsSync(requestedPath)) {
+ return requestedPath;
+ }
+
+ // Try with Unicode normalization
+ const normalizedPath = requestedPath.normalize('NFC');
+ if (secureFs.existsSync(normalizedPath)) {
+ return normalizedPath;
+ }
+
+ // If not found, try to find the file in the directory by matching the basename
+ // This handles cases where the space character differs (U+0020 vs U+202F vs U+00A0)
+ const dir = path.dirname(requestedPath);
+ const baseName = path.basename(requestedPath);
+
+ if (!secureFs.existsSync(dir)) {
+ return null;
+ }
+
+ try {
+ const files = secureFs.readdirSync(dir);
+
+ // Normalize the requested basename for comparison
+ // Replace various space-like characters with regular space for comparison
+ const normalizeSpaces = (s: string): string => s.replace(/[\u00A0\u202F\u2009\u200A]/g, ' ');
+
+ const normalizedBaseName = normalizeSpaces(baseName);
+
+ for (const file of files) {
+ if (normalizeSpaces(file) === normalizedBaseName) {
+ logger.info(`Found matching file with different space encoding: ${file}`);
+ return path.join(dir, file);
+ }
+ }
+ } catch (err) {
+ logger.error(`Error reading directory ${dir}: ${err}`);
+ }
+
+ return null;
+}
+
+/**
+ * Request body for the describe-image endpoint
+ */
+interface DescribeImageRequestBody {
+ /** Path to the image file */
+ imagePath: string;
+}
+
+/**
+ * Success response from the describe-image endpoint
+ */
+interface DescribeImageSuccessResponse {
+ success: true;
+ description: string;
+}
+
+/**
+ * Error response from the describe-image endpoint
+ */
+interface DescribeImageErrorResponse {
+ success: false;
+ error: string;
+ requestId?: string;
+}
+
+/**
+ * Map SDK/CLI errors to a stable status + user-facing message.
+ */
+function mapDescribeImageError(rawMessage: string | undefined): {
+ statusCode: number;
+ userMessage: string;
+} {
+ const baseResponse = {
+ statusCode: 500,
+ userMessage: 'Failed to generate an image description. Please try again.',
+ };
+
+ if (!rawMessage) return baseResponse;
+
+ if (
+ rawMessage.includes('Claude Code process exited') ||
+ rawMessage.includes('Claude Code process terminated by signal')
+ ) {
+ const exitCodeMatch = rawMessage.match(/exited with code (\d+)/);
+ const signalMatch = rawMessage.match(/terminated by signal (\w+)/);
+ const detail = exitCodeMatch
+ ? ` (exit code: ${exitCodeMatch[1]})`
+ : signalMatch
+ ? ` (signal: ${signalMatch[1]})`
+ : '';
+
+ // Crash/OS-kill signals suggest a process crash, not an auth failure —
+ // omit auth recovery advice and suggest retry/reporting instead.
+ const crashSignals = ['SIGSEGV', 'SIGABRT', 'SIGKILL', 'SIGBUS', 'SIGTRAP'];
+ const isCrashSignal = signalMatch ? crashSignals.includes(signalMatch[1]) : false;
+
+ if (isCrashSignal) {
+ return {
+ statusCode: 503,
+ userMessage: `Claude crashed unexpectedly${detail} while describing the image. This may be a transient condition. Please try again. If the problem persists, collect logs and report the issue.`,
+ };
+ }
+
+ return {
+ statusCode: 503,
+ userMessage: `Claude exited unexpectedly${detail} while describing the image. This is usually a transient issue. Try again. If it keeps happening, re-run \`claude login\` or update your API key in Setup.`,
+ };
+ }
+
+ if (
+ rawMessage.includes('Failed to spawn Claude Code process') ||
+ rawMessage.includes('Claude Code executable not found') ||
+ rawMessage.includes('Claude Code native binary not found')
+ ) {
+ return {
+ statusCode: 503,
+ userMessage:
+ 'Claude CLI could not be launched. Make sure the Claude CLI is installed and available in PATH, then try again.',
+ };
+ }
+
+ if (rawMessage.toLowerCase().includes('rate limit') || rawMessage.includes('429')) {
+ return {
+ statusCode: 429,
+ userMessage: 'Rate limited while describing the image. Please wait a moment and try again.',
+ };
+ }
+
+ if (rawMessage.toLowerCase().includes('payload too large') || rawMessage.includes('413')) {
+ return {
+ statusCode: 413,
+ userMessage:
+ 'The image is too large to send for description. Please resize/compress it and try again.',
+ };
+ }
+
+ return baseResponse;
+}
+
+/**
+ * Create the describe-image request handler
+ *
+ * Uses the provider abstraction with multi-part content blocks to include the image (base64),
+ * matching the agent runner behavior.
+ *
+ * @param settingsService - Optional settings service for loading autoLoadClaudeMd setting
+ * @returns Express request handler for image description
+ */
+export function createDescribeImageHandler(
+ settingsService?: SettingsService
+): (req: Request, res: Response) => Promise {
+ return async (req: Request, res: Response): Promise => {
+ const requestId = `describe-image-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
+ const startedAt = Date.now();
+
+ // Request envelope logs (high value when correlating failures)
+ // Only log safe headers to prevent leaking sensitive values (auth tokens, cookies, etc.)
+ logger.info(`[${requestId}] ===== POST /api/context/describe-image =====`);
+ logger.info(`[${requestId}] headers=${JSON.stringify(filterSafeHeaders(req.headers))}`);
+ logger.info(`[${requestId}] body=${JSON.stringify(req.body)}`);
+
+ try {
+ const { imagePath } = req.body as DescribeImageRequestBody;
+
+ // Validate required fields
+ if (!imagePath || typeof imagePath !== 'string') {
+ const response: DescribeImageErrorResponse = {
+ success: false,
+ error: 'imagePath is required and must be a string',
+ requestId,
+ };
+ res.status(400).json(response);
+ return;
+ }
+
+ logger.info(`[${requestId}] imagePath="${imagePath}" type=${typeof imagePath}`);
+
+ // Find the actual file path (handles Unicode space character variations)
+ const actualPath = findActualFilePath(imagePath);
+ if (!actualPath) {
+ logger.error(`[${requestId}] File not found: ${imagePath}`);
+ // Log hex representation of the path for debugging
+ const hexPath = Buffer.from(imagePath).toString('hex');
+ logger.error(`[${requestId}] imagePath hex: ${hexPath}`);
+ const response: DescribeImageErrorResponse = {
+ success: false,
+ error: `File not found: ${imagePath}`,
+ requestId,
+ };
+ res.status(404).json(response);
+ return;
+ }
+
+ if (actualPath !== imagePath) {
+ logger.info(`[${requestId}] Using actual path: ${actualPath}`);
+ }
+
+ // Log path + stats (this is often where issues start: missing file, perms, size)
+ let stat: ReturnType | null = null;
+ try {
+ stat = secureFs.statSync(actualPath);
+ logger.info(
+ `[${requestId}] fileStats size=${stat.size} bytes mtime=${stat.mtime.toISOString()}`
+ );
+ } catch (statErr) {
+ logger.warn(
+ `[${requestId}] Unable to stat image file (continuing to read base64): ${String(statErr)}`
+ );
+ }
+
+ // Read image and convert to base64 (same as agent runner)
+ logger.info(`[${requestId}] Reading image into base64...`);
+ const imageReadStart = Date.now();
+ const imageData = await readImageAsBase64(actualPath);
+ const imageReadMs = Date.now() - imageReadStart;
+
+ const base64Length = imageData.base64.length;
+ const estimatedBytes = Math.ceil((base64Length * 3) / 4);
+ logger.info(`[${requestId}] imageReadMs=${imageReadMs}`);
+ logger.info(
+ `[${requestId}] image meta filename=${imageData.filename} mime=${imageData.mimeType} base64Len=${base64Length} estBytes=${estimatedBytes}`
+ );
+
+ const cwd = path.dirname(actualPath);
+ logger.info(`[${requestId}] Using cwd=${cwd}`);
+
+ // Load autoLoadClaudeMd setting
+ const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
+ cwd,
+ settingsService,
+ '[DescribeImage]'
+ );
+
+ // Get model from phase settings with provider info
+ const {
+ phaseModel: phaseModelEntry,
+ provider,
+ credentials,
+ } = await getPhaseModelWithOverrides(
+ 'imageDescriptionModel',
+ settingsService,
+ cwd,
+ '[DescribeImage]'
+ );
+ const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
+
+ logger.info(
+ `[${requestId}] Using model: ${model}`,
+ provider ? `via provider: ${provider.name}` : 'direct API'
+ );
+
+ // Get customized prompts from settings
+ const prompts = await getPromptCustomization(settingsService, '[DescribeImage]');
+
+ // Build the instruction text from centralized prompts
+ const instructionText = prompts.contextDescription.describeImagePrompt;
+
+ // Build prompt based on provider capability
+ // Some providers (like Cursor) may not support image content blocks
+ let prompt: string | Array<{ type: string; text?: string; source?: object }>;
+
+ if (isCursorModel(model)) {
+ // Cursor may not support base64 image blocks directly
+ // Use text prompt with image path reference
+ logger.info(`[${requestId}] Using text prompt for Cursor model`);
+ prompt = `${instructionText}\n\nImage file: ${actualPath}\nMIME type: ${imageData.mimeType}`;
+ } else {
+ // Claude and other vision-capable models support multi-part prompts with images
+ logger.info(`[${requestId}] Using multi-part prompt with image block`);
+ prompt = [
+ { type: 'text', text: instructionText },
+ {
+ type: 'image',
+ source: {
+ type: 'base64',
+ media_type: imageData.mimeType,
+ data: imageData.base64,
+ },
+ },
+ ];
+ }
+
+ logger.info(`[${requestId}] Calling simpleQuery...`);
+ const queryStart = Date.now();
+
+ // Use simpleQuery - provider abstraction handles routing
+ const result = await simpleQuery({
+ prompt,
+ model,
+ cwd,
+ maxTurns: 1,
+ allowedTools: isCursorModel(model) ? ['Read'] : [], // Allow Read for Cursor to read image if needed
+ thinkingLevel,
+ readOnly: true, // Image description only reads, doesn't write
+ settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
+ claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
+ credentials, // Pass credentials for resolving 'credentials' apiKeySource
+ });
+
+ logger.info(`[${requestId}] simpleQuery completed in ${Date.now() - queryStart}ms`);
+
+ const description = result.text;
+
+ if (!description || description.trim().length === 0) {
+ logger.warn(`[${requestId}] Received empty response from AI`);
+ const response: DescribeImageErrorResponse = {
+ success: false,
+ error: 'Failed to generate description - empty response',
+ requestId,
+ };
+ res.status(500).json(response);
+ return;
+ }
+
+ const totalMs = Date.now() - startedAt;
+ logger.info(`[${requestId}] Success descriptionLen=${description.length} totalMs=${totalMs}`);
+
+ const response: DescribeImageSuccessResponse = {
+ success: true,
+ description: description.trim(),
+ };
+ res.json(response);
+ } catch (error) {
+ const totalMs = Date.now() - startedAt;
+ const err = error as unknown;
+ const errMessage = err instanceof Error ? err.message : String(err);
+ const errName = err instanceof Error ? err.name : 'UnknownError';
+ const errStack = err instanceof Error ? err.stack : undefined;
+
+ logger.error(`[${requestId}] FAILED totalMs=${totalMs}`);
+ logger.error(`[${requestId}] errorName=${errName}`);
+ logger.error(`[${requestId}] errorMessage=${errMessage}`);
+ if (errStack) logger.error(`[${requestId}] errorStack=${errStack}`);
+
+ // Dump all enumerable + non-enumerable props (this is where stderr/stdout/exitCode often live)
+ try {
+ const props = err && typeof err === 'object' ? Object.getOwnPropertyNames(err) : [];
+ logger.error(`[${requestId}] errorProps=${JSON.stringify(props)}`);
+ if (err && typeof err === 'object') {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const anyErr = err as any;
+ const details = JSON.stringify(anyErr, props as unknown as string[]);
+ logger.error(`[${requestId}] errorDetails=${details}`);
+ }
+ } catch (stringifyErr) {
+ logger.error(`[${requestId}] Failed to serialize error object: ${String(stringifyErr)}`);
+ }
+
+ const { statusCode, userMessage } = mapDescribeImageError(errMessage);
+ const response: DescribeImageErrorResponse = {
+ success: false,
+ error: `${userMessage} (requestId: ${requestId})`,
+ requestId,
+ };
+ res.status(statusCode).json(response);
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/enhance-prompt/index.ts b/temp_repo/apps/server/src/routes/enhance-prompt/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..db50ea4cd04e60c076ee07f9118afecfa2d504f9
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/enhance-prompt/index.ts
@@ -0,0 +1,24 @@
+/**
+ * Enhance prompt routes - HTTP API for AI-powered text enhancement
+ *
+ * Provides endpoints for enhancing user input text using Claude AI
+ * with different enhancement modes (improve, expand, simplify, etc.)
+ */
+
+import { Router } from 'express';
+import type { SettingsService } from '../../services/settings-service.js';
+import { createEnhanceHandler } from './routes/enhance.js';
+
+/**
+ * Create the enhance-prompt router
+ *
+ * @param settingsService - Settings service for loading custom prompts
+ * @returns Express router with enhance-prompt endpoints
+ */
+export function createEnhancePromptRoutes(settingsService?: SettingsService): Router {
+ const router = Router();
+
+ router.post('/', createEnhanceHandler(settingsService));
+
+ return router;
+}
diff --git a/temp_repo/apps/server/src/routes/enhance-prompt/routes/enhance.ts b/temp_repo/apps/server/src/routes/enhance-prompt/routes/enhance.ts
new file mode 100644
index 0000000000000000000000000000000000000000..5dbd72686aa041a99c023161c17ed0f173afc528
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/enhance-prompt/routes/enhance.ts
@@ -0,0 +1,276 @@
+/**
+ * POST /enhance-prompt endpoint - Enhance user input text
+ *
+ * Uses the provider abstraction to enhance text based on the specified
+ * enhancement mode. Works with any configured provider (Claude, Cursor, etc.).
+ * Supports modes: improve, technical, simplify, acceptance, ux-reviewer
+ */
+
+import type { Request, Response } from 'express';
+import { createLogger } from '@automaker/utils';
+import { resolveModelString } from '@automaker/model-resolver';
+import { CLAUDE_MODEL_MAP, type ThinkingLevel } from '@automaker/types';
+import { getAppSpecPath } from '@automaker/platform';
+import { simpleQuery } from '../../../providers/simple-query-service.js';
+import type { SettingsService } from '../../../services/settings-service.js';
+import { getPromptCustomization, getProviderByModelId } from '../../../lib/settings-helpers.js';
+import { FeatureLoader } from '../../../services/feature-loader.js';
+import * as secureFs from '../../../lib/secure-fs.js';
+import {
+ buildUserPrompt,
+ isValidEnhancementMode,
+ type EnhancementMode,
+} from '../../../lib/enhancement-prompts.js';
+import {
+ extractTechnologyStack,
+ extractXmlElements,
+ extractXmlSection,
+ unescapeXml,
+} from '../../../lib/xml-extractor.js';
+
+const logger = createLogger('EnhancePrompt');
+
+/**
+ * Request body for the enhance endpoint
+ */
+interface EnhanceRequestBody {
+ /** The original text to enhance */
+ originalText: string;
+ /** The enhancement mode to apply */
+ enhancementMode: string;
+ /** Optional model override */
+ model?: string;
+ /** Optional thinking level for Claude models */
+ thinkingLevel?: ThinkingLevel;
+ /** Optional project path for per-project Claude API profile */
+ projectPath?: string;
+}
+
+/**
+ * Success response from the enhance endpoint
+ */
+interface EnhanceSuccessResponse {
+ success: true;
+ enhancedText: string;
+}
+
+/**
+ * Error response from the enhance endpoint
+ */
+interface EnhanceErrorResponse {
+ success: false;
+ error: string;
+}
+
+async function buildProjectContext(projectPath: string): Promise {
+ const contextBlocks: string[] = [];
+
+ try {
+ const appSpecPath = getAppSpecPath(projectPath);
+ const specContent = (await secureFs.readFile(appSpecPath, 'utf-8')) as string;
+
+ const projectName = extractXmlSection(specContent, 'project_name');
+ const overview = extractXmlSection(specContent, 'overview');
+ const techStack = extractTechnologyStack(specContent);
+ const coreSection = extractXmlSection(specContent, 'core_capabilities');
+ const coreCapabilities = coreSection ? extractXmlElements(coreSection, 'capability') : [];
+
+ const summaryLines: string[] = [];
+ if (projectName) {
+ summaryLines.push(`Name: ${unescapeXml(projectName.trim())}`);
+ }
+ if (overview) {
+ summaryLines.push(`Overview: ${unescapeXml(overview.trim())}`);
+ }
+ if (techStack.length > 0) {
+ summaryLines.push(`Tech Stack: ${techStack.join(', ')}`);
+ }
+ if (coreCapabilities.length > 0) {
+ summaryLines.push(`Core Capabilities: ${coreCapabilities.slice(0, 10).join(', ')}`);
+ }
+
+ if (summaryLines.length > 0) {
+ contextBlocks.push(`PROJECT CONTEXT:\n${summaryLines.map((line) => `- ${line}`).join('\n')}`);
+ }
+ } catch (error) {
+ logger.debug('No app_spec.txt context available for enhancement', error);
+ }
+
+ try {
+ const featureLoader = new FeatureLoader();
+ const features = await featureLoader.getAll(projectPath);
+ const featureTitles = features
+ .map((feature) => feature.title || feature.name || feature.id)
+ .filter((title) => Boolean(title));
+
+ if (featureTitles.length > 0) {
+ const listed = featureTitles.slice(0, 30).map((title) => `- ${title}`);
+ contextBlocks.push(
+ `EXISTING FEATURES (avoid duplicates):\n${listed.join('\n')}${
+ featureTitles.length > 30 ? '\n- ...' : ''
+ }`
+ );
+ }
+ } catch (error) {
+ logger.debug('Failed to load existing features for enhancement context', error);
+ }
+
+ if (contextBlocks.length === 0) {
+ return null;
+ }
+
+ return contextBlocks.join('\n\n');
+}
+
+/**
+ * Create the enhance request handler
+ *
+ * @param settingsService - Optional settings service for loading custom prompts
+ * @returns Express request handler for text enhancement
+ */
+export function createEnhanceHandler(
+ settingsService?: SettingsService
+): (req: Request, res: Response) => Promise {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { originalText, enhancementMode, model, thinkingLevel, projectPath } =
+ req.body as EnhanceRequestBody;
+
+ // Validate required fields
+ if (!originalText || typeof originalText !== 'string') {
+ const response: EnhanceErrorResponse = {
+ success: false,
+ error: 'originalText is required and must be a string',
+ };
+ res.status(400).json(response);
+ return;
+ }
+
+ if (!enhancementMode || typeof enhancementMode !== 'string') {
+ const response: EnhanceErrorResponse = {
+ success: false,
+ error: 'enhancementMode is required and must be a string',
+ };
+ res.status(400).json(response);
+ return;
+ }
+
+ // Validate text is not empty
+ const trimmedText = originalText.trim();
+ if (trimmedText.length === 0) {
+ const response: EnhanceErrorResponse = {
+ success: false,
+ error: 'originalText cannot be empty',
+ };
+ res.status(400).json(response);
+ return;
+ }
+
+ // Validate and normalize enhancement mode
+ const normalizedMode = enhancementMode.toLowerCase();
+ const validMode: EnhancementMode = isValidEnhancementMode(normalizedMode)
+ ? normalizedMode
+ : 'improve';
+
+ logger.info(`Enhancing text with mode: ${validMode}, length: ${trimmedText.length} chars`);
+
+ // Load enhancement prompts from settings (merges custom + defaults)
+ const prompts = await getPromptCustomization(settingsService, '[EnhancePrompt]');
+
+ // Get the system prompt for this mode from merged prompts
+ const systemPromptMap: Record = {
+ improve: prompts.enhancement.improveSystemPrompt,
+ technical: prompts.enhancement.technicalSystemPrompt,
+ simplify: prompts.enhancement.simplifySystemPrompt,
+ acceptance: prompts.enhancement.acceptanceSystemPrompt,
+ 'ux-reviewer': prompts.enhancement.uxReviewerSystemPrompt,
+ };
+ const systemPrompt = systemPromptMap[validMode];
+
+ logger.debug(`Using ${validMode} system prompt (length: ${systemPrompt.length} chars)`);
+
+ // Build the user prompt with few-shot examples
+ const userPrompt = buildUserPrompt(validMode, trimmedText, true);
+ const projectContext = projectPath ? await buildProjectContext(projectPath) : null;
+ if (projectContext) {
+ logger.debug('Including project context in enhancement prompt');
+ }
+
+ // Check if the model is a provider model (like "GLM-4.5-Air")
+ // If so, get the provider config and resolved Claude model
+ let claudeCompatibleProvider: import('@automaker/types').ClaudeCompatibleProvider | undefined;
+ let providerResolvedModel: string | undefined;
+ let credentials = await settingsService?.getCredentials();
+
+ if (model && settingsService) {
+ const providerResult = await getProviderByModelId(
+ model,
+ settingsService,
+ '[EnhancePrompt]'
+ );
+ if (providerResult.provider) {
+ claudeCompatibleProvider = providerResult.provider;
+ providerResolvedModel = providerResult.resolvedModel;
+ credentials = providerResult.credentials;
+ logger.info(
+ `Using provider "${providerResult.provider.name}" for model "${model}"` +
+ (providerResolvedModel ? ` -> resolved to "${providerResolvedModel}"` : '')
+ );
+ }
+ }
+
+ // Resolve the model for API call.
+ // CRITICAL: For custom providers (GLM, MiniMax), pass the provider's model ID (e.g. "GLM-4.7")
+ // to the API, NOT the resolved Claude model - otherwise we get "model not found"
+ const modelForApi = claudeCompatibleProvider
+ ? model
+ : providerResolvedModel || resolveModelString(model, CLAUDE_MODEL_MAP.sonnet);
+
+ logger.debug(`Using model: ${modelForApi}`);
+
+ // Use simpleQuery - provider abstraction handles routing to correct provider
+ // The system prompt is combined with user prompt since some providers
+ // don't have a separate system prompt concept
+ const result = await simpleQuery({
+ prompt: [systemPrompt, projectContext, userPrompt].filter(Boolean).join('\n\n'),
+ model: modelForApi,
+ cwd: process.cwd(), // Enhancement doesn't need a specific working directory
+ maxTurns: 1,
+ allowedTools: [],
+ thinkingLevel,
+ readOnly: true, // Prompt enhancement only generates text, doesn't write files
+ credentials, // Pass credentials for resolving 'credentials' apiKeySource
+ claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
+ });
+
+ const enhancedText = result.text;
+
+ if (!enhancedText || enhancedText.trim().length === 0) {
+ logger.warn('Received empty response from AI');
+ const response: EnhanceErrorResponse = {
+ success: false,
+ error: 'Failed to generate enhanced text - empty response',
+ };
+ res.status(500).json(response);
+ return;
+ }
+
+ logger.info(`Enhancement complete, output length: ${enhancedText.length} chars`);
+
+ const response: EnhanceSuccessResponse = {
+ success: true,
+ enhancedText: enhancedText.trim(),
+ };
+ res.json(response);
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
+ logger.error('Enhancement failed:', errorMessage);
+
+ const response: EnhanceErrorResponse = {
+ success: false,
+ error: errorMessage,
+ };
+ res.status(500).json(response);
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/event-history/common.ts b/temp_repo/apps/server/src/routes/event-history/common.ts
new file mode 100644
index 0000000000000000000000000000000000000000..bd0ad3fe8d0dbbb887a85c74a9b3dac41e39fb39
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/event-history/common.ts
@@ -0,0 +1,19 @@
+/**
+ * Common utilities for event history routes
+ */
+
+import { createLogger } from '@automaker/utils';
+import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
+
+/** Logger instance for event history operations */
+export const logger = createLogger('EventHistory');
+
+/**
+ * Extract user-friendly error message from error objects
+ */
+export { getErrorMessageShared as getErrorMessage };
+
+/**
+ * Log error with automatic logger binding
+ */
+export const logError = createLogError(logger);
diff --git a/temp_repo/apps/server/src/routes/event-history/index.ts b/temp_repo/apps/server/src/routes/event-history/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..93297ddd926f8a6dfb87a26252633d6c3d27a442
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/event-history/index.ts
@@ -0,0 +1,68 @@
+/**
+ * Event History routes - HTTP API for event history management
+ *
+ * Provides endpoints for:
+ * - Listing events with filtering
+ * - Getting individual event details
+ * - Deleting events
+ * - Clearing all events
+ * - Replaying events to test hooks
+ *
+ * Mounted at /api/event-history in the main server.
+ */
+
+import { Router } from 'express';
+import type { EventHistoryService } from '../../services/event-history-service.js';
+import type { SettingsService } from '../../services/settings-service.js';
+import { validatePathParams } from '../../middleware/validate-paths.js';
+import { createListHandler } from './routes/list.js';
+import { createGetHandler } from './routes/get.js';
+import { createDeleteHandler } from './routes/delete.js';
+import { createClearHandler } from './routes/clear.js';
+import { createReplayHandler } from './routes/replay.js';
+
+/**
+ * Create event history router with all endpoints
+ *
+ * Endpoints:
+ * - POST /list - List events with optional filtering
+ * - POST /get - Get a single event by ID
+ * - POST /delete - Delete an event by ID
+ * - POST /clear - Clear all events for a project
+ * - POST /replay - Replay an event to trigger hooks
+ *
+ * @param eventHistoryService - Instance of EventHistoryService
+ * @param settingsService - Instance of SettingsService (for replay)
+ * @returns Express Router configured with all event history endpoints
+ */
+export function createEventHistoryRoutes(
+ eventHistoryService: EventHistoryService,
+ settingsService: SettingsService
+): Router {
+ const router = Router();
+
+ // List events with filtering
+ router.post('/list', validatePathParams('projectPath'), createListHandler(eventHistoryService));
+
+ // Get single event
+ router.post('/get', validatePathParams('projectPath'), createGetHandler(eventHistoryService));
+
+ // Delete event
+ router.post(
+ '/delete',
+ validatePathParams('projectPath'),
+ createDeleteHandler(eventHistoryService)
+ );
+
+ // Clear all events
+ router.post('/clear', validatePathParams('projectPath'), createClearHandler(eventHistoryService));
+
+ // Replay event
+ router.post(
+ '/replay',
+ validatePathParams('projectPath'),
+ createReplayHandler(eventHistoryService, settingsService)
+ );
+
+ return router;
+}
diff --git a/temp_repo/apps/server/src/routes/event-history/routes/clear.ts b/temp_repo/apps/server/src/routes/event-history/routes/clear.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c6e6bb5899bc806a64af0c2d05b05056642734ef
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/event-history/routes/clear.ts
@@ -0,0 +1,33 @@
+/**
+ * POST /api/event-history/clear - Clear all events for a project
+ *
+ * Request body: { projectPath: string }
+ * Response: { success: true, cleared: number }
+ */
+
+import type { Request, Response } from 'express';
+import type { EventHistoryService } from '../../../services/event-history-service.js';
+import { getErrorMessage, logError } from '../common.js';
+
+export function createClearHandler(eventHistoryService: EventHistoryService) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { projectPath } = req.body as { projectPath: string };
+
+ if (!projectPath || typeof projectPath !== 'string') {
+ res.status(400).json({ success: false, error: 'projectPath is required' });
+ return;
+ }
+
+ const cleared = await eventHistoryService.clearEvents(projectPath);
+
+ res.json({
+ success: true,
+ cleared,
+ });
+ } catch (error) {
+ logError(error, 'Clear events failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/event-history/routes/delete.ts b/temp_repo/apps/server/src/routes/event-history/routes/delete.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ea3f6b16c22627adb378f0e779290521bdd71db9
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/event-history/routes/delete.ts
@@ -0,0 +1,43 @@
+/**
+ * POST /api/event-history/delete - Delete an event by ID
+ *
+ * Request body: { projectPath: string, eventId: string }
+ * Response: { success: true } or { success: false, error: string }
+ */
+
+import type { Request, Response } from 'express';
+import type { EventHistoryService } from '../../../services/event-history-service.js';
+import { getErrorMessage, logError } from '../common.js';
+
+export function createDeleteHandler(eventHistoryService: EventHistoryService) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { projectPath, eventId } = req.body as {
+ projectPath: string;
+ eventId: string;
+ };
+
+ if (!projectPath || typeof projectPath !== 'string') {
+ res.status(400).json({ success: false, error: 'projectPath is required' });
+ return;
+ }
+
+ if (!eventId || typeof eventId !== 'string') {
+ res.status(400).json({ success: false, error: 'eventId is required' });
+ return;
+ }
+
+ const deleted = await eventHistoryService.deleteEvent(projectPath, eventId);
+
+ if (!deleted) {
+ res.status(404).json({ success: false, error: 'Event not found' });
+ return;
+ }
+
+ res.json({ success: true });
+ } catch (error) {
+ logError(error, 'Delete event failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/event-history/routes/get.ts b/temp_repo/apps/server/src/routes/event-history/routes/get.ts
new file mode 100644
index 0000000000000000000000000000000000000000..f892fd414b3700b60fa0db4e650a2bb8a3f07cf1
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/event-history/routes/get.ts
@@ -0,0 +1,46 @@
+/**
+ * POST /api/event-history/get - Get a single event by ID
+ *
+ * Request body: { projectPath: string, eventId: string }
+ * Response: { success: true, event: StoredEvent } or { success: false, error: string }
+ */
+
+import type { Request, Response } from 'express';
+import type { EventHistoryService } from '../../../services/event-history-service.js';
+import { getErrorMessage, logError } from '../common.js';
+
+export function createGetHandler(eventHistoryService: EventHistoryService) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { projectPath, eventId } = req.body as {
+ projectPath: string;
+ eventId: string;
+ };
+
+ if (!projectPath || typeof projectPath !== 'string') {
+ res.status(400).json({ success: false, error: 'projectPath is required' });
+ return;
+ }
+
+ if (!eventId || typeof eventId !== 'string') {
+ res.status(400).json({ success: false, error: 'eventId is required' });
+ return;
+ }
+
+ const event = await eventHistoryService.getEvent(projectPath, eventId);
+
+ if (!event) {
+ res.status(404).json({ success: false, error: 'Event not found' });
+ return;
+ }
+
+ res.json({
+ success: true,
+ event,
+ });
+ } catch (error) {
+ logError(error, 'Get event failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/event-history/routes/list.ts b/temp_repo/apps/server/src/routes/event-history/routes/list.ts
new file mode 100644
index 0000000000000000000000000000000000000000..551594f281a0d239206207d38ad6e7677d06e265
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/event-history/routes/list.ts
@@ -0,0 +1,53 @@
+/**
+ * POST /api/event-history/list - List events for a project
+ *
+ * Request body: {
+ * projectPath: string,
+ * filter?: {
+ * trigger?: EventHookTrigger,
+ * featureId?: string,
+ * since?: string,
+ * until?: string,
+ * limit?: number,
+ * offset?: number
+ * }
+ * }
+ * Response: { success: true, events: StoredEventSummary[], total: number }
+ */
+
+import type { Request, Response } from 'express';
+import type { EventHistoryService } from '../../../services/event-history-service.js';
+import type { EventHistoryFilter } from '@automaker/types';
+import { getErrorMessage, logError } from '../common.js';
+
+export function createListHandler(eventHistoryService: EventHistoryService) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { projectPath, filter } = req.body as {
+ projectPath: string;
+ filter?: EventHistoryFilter;
+ };
+
+ if (!projectPath || typeof projectPath !== 'string') {
+ res.status(400).json({ success: false, error: 'projectPath is required' });
+ return;
+ }
+
+ const events = await eventHistoryService.getEvents(projectPath, filter);
+ const total = await eventHistoryService.getEventCount(projectPath, {
+ ...filter,
+ limit: undefined,
+ offset: undefined,
+ });
+
+ res.json({
+ success: true,
+ events,
+ total,
+ });
+ } catch (error) {
+ logError(error, 'List events failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/event-history/routes/replay.ts b/temp_repo/apps/server/src/routes/event-history/routes/replay.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c6f27a404307fc673c75604b292b74b4cd645bf4
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/event-history/routes/replay.ts
@@ -0,0 +1,234 @@
+/**
+ * POST /api/event-history/replay - Replay an event to trigger hooks
+ *
+ * Request body: {
+ * projectPath: string,
+ * eventId: string,
+ * hookIds?: string[] // Optional: specific hooks to run (if not provided, runs all enabled matching hooks)
+ * }
+ * Response: { success: true, result: EventReplayResult }
+ */
+
+import type { Request, Response } from 'express';
+import type { EventHistoryService } from '../../../services/event-history-service.js';
+import type { SettingsService } from '../../../services/settings-service.js';
+import type { EventReplayResult, EventReplayHookResult, EventHook } from '@automaker/types';
+import { exec } from 'child_process';
+import { promisify } from 'util';
+import { getErrorMessage, logError, logger } from '../common.js';
+
+const execAsync = promisify(exec);
+
+/** Default timeout for shell commands (30 seconds) */
+const DEFAULT_SHELL_TIMEOUT = 30000;
+
+/** Default timeout for HTTP requests (10 seconds) */
+const DEFAULT_HTTP_TIMEOUT = 10000;
+
+interface HookContext {
+ featureId?: string;
+ featureName?: string;
+ projectPath?: string;
+ projectName?: string;
+ error?: string;
+ errorType?: string;
+ timestamp: string;
+ eventType: string;
+}
+
+/**
+ * Substitute {{variable}} placeholders in a string
+ */
+function substituteVariables(template: string, context: HookContext): string {
+ return template.replace(/\{\{(\w+)\}\}/g, (match, variable) => {
+ const value = context[variable as keyof HookContext];
+ if (value === undefined || value === null) {
+ return '';
+ }
+ return String(value);
+ });
+}
+
+/**
+ * Execute a single hook and return the result
+ */
+async function executeHook(hook: EventHook, context: HookContext): Promise {
+ const hookName = hook.name || hook.id;
+ const startTime = Date.now();
+
+ try {
+ if (hook.action.type === 'shell') {
+ const command = substituteVariables(hook.action.command, context);
+ const timeout = hook.action.timeout || DEFAULT_SHELL_TIMEOUT;
+
+ logger.info(`Replaying shell hook "${hookName}": ${command}`);
+
+ await execAsync(command, {
+ timeout,
+ maxBuffer: 1024 * 1024,
+ });
+
+ return {
+ hookId: hook.id,
+ hookName: hook.name,
+ success: true,
+ durationMs: Date.now() - startTime,
+ };
+ } else if (hook.action.type === 'http') {
+ const url = substituteVariables(hook.action.url, context);
+ const method = hook.action.method || 'POST';
+
+ const headers: Record = {
+ 'Content-Type': 'application/json',
+ };
+ if (hook.action.headers) {
+ for (const [key, value] of Object.entries(hook.action.headers)) {
+ headers[key] = substituteVariables(value, context);
+ }
+ }
+
+ let body: string | undefined;
+ if (hook.action.body) {
+ body = substituteVariables(hook.action.body, context);
+ } else if (method !== 'GET') {
+ body = JSON.stringify({
+ eventType: context.eventType,
+ timestamp: context.timestamp,
+ featureId: context.featureId,
+ projectPath: context.projectPath,
+ projectName: context.projectName,
+ error: context.error,
+ });
+ }
+
+ logger.info(`Replaying HTTP hook "${hookName}": ${method} ${url}`);
+
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), DEFAULT_HTTP_TIMEOUT);
+
+ const response = await fetch(url, {
+ method,
+ headers,
+ body: method !== 'GET' ? body : undefined,
+ signal: controller.signal,
+ });
+
+ clearTimeout(timeoutId);
+
+ if (!response.ok) {
+ return {
+ hookId: hook.id,
+ hookName: hook.name,
+ success: false,
+ error: `HTTP ${response.status}: ${response.statusText}`,
+ durationMs: Date.now() - startTime,
+ };
+ }
+
+ return {
+ hookId: hook.id,
+ hookName: hook.name,
+ success: true,
+ durationMs: Date.now() - startTime,
+ };
+ }
+
+ return {
+ hookId: hook.id,
+ hookName: hook.name,
+ success: false,
+ error: 'Unknown hook action type',
+ durationMs: Date.now() - startTime,
+ };
+ } catch (error) {
+ const errorMessage =
+ error instanceof Error
+ ? error.name === 'AbortError'
+ ? 'Request timed out'
+ : error.message
+ : String(error);
+
+ return {
+ hookId: hook.id,
+ hookName: hook.name,
+ success: false,
+ error: errorMessage,
+ durationMs: Date.now() - startTime,
+ };
+ }
+}
+
+export function createReplayHandler(
+ eventHistoryService: EventHistoryService,
+ settingsService: SettingsService
+) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { projectPath, eventId, hookIds } = req.body as {
+ projectPath: string;
+ eventId: string;
+ hookIds?: string[];
+ };
+
+ if (!projectPath || typeof projectPath !== 'string') {
+ res.status(400).json({ success: false, error: 'projectPath is required' });
+ return;
+ }
+
+ if (!eventId || typeof eventId !== 'string') {
+ res.status(400).json({ success: false, error: 'eventId is required' });
+ return;
+ }
+
+ // Get the event
+ const event = await eventHistoryService.getEvent(projectPath, eventId);
+ if (!event) {
+ res.status(404).json({ success: false, error: 'Event not found' });
+ return;
+ }
+
+ // Get hooks from settings
+ const settings = await settingsService.getGlobalSettings();
+ let hooks = settings.eventHooks || [];
+
+ // Filter to matching trigger and enabled hooks
+ hooks = hooks.filter((h) => h.enabled && h.trigger === event.trigger);
+
+ // If specific hook IDs requested, filter to those
+ if (hookIds && hookIds.length > 0) {
+ hooks = hooks.filter((h) => hookIds.includes(h.id));
+ }
+
+ // Build context for variable substitution
+ const context: HookContext = {
+ featureId: event.featureId,
+ featureName: event.featureName,
+ projectPath: event.projectPath,
+ projectName: event.projectName,
+ error: event.error,
+ errorType: event.errorType,
+ timestamp: event.timestamp,
+ eventType: event.trigger,
+ };
+
+ // Execute all hooks in parallel
+ const hookResults = await Promise.all(hooks.map((hook) => executeHook(hook, context)));
+
+ const result: EventReplayResult = {
+ eventId,
+ hooksTriggered: hooks.length,
+ hookResults,
+ };
+
+ logger.info(`Replayed event ${eventId}: ${hooks.length} hooks triggered`);
+
+ res.json({
+ success: true,
+ result,
+ });
+ } catch (error) {
+ logError(error, 'Replay event failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/features/common.ts b/temp_repo/apps/server/src/routes/features/common.ts
new file mode 100644
index 0000000000000000000000000000000000000000..7a5bf8f167d9bc141dbd3173240918a03e28bb4f
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/features/common.ts
@@ -0,0 +1,12 @@
+/**
+ * Common utilities for features routes
+ */
+
+import { createLogger } from '@automaker/utils';
+import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
+
+const logger = createLogger('Features');
+
+// Re-export shared utilities
+export { getErrorMessageShared as getErrorMessage };
+export const logError = createLogError(logger);
diff --git a/temp_repo/apps/server/src/routes/features/index.ts b/temp_repo/apps/server/src/routes/features/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..60ef92317af5cc73a82ffd94efdc486dc23c4ae2
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/features/index.ts
@@ -0,0 +1,95 @@
+/**
+ * Features routes - HTTP API for feature management
+ */
+
+import { Router } from 'express';
+import { FeatureLoader } from '../../services/feature-loader.js';
+import type { SettingsService } from '../../services/settings-service.js';
+import type { AutoModeServiceCompat } from '../../services/auto-mode/index.js';
+import type { EventEmitter } from '../../lib/events.js';
+import { validatePathParams } from '../../middleware/validate-paths.js';
+import { createListHandler } from './routes/list.js';
+import { createGetHandler } from './routes/get.js';
+import { createCreateHandler } from './routes/create.js';
+import { createUpdateHandler } from './routes/update.js';
+import { createBulkUpdateHandler } from './routes/bulk-update.js';
+import { createBulkDeleteHandler } from './routes/bulk-delete.js';
+import { createDeleteHandler } from './routes/delete.js';
+import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent-output.js';
+import { createGenerateTitleHandler } from './routes/generate-title.js';
+import { createExportHandler } from './routes/export.js';
+import { createImportHandler, createConflictCheckHandler } from './routes/import.js';
+import {
+ createOrphanedListHandler,
+ createOrphanedResolveHandler,
+ createOrphanedBulkResolveHandler,
+} from './routes/orphaned.js';
+
+export function createFeaturesRoutes(
+ featureLoader: FeatureLoader,
+ settingsService?: SettingsService,
+ events?: EventEmitter,
+ autoModeService?: AutoModeServiceCompat
+): Router {
+ const router = Router();
+
+ router.post(
+ '/list',
+ validatePathParams('projectPath'),
+ createListHandler(featureLoader, autoModeService)
+ );
+ router.get(
+ '/list',
+ validatePathParams('projectPath'),
+ createListHandler(featureLoader, autoModeService)
+ );
+ router.post('/get', validatePathParams('projectPath'), createGetHandler(featureLoader));
+ router.post(
+ '/create',
+ validatePathParams('projectPath'),
+ createCreateHandler(featureLoader, events)
+ );
+ router.post(
+ '/update',
+ validatePathParams('projectPath'),
+ createUpdateHandler(featureLoader, events)
+ );
+ router.post(
+ '/bulk-update',
+ validatePathParams('projectPath'),
+ createBulkUpdateHandler(featureLoader)
+ );
+ router.post(
+ '/bulk-delete',
+ validatePathParams('projectPath'),
+ createBulkDeleteHandler(featureLoader)
+ );
+ router.post('/delete', validatePathParams('projectPath'), createDeleteHandler(featureLoader));
+ router.post('/agent-output', createAgentOutputHandler(featureLoader));
+ router.post('/raw-output', createRawOutputHandler(featureLoader));
+ router.post('/generate-title', createGenerateTitleHandler(settingsService));
+ router.post('/export', validatePathParams('projectPath'), createExportHandler(featureLoader));
+ router.post('/import', validatePathParams('projectPath'), createImportHandler(featureLoader));
+ router.post(
+ '/check-conflicts',
+ validatePathParams('projectPath'),
+ createConflictCheckHandler(featureLoader)
+ );
+ router.post(
+ '/orphaned',
+ validatePathParams('projectPath'),
+ createOrphanedListHandler(featureLoader, autoModeService)
+ );
+ router.post(
+ '/orphaned/resolve',
+ validatePathParams('projectPath'),
+ createOrphanedResolveHandler(featureLoader, autoModeService)
+ );
+ router.post(
+ '/orphaned/bulk-resolve',
+ validatePathParams('projectPath'),
+ createOrphanedBulkResolveHandler(featureLoader)
+ );
+
+ return router;
+}
diff --git a/temp_repo/apps/server/src/routes/features/routes/agent-output.ts b/temp_repo/apps/server/src/routes/features/routes/agent-output.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d88e6d6f1f5b9cbdf469e4a25cee67a154e20f9c
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/features/routes/agent-output.ts
@@ -0,0 +1,61 @@
+/**
+ * POST /agent-output endpoint - Get agent output for a feature
+ * POST /raw-output endpoint - Get raw JSONL output for debugging
+ */
+
+import type { Request, Response } from 'express';
+import { FeatureLoader } from '../../../services/feature-loader.js';
+import { getErrorMessage, logError } from '../common.js';
+
+export function createAgentOutputHandler(featureLoader: FeatureLoader) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { projectPath, featureId } = req.body as {
+ projectPath: string;
+ featureId: string;
+ };
+
+ if (!projectPath || !featureId) {
+ res.status(400).json({
+ success: false,
+ error: 'projectPath and featureId are required',
+ });
+ return;
+ }
+
+ const content = await featureLoader.getAgentOutput(projectPath, featureId);
+ res.json({ success: true, content });
+ } catch (error) {
+ logError(error, 'Get agent output failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
+
+/**
+ * Handler for getting raw JSONL output for debugging
+ */
+export function createRawOutputHandler(featureLoader: FeatureLoader) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { projectPath, featureId } = req.body as {
+ projectPath: string;
+ featureId: string;
+ };
+
+ if (!projectPath || !featureId) {
+ res.status(400).json({
+ success: false,
+ error: 'projectPath and featureId are required',
+ });
+ return;
+ }
+
+ const content = await featureLoader.getRawOutput(projectPath, featureId);
+ res.json({ success: true, content });
+ } catch (error) {
+ logError(error, 'Get raw output failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/features/routes/bulk-delete.ts b/temp_repo/apps/server/src/routes/features/routes/bulk-delete.ts
new file mode 100644
index 0000000000000000000000000000000000000000..851c288cf9bbba78d39a8da407ad2e71fc748677
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/features/routes/bulk-delete.ts
@@ -0,0 +1,69 @@
+/**
+ * POST /bulk-delete endpoint - Delete multiple features at once
+ */
+
+import type { Request, Response } from 'express';
+import { FeatureLoader } from '../../../services/feature-loader.js';
+import { getErrorMessage, logError } from '../common.js';
+
+interface BulkDeleteRequest {
+ projectPath: string;
+ featureIds: string[];
+}
+
+interface BulkDeleteResult {
+ featureId: string;
+ success: boolean;
+ error?: string;
+}
+
+export function createBulkDeleteHandler(featureLoader: FeatureLoader) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { projectPath, featureIds } = req.body as BulkDeleteRequest;
+
+ if (!projectPath || !featureIds || !Array.isArray(featureIds) || featureIds.length === 0) {
+ res.status(400).json({
+ success: false,
+ error: 'projectPath and featureIds (non-empty array) are required',
+ });
+ return;
+ }
+
+ // Process in parallel batches of 20 for efficiency
+ const BATCH_SIZE = 20;
+ const results: BulkDeleteResult[] = [];
+
+ for (let i = 0; i < featureIds.length; i += BATCH_SIZE) {
+ const batch = featureIds.slice(i, i + BATCH_SIZE);
+ const batchResults = await Promise.all(
+ batch.map(async (featureId) => {
+ const success = await featureLoader.delete(projectPath, featureId);
+ if (success) {
+ return { featureId, success: true };
+ }
+ return {
+ featureId,
+ success: false,
+ error: 'Deletion failed. Check server logs for details.',
+ };
+ })
+ );
+ results.push(...batchResults);
+ }
+
+ const successCount = results.reduce((count, r) => count + (r.success ? 1 : 0), 0);
+ const failureCount = results.length - successCount;
+
+ res.json({
+ success: failureCount === 0,
+ deletedCount: successCount,
+ failedCount: failureCount,
+ results,
+ });
+ } catch (error) {
+ logError(error, 'Bulk delete features failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/features/routes/bulk-update.ts b/temp_repo/apps/server/src/routes/features/routes/bulk-update.ts
new file mode 100644
index 0000000000000000000000000000000000000000..3fb8cc9f8aca14e9f7a99d4f772db0e848ce23b6
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/features/routes/bulk-update.ts
@@ -0,0 +1,94 @@
+/**
+ * POST /bulk-update endpoint - Update multiple features at once
+ */
+
+import type { Request, Response } from 'express';
+import { FeatureLoader } from '../../../services/feature-loader.js';
+import type { Feature } from '@automaker/types';
+import { getErrorMessage, logError } from '../common.js';
+
+interface BulkUpdateRequest {
+ projectPath: string;
+ featureIds: string[];
+ updates: Partial;
+}
+
+interface BulkUpdateResult {
+ featureId: string;
+ success: boolean;
+ error?: string;
+}
+
+export function createBulkUpdateHandler(featureLoader: FeatureLoader) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { projectPath, featureIds, updates } = req.body as BulkUpdateRequest;
+
+ if (!projectPath || !featureIds || !Array.isArray(featureIds) || featureIds.length === 0) {
+ res.status(400).json({
+ success: false,
+ error: 'projectPath and featureIds (non-empty array) are required',
+ });
+ return;
+ }
+
+ if (!updates || Object.keys(updates).length === 0) {
+ res.status(400).json({
+ success: false,
+ error: 'updates object with at least one field is required',
+ });
+ return;
+ }
+
+ const results: BulkUpdateResult[] = [];
+ const updatedFeatures: Feature[] = [];
+
+ // Process in parallel batches of 20 for efficiency
+ const BATCH_SIZE = 20;
+ for (let i = 0; i < featureIds.length; i += BATCH_SIZE) {
+ const batch = featureIds.slice(i, i + BATCH_SIZE);
+ const batchResults = await Promise.all(
+ batch.map(async (featureId) => {
+ try {
+ const updated = await featureLoader.update(projectPath, featureId, updates);
+ return { featureId, success: true as const, feature: updated };
+ } catch (error) {
+ return {
+ featureId,
+ success: false as const,
+ error: getErrorMessage(error),
+ };
+ }
+ })
+ );
+
+ for (const result of batchResults) {
+ if (result.success) {
+ results.push({ featureId: result.featureId, success: true });
+ updatedFeatures.push(result.feature);
+ } else {
+ results.push({
+ featureId: result.featureId,
+ success: false,
+ error: result.error,
+ });
+ }
+ }
+ }
+
+ const successCount = results.filter((r) => r.success).length;
+ const failureCount = results.filter((r) => !r.success).length;
+
+ res.json({
+ success: failureCount === 0,
+ updatedCount: successCount,
+ failedCount: failureCount,
+ results,
+ features: updatedFeatures,
+ });
+ } catch (error) {
+ logError(error, 'Bulk update features failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/features/routes/create.ts b/temp_repo/apps/server/src/routes/features/routes/create.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c607e72e414b05d7dbf4f1d7922508b1037f3893
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/features/routes/create.ts
@@ -0,0 +1,44 @@
+/**
+ * POST /create endpoint - Create a new feature
+ */
+
+import type { Request, Response } from 'express';
+import { FeatureLoader } from '../../../services/feature-loader.js';
+import type { EventEmitter } from '../../../lib/events.js';
+import type { Feature } from '@automaker/types';
+import { getErrorMessage, logError } from '../common.js';
+
+export function createCreateHandler(featureLoader: FeatureLoader, events?: EventEmitter) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { projectPath, feature } = req.body as {
+ projectPath: string;
+ feature: Partial;
+ };
+
+ if (!projectPath || !feature) {
+ res.status(400).json({
+ success: false,
+ error: 'projectPath and feature are required',
+ });
+ return;
+ }
+
+ const created = await featureLoader.create(projectPath, feature);
+
+ // Emit feature_created event for hooks
+ if (events) {
+ events.emit('feature:created', {
+ featureId: created.id,
+ featureName: created.title || 'Untitled Feature',
+ projectPath,
+ });
+ }
+
+ res.json({ success: true, feature: created });
+ } catch (error) {
+ logError(error, 'Create feature failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/features/routes/delete.ts b/temp_repo/apps/server/src/routes/features/routes/delete.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2b6831f6770d4e6a01771c13beef06dbe59857f5
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/features/routes/delete.ts
@@ -0,0 +1,32 @@
+/**
+ * POST /delete endpoint - Delete a feature
+ */
+
+import type { Request, Response } from 'express';
+import { FeatureLoader } from '../../../services/feature-loader.js';
+import { getErrorMessage, logError } from '../common.js';
+
+export function createDeleteHandler(featureLoader: FeatureLoader) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { projectPath, featureId } = req.body as {
+ projectPath: string;
+ featureId: string;
+ };
+
+ if (!projectPath || !featureId) {
+ res.status(400).json({
+ success: false,
+ error: 'projectPath and featureId are required',
+ });
+ return;
+ }
+
+ const success = await featureLoader.delete(projectPath, featureId);
+ res.json({ success });
+ } catch (error) {
+ logError(error, 'Delete feature failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/features/routes/export.ts b/temp_repo/apps/server/src/routes/features/routes/export.ts
new file mode 100644
index 0000000000000000000000000000000000000000..28a048b4b59f74fd5a3d843fc6d3d93a21c922c3
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/features/routes/export.ts
@@ -0,0 +1,96 @@
+/**
+ * POST /export endpoint - Export features to JSON or YAML format
+ */
+
+import type { Request, Response } from 'express';
+import type { FeatureLoader } from '../../../services/feature-loader.js';
+import {
+ getFeatureExportService,
+ type ExportFormat,
+ type BulkExportOptions,
+} from '../../../services/feature-export-service.js';
+import { getErrorMessage, logError } from '../common.js';
+
+interface ExportRequest {
+ projectPath: string;
+ /** Feature IDs to export. If empty/undefined, exports all features */
+ featureIds?: string[];
+ /** Export format: 'json' or 'yaml' */
+ format?: ExportFormat;
+ /** Whether to include description history */
+ includeHistory?: boolean;
+ /** Whether to include plan spec */
+ includePlanSpec?: boolean;
+ /** Filter by category */
+ category?: string;
+ /** Filter by status */
+ status?: string;
+ /** Pretty print output */
+ prettyPrint?: boolean;
+ /** Optional metadata to include */
+ metadata?: {
+ projectName?: string;
+ projectPath?: string;
+ branch?: string;
+ [key: string]: unknown;
+ };
+}
+
+export function createExportHandler(_featureLoader: FeatureLoader) {
+ const exportService = getFeatureExportService();
+
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const {
+ projectPath,
+ featureIds,
+ format = 'json',
+ includeHistory = true,
+ includePlanSpec = true,
+ category,
+ status,
+ prettyPrint = true,
+ metadata,
+ } = req.body as ExportRequest;
+
+ if (!projectPath) {
+ res.status(400).json({ success: false, error: 'projectPath is required' });
+ return;
+ }
+
+ // Validate format
+ if (format !== 'json' && format !== 'yaml') {
+ res.status(400).json({
+ success: false,
+ error: 'format must be "json" or "yaml"',
+ });
+ return;
+ }
+
+ const options: BulkExportOptions = {
+ format,
+ includeHistory,
+ includePlanSpec,
+ category,
+ status,
+ featureIds,
+ prettyPrint,
+ metadata,
+ };
+
+ const exportData = await exportService.exportFeatures(projectPath, options);
+
+ // Return the export data as a string in the response
+ res.json({
+ success: true,
+ data: exportData,
+ format,
+ contentType: format === 'json' ? 'application/json' : 'application/x-yaml',
+ filename: `features-export.${format === 'json' ? 'json' : 'yaml'}`,
+ });
+ } catch (error) {
+ logError(error, 'Export features failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/features/routes/generate-title.ts b/temp_repo/apps/server/src/routes/features/routes/generate-title.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a84680b0c45b8da06382d937d98aed64f511acc6
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/features/routes/generate-title.ts
@@ -0,0 +1,109 @@
+/**
+ * POST /features/generate-title endpoint - Generate a concise title from description
+ *
+ * Uses the provider abstraction to generate a short, descriptive title
+ * from a feature description. Works with any configured provider (Claude, Cursor, etc.).
+ */
+
+import type { Request, Response } from 'express';
+import { createLogger } from '@automaker/utils';
+import { CLAUDE_MODEL_MAP } from '@automaker/model-resolver';
+import { simpleQuery } from '../../../providers/simple-query-service.js';
+import type { SettingsService } from '../../../services/settings-service.js';
+import { getPromptCustomization } from '../../../lib/settings-helpers.js';
+
+const logger = createLogger('GenerateTitle');
+
+interface GenerateTitleRequestBody {
+ description: string;
+ projectPath?: string;
+}
+
+interface GenerateTitleSuccessResponse {
+ success: true;
+ title: string;
+}
+
+interface GenerateTitleErrorResponse {
+ success: false;
+ error: string;
+}
+
+export function createGenerateTitleHandler(
+ settingsService?: SettingsService
+): (req: Request, res: Response) => Promise {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { description } = req.body as GenerateTitleRequestBody;
+
+ if (!description || typeof description !== 'string') {
+ const response: GenerateTitleErrorResponse = {
+ success: false,
+ error: 'description is required and must be a string',
+ };
+ res.status(400).json(response);
+ return;
+ }
+
+ const trimmedDescription = description.trim();
+ if (trimmedDescription.length === 0) {
+ const response: GenerateTitleErrorResponse = {
+ success: false,
+ error: 'description cannot be empty',
+ };
+ res.status(400).json(response);
+ return;
+ }
+
+ logger.info(`Generating title for description: ${trimmedDescription.substring(0, 50)}...`);
+
+ // Get customized prompts from settings
+ const prompts = await getPromptCustomization(settingsService, '[GenerateTitle]');
+ const systemPrompt = prompts.titleGeneration.systemPrompt;
+
+ // Get credentials for API calls (uses hardcoded haiku model, no phase setting)
+ const credentials = await settingsService?.getCredentials();
+
+ const userPrompt = `Generate a concise title for this feature:\n\n${trimmedDescription}`;
+
+ // Use simpleQuery - provider abstraction handles all the streaming/extraction
+ const result = await simpleQuery({
+ prompt: `${systemPrompt}\n\n${userPrompt}`,
+ model: CLAUDE_MODEL_MAP.haiku,
+ cwd: process.cwd(),
+ maxTurns: 1,
+ allowedTools: [],
+ credentials, // Pass credentials for resolving 'credentials' apiKeySource
+ });
+
+ const title = result.text;
+
+ if (!title || title.trim().length === 0) {
+ logger.warn('Received empty response from AI');
+ const response: GenerateTitleErrorResponse = {
+ success: false,
+ error: 'Failed to generate title - empty response',
+ };
+ res.status(500).json(response);
+ return;
+ }
+
+ logger.info(`Generated title: ${title.trim()}`);
+
+ const response: GenerateTitleSuccessResponse = {
+ success: true,
+ title: title.trim(),
+ };
+ res.json(response);
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
+ logger.error('Title generation failed:', errorMessage);
+
+ const response: GenerateTitleErrorResponse = {
+ success: false,
+ error: errorMessage,
+ };
+ res.status(500).json(response);
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/features/routes/get.ts b/temp_repo/apps/server/src/routes/features/routes/get.ts
new file mode 100644
index 0000000000000000000000000000000000000000..96f63fb8a06763755a0eb6d7531a8ff440c9617a
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/features/routes/get.ts
@@ -0,0 +1,37 @@
+/**
+ * POST /get endpoint - Get a single feature
+ */
+
+import type { Request, Response } from 'express';
+import { FeatureLoader } from '../../../services/feature-loader.js';
+import { getErrorMessage, logError } from '../common.js';
+
+export function createGetHandler(featureLoader: FeatureLoader) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { projectPath, featureId } = req.body as {
+ projectPath: string;
+ featureId: string;
+ };
+
+ if (!projectPath || !featureId) {
+ res.status(400).json({
+ success: false,
+ error: 'projectPath and featureId are required',
+ });
+ return;
+ }
+
+ const feature = await featureLoader.get(projectPath, featureId);
+ if (!feature) {
+ res.status(404).json({ success: false, error: 'Feature not found' });
+ return;
+ }
+
+ res.json({ success: true, feature });
+ } catch (error) {
+ logError(error, 'Get feature failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/features/routes/import.ts b/temp_repo/apps/server/src/routes/features/routes/import.ts
new file mode 100644
index 0000000000000000000000000000000000000000..aa8cfce14eab7ac1dd041388527301994f5ca100
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/features/routes/import.ts
@@ -0,0 +1,210 @@
+/**
+ * POST /import endpoint - Import features from JSON or YAML format
+ */
+
+import type { Request, Response } from 'express';
+import type { FeatureLoader } from '../../../services/feature-loader.js';
+import type { FeatureImportResult, Feature, FeatureExport } from '@automaker/types';
+import { getFeatureExportService } from '../../../services/feature-export-service.js';
+import { getErrorMessage, logError } from '../common.js';
+
+interface ImportRequest {
+ projectPath: string;
+ /** Raw JSON or YAML string containing feature data */
+ data: string;
+ /** Whether to overwrite existing features with same ID */
+ overwrite?: boolean;
+ /** Whether to preserve branch info from imported features */
+ preserveBranchInfo?: boolean;
+ /** Optional category to assign to all imported features */
+ targetCategory?: string;
+}
+
+interface ConflictCheckRequest {
+ projectPath: string;
+ /** Raw JSON or YAML string containing feature data */
+ data: string;
+}
+
+interface ConflictInfo {
+ featureId: string;
+ title?: string;
+ existingTitle?: string;
+ hasConflict: boolean;
+}
+
+export function createImportHandler(_featureLoader: FeatureLoader) {
+ const exportService = getFeatureExportService();
+
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const {
+ projectPath,
+ data,
+ overwrite = false,
+ preserveBranchInfo = false,
+ targetCategory,
+ } = req.body as ImportRequest;
+
+ if (!projectPath) {
+ res.status(400).json({ success: false, error: 'projectPath is required' });
+ return;
+ }
+
+ if (!data) {
+ res.status(400).json({ success: false, error: 'data is required' });
+ return;
+ }
+
+ // Detect format and parse the data
+ const format = exportService.detectFormat(data);
+ if (!format) {
+ res.status(400).json({
+ success: false,
+ error: 'Invalid data format. Expected valid JSON or YAML.',
+ });
+ return;
+ }
+
+ const parsed = exportService.parseImportData(data);
+ if (!parsed) {
+ res.status(400).json({
+ success: false,
+ error: 'Failed to parse import data. Ensure it is valid JSON or YAML.',
+ });
+ return;
+ }
+
+ // Determine if this is a single feature or bulk import
+ const isBulkImport =
+ 'features' in parsed && Array.isArray((parsed as { features: unknown }).features);
+
+ let results: FeatureImportResult[];
+
+ if (isBulkImport) {
+ // Bulk import
+ results = await exportService.importFeatures(projectPath, data, {
+ overwrite,
+ preserveBranchInfo,
+ targetCategory,
+ });
+ } else {
+ // Single feature import - we know it's not a bulk export at this point
+ // It must be either a Feature or FeatureExport
+ const singleData = parsed as Feature | FeatureExport;
+
+ const result = await exportService.importFeature(projectPath, {
+ data: singleData,
+ overwrite,
+ preserveBranchInfo,
+ targetCategory,
+ });
+ results = [result];
+ }
+
+ const successCount = results.filter((r) => r.success).length;
+ const failureCount = results.filter((r) => !r.success).length;
+ const allSuccessful = failureCount === 0;
+
+ res.json({
+ success: allSuccessful,
+ importedCount: successCount,
+ failedCount: failureCount,
+ results,
+ });
+ } catch (error) {
+ logError(error, 'Import features failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
+
+/**
+ * Create handler for checking conflicts before import
+ */
+export function createConflictCheckHandler(featureLoader: FeatureLoader) {
+ const exportService = getFeatureExportService();
+
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { projectPath, data } = req.body as ConflictCheckRequest;
+
+ if (!projectPath) {
+ res.status(400).json({ success: false, error: 'projectPath is required' });
+ return;
+ }
+
+ if (!data) {
+ res.status(400).json({ success: false, error: 'data is required' });
+ return;
+ }
+
+ // Parse the import data
+ const format = exportService.detectFormat(data);
+ if (!format) {
+ res.status(400).json({
+ success: false,
+ error: 'Invalid data format. Expected valid JSON or YAML.',
+ });
+ return;
+ }
+
+ const parsed = exportService.parseImportData(data);
+ if (!parsed) {
+ res.status(400).json({
+ success: false,
+ error: 'Failed to parse import data.',
+ });
+ return;
+ }
+
+ // Extract features from the data using type guards
+ let featuresToCheck: Array<{ id: string; title?: string }> = [];
+
+ if (exportService.isBulkExport(parsed)) {
+ // Bulk export format
+ featuresToCheck = parsed.features.map((f) => ({
+ id: f.feature.id,
+ title: f.feature.title,
+ }));
+ } else if (exportService.isFeatureExport(parsed)) {
+ // Single FeatureExport format
+ featuresToCheck = [
+ {
+ id: parsed.feature.id,
+ title: parsed.feature.title,
+ },
+ ];
+ } else if (exportService.isRawFeature(parsed)) {
+ // Raw Feature format
+ featuresToCheck = [{ id: parsed.id, title: parsed.title }];
+ }
+
+ // Check each feature for conflicts in parallel
+ const conflicts: ConflictInfo[] = await Promise.all(
+ featuresToCheck.map(async (feature) => {
+ const existing = await featureLoader.get(projectPath, feature.id);
+ return {
+ featureId: feature.id,
+ title: feature.title,
+ existingTitle: existing?.title,
+ hasConflict: !!existing,
+ };
+ })
+ );
+
+ const hasConflicts = conflicts.some((c) => c.hasConflict);
+
+ res.json({
+ success: true,
+ hasConflicts,
+ conflicts,
+ totalFeatures: featuresToCheck.length,
+ conflictCount: conflicts.filter((c) => c.hasConflict).length,
+ });
+ } catch (error) {
+ logError(error, 'Conflict check failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/features/routes/list.ts b/temp_repo/apps/server/src/routes/features/routes/list.ts
new file mode 100644
index 0000000000000000000000000000000000000000..46ff3b921e44ba51744097fad3abecb2264e4e7e
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/features/routes/list.ts
@@ -0,0 +1,73 @@
+/**
+ * POST/GET /list endpoint - List all features for a project
+ *
+ * projectPath may come from req.body (POST) or req.query (GET fallback).
+ *
+ * Also performs orphan detection when a project is loaded to identify
+ * features whose branches no longer exist. This runs on every project load/switch.
+ */
+
+import type { Request, Response } from 'express';
+import { FeatureLoader } from '../../../services/feature-loader.js';
+import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
+import { getErrorMessage, logError } from '../common.js';
+import { createLogger } from '@automaker/utils';
+
+const logger = createLogger('FeaturesListRoute');
+
+export function createListHandler(
+ featureLoader: FeatureLoader,
+ autoModeService?: AutoModeServiceCompat
+) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const bodyProjectPath =
+ typeof req.body === 'object' && req.body !== null
+ ? (req.body as { projectPath?: unknown }).projectPath
+ : undefined;
+ const queryProjectPath = req.query.projectPath;
+ const projectPath =
+ typeof bodyProjectPath === 'string'
+ ? bodyProjectPath
+ : typeof queryProjectPath === 'string'
+ ? queryProjectPath
+ : undefined;
+
+ if (!projectPath) {
+ res.status(400).json({ success: false, error: 'projectPath is required' });
+ return;
+ }
+
+ const features = await featureLoader.getAll(projectPath);
+
+ // Run orphan detection in background when project is loaded
+ // This detects features whose branches no longer exist (e.g., after merge/delete)
+ // We don't await this to keep the list response fast
+ // Note: detectOrphanedFeatures handles errors internally and always resolves
+ if (autoModeService) {
+ autoModeService
+ .detectOrphanedFeatures(projectPath, features)
+ .then((orphanedFeatures) => {
+ if (orphanedFeatures.length > 0) {
+ logger.info(
+ `[ProjectLoad] Detected ${orphanedFeatures.length} orphaned feature(s) in ${projectPath}`
+ );
+ for (const { feature, missingBranch } of orphanedFeatures) {
+ logger.info(
+ `[ProjectLoad] Orphaned: ${feature.title || feature.id} - branch "${missingBranch}" no longer exists`
+ );
+ }
+ }
+ })
+ .catch((error) => {
+ logger.warn(`[ProjectLoad] Orphan detection failed for ${projectPath}:`, error);
+ });
+ }
+
+ res.json({ success: true, features });
+ } catch (error) {
+ logError(error, 'List features failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/temp_repo/apps/server/src/routes/features/routes/orphaned.ts b/temp_repo/apps/server/src/routes/features/routes/orphaned.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e44711be1be64edeb2fe329930c9dfa472834b5a
--- /dev/null
+++ b/temp_repo/apps/server/src/routes/features/routes/orphaned.ts
@@ -0,0 +1,287 @@
+/**
+ * POST /orphaned endpoint - Detect orphaned features (features with missing branches)
+ * POST /orphaned/resolve endpoint - Resolve an orphaned feature (delete, create-worktree, or move-to-branch)
+ * POST /orphaned/bulk-resolve endpoint - Resolve multiple orphaned features at once
+ */
+
+import crypto from 'crypto';
+import path from 'path';
+import type { Request, Response } from 'express';
+import { FeatureLoader } from '../../../services/feature-loader.js';
+import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
+import { getErrorMessage, logError } from '../common.js';
+import { execGitCommand } from '../../../lib/git.js';
+import { deleteWorktreeMetadata } from '../../../lib/worktree-metadata.js';
+import { createLogger } from '@automaker/utils';
+
+const logger = createLogger('OrphanedFeatures');
+
+type ResolveAction = 'delete' | 'create-worktree' | 'move-to-branch';
+const VALID_ACTIONS: ResolveAction[] = ['delete', 'create-worktree', 'move-to-branch'];
+
+export function createOrphanedListHandler(
+ featureLoader: FeatureLoader,
+ autoModeService?: AutoModeServiceCompat
+) {
+ return async (req: Request, res: Response): Promise