Compare commits

..

1 Commits

Author SHA1 Message Date
Kacper
78d08c2b5b feat: introduce debug panel for performance monitoring
- Added a debug panel to monitor server performance, including memory and CPU metrics.
- Implemented debug services for real-time tracking of processes and performance metrics.
- Created API endpoints for metrics collection and process management.
- Enhanced UI components for displaying metrics and process statuses.
- Updated documentation to include new debug API details.

This feature is intended for development use and can be toggled with the `ENABLE_DEBUG_PANEL` environment variable.
2026-01-05 18:59:09 +01:00
311 changed files with 13863 additions and 31187 deletions

3
.claude/.gitignore vendored
View File

@@ -1,2 +1 @@
hans/
skills/
hans/

View File

@@ -1,19 +1 @@
# Dependencies
node_modules/
**/node_modules/
# Build outputs
dist/
**/dist/
dist-electron/
**/dist-electron/
build/
**/build/
.next/
**/.next/
.nuxt/
**/.nuxt/
out/
**/out/
.cache/
**/.cache/
node_modules/

View File

@@ -31,43 +31,24 @@ jobs:
- 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: npm run start --workspace=apps/server &
env:
PORT: 3008
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..."
for i in {1..60}; do
if curl -s -f http://localhost:3008/api/health > /dev/null 2>&1; then
for i in {1..30}; do
if curl -s http://localhost:3008/api/health > /dev/null 2>&1; then
echo "Backend server is ready!"
curl -s http://localhost:3008/api/health | jq . 2>/dev/null || echo "Health check response: $(curl -s http://localhost:3008/api/health 2>/dev/null || echo 'No response')"
exit 0
fi
echo "Waiting... ($i/60)"
echo "Waiting... ($i/30)"
sleep 1
done
echo "Backend server failed to start!"
echo "Checking server status..."
ps aux | grep -E "(node|tsx)" | grep -v grep || echo "No node processes found"
netstat -tlnp 2>/dev/null | grep :3008 || echo "Port 3008 not listening"
echo "Testing health endpoint..."
curl -v http://localhost:3008/api/health 2>&1 || echo "Health endpoint failed"
exit 1
- name: Run E2E tests
@@ -78,8 +59,6 @@ jobs:
CI: true
VITE_SERVER_URL: http://localhost:3008
VITE_SKIP_SETUP: 'true'
# Keep UI-side login/defaults consistent
AUTOMAKER_API_KEY: test-api-key-for-e2e-tests
- name: Upload Playwright report
uses: actions/upload-artifact@v4
@@ -89,12 +68,10 @@ jobs:
path: apps/ui/playwright-report/
retention-days: 7
- name: Upload test results (screenshots, traces, videos)
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
if: failure()
with:
name: test-results
path: |
apps/ui/test-results/
path: apps/ui/test-results/
retention-days: 7
if-no-files-found: ignore

View File

@@ -26,5 +26,5 @@ jobs:
check-lockfile: 'true'
- name: Run npm audit
run: npm audit --audit-level=critical
run: npm audit --audit-level=moderate
continue-on-error: false

View File

@@ -170,3 +170,44 @@ Use `resolveModelString()` from `@automaker/model-resolver` to convert model ali
- `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
- `ENABLE_DEBUG_PANEL=true` - Enable debug panel in non-development builds
## Debug System (Development Only)
The debug system provides real-time monitoring of server performance. Toggle with `Cmd/Ctrl+Shift+D`.
### Debug Services (`apps/server/src/services/`)
- `PerformanceMonitorService` - Collects memory/CPU metrics, detects leaks via linear regression
- `ProcessRegistryService` - Tracks spawned agents, terminals, CLIs with lifecycle events
### Debug API (`apps/server/src/routes/debug/`)
- `GET /api/debug/metrics` - Current metrics snapshot
- `POST /api/debug/metrics/start` - Start collection with optional config
- `POST /api/debug/metrics/stop` - Stop collection
- `GET /api/debug/processes` - List tracked processes with filters
### Debug Types (`libs/types/src/debug.ts`)
All debug types are exported from `@automaker/types`:
```typescript
import type {
DebugMetricsSnapshot,
TrackedProcess,
MemoryTrend,
ProcessSummary,
} from '@automaker/types';
import { formatBytes, formatDuration } from '@automaker/types';
```
### UI Components (`apps/ui/src/components/debug/`)
- `DebugPanel` - Main draggable container with tabs
- `MemoryMonitor` - Heap usage charts and leak indicators
- `CPUMonitor` - CPU gauge and event loop lag display
- `ProcessKanban` - Visual board of tracked processes
- `RenderTracker` - React component render statistics
See `docs/server/debug-api.md` for full API documentation.

View File

@@ -8,12 +8,10 @@
# =============================================================================
# BASE STAGE - Common setup for all builds (DRY: defined once, used by all)
# =============================================================================
FROM node:22-slim AS base
FROM node:22-alpine 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/*
RUN apk add --no-cache python3 make g++
WORKDIR /app
@@ -53,64 +51,31 @@ RUN npm run build:packages && npm run build --workspace=apps/server
# =============================================================================
# SERVER PRODUCTION STAGE
# =============================================================================
FROM node:22-slim AS server
FROM node:22-alpine 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}"
# Install git, curl, bash (for terminal), gosu (for user switching), and GitHub CLI (pinned version, multi-arch)
RUN apt-get update && apt-get install -y --no-install-recommends \
git curl bash gosu ca-certificates openssh-client \
&& GH_VERSION="2.63.2" \
&& ARCH=$(uname -m) \
&& case "$ARCH" in \
# Install git, curl, bash (for terminal), and GitHub CLI (pinned version, multi-arch)
RUN apk add --no-cache git curl bash && \
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/*
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}
# Install Claude CLI globally (available to all users via npm global bin)
# Install Claude CLI globally
RUN npm install -g @anthropic-ai/claude-code
# Create non-root user with home directory BEFORE installing Cursor CLI
RUN groupadd -g 1001 automaker && \
useradd -u 1001 -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)"
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
# Create non-root user
RUN addgroup -g 1001 -S automaker && \
adduser -S automaker -u 1001
# Copy root package.json (needed for workspace resolution)
COPY --from=server-builder /app/package*.json ./
@@ -133,19 +98,12 @@ 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
# Switch to non-root user
USER automaker
# 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
@@ -154,9 +112,6 @@ EXPOSE 3008
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"]
@@ -188,10 +143,6 @@ RUN npm run build:packages && npm run build --workspace=apps/ui
# =============================================================================
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

View File

@@ -1,80 +0,0 @@
# 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
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ \
git curl bash gosu ca-certificates openssh-client \
&& 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
# Create non-root user
RUN groupadd -g 1001 automaker && \
useradd -u 1001 -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"]

View File

@@ -117,16 +117,24 @@ cd automaker
# 2. Install dependencies
npm install
# 3. Build shared packages (can be skipped - npm run dev does it automatically)
# 3. Build shared packages (Now can be skipped npm install / run dev does it automaticly)
npm run build:packages
# 4. Start Automaker
npm run dev
# 4. Start Automaker (production mode)
npm run start
# Choose between:
# 1. Web Application (browser at localhost:3007)
# 2. Desktop Application (Electron - recommended)
```
**Note:** The `npm run start` command will:
- Check for dependencies and install if needed
- Build the application if needed
- Kill any processes on ports 3007/3008
- Present an interactive menu to choose your run mode
- Run in production mode (no hot reload)
**Authentication Setup:** On first run, Automaker will automatically show a setup wizard where you can configure authentication. You can choose to:
- Use **Claude Code CLI** (recommended) - Automaker will detect your CLI credentials automatically
@@ -142,7 +150,7 @@ export ANTHROPIC_API_KEY="sk-ant-..."
echo "ANTHROPIC_API_KEY=sk-ant-..." > .env
```
**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.
**For Development:** If you want to develop on Automaker with Vite live reload and hot module replacement, use `npm run dev` instead. This will start the development server with fast refresh and instant updates as you make changes.
## How to Run
@@ -186,6 +194,9 @@ npm run dev:web
```bash
# Build for web deployment (uses Vite)
npm run build
# Run production build
npm run start
```
#### Desktop Application
@@ -363,6 +374,7 @@ npm run lint
- `VITE_SKIP_ELECTRON` - Skip Electron in dev mode
- `OPEN_DEVTOOLS` - Auto-open DevTools in Electron
- `ENABLE_DEBUG_PANEL` - Enable the debug panel in non-development builds (for staging environments)
### Authentication Setup
@@ -444,6 +456,7 @@ The application can store your API key securely in the settings UI. The key is p
- 🎨 **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
- 🐛 **Debug Panel** - Floating debug overlay for monitoring memory, CPU, and process performance (dev mode only, toggle with `Cmd/Ctrl+Shift+D`)
### Advanced Features

View File

@@ -8,20 +8,6 @@
# 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
# ============================================

View File

@@ -1,6 +1,6 @@
{
"name": "@automaker/server",
"version": "0.9.0",
"version": "0.7.3",
"description": "Backend server for Automaker - provides API for both web and Electron modes",
"author": "AutoMaker Team",
"license": "SEE LICENSE IN LICENSE",
@@ -32,8 +32,7 @@
"@automaker/prompts": "1.0.0",
"@automaker/types": "1.0.0",
"@automaker/utils": "1.0.0",
"@modelcontextprotocol/sdk": "1.25.2",
"@openai/codex-sdk": "^0.77.0",
"@modelcontextprotocol/sdk": "1.25.1",
"cookie-parser": "1.4.7",
"cors": "2.8.5",
"dotenv": "17.2.3",

View File

@@ -53,8 +53,6 @@ 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 { createGitHubRoutes } from './routes/github/index.js';
import { createContextRoutes } from './routes/context/index.js';
import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js';
@@ -65,6 +63,12 @@ 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 {
createDebugRoutes,
createDebugServices,
stopDebugServices,
type DebugServices,
} from './routes/debug/index.js';
// Load environment variables
dotenv.config();
@@ -72,6 +76,8 @@ dotenv.config();
const PORT = parseInt(process.env.PORT || '3008', 10);
const DATA_DIR = process.env.DATA_DIR || './data';
const ENABLE_REQUEST_LOGGING = process.env.ENABLE_REQUEST_LOGGING !== 'false'; // Default to true
const ENABLE_DEBUG_PANEL =
process.env.NODE_ENV !== 'production' || process.env.ENABLE_DEBUG_PANEL === 'true';
// Check for required environment variables
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
@@ -168,10 +174,16 @@ const agentService = new AgentService(DATA_DIR, events, settingsService);
const featureLoader = new FeatureLoader();
const autoModeService = new AutoModeService(events, settingsService);
const claudeUsageService = new ClaudeUsageService();
const codexUsageService = new CodexUsageService();
const mcpTestService = new MCPTestService(settingsService);
const ideationService = new IdeationService(events, settingsService, featureLoader);
// Create debug services (dev mode only)
let debugServices: DebugServices | null = null;
if (ENABLE_DEBUG_PANEL) {
debugServices = createDebugServices(events);
logger.info('Debug services enabled');
}
// Initialize services
(async () => {
await agentService.initialize();
@@ -191,10 +203,9 @@ setInterval(() => {
// This helps prevent CSRF and content-type confusion attacks
app.use('/api', requireJsonContentType);
// Mount API routes - health, auth, and setup are unauthenticated
// Mount API routes - health and auth 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);
@@ -210,6 +221,7 @@ app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
app.use('/api/worktree', createWorktreeRoutes());
app.use('/api/git', createGitRoutes());
app.use('/api/setup', createSetupRoutes());
app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService));
app.use('/api/models', createModelsRoutes());
app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events, settingsService));
@@ -219,7 +231,6 @@ 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));
app.use('/api/github', createGitHubRoutes(events, settingsService));
app.use('/api/context', createContextRoutes(settingsService));
app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));
@@ -227,6 +238,12 @@ app.use('/api/mcp', createMCPRoutes(mcpTestService));
app.use('/api/pipeline', createPipelineRoutes(pipelineService));
app.use('/api/ideation', createIdeationRoutes(events, ideationService, featureLoader));
// Debug routes (dev mode only)
if (debugServices) {
app.use('/api/debug', createDebugRoutes(debugServices));
logger.info('Debug API routes mounted at /api/debug');
}
// Create HTTP server
const server = createServer(app);
@@ -592,6 +609,9 @@ startServer(PORT);
process.on('SIGTERM', () => {
logger.info('SIGTERM received, shutting down...');
terminalService.cleanup();
if (debugServices) {
stopDebugServices(debugServices);
}
server.close(() => {
logger.info('Server closed');
process.exit(0);
@@ -601,6 +621,9 @@ process.on('SIGTERM', () => {
process.on('SIGINT', () => {
logger.info('SIGINT received, shutting down...');
terminalService.cleanup();
if (debugServices) {
stopDebugServices(debugServices);
}
server.close(() => {
logger.info('Server closed');
process.exit(0);

View File

@@ -1,257 +0,0 @@
/**
* 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<boolean>;
readdir: (dirPath: string) => Promise<DirEntry[]>;
readFile: (filePath: string) => Promise<string>;
}
/**
* 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<string>,
};
}
/**
* 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<string>,
};
}
/**
* Parse agent file using the provided filesystem adapter
*/
async function parseAgentFileWithAdapter(
filePath: string,
fsAdapter: FsAdapter
): Promise<AgentDefinition | null> {
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<FilesystemAgent[]> {
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<FilesystemAgent[]> {
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;
}

View File

@@ -1,263 +0,0 @@
/**
* 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<string, SecureAuthEnv>();
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<string, { count: number; lastAttempt: number }>();
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);
}
}

View File

@@ -262,7 +262,7 @@ export function getSessionCookieOptions(): {
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
sameSite: 'strict', // Only sent for same-site requests (CSRF protection)
maxAge: SESSION_MAX_AGE_MS,
path: '/',
};

View File

@@ -1,447 +0,0 @@
/**
* 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';
import { createLogger } from '@automaker/utils';
const logger = createLogger('CliDetection');
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<CliDetectionResult> {
const config = CLI_CONFIGS[provider];
const { timeout = 5000, includeWsl = false, wslDistribution } = 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<UnifiedCliDetection> {
const results: UnifiedCliDetection = {};
// Detect all providers in parallel
const providers = Object.keys(CLI_CONFIGS) as Array<keyof typeof CLI_CONFIGS>;
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<string | null> {
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<string> {
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,
};
}

View File

@@ -1,98 +0,0 @@
/**
* 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, getCodexAuthPath } from '@automaker/platform';
import { findCodexCliPath } from '@automaker/platform';
import * as fs from 'fs';
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<CodexAuthCheckResult> {
console.log('[CodexAuth] checkCodexAuthentication called with cliPath:', cliPath);
const resolvedCliPath = cliPath || (await findCodexCliPath());
const hasApiKey = !!process.env[OPENAI_API_KEY_ENV];
console.log('[CodexAuth] resolvedCliPath:', resolvedCliPath);
console.log('[CodexAuth] hasApiKey:', hasApiKey);
// Debug: Check auth file
const authFilePath = getCodexAuthPath();
console.log('[CodexAuth] Auth file path:', authFilePath);
try {
const authFileExists = fs.existsSync(authFilePath);
console.log('[CodexAuth] Auth file exists:', authFileExists);
if (authFileExists) {
const authContent = fs.readFileSync(authFilePath, 'utf-8');
console.log('[CodexAuth] Auth file content:', authContent.substring(0, 500)); // First 500 chars
}
} catch (error) {
console.log('[CodexAuth] Error reading auth file:', error);
}
// If CLI is not installed, cannot be authenticated
if (!resolvedCliPath) {
console.log('[CodexAuth] No CLI path found, returning not authenticated');
return { authenticated: false, method: 'none' };
}
try {
console.log('[CodexAuth] Running: ' + resolvedCliPath + ' login status');
const result = await spawnProcess({
command: resolvedCliPath || CODEX_COMMAND,
args: ['login', 'status'],
cwd: process.cwd(),
env: {
...process.env,
TERM: 'dumb', // Avoid interactive output
},
});
console.log('[CodexAuth] Command result:');
console.log('[CodexAuth] exitCode:', result.exitCode);
console.log('[CodexAuth] stdout:', JSON.stringify(result.stdout));
console.log('[CodexAuth] stderr:', JSON.stringify(result.stderr));
// 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');
console.log('[CodexAuth] isLoggedIn (contains "logged in" in stdout or stderr):', isLoggedIn);
if (result.exitCode === 0 && isLoggedIn) {
// Determine auth method based on what we know
const method = hasApiKey ? 'api_key_env' : 'cli_authenticated';
console.log('[CodexAuth] Authenticated! method:', method);
return { authenticated: true, method };
}
console.log(
'[CodexAuth] Not authenticated. exitCode:',
result.exitCode,
'isLoggedIn:',
isLoggedIn
);
} catch (error) {
console.log('[CodexAuth] Error running command:', error);
}
console.log('[CodexAuth] Returning not authenticated');
return { authenticated: false, method: 'none' };
}

View File

@@ -1,414 +0,0 @@
/**
* 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<string, any>;
}
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<string, any>
): 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 any;
if (errorObj.message) {
return errorObj.message;
}
if (errorObj.error?.message) {
return errorObj.error.message;
}
if (errorObj.error) {
return typeof errorObj.error === 'string' ? errorObj.error : JSON.stringify(errorObj.error);
}
return JSON.stringify(error);
}
return String(error);
}
/**
* Create a standardized error response
*/
export function createErrorResponse(
error: unknown,
provider?: string,
context?: Record<string, any>
): {
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<string, any>
): 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 <T>(
operation: () => Promise<T>,
shouldRetry: (error: unknown) => boolean = isRetryableError
): Promise<T> {
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;
};
}

View File

@@ -1,173 +0,0 @@
/**
* 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;
}
/**
* Check if a tool call is allowed based on permissions
*/
export function checkToolCallPermission(
toolCall: any,
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: any, 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 });
}
}

View File

@@ -16,6 +16,7 @@
*/
import type { Options } from '@anthropic-ai/claude-agent-sdk';
import os from 'os';
import path from 'path';
import { resolveModelString } from '@automaker/model-resolver';
import { createLogger } from '@automaker/utils';
@@ -30,68 +31,6 @@ import {
} 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.
@@ -118,6 +57,139 @@ export function validateWorkingDirectory(cwd: string): void {
}
}
/**
* Known cloud storage path patterns where sandbox mode is incompatible.
*
* The Claude CLI sandbox feature uses filesystem isolation that conflicts with
* cloud storage providers' virtual filesystem implementations. This causes the
* Claude process to exit with code 1 when sandbox is enabled for these paths.
*
* Affected providers (macOS paths):
* - Dropbox: ~/Library/CloudStorage/Dropbox-*
* - Google Drive: ~/Library/CloudStorage/GoogleDrive-*
* - OneDrive: ~/Library/CloudStorage/OneDrive-*
* - iCloud Drive: ~/Library/Mobile Documents/
* - Box: ~/Library/CloudStorage/Box-*
*
* Note: This is a known limitation when using cloud storage paths.
*/
/**
* macOS-specific cloud storage patterns that appear under ~/Library/
* These are specific enough to use with includes() safely.
*/
const MACOS_CLOUD_STORAGE_PATTERNS = [
'/Library/CloudStorage/', // Dropbox, Google Drive, OneDrive, Box on macOS
'/Library/Mobile Documents/', // iCloud Drive on macOS
] as const;
/**
* Generic cloud storage folder names that need to be anchored to the home directory
* to avoid false positives (e.g., /home/user/my-project-about-dropbox/).
*/
const HOME_ANCHORED_CLOUD_FOLDERS = [
'Google Drive', // Google Drive on some systems
'Dropbox', // Dropbox on Linux/alternative installs
'OneDrive', // OneDrive on Linux/alternative installs
] as const;
/**
* Check if a path is within a cloud storage location.
*
* Cloud storage providers use virtual filesystem implementations that are
* incompatible with the Claude CLI sandbox feature, causing process crashes.
*
* Uses two detection strategies:
* 1. macOS-specific patterns (under ~/Library/) - checked via includes()
* 2. Generic folder names - anchored to home directory to avoid false positives
*
* @param cwd - The working directory path to check
* @returns true if the path is in a cloud storage location
*/
export function isCloudStoragePath(cwd: string): boolean {
const resolvedPath = path.resolve(cwd);
// Normalize to forward slashes for consistent pattern matching across platforms
let normalizedPath = resolvedPath.split(path.sep).join('/');
// Remove Windows drive letter if present (e.g., "C:/Users" -> "/Users")
// This ensures Unix paths in tests work the same on Windows
normalizedPath = normalizedPath.replace(/^[A-Za-z]:/, '');
// Check macOS-specific patterns (these are specific enough to use includes)
if (MACOS_CLOUD_STORAGE_PATTERNS.some((pattern) => normalizedPath.includes(pattern))) {
return true;
}
// Check home-anchored patterns to avoid false positives
// e.g., /home/user/my-project-about-dropbox/ should NOT match
const home = os.homedir();
for (const folder of HOME_ANCHORED_CLOUD_FOLDERS) {
const cloudPath = path.join(home, folder);
let normalizedCloudPath = cloudPath.split(path.sep).join('/');
// Remove Windows drive letter if present
normalizedCloudPath = normalizedCloudPath.replace(/^[A-Za-z]:/, '');
// Check if resolved path starts with the cloud storage path followed by a separator
// This ensures we match ~/Dropbox/project but not ~/Dropbox-archive or ~/my-dropbox-tool
if (
normalizedPath === normalizedCloudPath ||
normalizedPath.startsWith(normalizedCloudPath + '/')
) {
return true;
}
}
return false;
}
/**
* Result of sandbox compatibility check
*/
export interface SandboxCheckResult {
/** Whether sandbox should be enabled */
enabled: boolean;
/** If disabled, the reason why */
disabledReason?: 'cloud_storage' | 'user_setting';
/** Human-readable message for logging/UI */
message?: string;
}
/**
* Determine if sandbox mode should be enabled for a given configuration.
*
* Sandbox mode is automatically disabled for cloud storage paths because the
* Claude CLI sandbox feature is incompatible with virtual filesystem
* implementations used by cloud storage providers (Dropbox, Google Drive, etc.).
*
* @param cwd - The working directory
* @param enableSandboxMode - User's sandbox mode setting
* @returns SandboxCheckResult with enabled status and reason if disabled
*/
export function checkSandboxCompatibility(
cwd: string,
enableSandboxMode?: boolean
): SandboxCheckResult {
// User has explicitly disabled sandbox mode
if (enableSandboxMode === false) {
return {
enabled: false,
disabledReason: 'user_setting',
};
}
// Check for cloud storage incompatibility (applies when enabled or undefined)
if (isCloudStoragePath(cwd)) {
return {
enabled: false,
disabledReason: 'cloud_storage',
message: `Sandbox mode auto-disabled: Project is in a cloud storage location (${cwd}). The Claude CLI sandbox feature is incompatible with cloud storage filesystems. To use sandbox mode, move your project to a local directory.`,
};
}
// Sandbox is compatible and enabled (true or undefined defaults to enabled)
return {
enabled: true,
};
}
/**
* Tool presets for different use cases
*/
@@ -200,31 +272,55 @@ export function getModelForUseCase(
/**
* Base options that apply to all SDK calls
* AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation
*/
function getBaseOptions(): Partial<Options> {
return {
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
permissionMode: 'acceptEdits',
};
}
/**
* MCP options result
* MCP permission options result
*/
interface McpOptions {
interface McpPermissionOptions {
/** Whether tools should be restricted to a preset */
shouldRestrictTools: boolean;
/** Options to spread when MCP bypass is enabled */
bypassOptions: Partial<Options>;
/** Options to spread for MCP servers */
mcpServerOptions: Partial<Options>;
}
/**
* Build MCP-related options based on configuration.
* Centralizes the logic for determining permission modes and tool restrictions
* when MCP servers are configured.
*
* @param config - The SDK options config
* @returns Object with MCP server settings to spread into final options
* @returns Object with MCP permission settings to spread into final options
*/
function buildMcpOptions(config: CreateSdkOptionsConfig): McpOptions {
function buildMcpOptions(config: CreateSdkOptionsConfig): McpPermissionOptions {
const hasMcpServers = config.mcpServers && Object.keys(config.mcpServers).length > 0;
// Default to true for autonomous workflow. Security is enforced when adding servers
// via the security warning dialog that explains the risks.
const mcpAutoApprove = config.mcpAutoApproveTools ?? true;
const mcpUnrestricted = config.mcpUnrestrictedTools ?? true;
// Determine if we should bypass permissions based on settings
const shouldBypassPermissions = hasMcpServers && mcpAutoApprove;
// Determine if we should restrict tools (only when no MCP or unrestricted is disabled)
const shouldRestrictTools = !hasMcpServers || !mcpUnrestricted;
return {
shouldRestrictTools,
// Only include bypass options when MCP is configured and auto-approve is enabled
bypassOptions: shouldBypassPermissions
? {
permissionMode: 'bypassPermissions' as const,
// Required flag when using bypassPermissions mode
allowDangerouslySkipPermissions: true,
}
: {},
// Include MCP servers if configured
mcpServerOptions: config.mcpServers ? { mcpServers: config.mcpServers } : {},
};
@@ -326,9 +422,18 @@ export interface CreateSdkOptionsConfig {
/** Enable auto-loading of CLAUDE.md files via SDK's settingSources */
autoLoadClaudeMd?: boolean;
/** Enable sandbox mode for bash command isolation */
enableSandboxMode?: boolean;
/** MCP servers to make available to the agent */
mcpServers?: Record<string, McpServerConfig>;
/** Auto-approve MCP tool calls without permission prompts */
mcpAutoApproveTools?: boolean;
/** Allow unrestricted tools when MCP servers are enabled */
mcpUnrestrictedTools?: boolean;
/** Extended thinking level for Claude models */
thinkingLevel?: ThinkingLevel;
}
@@ -449,6 +554,7 @@ export function createSuggestionsOptions(config: CreateSdkOptionsConfig): Option
* - Full tool access for code modification
* - Standard turns for interactive sessions
* - Model priority: explicit model > session model > chat default
* - Sandbox mode controlled by enableSandboxMode setting (auto-disabled for cloud storage)
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
*/
export function createChatOptions(config: CreateSdkOptionsConfig): Options {
@@ -467,12 +573,24 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
// Build thinking options
const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
// Check sandbox compatibility (auto-disables for cloud storage paths)
const sandboxCheck = checkSandboxCompatibility(config.cwd, config.enableSandboxMode);
return {
...getBaseOptions(),
model: getModelForUseCase('chat', effectiveModel),
maxTurns: MAX_TURNS.standard,
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.chat],
// Only restrict tools if no MCP servers configured or unrestricted is disabled
...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.chat] }),
// Apply MCP bypass options if configured
...mcpOptions.bypassOptions,
...(sandboxCheck.enabled && {
sandbox: {
enabled: true,
autoAllowBashIfSandboxed: true,
},
}),
...claudeMdOptions,
...thinkingOptions,
...(config.abortController && { abortController: config.abortController }),
@@ -487,6 +605,7 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
* - Full tool access for code modification and implementation
* - Extended turns for thorough feature implementation
* - Uses default model (can be overridden)
* - Sandbox mode controlled by enableSandboxMode setting (auto-disabled for cloud storage)
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
*/
export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
@@ -502,12 +621,24 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
// Build thinking options
const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
// Check sandbox compatibility (auto-disables for cloud storage paths)
const sandboxCheck = checkSandboxCompatibility(config.cwd, config.enableSandboxMode);
return {
...getBaseOptions(),
model: getModelForUseCase('auto', config.model),
maxTurns: MAX_TURNS.maximum,
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.fullAccess],
// Only restrict tools if no MCP servers configured or unrestricted is disabled
...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.fullAccess] }),
// Apply MCP bypass options if configured
...mcpOptions.bypassOptions,
...(sandboxCheck.enabled && {
sandbox: {
enabled: true,
autoAllowBashIfSandboxed: true,
},
}),
...claudeMdOptions,
...thinkingOptions,
...(config.abortController && { abortController: config.abortController }),
@@ -525,6 +656,7 @@ export function createCustomOptions(
config: CreateSdkOptionsConfig & {
maxTurns?: number;
allowedTools?: readonly string[];
sandbox?: { enabled: boolean; autoAllowBashIfSandboxed?: boolean };
}
): Options {
// Validate working directory before creating options
@@ -539,17 +671,22 @@ export function createCustomOptions(
// Build thinking options
const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
// For custom options: use explicit allowedTools if provided, otherwise default to readOnly
// For custom options: use explicit allowedTools if provided, otherwise use preset based on MCP settings
const effectiveAllowedTools = config.allowedTools
? [...config.allowedTools]
: [...TOOL_PRESETS.readOnly];
: mcpOptions.shouldRestrictTools
? [...TOOL_PRESETS.readOnly]
: undefined;
return {
...getBaseOptions(),
model: getModelForUseCase('default', config.model),
maxTurns: config.maxTurns ?? MAX_TURNS.maximum,
cwd: config.cwd,
allowedTools: effectiveAllowedTools,
...(effectiveAllowedTools && { allowedTools: effectiveAllowedTools }),
...(config.sandbox && { sandbox: config.sandbox }),
// Apply MCP bypass options if configured
...mcpOptions.bypassOptions,
...claudeMdOptions,
...thinkingOptions,
...(config.abortController && { abortController: config.abortController }),

View File

@@ -55,6 +55,34 @@ export async function getAutoLoadClaudeMdSetting(
}
}
/**
* Get the enableSandboxMode setting from global settings.
* Returns false if settings service is not available.
*
* @param settingsService - Optional settings service instance
* @param logPrefix - Prefix for log messages (e.g., '[AgentService]')
* @returns Promise resolving to the enableSandboxMode setting value
*/
export async function getEnableSandboxModeSetting(
settingsService?: SettingsService | null,
logPrefix = '[SettingsHelper]'
): Promise<boolean> {
if (!settingsService) {
logger.info(`${logPrefix} SettingsService not available, sandbox mode disabled`);
return false;
}
try {
const globalSettings = await settingsService.getGlobalSettings();
const result = globalSettings.enableSandboxMode ?? false;
logger.info(`${logPrefix} enableSandboxMode from global settings: ${result}`);
return result;
} catch (error) {
logger.error(`${logPrefix} Failed to load enableSandboxMode setting:`, error);
throw error;
}
}
/**
* Filters out CLAUDE.md from context files when autoLoadClaudeMd is enabled
* and rebuilds the formatted prompt without it.
@@ -241,83 +269,3 @@ export async function getPromptCustomization(
enhancement: mergeEnhancementPrompts(customization.enhancement),
};
}
/**
* 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<Record<string, import('@automaker/types').AgentDefinition> | 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;
}

View File

@@ -10,7 +10,7 @@ import { BaseProvider } from './base-provider.js';
import { classifyError, getUserFriendlyErrorMessage, createLogger } from '@automaker/utils';
const logger = createLogger('ClaudeProvider');
import { getThinkingTokenBudget, validateBareModelId } from '@automaker/types';
import { getThinkingTokenBudget } from '@automaker/types';
import type {
ExecuteOptions,
ProviderMessage,
@@ -53,10 +53,6 @@ export class ClaudeProvider extends BaseProvider {
* Execute a query using Claude Agent SDK
*/
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
// Validate that model doesn't have a provider prefix
// AgentService should strip prefixes before passing to providers
validateBareModelId(options.model, 'ClaudeProvider');
const {
prompt,
model,
@@ -74,6 +70,14 @@ export class ClaudeProvider extends BaseProvider {
const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel);
// Build Claude SDK options
// AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation
const hasMcpServers = options.mcpServers && Object.keys(options.mcpServers).length > 0;
const defaultTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'];
// AUTONOMOUS MODE: Always bypass permissions and allow unrestricted tools
// Only restrict tools when no MCP servers are configured
const shouldRestrictTools = !hasMcpServers;
const sdkOptions: Options = {
model,
systemPrompt,
@@ -81,9 +85,10 @@ export class ClaudeProvider extends BaseProvider {
cwd,
// Pass only explicitly allowed environment variables to SDK
env: buildEnv(),
// Pass through allowedTools if provided by caller (decided by sdk-options.ts)
...(allowedTools && { allowedTools }),
// AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation
// Only restrict tools if explicitly set OR (no MCP / unrestricted disabled)
...(allowedTools && shouldRestrictTools && { allowedTools }),
...(!allowedTools && shouldRestrictTools && { allowedTools: defaultTools }),
// AUTONOMOUS MODE: Always bypass permissions and allow dangerous operations
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
abortController,
@@ -93,12 +98,12 @@ export class ClaudeProvider extends BaseProvider {
: {}),
// Forward settingSources for CLAUDE.md file loading
...(options.settingSources && { settingSources: options.settingSources }),
// Forward sandbox configuration
...(options.sandbox && { sandbox: options.sandbox }),
// 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 }),
};
// Build prompt payload

View File

@@ -1,85 +0,0 @@
/**
* 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, string>): 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<string, McpServerConfig>
): Promise<void> {
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');
}
}
}

View File

@@ -1,111 +0,0 @@
/**
* 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.gpt52Codex,
name: 'GPT-5.2-Codex',
modelString: CODEX_MODEL_MAP.gpt52Codex,
provider: 'openai',
description:
'Most advanced agentic coding model for complex software engineering (default for ChatGPT users).',
contextWindow: CONTEXT_WINDOW_256K,
maxOutputTokens: MAX_OUTPUT_32K,
supportsVision: true,
supportsTools: true,
tier: 'premium' as const,
default: true,
hasReasoning: true,
},
{
id: CODEX_MODEL_MAP.gpt51CodexMax,
name: 'GPT-5.1-Codex-Max',
modelString: CODEX_MODEL_MAP.gpt51CodexMax,
provider: 'openai',
description: 'Optimized for long-horizon, agentic coding tasks in Codex.',
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: 'Smaller, more cost-effective version for faster workflows.',
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: 'Best general agentic model for tasks across industries and domains.',
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,
},
];
/**
* 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);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,173 +0,0 @@
/**
* 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 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<ProviderMessage> {
try {
const apiKey = resolveApiKey();
const codex = new Codex({ apiKey });
// Resume existing thread or start new one
let thread;
if (options.sdkSessionId) {
try {
thread = codex.resumeThread(options.sdkSessionId);
} catch {
// If resume fails, start a new thread
thread = codex.startThread();
}
} else {
thread = codex.startThread();
}
const promptText = buildPromptText(options, systemPrompt);
// Build run options with reasoning effort if supported
const runOptions: {
signal?: AbortSignal;
reasoning?: { effort: string };
} = {
signal: options.abortController?.signal,
};
// Add reasoning effort if model supports it and reasoningEffort is specified
if (
options.reasoningEffort &&
supportsReasoningEffort(options.model) &&
options.reasoningEffort !== 'none'
) {
runOptions.reasoning = { effort: options.reasoningEffort };
}
// 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);
const combinedMessage = buildSdkErrorMessage(errorInfo.message, userMessage);
console.error('[CodexSDK] executeQuery() error during execution:', {
type: errorInfo.type,
message: errorInfo.message,
isRateLimit: errorInfo.isRateLimit,
retryAfter: errorInfo.retryAfter,
stack: error instanceof Error ? error.stack : undefined,
});
yield { type: 'error', error: combinedMessage };
}
}

View File

@@ -1,436 +0,0 @@
export type CodexToolResolution = {
name: string;
input: Record<string, unknown>;
};
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*(?:\[(?<status>[ x~])\]\s*)?(?<content>.+)$/;
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<string, unknown> {
return filePath ? { [INPUT_KEY_FILE_PATH]: filePath } : {};
}
function buildInputWithPattern(pattern: string | null): Record<string, unknown> {
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<string, unknown>;
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<string, unknown>): 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;
}

View File

@@ -28,9 +28,7 @@ import type {
ModelDefinition,
ContentBlock,
} from './types.js';
import { validateBareModelId } from '@automaker/types';
import { validateApiKey } from '../lib/auth-utils.js';
import { getEffectivePermissions } from '../services/cursor-config-service.js';
import { stripProviderPrefix } from '@automaker/types';
import {
type CursorStreamEvent,
type CursorSystemEvent,
@@ -317,25 +315,18 @@ export class CursorProvider extends CliProvider {
}
buildCliArgs(options: ExecuteOptions): string[] {
// Model is already bare (no prefix) - validated by executeQuery
const model = options.model || 'auto';
// Extract model (strip 'cursor-' prefix if present)
const model = stripProviderPrefix(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(
const cliArgs: string[] = [
'-p', // Print mode (non-interactive)
'--output-format',
'stream-json',
'--stream-partial-output' // Real-time streaming
);
'--stream-partial-output', // Real-time streaming
];
// Only add --force if NOT in read-only mode
// Without --force, Cursor CLI suggests changes but doesn't apply them
@@ -481,9 +472,7 @@ export class CursorProvider extends CliProvider {
// ==========================================================================
/**
* Override CLI detection to add Cursor-specific checks:
* 1. Versions directory for cursor-agent installations
* 2. Cursor IDE with 'cursor agent' subcommand support
* Override CLI detection to add Cursor-specific versions directory check
*/
protected detectCli(): CliDetectionResult {
// First try standard detection (PATH, common paths, WSL)
@@ -518,39 +507,6 @@ export class CursorProvider extends CliProvider {
}
}
// 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
if (process.platform !== 'win32') {
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;
}
@@ -649,10 +605,6 @@ export class CursorProvider extends CliProvider {
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
this.ensureCliDetected();
// Validate that model doesn't have a provider prefix
// AgentService should strip prefixes before passing to providers
validateBareModelId(options.model, 'CursorProvider');
if (!this.cliPath) {
throw this.createError(
CursorErrorCode.NOT_INSTALLED,
@@ -690,9 +642,6 @@ export class CursorProvider extends CliProvider {
logger.debug(`CursorProvider.executeQuery called with model: "${options.model}"`);
// Get effective permissions for this project
const effectivePermissions = await getEffectivePermissions(options.cwd || process.cwd());
// Debug: log raw events when AUTOMAKER_DEBUG_RAW_OUTPUT is enabled
const debugRawEvents =
process.env.AUTOMAKER_DEBUG_RAW_OUTPUT === 'true' ||
@@ -889,16 +838,9 @@ export class CursorProvider extends CliProvider {
});
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, {
const result = execSync(`"${this.cliPath}" --version`, {
encoding: 'utf8',
timeout: 5000,
stdio: 'pipe',
}).trim();
return result;
} catch {
@@ -915,13 +857,8 @@ export class CursorProvider extends CliProvider {
return { authenticated: false, method: 'none' };
}
// Check for API key in environment with validation
// Check for API key in environment
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' };
}

View File

@@ -25,8 +25,5 @@ export { ClaudeProvider } from './claude-provider.js';
export { CursorProvider, CursorErrorCode, CursorError } from './cursor-provider.js';
export { CursorConfigManager } from './cursor-config-manager.js';
// OpenCode provider
export { OpencodeProvider } from './opencode-provider.js';
// Provider factory
export { ProviderFactory } from './provider-factory.js';

View File

@@ -1,666 +0,0 @@
/**
* OpenCode Provider - Executes queries using opencode CLI
*
* Extends CliProvider with OpenCode-specific configuration:
* - Event normalization for OpenCode's stream-json format
* - Model definitions for anthropic, openai, and google models
* - 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 { CliProvider, type CliSpawnConfig } from './cli-provider.js';
import type {
ProviderConfig,
ExecuteOptions,
ProviderMessage,
ModelDefinition,
InstallationStatus,
ContentBlock,
} from '@automaker/types';
import { stripProviderPrefix } from '@automaker/types';
import { type SubprocessOptions, getOpenCodeAuthIndicators } from '@automaker/platform';
// =============================================================================
// OpenCode Auth Types
// =============================================================================
export interface OpenCodeAuthStatus {
authenticated: boolean;
method: 'api_key' | 'oauth' | 'none';
hasOAuthToken?: boolean;
hasApiKey?: boolean;
}
// =============================================================================
// OpenCode Stream Event Types
// =============================================================================
/**
* Base interface for all OpenCode stream events
*/
interface OpenCodeBaseEvent {
/** Event type identifier */
type: string;
/** Optional session identifier */
session_id?: string;
}
/**
* Text delta event - Incremental text output from the model
*/
export interface OpenCodeTextDeltaEvent extends OpenCodeBaseEvent {
type: 'text-delta';
/** The incremental text content */
text: string;
}
/**
* Text end event - Signals completion of text generation
*/
export interface OpenCodeTextEndEvent extends OpenCodeBaseEvent {
type: 'text-end';
}
/**
* Tool call event - Request to execute a tool
*/
export interface OpenCodeToolCallEvent extends OpenCodeBaseEvent {
type: 'tool-call';
/** Unique identifier for this tool call */
call_id?: string;
/** Tool name to invoke */
name: string;
/** Arguments to pass to the tool */
args: unknown;
}
/**
* Tool result event - Output from a tool execution
*/
export interface OpenCodeToolResultEvent extends OpenCodeBaseEvent {
type: 'tool-result';
/** The tool call ID this result corresponds to */
call_id?: string;
/** Output from the tool execution */
output: string;
}
/**
* Tool error event - Tool execution failed
*/
export interface OpenCodeToolErrorEvent extends OpenCodeBaseEvent {
type: 'tool-error';
/** The tool call ID that failed */
call_id?: string;
/** Error message describing the failure */
error: string;
}
/**
* Start step event - Begins an agentic loop iteration
*/
export interface OpenCodeStartStepEvent extends OpenCodeBaseEvent {
type: 'start-step';
/** Step number in the agentic loop */
step?: number;
}
/**
* Finish step event - Completes an agentic loop iteration
*/
export interface OpenCodeFinishStepEvent extends OpenCodeBaseEvent {
type: 'finish-step';
/** Step number that completed */
step?: number;
/** Whether the step completed successfully */
success?: boolean;
/** Optional result data */
result?: string;
/** Optional error if step failed */
error?: string;
}
/**
* Union type of all OpenCode stream events
*/
export type OpenCodeStreamEvent =
| OpenCodeTextDeltaEvent
| OpenCodeTextEndEvent
| OpenCodeToolCallEvent
| OpenCodeToolResultEvent
| OpenCodeToolErrorEvent
| OpenCodeStartStepEvent
| OpenCodeFinishStepEvent;
// =============================================================================
// 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.
*/
export class OpencodeProvider extends CliProvider {
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', 'stream-json' for JSONL streaming output
* - '-q' / '--quiet' to suppress spinner and interactive elements
* - '-c', '<cwd>' for working directory
* - '--model', '<model>' for model selection (if specified)
* - '-' as final arg to read prompt from stdin
*
* The prompt is NOT included in CLI args - it's passed via stdin to avoid
* shell escaping issues with special characters in content.
*
* @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 streaming JSON output format for JSONL parsing
args.push('--format', 'stream-json');
// Suppress spinner and interactive elements for non-TTY usage
args.push('-q');
// Set working directory
if (options.cwd) {
args.push('-c', options.cwd);
}
// Handle model selection
// Strip 'opencode-' prefix if present, OpenCode uses format like 'anthropic/claude-sonnet-4-5'
if (options.model) {
const model = stripProviderPrefix(options.model);
args.push('--model', model);
}
// Use '-' to indicate reading prompt from stdin
// This avoids shell escaping issues with special characters
args.push('-');
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;
}
/**
* Normalize a raw CLI event to ProviderMessage format
*
* Maps OpenCode event types to the standard ProviderMessage structure:
* - text-delta -> type: 'assistant', content with type: 'text'
* - text-end -> null (informational, no message needed)
* - tool-call -> type: 'assistant', content with type: 'tool_use'
* - tool-result -> type: 'assistant', content with type: 'tool_result'
* - tool-error -> type: 'error'
* - start-step -> null (informational, no message needed)
* - finish-step with success -> type: 'result', subtype: 'success'
* - finish-step with 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-delta': {
const textEvent = openCodeEvent as OpenCodeTextDeltaEvent;
// Skip empty text deltas
if (!textEvent.text) {
return null;
}
const content: ContentBlock[] = [
{
type: 'text',
text: textEvent.text,
},
];
return {
type: 'assistant',
session_id: textEvent.session_id,
message: {
role: 'assistant',
content,
},
};
}
case 'text-end': {
// Text end is informational - no message needed
return null;
}
case 'tool-call': {
const toolEvent = openCodeEvent as OpenCodeToolCallEvent;
// Generate a tool use ID if not provided
const toolUseId = toolEvent.call_id || generateToolUseId();
const content: ContentBlock[] = [
{
type: 'tool_use',
name: toolEvent.name,
tool_use_id: toolUseId,
input: toolEvent.args,
},
];
return {
type: 'assistant',
session_id: toolEvent.session_id,
message: {
role: 'assistant',
content,
},
};
}
case 'tool-result': {
const resultEvent = openCodeEvent as OpenCodeToolResultEvent;
const content: ContentBlock[] = [
{
type: 'tool_result',
tool_use_id: resultEvent.call_id,
content: resultEvent.output,
},
];
return {
type: 'assistant',
session_id: resultEvent.session_id,
message: {
role: 'assistant',
content,
},
};
}
case 'tool-error': {
const errorEvent = openCodeEvent as OpenCodeToolErrorEvent;
return {
type: 'error',
session_id: errorEvent.session_id,
error: errorEvent.error || 'Tool execution failed',
};
}
case 'start-step': {
// Start step is informational - no message needed
return null;
}
case 'finish-step': {
const finishEvent = openCodeEvent as OpenCodeFinishStepEvent;
// Check if the step failed
if (finishEvent.success === false || finishEvent.error) {
return {
type: 'error',
session_id: finishEvent.session_id,
error: finishEvent.error || 'Step execution failed',
};
}
// Successful completion
return {
type: 'result',
subtype: 'success',
session_id: finishEvent.session_id,
result: finishEvent.result,
};
}
default: {
// Unknown event type - skip it
return null;
}
}
}
// ==========================================================================
// Model Configuration
// ==========================================================================
/**
* Get available models for OpenCode
*
* Returns model definitions for supported AI providers:
* - Anthropic Claude models (Sonnet, Opus, Haiku)
* - OpenAI GPT-4o
* - Google Gemini 2.5 Pro
*/
getAvailableModels(): 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',
},
{
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/grok-code',
name: 'Grok Code (Free)',
modelString: 'opencode/grok-code',
provider: 'opencode',
description: 'OpenCode free tier Grok model for coding',
supportsTools: true,
supportsVision: false,
tier: 'basic',
},
// Amazon Bedrock - Claude Models
{
id: 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0',
name: 'Claude Sonnet 4.5 (Bedrock)',
modelString: 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0',
provider: 'opencode',
description: 'Latest Claude Sonnet via AWS Bedrock - fast and intelligent',
supportsTools: true,
supportsVision: true,
tier: 'premium',
default: true,
},
{
id: 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0',
name: 'Claude Opus 4.5 (Bedrock)',
modelString: 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0',
provider: 'opencode',
description: 'Most capable Claude model via AWS Bedrock',
supportsTools: true,
supportsVision: true,
tier: 'premium',
},
{
id: 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0',
name: 'Claude Haiku 4.5 (Bedrock)',
modelString: 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0',
provider: 'opencode',
description: 'Fastest Claude model via AWS Bedrock',
supportsTools: true,
supportsVision: true,
tier: 'standard',
},
// Amazon Bedrock - DeepSeek Models
{
id: 'amazon-bedrock/deepseek.r1-v1:0',
name: 'DeepSeek R1 (Bedrock)',
modelString: 'amazon-bedrock/deepseek.r1-v1:0',
provider: 'opencode',
description: 'DeepSeek R1 reasoning model - excellent for coding',
supportsTools: true,
supportsVision: false,
tier: 'premium',
},
// Amazon Bedrock - Amazon Nova Models
{
id: 'amazon-bedrock/amazon.nova-pro-v1:0',
name: 'Amazon Nova Pro (Bedrock)',
modelString: 'amazon-bedrock/amazon.nova-pro-v1:0',
provider: 'opencode',
description: 'Amazon Nova Pro - balanced performance',
supportsTools: true,
supportsVision: true,
tier: 'standard',
},
// Amazon Bedrock - Meta Llama Models
{
id: 'amazon-bedrock/meta.llama4-maverick-17b-instruct-v1:0',
name: 'Llama 4 Maverick 17B (Bedrock)',
modelString: 'amazon-bedrock/meta.llama4-maverick-17b-instruct-v1:0',
provider: 'opencode',
description: 'Meta Llama 4 Maverick via AWS Bedrock',
supportsTools: true,
supportsVision: false,
tier: 'standard',
},
// Amazon Bedrock - Qwen Models
{
id: 'amazon-bedrock/qwen.qwen3-coder-480b-a35b-v1:0',
name: 'Qwen3 Coder 480B (Bedrock)',
modelString: 'amazon-bedrock/qwen.qwen3-coder-480b-a35b-v1:0',
provider: 'opencode',
description: 'Qwen3 Coder 480B - excellent for coding',
supportsTools: true,
supportsVision: false,
tier: 'premium',
},
];
}
// ==========================================================================
// 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<OpenCodeAuthStatus> {
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<InstallationStatus> {
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,
};
}
}

View File

@@ -7,27 +7,7 @@
import { BaseProvider } from './base-provider.js';
import type { InstallationStatus, ModelDefinition } from './types.js';
import { isCursorModel, isCodexModel, isOpencodeModel, type ModelProvider } from '@automaker/types';
import * as fs from 'fs';
import * as path from 'path';
const DISCONNECTED_MARKERS: Record<string, string> = {
claude: '.claude-disconnected',
codex: '.codex-disconnected',
cursor: '.cursor-disconnected',
opencode: '.opencode-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);
}
import { isCursorModel, type ModelProvider } from '@automaker/types';
/**
* Provider registration entry
@@ -95,26 +75,10 @@ export class ProviderFactory {
* Get the appropriate provider for a given model ID
*
* @param modelId Model identifier (e.g., "claude-opus-4-5-20251101", "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 {
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.`
);
}
static getProviderForModel(modelId: string): BaseProvider {
const providerName = this.getProviderNameForModel(modelId);
const provider = this.getProviderByName(providerName);
if (!provider) {
@@ -129,35 +93,6 @@ export class ProviderFactory {
return provider;
}
/**
* Get the provider name for a given model ID (without creating provider instance)
*/
static getProviderForModelName(modelId: string): string {
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
*/
@@ -221,41 +156,6 @@ export class ProviderFactory {
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)-/, '') ||
model.modelString === modelId.replace(/-(claude|cursor|codex)$/, '')
) {
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;
}
}
// =============================================================================
@@ -265,8 +165,6 @@ export class ProviderFactory {
// Import providers for registration side-effects
import { ClaudeProvider } from './claude-provider.js';
import { CursorProvider } from './cursor-provider.js';
import { CodexProvider } from './codex-provider.js';
import { OpencodeProvider } from './opencode-provider.js';
// Register Claude provider
registerProvider('claude', {
@@ -286,18 +184,3 @@ registerProvider('cursor', {
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)
});

View File

@@ -9,7 +9,7 @@ import { query } from '@anthropic-ai/claude-agent-sdk';
import * as secureFs from '../../lib/secure-fs.js';
import type { EventEmitter } from '../../lib/events.js';
import { createLogger } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types';
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { createFeatureGenerationOptions } from '../../lib/sdk-options.js';
import { ProviderFactory } from '../../providers/provider-factory.js';
@@ -124,8 +124,6 @@ IMPORTANT: Do not ask for clarification. The specification is provided above. Ge
logger.info('[FeatureGeneration] Using Cursor provider');
const provider = ProviderFactory.getProviderForModel(model);
// Strip provider prefix - providers expect bare model IDs
const bareModel = stripProviderPrefix(model);
// Add explicit instructions for Cursor to return JSON in response
const cursorPrompt = `${prompt}
@@ -137,7 +135,7 @@ CRITICAL INSTRUCTIONS:
for await (const msg of provider.executeQuery({
prompt: cursorPrompt,
model: bareModel,
model,
cwd: projectPath,
maxTurns: 250,
allowedTools: ['Read', 'Glob', 'Grep'],

View File

@@ -16,7 +16,7 @@ import {
type SpecOutput,
} from '../../lib/app-spec-format.js';
import { createLogger } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types';
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { createSpecGenerationOptions } from '../../lib/sdk-options.js';
import { extractJson } from '../../lib/json-extractor.js';
@@ -118,8 +118,6 @@ ${getStructuredSpecPromptInstruction()}`;
logger.info('[SpecGeneration] Using Cursor provider');
const provider = ProviderFactory.getProviderForModel(model);
// Strip provider prefix - providers expect bare model IDs
const bareModel = stripProviderPrefix(model);
// For Cursor, include the JSON schema in the prompt with clear instructions
// to return JSON in the response (not write to a file)
@@ -136,7 +134,7 @@ Your entire response should be valid JSON starting with { and ending with }. No
for await (const msg of provider.executeQuery({
prompt: cursorPrompt,
model: bareModel,
model,
cwd: projectPath,
maxTurns: 250,
allowedTools: ['Read', 'Glob', 'Grep'],

View File

@@ -229,13 +229,12 @@ export function createAuthRoutes(): Router {
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),
// Clear the cookie
res.clearCookie(cookieName, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/',
});
res.json({

View File

@@ -31,9 +31,7 @@ export function createFollowUpFeatureHandler(autoModeService: AutoModeService) {
// Start follow-up in background
// followUpFeature derives workDir from feature.branchName
autoModeService
// Default to false to match run-feature/resume-feature behavior.
// Worktrees should only be used when explicitly enabled by the user.
.followUpFeature(projectPath, featureId, prompt, imagePaths, useWorktrees ?? false)
.followUpFeature(projectPath, featureId, prompt, imagePaths, useWorktrees ?? true)
.catch((error) => {
logger.error(`[AutoMode] Follow up feature ${featureId} error:`, error);
})

View File

@@ -7,12 +7,7 @@
import type { EventEmitter } from '../../lib/events.js';
import type { Feature, BacklogPlanResult, BacklogChange, DependencyUpdate } from '@automaker/types';
import {
DEFAULT_PHASE_MODELS,
isCursorModel,
stripProviderPrefix,
type ThinkingLevel,
} from '@automaker/types';
import { DEFAULT_PHASE_MODELS, isCursorModel, type ThinkingLevel } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { FeatureLoader } from '../../services/feature-loader.js';
import { ProviderFactory } from '../../providers/provider-factory.js';
@@ -125,8 +120,6 @@ export async function generateBacklogPlan(
logger.info('[BacklogPlan] Using model:', effectiveModel);
const provider = ProviderFactory.getProviderForModel(effectiveModel);
// Strip provider prefix - providers expect bare model IDs
const bareModel = stripProviderPrefix(effectiveModel);
// Get autoLoadClaudeMd setting
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
@@ -158,7 +151,7 @@ ${userPrompt}`;
// Execute the query
const stream = provider.executeQuery({
prompt: finalPrompt,
model: bareModel,
model: effectiveModel,
cwd: projectPath,
systemPrompt: finalSystemPrompt,
maxTurns: 1,

View File

@@ -13,10 +13,7 @@ export function createClaudeRoutes(service: ClaudeUsageService): Router {
// 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({
res.status(503).json({
error: 'Claude CLI not found',
message: "Please install Claude Code CLI and run 'claude login' to authenticate",
});
@@ -29,13 +26,12 @@ export function createClaudeRoutes(service: ClaudeUsageService): Router {
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({
res.status(401).json({
error: 'Authentication required',
message: "Please run 'claude login' to authenticate",
});
} else if (message.includes('timed out')) {
res.status(200).json({
res.status(504).json({
error: 'Command timed out',
message: 'The Claude CLI took too long to respond',
});

View File

@@ -1,56 +0,0 @@
import { Router, Request, Response } from 'express';
import { CodexUsageService } from '../../services/codex-usage-service.js';
import { createLogger } from '@automaker/utils';
const logger = createLogger('Codex');
export function createCodexRoutes(service: CodexUsageService): 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 service.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 service.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 });
}
}
});
return router;
}

View File

@@ -13,7 +13,7 @@
import type { Request, Response } from 'express';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { createLogger } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types';
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
import { PathNotAllowedError } from '@automaker/platform';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { createCustomOptions } from '../../../lib/sdk-options.js';
@@ -198,8 +198,6 @@ File: ${fileName}${truncated ? ' (truncated)' : ''}`;
logger.info(`Using Cursor provider for model: ${model}`);
const provider = ProviderFactory.getProviderForModel(model);
// Strip provider prefix - providers expect bare model IDs
const bareModel = stripProviderPrefix(model);
// Build a simple text prompt for Cursor (no multi-part content blocks)
const cursorPrompt = `${instructionText}\n\n--- FILE CONTENT ---\n${contentToAnalyze}`;
@@ -207,7 +205,7 @@ File: ${fileName}${truncated ? ' (truncated)' : ''}`;
let responseText = '';
for await (const msg of provider.executeQuery({
prompt: cursorPrompt,
model: bareModel,
model,
cwd,
maxTurns: 1,
allowedTools: [],
@@ -234,6 +232,7 @@ File: ${fileName}${truncated ? ' (truncated)' : ''}`;
maxTurns: 1,
allowedTools: [],
autoLoadClaudeMd,
sandbox: { enabled: true, autoAllowBashIfSandboxed: true },
thinkingLevel, // Pass thinking level for extended thinking
});

View File

@@ -14,7 +14,7 @@
import type { Request, Response } from 'express';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { createLogger, readImageAsBase64 } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types';
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { createCustomOptions } from '../../../lib/sdk-options.js';
import { ProviderFactory } from '../../../providers/provider-factory.js';
@@ -357,8 +357,6 @@ export function createDescribeImageHandler(
logger.info(`[${requestId}] Using Cursor provider for model: ${model}`);
const provider = ProviderFactory.getProviderForModel(model);
// Strip provider prefix - providers expect bare model IDs
const bareModel = stripProviderPrefix(model);
// Build prompt with image reference for Cursor
// Note: Cursor CLI may not support base64 image blocks directly,
@@ -369,7 +367,7 @@ export function createDescribeImageHandler(
const queryStart = Date.now();
for await (const msg of provider.executeQuery({
prompt: cursorPrompt,
model: bareModel,
model,
cwd,
maxTurns: 1,
allowedTools: ['Read'], // Allow Read tool so Cursor can read the image if needed
@@ -396,13 +394,14 @@ export function createDescribeImageHandler(
maxTurns: 1,
allowedTools: [],
autoLoadClaudeMd,
sandbox: { enabled: true, autoAllowBashIfSandboxed: true },
thinkingLevel, // Pass thinking level for extended thinking
});
logger.info(
`[${requestId}] SDK options model=${sdkOptions.model} maxTurns=${sdkOptions.maxTurns} allowedTools=${JSON.stringify(
sdkOptions.allowedTools
)}`
)} sandbox=${JSON.stringify(sdkOptions.sandbox)}`
);
const promptGenerator = (async function* () {

View File

@@ -0,0 +1,332 @@
/**
* Debug routes - HTTP API for debug panel and performance monitoring
*
* These routes are only enabled in development mode.
*/
import { Router } from 'express';
import type { EventEmitter } from '../../lib/events.js';
import { PerformanceMonitorService } from '../../services/performance-monitor-service.js';
import { ProcessRegistryService } from '../../services/process-registry-service.js';
import {
createGetMetricsHandler,
createStartMetricsHandler,
createStopMetricsHandler,
createForceGCHandler,
createClearHistoryHandler,
} from './routes/metrics.js';
import {
createGetProcessesHandler,
createGetProcessHandler,
createGetSummaryHandler,
createGetAgentsHandler,
createGetAgentMetricsHandler,
createGetAgentSummaryHandler,
} from './routes/processes.js';
export interface DebugServices {
performanceMonitor: PerformanceMonitorService;
processRegistry: ProcessRegistryService;
}
/**
* Create and initialize debug services
*/
export function createDebugServices(events: EventEmitter): DebugServices {
// Create services
const processRegistry = new ProcessRegistryService(events);
const performanceMonitor = new PerformanceMonitorService(events);
// Wire them together - performance monitor gets processes from registry
performanceMonitor.setProcessProvider(processRegistry.getProcessProvider());
// Subscribe to AutoMode events to track feature execution as processes
// Events are wrapped in 'auto-mode:event' with the actual type in data.type
events.subscribe((eventType, data) => {
// Handle auto-mode:event
if (eventType === 'auto-mode:event') {
handleAutoModeEvent(processRegistry, data);
return;
}
// Handle agent:stream events for chat sessions
if (eventType === 'agent:stream') {
handleAgentStreamEvent(processRegistry, data);
return;
}
});
/**
* Handle AutoMode events for feature execution tracking
*/
function handleAutoModeEvent(registry: ProcessRegistryService, data: unknown): void {
const eventData = data as { type?: string; [key: string]: unknown };
const innerType = eventData.type;
if (innerType === 'auto_mode_feature_start') {
const { featureId, projectPath, feature, model } = eventData as {
featureId: string;
projectPath: string;
feature?: { id: string; title: string; description?: string };
model?: string;
};
// Register the feature as a tracked process
// Use -1 for pid since this isn't a real OS process
registry.registerProcess({
id: `agent-${featureId}`,
pid: -1,
type: 'agent',
name: feature?.title || `Feature ${featureId}`,
featureId,
cwd: projectPath,
command: model ? `claude ${model}` : 'claude agent',
});
// Initialize resource metrics
registry.initializeAgentMetrics(`agent-${featureId}`, { featureId });
// Mark it as running
registry.markRunning(`agent-${featureId}`);
} else if (innerType === 'auto_mode_feature_complete') {
const { featureId, passes, message } = eventData as {
featureId: string;
passes: boolean;
message?: string;
};
const processId = `agent-${featureId}`;
if (registry.hasProcess(processId)) {
// Finalize the metrics before marking as stopped
registry.finalizeAgentMetrics(processId);
if (passes) {
registry.markStopped(processId, 0);
} else {
registry.markError(processId, message || 'Feature failed');
}
}
} else if (innerType === 'auto_mode_error') {
const { featureId, error } = eventData as {
featureId?: string;
error: string;
};
if (featureId) {
const processId = `agent-${featureId}`;
if (registry.hasProcess(processId)) {
registry.finalizeAgentMetrics(processId);
registry.markError(processId, error);
}
}
} else if (innerType === 'auto_mode_tool_use') {
// Track tool usage for the feature
const { featureId, tool } = eventData as {
featureId: string;
tool: { name: string; input?: unknown };
};
const processId = `agent-${featureId}`;
if (registry.hasProcess(processId)) {
registry.recordToolUse(processId, { toolName: tool.name });
// Record file operations based on tool type
if (tool.name === 'Read' && tool.input) {
const input = tool.input as { file_path?: string };
if (input.file_path) {
registry.recordFileOperation(processId, {
operation: 'read',
filePath: input.file_path,
});
}
} else if (tool.name === 'Write' && tool.input) {
const input = tool.input as { file_path?: string; content?: string };
if (input.file_path) {
registry.recordFileOperation(processId, {
operation: 'write',
filePath: input.file_path,
bytes: input.content?.length,
});
}
} else if (tool.name === 'Edit' && tool.input) {
const input = tool.input as { file_path?: string; new_string?: string };
if (input.file_path) {
registry.recordFileOperation(processId, {
operation: 'edit',
filePath: input.file_path,
bytes: input.new_string?.length,
});
}
} else if (tool.name === 'Glob') {
const input = tool.input as { path?: string };
registry.recordFileOperation(processId, {
operation: 'glob',
filePath: input?.path || '.',
});
} else if (tool.name === 'Grep') {
const input = tool.input as { path?: string };
registry.recordFileOperation(processId, {
operation: 'grep',
filePath: input?.path || '.',
});
} else if (tool.name === 'Bash' && tool.input) {
const input = tool.input as { command?: string };
if (input.command) {
registry.recordBashCommand(processId, {
command: input.command,
executionTime: 0, // Will be updated on completion
exitCode: null,
});
}
}
}
}
}
/**
* Handle agent:stream events for chat session tracking
*/
function handleAgentStreamEvent(registry: ProcessRegistryService, data: unknown): void {
const eventData = data as {
sessionId?: string;
type?: string;
tool?: { name: string; input?: unknown };
[key: string]: unknown;
};
const { sessionId, type } = eventData;
if (!sessionId) return;
const processId = `chat-${sessionId}`;
// Register chat session as a process if not already tracked
if (!registry.hasProcess(processId) && type !== 'complete' && type !== 'error') {
registry.registerProcess({
id: processId,
pid: -1,
type: 'agent',
name: `Chat Session`,
sessionId,
command: 'claude chat',
});
registry.initializeAgentMetrics(processId, { sessionId });
registry.markRunning(processId);
}
// Handle different event types
if (type === 'tool_use' && eventData.tool) {
const tool = eventData.tool;
registry.recordToolUse(processId, { toolName: tool.name });
// Record file operations based on tool type
if (tool.name === 'Read' && tool.input) {
const input = tool.input as { file_path?: string };
if (input.file_path) {
registry.recordFileOperation(processId, {
operation: 'read',
filePath: input.file_path,
});
}
} else if (tool.name === 'Write' && tool.input) {
const input = tool.input as { file_path?: string; content?: string };
if (input.file_path) {
registry.recordFileOperation(processId, {
operation: 'write',
filePath: input.file_path,
bytes: input.content?.length,
});
}
} else if (tool.name === 'Edit' && tool.input) {
const input = tool.input as { file_path?: string; new_string?: string };
if (input.file_path) {
registry.recordFileOperation(processId, {
operation: 'edit',
filePath: input.file_path,
bytes: input.new_string?.length,
});
}
} else if (tool.name === 'Glob') {
const input = tool.input as { path?: string };
registry.recordFileOperation(processId, {
operation: 'glob',
filePath: input?.path || '.',
});
} else if (tool.name === 'Grep') {
const input = tool.input as { path?: string };
registry.recordFileOperation(processId, {
operation: 'grep',
filePath: input?.path || '.',
});
} else if (tool.name === 'Bash' && tool.input) {
const input = tool.input as { command?: string };
if (input.command) {
registry.recordBashCommand(processId, {
command: input.command,
executionTime: 0,
exitCode: null,
});
}
}
} else if (type === 'complete') {
if (registry.hasProcess(processId)) {
registry.finalizeAgentMetrics(processId);
// Keep the session as "idle" rather than "stopped" since it can receive more messages
registry.markIdle(processId);
}
} else if (type === 'error') {
if (registry.hasProcess(processId)) {
registry.finalizeAgentMetrics(processId);
const errorMsg = (eventData.error as string) || 'Unknown error';
registry.markError(processId, errorMsg);
}
}
}
// Start services
processRegistry.start();
performanceMonitor.start();
return {
performanceMonitor,
processRegistry,
};
}
/**
* Stop debug services
*/
export function stopDebugServices(services: DebugServices): void {
services.performanceMonitor.stop();
services.processRegistry.stop();
}
/**
* Create debug routes
*/
export function createDebugRoutes(services: DebugServices): Router {
const router = Router();
const { performanceMonitor, processRegistry } = services;
// Metrics routes
router.get('/metrics', createGetMetricsHandler(performanceMonitor));
router.post('/metrics/start', createStartMetricsHandler(performanceMonitor));
router.post('/metrics/stop', createStopMetricsHandler(performanceMonitor));
router.post('/metrics/gc', createForceGCHandler(performanceMonitor));
router.post('/metrics/clear', createClearHistoryHandler(performanceMonitor));
// Process routes
router.get('/processes', createGetProcessesHandler(processRegistry));
router.get('/processes/summary', createGetSummaryHandler(processRegistry));
router.get('/processes/:id', createGetProcessHandler(processRegistry));
// Agent resource metrics routes
router.get('/agents', createGetAgentsHandler(processRegistry));
router.get('/agents/summary', createGetAgentSummaryHandler(processRegistry));
router.get('/agents/:id/metrics', createGetAgentMetricsHandler(processRegistry));
return router;
}
// Re-export services for use elsewhere
export { PerformanceMonitorService } from '../../services/performance-monitor-service.js';
export { ProcessRegistryService } from '../../services/process-registry-service.js';

View File

@@ -0,0 +1,152 @@
/**
* Debug metrics route handler
*
* GET /api/debug/metrics - Get current metrics snapshot
* POST /api/debug/metrics/start - Start metrics collection
* POST /api/debug/metrics/stop - Stop metrics collection
*/
import type { Request, Response } from 'express';
import type { PerformanceMonitorService } from '../../../services/performance-monitor-service.js';
import type { StartDebugMetricsRequest, DebugMetricsResponse } from '@automaker/types';
/**
* Create handler for GET /api/debug/metrics
* Returns current metrics snapshot
*/
export function createGetMetricsHandler(performanceMonitor: PerformanceMonitorService) {
return (_req: Request, res: Response) => {
const snapshot = performanceMonitor.getLatestSnapshot();
const config = performanceMonitor.getConfig();
const active = performanceMonitor.isActive();
const response: DebugMetricsResponse = {
active,
config,
snapshot: snapshot ?? undefined,
};
res.json(response);
};
}
/**
* Validate and sanitize debug metrics config values
* Prevents DoS via extreme configuration values
*/
function sanitizeConfig(
config: Partial<import('@automaker/types').DebugMetricsConfig>
): Partial<import('@automaker/types').DebugMetricsConfig> {
const sanitized: Partial<import('@automaker/types').DebugMetricsConfig> = {};
// Collection interval: min 100ms, max 60s (prevents CPU exhaustion)
if (typeof config.collectionInterval === 'number') {
sanitized.collectionInterval = Math.min(
60000,
Math.max(100, Math.floor(config.collectionInterval))
);
}
// Max data points: min 10, max 10000 (prevents memory exhaustion)
if (typeof config.maxDataPoints === 'number') {
sanitized.maxDataPoints = Math.min(10000, Math.max(10, Math.floor(config.maxDataPoints)));
}
// Leak threshold: min 1KB, max 100MB (reasonable bounds)
if (typeof config.leakThreshold === 'number') {
sanitized.leakThreshold = Math.min(
100 * 1024 * 1024,
Math.max(1024, Math.floor(config.leakThreshold))
);
}
// Boolean flags - only accept actual booleans
if (typeof config.memoryEnabled === 'boolean') {
sanitized.memoryEnabled = config.memoryEnabled;
}
if (typeof config.cpuEnabled === 'boolean') {
sanitized.cpuEnabled = config.cpuEnabled;
}
if (typeof config.processTrackingEnabled === 'boolean') {
sanitized.processTrackingEnabled = config.processTrackingEnabled;
}
return sanitized;
}
/**
* Create handler for POST /api/debug/metrics/start
* Starts metrics collection with optional config overrides
*/
export function createStartMetricsHandler(performanceMonitor: PerformanceMonitorService) {
return (req: Request, res: Response) => {
const body = req.body as StartDebugMetricsRequest | undefined;
// Update config if provided (with validation)
if (body?.config && typeof body.config === 'object') {
const sanitizedConfig = sanitizeConfig(body.config);
if (Object.keys(sanitizedConfig).length > 0) {
performanceMonitor.updateConfig(sanitizedConfig);
}
}
// Start collection
performanceMonitor.start();
const response: DebugMetricsResponse = {
active: true,
config: performanceMonitor.getConfig(),
};
res.json(response);
};
}
/**
* Create handler for POST /api/debug/metrics/stop
* Stops metrics collection
*/
export function createStopMetricsHandler(performanceMonitor: PerformanceMonitorService) {
return (_req: Request, res: Response) => {
performanceMonitor.stop();
const response: DebugMetricsResponse = {
active: false,
config: performanceMonitor.getConfig(),
};
res.json(response);
};
}
/**
* Create handler for POST /api/debug/metrics/gc
* Forces garbage collection if available
*/
export function createForceGCHandler(performanceMonitor: PerformanceMonitorService) {
return (_req: Request, res: Response) => {
const success = performanceMonitor.forceGC();
res.json({
success,
message: success
? 'Garbage collection triggered'
: 'Garbage collection not available (start Node.js with --expose-gc flag)',
});
};
}
/**
* Create handler for POST /api/debug/metrics/clear
* Clears metrics history
*/
export function createClearHistoryHandler(performanceMonitor: PerformanceMonitorService) {
return (_req: Request, res: Response) => {
performanceMonitor.clearHistory();
res.json({
success: true,
message: 'Metrics history cleared',
});
};
}

View File

@@ -0,0 +1,170 @@
/**
* Debug processes route handler
*
* GET /api/debug/processes - Get list of tracked processes
* GET /api/debug/processes/:id - Get specific process by ID
* POST /api/debug/processes/:id/terminate - Terminate a process
*/
import type { Request, Response } from 'express';
import type { ProcessRegistryService } from '../../../services/process-registry-service.js';
import type {
GetProcessesRequest,
GetProcessesResponse,
ProcessType,
ProcessStatus,
} from '@automaker/types';
/**
* Create handler for GET /api/debug/processes
* Returns list of tracked processes with optional filtering
*/
export function createGetProcessesHandler(processRegistry: ProcessRegistryService) {
return (req: Request, res: Response) => {
const query = req.query as {
type?: string;
status?: string;
includeStopped?: string;
sessionId?: string;
featureId?: string;
};
// Build query options
const options: GetProcessesRequest = {};
if (query.type) {
options.type = query.type as ProcessType;
}
if (query.status) {
options.status = query.status as ProcessStatus;
}
if (query.includeStopped === 'true') {
options.includeStoppedProcesses = true;
}
const processes = processRegistry.getProcesses({
type: options.type,
status: options.status,
includeStopped: options.includeStoppedProcesses,
sessionId: query.sessionId,
featureId: query.featureId,
});
const summary = processRegistry.getProcessSummary();
const response: GetProcessesResponse = {
processes,
summary,
};
res.json(response);
};
}
/**
* Validate process ID format
* Process IDs should be non-empty strings with reasonable length
*/
function isValidProcessId(id: unknown): id is string {
return typeof id === 'string' && id.length > 0 && id.length <= 256;
}
/**
* Create handler for GET /api/debug/processes/:id
* Returns a specific process by ID
*/
export function createGetProcessHandler(processRegistry: ProcessRegistryService) {
return (req: Request, res: Response) => {
const { id } = req.params;
// Validate process ID format
if (!isValidProcessId(id)) {
res.status(400).json({
error: 'Invalid process ID format',
});
return;
}
const process = processRegistry.getProcess(id);
if (!process) {
res.status(404).json({
error: 'Process not found',
id,
});
return;
}
res.json(process);
};
}
/**
* Create handler for GET /api/debug/processes/summary
* Returns summary statistics
*/
export function createGetSummaryHandler(processRegistry: ProcessRegistryService) {
return (_req: Request, res: Response) => {
const summary = processRegistry.getProcessSummary();
res.json(summary);
};
}
/**
* Create handler for GET /api/debug/agents
* Returns all agent processes with their resource metrics
*/
export function createGetAgentsHandler(processRegistry: ProcessRegistryService) {
return (_req: Request, res: Response) => {
const agents = processRegistry.getAgentProcessesWithMetrics();
const summary = processRegistry.getAgentResourceSummary();
res.json({
agents,
summary,
});
};
}
/**
* Create handler for GET /api/debug/agents/:id/metrics
* Returns detailed resource metrics for a specific agent
*/
export function createGetAgentMetricsHandler(processRegistry: ProcessRegistryService) {
return (req: Request, res: Response) => {
const { id } = req.params;
// Validate process ID format
if (!isValidProcessId(id)) {
res.status(400).json({
error: 'Invalid agent ID format',
});
return;
}
const metrics = processRegistry.getAgentMetrics(id);
if (!metrics) {
res.status(404).json({
error: 'Agent metrics not found',
id,
});
return;
}
res.json(metrics);
};
}
/**
* Create handler for GET /api/debug/agents/summary
* Returns summary of resource usage across all agents
*/
export function createGetAgentSummaryHandler(processRegistry: ProcessRegistryService) {
return (_req: Request, res: Response) => {
const summary = processRegistry.getAgentResourceSummary();
res.json(summary);
};
}

View File

@@ -12,7 +12,6 @@ import { resolveModelString } from '@automaker/model-resolver';
import {
CLAUDE_MODEL_MAP,
isCursorModel,
stripProviderPrefix,
ThinkingLevel,
getThinkingTokenBudget,
} from '@automaker/types';
@@ -99,14 +98,12 @@ async function extractTextFromStream(
*/
async function executeWithCursor(prompt: string, model: string): Promise<string> {
const provider = ProviderFactory.getProviderForModel(model);
// Strip provider prefix - providers expect bare model IDs
const bareModel = stripProviderPrefix(model);
let responseText = '';
for await (const msg of provider.executeQuery({
prompt,
model: bareModel,
model,
cwd: process.cwd(), // Enhancement doesn't need a specific working directory
readOnly: true, // Prompt enhancement only generates text, doesn't write files
})) {

View File

@@ -9,7 +9,6 @@ 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 { createDeleteHandler } from './routes/delete.js';
import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent-output.js';
import { createGenerateTitleHandler } from './routes/generate-title.js';
@@ -21,11 +20,6 @@ export function createFeaturesRoutes(featureLoader: FeatureLoader): Router {
router.post('/get', validatePathParams('projectPath'), createGetHandler(featureLoader));
router.post('/create', validatePathParams('projectPath'), createCreateHandler(featureLoader));
router.post('/update', validatePathParams('projectPath'), createUpdateHandler(featureLoader));
router.post(
'/bulk-update',
validatePathParams('projectPath'),
createBulkUpdateHandler(featureLoader)
);
router.post('/delete', validatePathParams('projectPath'), createDeleteHandler(featureLoader));
router.post('/agent-output', createAgentOutputHandler(featureLoader));
router.post('/raw-output', createRawOutputHandler(featureLoader));

View File

@@ -1,75 +0,0 @@
/**
* 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<Feature>;
}
interface BulkUpdateResult {
featureId: string;
success: boolean;
error?: string;
}
export function createBulkUpdateHandler(featureLoader: FeatureLoader) {
return async (req: Request, res: Response): Promise<void> => {
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[] = [];
for (const featureId of featureIds) {
try {
const updated = await featureLoader.update(projectPath, featureId, updates);
results.push({ featureId, success: true });
updatedFeatures.push(updated);
} catch (error) {
results.push({
featureId,
success: false,
error: getErrorMessage(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) });
}
};
}

View File

@@ -10,14 +10,11 @@ import { getErrorMessage, logError } from '../common.js';
export function createUpdateHandler(featureLoader: FeatureLoader) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureId, updates, descriptionHistorySource, enhancementMode } =
req.body as {
projectPath: string;
featureId: string;
updates: Partial<Feature>;
descriptionHistorySource?: 'enhance' | 'edit';
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance';
};
const { projectPath, featureId, updates } = req.body as {
projectPath: string;
featureId: string;
updates: Partial<Feature>;
};
if (!projectPath || !featureId || !updates) {
res.status(400).json({
@@ -27,13 +24,7 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
return;
}
const updated = await featureLoader.update(
projectPath,
featureId,
updates,
descriptionHistorySource,
enhancementMode
);
const updated = await featureLoader.update(projectPath, featureId, updates);
res.json({ success: true, feature: updated });
} catch (error) {
logError(error, 'Update feature failed');

View File

@@ -18,7 +18,7 @@ import type {
LinkedPRInfo,
ThinkingLevel,
} from '@automaker/types';
import { isCursorModel, DEFAULT_PHASE_MODELS, stripProviderPrefix } from '@automaker/types';
import { isCursorModel, DEFAULT_PHASE_MODELS } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { createSuggestionsOptions } from '../../../lib/sdk-options.js';
import { extractJson } from '../../../lib/json-extractor.js';
@@ -120,8 +120,6 @@ async function runValidation(
logger.info(`Using Cursor provider for validation with model: ${model}`);
const provider = ProviderFactory.getProviderForModel(model);
// Strip provider prefix - providers expect bare model IDs
const bareModel = stripProviderPrefix(model);
// For Cursor, include the system prompt and schema in the user prompt
const cursorPrompt = `${ISSUE_VALIDATION_SYSTEM_PROMPT}
@@ -139,7 +137,7 @@ ${prompt}`;
for await (const msg of provider.executeQuery({
prompt: cursorPrompt,
model: bareModel,
model,
cwd: projectPath,
readOnly: true, // Issue validation only reads code, doesn't write
})) {

View File

@@ -23,7 +23,6 @@ import { createGetProjectHandler } from './routes/get-project.js';
import { createUpdateProjectHandler } from './routes/update-project.js';
import { createMigrateHandler } from './routes/migrate.js';
import { createStatusHandler } from './routes/status.js';
import { createDiscoverAgentsHandler } from './routes/discover-agents.js';
/**
* Create settings router with all endpoints
@@ -40,7 +39,6 @@ import { createDiscoverAgentsHandler } from './routes/discover-agents.js';
* - POST /project - Get project settings (requires projectPath in body)
* - PUT /project - Update project settings
* - POST /migrate - Migrate settings from localStorage
* - POST /agents/discover - Discover filesystem agents from .claude/agents/ (read-only)
*
* @param settingsService - Instance of SettingsService for file I/O
* @returns Express Router configured with all settings endpoints
@@ -74,8 +72,5 @@ export function createSettingsRoutes(settingsService: SettingsService): Router {
// Migration from localStorage
router.post('/migrate', createMigrateHandler(settingsService));
// Filesystem agents discovery (read-only)
router.post('/agents/discover', createDiscoverAgentsHandler());
return router;
}

View File

@@ -1,61 +0,0 @@
/**
* Discover Agents Route - Returns filesystem-based agents from .claude/agents/
*
* Scans both user-level (~/.claude/agents/) and project-level (.claude/agents/)
* directories for AGENT.md files and returns parsed agent definitions.
*/
import type { Request, Response } from 'express';
import { discoverFilesystemAgents } from '../../../lib/agent-discovery.js';
import { createLogger } from '@automaker/utils';
const logger = createLogger('DiscoverAgentsRoute');
interface DiscoverAgentsRequest {
projectPath?: string;
sources?: Array<'user' | 'project'>;
}
/**
* Create handler for discovering filesystem agents
*
* POST /api/settings/agents/discover
* Body: { projectPath?: string, sources?: ['user', 'project'] }
*
* Returns:
* {
* success: true,
* agents: Array<{
* name: string,
* definition: AgentDefinition,
* source: 'user' | 'project',
* filePath: string
* }>
* }
*/
export function createDiscoverAgentsHandler() {
return async (req: Request, res: Response) => {
try {
const { projectPath, sources = ['user', 'project'] } = req.body as DiscoverAgentsRequest;
logger.info(
`Discovering agents from sources: ${sources.join(', ')}${projectPath ? ` (project: ${projectPath})` : ''}`
);
const agents = await discoverFilesystemAgents(projectPath, sources);
logger.info(`Discovered ${agents.length} filesystem agents`);
res.json({
success: true,
agents,
});
} catch (error) {
logger.error('Failed to discover agents:', error);
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to discover agents',
});
}
};
}

View File

@@ -11,7 +11,7 @@
import type { Request, Response } from 'express';
import type { SettingsService } from '../../../services/settings-service.js';
import type { GlobalSettings } from '../../../types/settings.js';
import { getErrorMessage, logError, logger } from '../common.js';
import { getErrorMessage, logError } from '../common.js';
/**
* Create handler factory for PUT /api/settings/global
@@ -32,18 +32,6 @@ export function createUpdateGlobalHandler(settingsService: SettingsService) {
return;
}
// Minimal debug logging to help diagnose accidental wipes.
if ('projects' in updates || 'theme' in updates || 'localStorageMigrated' in updates) {
const projectsLen = Array.isArray((updates as any).projects)
? (updates as any).projects.length
: undefined;
logger.info(
`Update global settings request: projects=${projectsLen ?? 'n/a'}, theme=${
(updates as any).theme ?? 'n/a'
}, localStorageMigrated=${(updates as any).localStorageMigrated ?? 'n/a'}`
);
}
const settings = await settingsService.updateGlobalSettings(updates);
res.json({

View File

@@ -6,24 +6,9 @@ import { exec } from 'child_process';
import { promisify } from 'util';
import { getClaudeCliPaths, getClaudeAuthIndicators, systemPathAccess } from '@automaker/platform';
import { getApiKey } from './common.js';
import * as fs from 'fs';
import * as path from 'path';
const execAsync = promisify(exec);
const DISCONNECTED_MARKER_FILE = '.claude-disconnected';
function isDisconnectedFromApp(): boolean {
try {
// Check if we're in a project directory
const projectRoot = process.cwd();
const markerPath = path.join(projectRoot, '.automaker', DISCONNECTED_MARKER_FILE);
return fs.existsSync(markerPath);
} catch {
return false;
}
}
export async function getClaudeStatus() {
let installed = false;
let version = '';
@@ -75,30 +60,6 @@ export async function getClaudeStatus() {
}
}
// Check if user has manually disconnected from the app
if (isDisconnectedFromApp()) {
return {
status: installed ? 'installed' : 'not_installed',
installed,
method,
version,
path: cliPath,
auth: {
authenticated: false,
method: 'none',
hasCredentialsFile: false,
hasToken: false,
hasStoredOAuthToken: false,
hasStoredApiKey: false,
hasEnvApiKey: false,
oauthTokenValid: false,
apiKeyValid: false,
hasCliAuth: false,
hasRecentActivity: false,
},
};
}
// Check authentication - detect all possible auth methods
// Note: apiKeys.anthropic_oauth_token stores OAuth tokens from subscription auth
// apiKeys.anthropic stores direct API keys for pay-per-use

View File

@@ -11,19 +11,8 @@ import { createDeleteApiKeyHandler } from './routes/delete-api-key.js';
import { createApiKeysHandler } from './routes/api-keys.js';
import { createPlatformHandler } from './routes/platform.js';
import { createVerifyClaudeAuthHandler } from './routes/verify-claude-auth.js';
import { createVerifyCodexAuthHandler } from './routes/verify-codex-auth.js';
import { createGhStatusHandler } from './routes/gh-status.js';
import { createCursorStatusHandler } from './routes/cursor-status.js';
import { createCodexStatusHandler } from './routes/codex-status.js';
import { createInstallCodexHandler } from './routes/install-codex.js';
import { createAuthCodexHandler } from './routes/auth-codex.js';
import { createAuthCursorHandler } from './routes/auth-cursor.js';
import { createDeauthClaudeHandler } from './routes/deauth-claude.js';
import { createDeauthCodexHandler } from './routes/deauth-codex.js';
import { createDeauthCursorHandler } from './routes/deauth-cursor.js';
import { createAuthOpencodeHandler } from './routes/auth-opencode.js';
import { createDeauthOpencodeHandler } from './routes/deauth-opencode.js';
import { createOpencodeStatusHandler } from './routes/opencode-status.js';
import {
createGetCursorConfigHandler,
createSetCursorDefaultModelHandler,
@@ -41,30 +30,15 @@ export function createSetupRoutes(): Router {
router.get('/claude-status', createClaudeStatusHandler());
router.post('/install-claude', createInstallClaudeHandler());
router.post('/auth-claude', createAuthClaudeHandler());
router.post('/deauth-claude', createDeauthClaudeHandler());
router.post('/store-api-key', createStoreApiKeyHandler());
router.post('/delete-api-key', createDeleteApiKeyHandler());
router.get('/api-keys', createApiKeysHandler());
router.get('/platform', createPlatformHandler());
router.post('/verify-claude-auth', createVerifyClaudeAuthHandler());
router.post('/verify-codex-auth', createVerifyCodexAuthHandler());
router.get('/gh-status', createGhStatusHandler());
// Cursor CLI routes
router.get('/cursor-status', createCursorStatusHandler());
router.post('/auth-cursor', createAuthCursorHandler());
router.post('/deauth-cursor', createDeauthCursorHandler());
// Codex CLI routes
router.get('/codex-status', createCodexStatusHandler());
router.post('/install-codex', createInstallCodexHandler());
router.post('/auth-codex', createAuthCodexHandler());
router.post('/deauth-codex', createDeauthCodexHandler());
// OpenCode CLI routes
router.get('/opencode-status', createOpencodeStatusHandler());
router.post('/auth-opencode', createAuthOpencodeHandler());
router.post('/deauth-opencode', createDeauthOpencodeHandler());
router.get('/cursor-config', createGetCursorConfigHandler());
router.post('/cursor-config/default-model', createSetCursorDefaultModelHandler());
router.post('/cursor-config/models', createSetCursorModelsHandler());

View File

@@ -11,7 +11,6 @@ export function createApiKeysHandler() {
res.json({
success: true,
hasAnthropicKey: !!getApiKey('anthropic') || !!process.env.ANTHROPIC_API_KEY,
hasOpenaiKey: !!getApiKey('openai') || !!process.env.OPENAI_API_KEY,
});
} catch (error) {
logError(error, 'Get API keys failed');

View File

@@ -4,54 +4,19 @@
import type { Request, Response } from 'express';
import { getErrorMessage, logError } from '../common.js';
import { exec } from 'child_process';
import { promisify } from 'util';
import * as fs from 'fs';
import * as path from 'path';
const execAsync = promisify(exec);
export function createAuthClaudeHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
// Remove the disconnected marker file to reconnect the app to the CLI
const markerPath = path.join(process.cwd(), '.automaker', '.claude-disconnected');
if (fs.existsSync(markerPath)) {
fs.unlinkSync(markerPath);
}
// Check if CLI is already authenticated by checking auth indicators
const { getClaudeAuthIndicators } = await import('@automaker/platform');
const indicators = await getClaudeAuthIndicators();
const isAlreadyAuthenticated =
indicators.hasStatsCacheWithActivity ||
(indicators.hasSettingsFile && indicators.hasProjectsSessions) ||
indicators.hasCredentialsFile;
if (isAlreadyAuthenticated) {
// CLI is already authenticated, just reconnect
res.json({
success: true,
message: 'Claude CLI is now linked with the app',
wasAlreadyAuthenticated: true,
});
} else {
// CLI needs authentication - but we can't run claude login here
// because it requires browser OAuth. Just reconnect and let the user authenticate if needed.
res.json({
success: true,
message:
'Claude CLI is now linked with the app. If prompted, please authenticate with "claude login" in your terminal.',
requiresManualAuth: true,
});
}
res.json({
success: true,
requiresManualAuth: true,
command: 'claude login',
message: "Please run 'claude login' in your terminal to authenticate",
});
} catch (error) {
logError(error, 'Auth Claude failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
message: 'Failed to link Claude CLI with the app',
});
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,50 +0,0 @@
/**
* POST /auth-codex endpoint - Authenticate Codex CLI
*/
import type { Request, Response } from 'express';
import { logError, getErrorMessage } from '../common.js';
import * as fs from 'fs';
import * as path from 'path';
export function createAuthCodexHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
// Remove the disconnected marker file to reconnect the app to the CLI
const markerPath = path.join(process.cwd(), '.automaker', '.codex-disconnected');
if (fs.existsSync(markerPath)) {
fs.unlinkSync(markerPath);
}
// Use the same detection logic as the Codex provider
const { getCodexAuthIndicators } = await import('@automaker/platform');
const indicators = await getCodexAuthIndicators();
const isAlreadyAuthenticated =
indicators.hasApiKey || indicators.hasAuthFile || indicators.hasOAuthToken;
if (isAlreadyAuthenticated) {
// Already has authentication, just reconnect
res.json({
success: true,
message: 'Codex CLI is now linked with the app',
wasAlreadyAuthenticated: true,
});
} else {
res.json({
success: true,
message:
'Codex CLI is now linked with the app. If prompted, please authenticate with "codex login" in your terminal.',
requiresManualAuth: true,
});
}
} catch (error) {
logError(error, 'Auth Codex failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
message: 'Failed to link Codex CLI with the app',
});
}
};
}

View File

@@ -1,73 +0,0 @@
/**
* POST /auth-cursor endpoint - Authenticate Cursor CLI
*/
import type { Request, Response } from 'express';
import { logError, getErrorMessage } from '../common.js';
import * as fs from 'fs';
import * as path from 'path';
import os from 'os';
export function createAuthCursorHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
// Remove the disconnected marker file to reconnect the app to the CLI
const markerPath = path.join(process.cwd(), '.automaker', '.cursor-disconnected');
if (fs.existsSync(markerPath)) {
fs.unlinkSync(markerPath);
}
// Check if Cursor is already authenticated using the same logic as CursorProvider
const isAlreadyAuthenticated = (): boolean => {
// Check for API key in environment
if (process.env.CURSOR_API_KEY) {
return true;
}
// Check for credentials files
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 true;
}
} catch {
// Invalid credentials file, continue checking
}
}
}
return false;
};
if (isAlreadyAuthenticated()) {
res.json({
success: true,
message: 'Cursor CLI is now linked with the app',
wasAlreadyAuthenticated: true,
});
} else {
res.json({
success: true,
message:
'Cursor CLI is now linked with the app. If prompted, please authenticate with "cursor auth" in your terminal.',
requiresManualAuth: true,
});
}
} catch (error) {
logError(error, 'Auth Cursor failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
message: 'Failed to link Cursor CLI with the app',
});
}
};
}

View File

@@ -1,51 +0,0 @@
/**
* POST /auth-opencode endpoint - Authenticate OpenCode CLI
*/
import type { Request, Response } from 'express';
import { logError, getErrorMessage } from '../common.js';
import { exec } from 'child_process';
import { promisify } from 'util';
import * as fs from 'fs';
import * as path from 'path';
const execAsync = promisify(exec);
export function createAuthOpencodeHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
// Remove the disconnected marker file to reconnect the app to the CLI
const markerPath = path.join(process.cwd(), '.automaker', '.opencode-disconnected');
if (fs.existsSync(markerPath)) {
fs.unlinkSync(markerPath);
}
// Check if OpenCode is already authenticated
// For OpenCode, check if there's an auth token or API key
const hasApiKey = !!process.env.OPENCODE_API_KEY;
if (hasApiKey) {
// Already has authentication, just reconnect
res.json({
success: true,
message: 'OpenCode CLI is now linked with the app',
wasAlreadyAuthenticated: true,
});
} else {
res.json({
success: true,
message:
'OpenCode CLI is now linked with the app. If prompted, please authenticate with OpenCode.',
requiresManualAuth: true,
});
}
} catch (error) {
logError(error, 'Auth OpenCode failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
message: 'Failed to link OpenCode CLI with the app',
});
}
};
}

View File

@@ -1,81 +0,0 @@
/**
* GET /codex-status endpoint - Get Codex CLI installation and auth status
*/
import type { Request, Response } from 'express';
import { CodexProvider } from '../../../providers/codex-provider.js';
import { getErrorMessage, logError } from '../common.js';
import * as fs from 'fs';
import * as path from 'path';
const DISCONNECTED_MARKER_FILE = '.codex-disconnected';
function isCodexDisconnectedFromApp(): boolean {
try {
const projectRoot = process.cwd();
const markerPath = path.join(projectRoot, '.automaker', DISCONNECTED_MARKER_FILE);
return fs.existsSync(markerPath);
} catch {
return false;
}
}
/**
* Creates handler for GET /api/setup/codex-status
* Returns Codex CLI installation and authentication status
*/
export function createCodexStatusHandler() {
const installCommand = 'npm install -g @openai/codex';
const loginCommand = 'codex login';
return async (_req: Request, res: Response): Promise<void> => {
try {
// Check if user has manually disconnected from the app
if (isCodexDisconnectedFromApp()) {
res.json({
success: true,
installed: true,
version: null,
path: null,
auth: {
authenticated: false,
method: 'none',
hasApiKey: false,
},
installCommand,
loginCommand,
});
return;
}
const provider = new CodexProvider();
const status = await provider.detectInstallation();
// Derive auth method from authenticated status and API key presence
let authMethod = 'none';
if (status.authenticated) {
authMethod = status.hasApiKey ? 'api_key_env' : 'cli_authenticated';
}
res.json({
success: true,
installed: status.installed,
version: status.version || null,
path: status.path || null,
auth: {
authenticated: status.authenticated || false,
method: authMethod,
hasApiKey: status.hasApiKey || false,
},
installCommand,
loginCommand,
});
} catch (error) {
logError(error, 'Get Codex status failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -5,20 +5,6 @@
import type { Request, Response } from 'express';
import { CursorProvider } from '../../../providers/cursor-provider.js';
import { getErrorMessage, logError } from '../common.js';
import * as fs from 'fs';
import * as path from 'path';
const DISCONNECTED_MARKER_FILE = '.cursor-disconnected';
function isCursorDisconnectedFromApp(): boolean {
try {
const projectRoot = process.cwd();
const markerPath = path.join(projectRoot, '.automaker', DISCONNECTED_MARKER_FILE);
return fs.existsSync(markerPath);
} catch {
return false;
}
}
/**
* Creates handler for GET /api/setup/cursor-status
@@ -30,30 +16,6 @@ export function createCursorStatusHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
// Check if user has manually disconnected from the app
if (isCursorDisconnectedFromApp()) {
const provider = new CursorProvider();
const [installed, version] = await Promise.all([
provider.isInstalled(),
provider.getVersion(),
]);
const cliPath = installed ? provider.getCliPath() : null;
res.json({
success: true,
installed,
version: version || null,
path: cliPath,
auth: {
authenticated: false,
method: 'none',
},
installCommand,
loginCommand,
});
return;
}
const provider = new CursorProvider();
const [installed, version, auth] = await Promise.all([

View File

@@ -1,44 +0,0 @@
/**
* POST /deauth-claude endpoint - Sign out from Claude CLI
*/
import type { Request, Response } from 'express';
import { getErrorMessage, logError } from '../common.js';
import * as fs from 'fs';
import * as path from 'path';
export function createDeauthClaudeHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
// Create a marker file to indicate the CLI is disconnected from the app
const automakerDir = path.join(process.cwd(), '.automaker');
const markerPath = path.join(automakerDir, '.claude-disconnected');
// Ensure .automaker directory exists
if (!fs.existsSync(automakerDir)) {
fs.mkdirSync(automakerDir, { recursive: true });
}
// Create the marker file with timestamp
fs.writeFileSync(
markerPath,
JSON.stringify({
disconnectedAt: new Date().toISOString(),
message: 'Claude CLI is disconnected from the app',
})
);
res.json({
success: true,
message: 'Claude CLI is now disconnected from the app',
});
} catch (error) {
logError(error, 'Deauth Claude failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
message: 'Failed to disconnect Claude CLI from the app',
});
}
};
}

View File

@@ -1,44 +0,0 @@
/**
* POST /deauth-codex endpoint - Sign out from Codex CLI
*/
import type { Request, Response } from 'express';
import { logError, getErrorMessage } from '../common.js';
import * as fs from 'fs';
import * as path from 'path';
export function createDeauthCodexHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
// Create a marker file to indicate the CLI is disconnected from the app
const automakerDir = path.join(process.cwd(), '.automaker');
const markerPath = path.join(automakerDir, '.codex-disconnected');
// Ensure .automaker directory exists
if (!fs.existsSync(automakerDir)) {
fs.mkdirSync(automakerDir, { recursive: true });
}
// Create the marker file with timestamp
fs.writeFileSync(
markerPath,
JSON.stringify({
disconnectedAt: new Date().toISOString(),
message: 'Codex CLI is disconnected from the app',
})
);
res.json({
success: true,
message: 'Codex CLI is now disconnected from the app',
});
} catch (error) {
logError(error, 'Deauth Codex failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
message: 'Failed to disconnect Codex CLI from the app',
});
}
};
}

View File

@@ -1,44 +0,0 @@
/**
* POST /deauth-cursor endpoint - Sign out from Cursor CLI
*/
import type { Request, Response } from 'express';
import { logError, getErrorMessage } from '../common.js';
import * as fs from 'fs';
import * as path from 'path';
export function createDeauthCursorHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
// Create a marker file to indicate the CLI is disconnected from the app
const automakerDir = path.join(process.cwd(), '.automaker');
const markerPath = path.join(automakerDir, '.cursor-disconnected');
// Ensure .automaker directory exists
if (!fs.existsSync(automakerDir)) {
fs.mkdirSync(automakerDir, { recursive: true });
}
// Create the marker file with timestamp
fs.writeFileSync(
markerPath,
JSON.stringify({
disconnectedAt: new Date().toISOString(),
message: 'Cursor CLI is disconnected from the app',
})
);
res.json({
success: true,
message: 'Cursor CLI is now disconnected from the app',
});
} catch (error) {
logError(error, 'Deauth Cursor failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
message: 'Failed to disconnect Cursor CLI from the app',
});
}
};
}

View File

@@ -1,40 +0,0 @@
import type { Request, Response } from 'express';
import { logError, getErrorMessage } from '../common.js';
import * as fs from 'fs';
import * as path from 'path';
export function createDeauthOpencodeHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
// Create a marker file to indicate the CLI is disconnected from the app
const automakerDir = path.join(process.cwd(), '.automaker');
const markerPath = path.join(automakerDir, '.opencode-disconnected');
// Ensure .automaker directory exists
if (!fs.existsSync(automakerDir)) {
fs.mkdirSync(automakerDir, { recursive: true });
}
// Create the marker file with timestamp
fs.writeFileSync(
markerPath,
JSON.stringify({
disconnectedAt: new Date().toISOString(),
message: 'OpenCode CLI is disconnected from the app',
})
);
res.json({
success: true,
message: 'OpenCode CLI is now disconnected from the app',
});
} catch (error) {
logError(error, 'Deauth OpenCode failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
message: 'Failed to disconnect OpenCode CLI from the app',
});
}
};
}

View File

@@ -46,14 +46,13 @@ export function createDeleteApiKeyHandler() {
// Map provider to env key name
const envKeyMap: Record<string, string> = {
anthropic: 'ANTHROPIC_API_KEY',
openai: 'OPENAI_API_KEY',
};
const envKey = envKeyMap[provider];
if (!envKey) {
res.status(400).json({
success: false,
error: `Unknown provider: ${provider}. Only anthropic and openai are supported.`,
error: `Unknown provider: ${provider}. Only anthropic is supported.`,
});
return;
}

View File

@@ -1,33 +0,0 @@
/**
* POST /install-codex endpoint - Install Codex CLI
*/
import type { Request, Response } from 'express';
import { logError, getErrorMessage } from '../common.js';
/**
* Creates handler for POST /api/setup/install-codex
* Installs Codex CLI (currently returns instructions for manual install)
*/
export function createInstallCodexHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
// For now, return manual installation instructions
// In the future, this could potentially trigger npm global install
const installCommand = 'npm install -g @openai/codex';
res.json({
success: true,
message: `Please install Codex CLI manually by running: ${installCommand}`,
requiresManualInstall: true,
installCommand,
});
} catch (error) {
logError(error, 'Install Codex failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -1,59 +0,0 @@
/**
* GET /opencode-status endpoint - Get OpenCode CLI installation and auth status
*/
import type { Request, Response } from 'express';
import { OpencodeProvider } from '../../../providers/opencode-provider.js';
import { getErrorMessage, logError } from '../common.js';
/**
* Creates handler for GET /api/setup/opencode-status
* Returns OpenCode CLI installation and authentication status
*/
export function createOpencodeStatusHandler() {
const installCommand = 'curl -fsSL https://opencode.ai/install | bash';
const loginCommand = 'opencode auth login';
return async (_req: Request, res: Response): Promise<void> => {
try {
const provider = new OpencodeProvider();
const status = await provider.detectInstallation();
// Derive auth method from authenticated status and API key presence
let authMethod = 'none';
if (status.authenticated) {
authMethod = status.hasApiKey ? 'api_key_env' : 'cli_authenticated';
}
res.json({
success: true,
installed: status.installed,
version: status.version || null,
path: status.path || null,
auth: {
authenticated: status.authenticated || false,
method: authMethod,
hasApiKey: status.hasApiKey || false,
hasEnvApiKey: !!process.env.ANTHROPIC_API_KEY || !!process.env.OPENAI_API_KEY,
hasOAuthToken: status.hasOAuthToken || false,
},
recommendation: status.installed
? undefined
: 'Install OpenCode CLI to use multi-provider AI models.',
installCommand,
loginCommand,
installCommands: {
macos: installCommand,
linux: installCommand,
npm: 'npm install -g opencode-ai',
},
});
} catch (error) {
logError(error, 'Get OpenCode status failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -7,16 +7,8 @@ import type { Request, Response } from 'express';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { createLogger } from '@automaker/utils';
import { getApiKey } from '../common.js';
import {
createSecureAuthEnv,
AuthSessionManager,
AuthRateLimiter,
validateApiKey,
createTempEnvOverride,
} from '../../../lib/auth-utils.js';
const logger = createLogger('Setup');
const rateLimiter = new AuthRateLimiter();
// Known error patterns that indicate auth failure
const AUTH_ERROR_PATTERNS = [
@@ -85,19 +77,6 @@ export function createVerifyClaudeAuthHandler() {
apiKey?: string;
};
// Rate limiting to prevent abuse
const clientIp = req.ip || req.socket.remoteAddress || 'unknown';
if (!rateLimiter.canAttempt(clientIp)) {
const resetTime = rateLimiter.getResetTime(clientIp);
res.status(429).json({
success: false,
authenticated: false,
error: 'Too many authentication attempts. Please try again later.',
resetTime,
});
return;
}
logger.info(
`[Setup] Verifying Claude authentication using method: ${authMethod || 'auto'}${apiKey ? ' (with provided key)' : ''}`
);
@@ -110,48 +89,37 @@ export function createVerifyClaudeAuthHandler() {
let errorMessage = '';
let receivedAnyContent = false;
// Create secure auth session
const sessionId = `claude-auth-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// Save original env values
const originalAnthropicKey = process.env.ANTHROPIC_API_KEY;
try {
// For API key verification, validate the key first
if (authMethod === 'api_key' && apiKey) {
const validation = validateApiKey(apiKey, 'anthropic');
if (!validation.isValid) {
res.json({
success: true,
authenticated: false,
error: validation.error,
});
return;
// Configure environment based on auth method
if (authMethod === 'cli') {
// For CLI verification, remove any API key so it uses CLI credentials only
delete process.env.ANTHROPIC_API_KEY;
logger.info('[Setup] Cleared API key environment for CLI verification');
} else if (authMethod === 'api_key') {
// For API key verification, use provided key, stored key, or env var (in order of priority)
if (apiKey) {
// Use the provided API key (allows testing unsaved keys)
process.env.ANTHROPIC_API_KEY = apiKey;
logger.info('[Setup] Using provided API key for verification');
} else {
const storedApiKey = getApiKey('anthropic');
if (storedApiKey) {
process.env.ANTHROPIC_API_KEY = storedApiKey;
logger.info('[Setup] Using stored API key for verification');
} else if (!process.env.ANTHROPIC_API_KEY) {
res.json({
success: true,
authenticated: false,
error: 'No API key configured. Please enter an API key first.',
});
return;
}
}
}
// Create secure environment without modifying process.env
const authEnv = createSecureAuthEnv(authMethod || 'api_key', apiKey, 'anthropic');
// For API key verification without provided key, use stored key or env var
if (authMethod === 'api_key' && !apiKey) {
const storedApiKey = getApiKey('anthropic');
if (storedApiKey) {
authEnv.ANTHROPIC_API_KEY = storedApiKey;
logger.info('[Setup] Using stored API key for verification');
} else if (!authEnv.ANTHROPIC_API_KEY) {
res.json({
success: true,
authenticated: false,
error: 'No API key configured. Please enter an API key first.',
});
return;
}
}
// Store the secure environment in session manager
AuthSessionManager.createSession(sessionId, authMethod || 'api_key', apiKey, 'anthropic');
// Create temporary environment override for SDK call
const cleanupEnv = createTempEnvOverride(authEnv);
// Run a minimal query to verify authentication
const stream = query({
prompt: "Reply with only the word 'ok'",
@@ -310,8 +278,13 @@ export function createVerifyClaudeAuthHandler() {
}
} finally {
clearTimeout(timeoutId);
// Clean up the auth session
AuthSessionManager.destroySession(sessionId);
// Restore original environment
if (originalAnthropicKey !== undefined) {
process.env.ANTHROPIC_API_KEY = originalAnthropicKey;
} else if (authMethod === 'cli') {
// If we cleared it and there was no original, keep it cleared
delete process.env.ANTHROPIC_API_KEY;
}
}
logger.info('[Setup] Verification result:', {

View File

@@ -1,282 +0,0 @@
/**
* POST /verify-codex-auth endpoint - Verify Codex authentication
*/
import type { Request, Response } from 'express';
import { createLogger } from '@automaker/utils';
import { CODEX_MODEL_MAP } from '@automaker/types';
import { ProviderFactory } from '../../../providers/provider-factory.js';
import { getApiKey } from '../common.js';
import { getCodexAuthIndicators } from '@automaker/platform';
import {
createSecureAuthEnv,
AuthSessionManager,
AuthRateLimiter,
validateApiKey,
createTempEnvOverride,
} from '../../../lib/auth-utils.js';
const logger = createLogger('Setup');
const rateLimiter = new AuthRateLimiter();
const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY';
const AUTH_PROMPT = "Reply with only the word 'ok'";
const AUTH_TIMEOUT_MS = 30000;
const ERROR_BILLING_MESSAGE =
'Credit balance is too low. Please add credits to your OpenAI account.';
const ERROR_RATE_LIMIT_MESSAGE =
'Rate limit reached. Please wait a while before trying again or upgrade your plan.';
const ERROR_CLI_AUTH_REQUIRED =
"CLI authentication failed. Please run 'codex login' to authenticate.";
const ERROR_API_KEY_REQUIRED = 'No API key configured. Please enter an API key first.';
const AUTH_ERROR_PATTERNS = [
'authentication',
'unauthorized',
'invalid_api_key',
'invalid api key',
'api key is invalid',
'not authenticated',
'login',
'auth(',
'token refresh',
'tokenrefresh',
'failed to parse server response',
'transport channel closed',
];
const BILLING_ERROR_PATTERNS = [
'credit balance is too low',
'credit balance too low',
'insufficient credits',
'insufficient balance',
'no credits',
'out of credits',
'billing',
'payment required',
'add credits',
];
const RATE_LIMIT_PATTERNS = [
'limit reached',
'rate limit',
'rate_limit',
'too many requests',
'resets',
'429',
];
function containsAuthError(text: string): boolean {
const lowerText = text.toLowerCase();
return AUTH_ERROR_PATTERNS.some((pattern) => lowerText.includes(pattern));
}
function isBillingError(text: string): boolean {
const lowerText = text.toLowerCase();
return BILLING_ERROR_PATTERNS.some((pattern) => lowerText.includes(pattern));
}
function isRateLimitError(text: string): boolean {
if (isBillingError(text)) {
return false;
}
const lowerText = text.toLowerCase();
return RATE_LIMIT_PATTERNS.some((pattern) => lowerText.includes(pattern));
}
export function createVerifyCodexAuthHandler() {
return async (req: Request, res: Response): Promise<void> => {
const { authMethod, apiKey } = req.body as {
authMethod?: 'cli' | 'api_key';
apiKey?: string;
};
// Create session ID for cleanup
const sessionId = `codex-auth-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// Rate limiting
const clientIp = req.ip || req.socket.remoteAddress || 'unknown';
if (!rateLimiter.canAttempt(clientIp)) {
const resetTime = rateLimiter.getResetTime(clientIp);
res.status(429).json({
success: false,
authenticated: false,
error: 'Too many authentication attempts. Please try again later.',
resetTime,
});
return;
}
const abortController = new AbortController();
const timeoutId = setTimeout(() => abortController.abort(), AUTH_TIMEOUT_MS);
try {
// Create secure environment without modifying process.env
const authEnv = createSecureAuthEnv(authMethod || 'api_key', apiKey, 'openai');
// For API key auth, validate and use the provided key or stored key
if (authMethod === 'api_key') {
if (apiKey) {
// Use the provided API key
const validation = validateApiKey(apiKey, 'openai');
if (!validation.isValid) {
res.json({ success: true, authenticated: false, error: validation.error });
return;
}
authEnv[OPENAI_API_KEY_ENV] = validation.normalizedKey;
} else {
// Try stored key
const storedApiKey = getApiKey('openai');
if (storedApiKey) {
const validation = validateApiKey(storedApiKey, 'openai');
if (!validation.isValid) {
res.json({ success: true, authenticated: false, error: validation.error });
return;
}
authEnv[OPENAI_API_KEY_ENV] = validation.normalizedKey;
} else if (!authEnv[OPENAI_API_KEY_ENV]) {
res.json({ success: true, authenticated: false, error: ERROR_API_KEY_REQUIRED });
return;
}
}
}
// Create session and temporary environment override
AuthSessionManager.createSession(sessionId, authMethod || 'api_key', undefined, 'openai');
const cleanupEnv = createTempEnvOverride(authEnv);
try {
if (authMethod === 'cli') {
const authIndicators = await getCodexAuthIndicators();
if (!authIndicators.hasOAuthToken && !authIndicators.hasApiKey) {
res.json({
success: true,
authenticated: false,
error: ERROR_CLI_AUTH_REQUIRED,
});
return;
}
}
// Use Codex provider explicitly (not ProviderFactory.getProviderForModel)
// because Cursor also supports GPT models and has higher priority
const provider = ProviderFactory.getProviderByName('codex');
if (!provider) {
throw new Error('Codex provider not available');
}
const stream = provider.executeQuery({
prompt: AUTH_PROMPT,
model: CODEX_MODEL_MAP.gpt52Codex,
cwd: process.cwd(),
maxTurns: 1,
allowedTools: [],
abortController,
});
let receivedAnyContent = false;
let errorMessage = '';
for await (const msg of stream) {
if (msg.type === 'error' && msg.error) {
if (isBillingError(msg.error)) {
errorMessage = ERROR_BILLING_MESSAGE;
} else if (isRateLimitError(msg.error)) {
errorMessage = ERROR_RATE_LIMIT_MESSAGE;
} else {
errorMessage = msg.error;
}
break;
}
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
receivedAnyContent = true;
if (isBillingError(block.text)) {
errorMessage = ERROR_BILLING_MESSAGE;
break;
}
if (isRateLimitError(block.text)) {
errorMessage = ERROR_RATE_LIMIT_MESSAGE;
break;
}
if (containsAuthError(block.text)) {
errorMessage = block.text;
break;
}
}
}
}
if (msg.type === 'result' && msg.result) {
receivedAnyContent = true;
if (isBillingError(msg.result)) {
errorMessage = ERROR_BILLING_MESSAGE;
} else if (isRateLimitError(msg.result)) {
errorMessage = ERROR_RATE_LIMIT_MESSAGE;
} else if (containsAuthError(msg.result)) {
errorMessage = msg.result;
break;
}
}
}
if (errorMessage) {
// Rate limit and billing errors mean auth succeeded but usage is limited
const isUsageLimitError =
errorMessage === ERROR_BILLING_MESSAGE || errorMessage === ERROR_RATE_LIMIT_MESSAGE;
const response: {
success: boolean;
authenticated: boolean;
error: string;
details?: string;
} = {
success: true,
authenticated: isUsageLimitError ? true : false,
error: isUsageLimitError
? errorMessage
: authMethod === 'cli'
? ERROR_CLI_AUTH_REQUIRED
: 'API key is invalid or has been revoked.',
};
// Include detailed error for auth failures so users can debug
if (!isUsageLimitError && errorMessage !== response.error) {
response.details = errorMessage;
}
res.json(response);
return;
}
if (!receivedAnyContent) {
res.json({
success: true,
authenticated: false,
error: 'No response received from Codex. Please check your authentication.',
});
return;
}
res.json({ success: true, authenticated: true });
} finally {
// Clean up environment override
cleanupEnv();
}
} catch (error: unknown) {
const errMessage = error instanceof Error ? error.message : String(error);
logger.error('[Setup] Codex auth verification error:', errMessage);
const normalizedError = isBillingError(errMessage)
? ERROR_BILLING_MESSAGE
: isRateLimitError(errMessage)
? ERROR_RATE_LIMIT_MESSAGE
: errMessage;
res.json({
success: true,
authenticated: false,
error: normalizedError,
});
} finally {
clearTimeout(timeoutId);
// Clean up session
AuthSessionManager.destroySession(sessionId);
}
};
}

View File

@@ -8,12 +8,7 @@
import { query } from '@anthropic-ai/claude-agent-sdk';
import type { EventEmitter } from '../../lib/events.js';
import { createLogger } from '@automaker/utils';
import {
DEFAULT_PHASE_MODELS,
isCursorModel,
stripProviderPrefix,
type ThinkingLevel,
} from '@automaker/types';
import { DEFAULT_PHASE_MODELS, isCursorModel, type ThinkingLevel } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { createSuggestionsOptions } from '../../lib/sdk-options.js';
import { extractJsonWithArray } from '../../lib/json-extractor.js';
@@ -212,8 +207,6 @@ The response will be automatically formatted as structured JSON.`;
logger.info('[Suggestions] Using Cursor provider');
const provider = ProviderFactory.getProviderForModel(model);
// Strip provider prefix - providers expect bare model IDs
const bareModel = stripProviderPrefix(model);
// For Cursor, include the JSON schema in the prompt with clear instructions
const cursorPrompt = `${prompt}
@@ -229,7 +222,7 @@ Your entire response should be valid JSON starting with { and ending with }. No
for await (const msg of provider.executeQuery({
prompt: cursorPrompt,
model: bareModel,
model,
cwd: projectPath,
maxTurns: 250,
allowedTools: ['Read', 'Glob', 'Grep'],

View File

@@ -11,10 +11,9 @@ import { getGitRepositoryDiffs } from '../../common.js';
export function createDiffsHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureId, useWorktrees } = req.body as {
const { projectPath, featureId } = req.body as {
projectPath: string;
featureId: string;
useWorktrees?: boolean;
};
if (!projectPath || !featureId) {
@@ -25,19 +24,6 @@ export function createDiffsHandler() {
return;
}
// If worktrees aren't enabled, don't probe .worktrees at all.
// This avoids noisy logs that make it look like features are "running in worktrees".
if (useWorktrees === false) {
const result = await getGitRepositoryDiffs(projectPath);
res.json({
success: true,
diff: result.diff,
files: result.files,
hasChanges: result.hasChanges,
});
return;
}
// Git worktrees are stored in project directory
const worktreePath = path.join(projectPath, '.worktrees', featureId);
@@ -55,11 +41,7 @@ export function createDiffsHandler() {
});
} catch (innerError) {
// Worktree doesn't exist - fallback to main project path
const code = (innerError as NodeJS.ErrnoException | undefined)?.code;
// ENOENT is expected when a feature has no worktree; don't log as an error.
if (code && code !== 'ENOENT') {
logError(innerError, 'Worktree access failed, falling back to main project');
}
logError(innerError, 'Worktree access failed, falling back to main project');
try {
const result = await getGitRepositoryDiffs(projectPath);

View File

@@ -15,11 +15,10 @@ const execAsync = promisify(exec);
export function createFileDiffHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureId, filePath, useWorktrees } = req.body as {
const { projectPath, featureId, filePath } = req.body as {
projectPath: string;
featureId: string;
filePath: string;
useWorktrees?: boolean;
};
if (!projectPath || !featureId || !filePath) {
@@ -30,12 +29,6 @@ export function createFileDiffHandler() {
return;
}
// If worktrees aren't enabled, don't probe .worktrees at all.
if (useWorktrees === false) {
res.json({ success: true, diff: '', filePath });
return;
}
// Git worktrees are stored in project directory
const worktreePath = path.join(projectPath, '.worktrees', featureId);
@@ -64,11 +57,7 @@ export function createFileDiffHandler() {
res.json({ success: true, diff, filePath });
} catch (innerError) {
const code = (innerError as NodeJS.ErrnoException | undefined)?.code;
// ENOENT is expected when a feature has no worktree; don't log as an error.
if (code && code !== 'ENOENT') {
logError(innerError, 'Worktree file diff failed');
}
logError(innerError, 'Worktree file diff failed');
res.json({ success: true, diff: '', filePath });
}
} catch (error) {

View File

@@ -6,16 +6,13 @@
import path from 'path';
import * as secureFs from '../lib/secure-fs.js';
import type { EventEmitter } from '../lib/events.js';
import type { ExecuteOptions, ThinkingLevel, ReasoningEffort } from '@automaker/types';
import { stripProviderPrefix } from '@automaker/types';
import type { ExecuteOptions, ThinkingLevel } from '@automaker/types';
import {
readImageAsBase64,
buildPromptWithImages,
isAbortError,
loadContextFiles,
createLogger,
classifyError,
getUserFriendlyErrorMessage,
} from '@automaker/utils';
import { ProviderFactory } from '../providers/provider-factory.js';
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
@@ -23,12 +20,10 @@ import { PathNotAllowedError } from '@automaker/platform';
import type { SettingsService } from './settings-service.js';
import {
getAutoLoadClaudeMdSetting,
getEnableSandboxModeSetting,
filterClaudeMdFromContext,
getMCPServersFromSettings,
getPromptCustomization,
getSkillsConfiguration,
getSubagentsConfiguration,
getCustomSubagents,
} from '../lib/settings-helpers.js';
interface Message {
@@ -60,7 +55,6 @@ interface Session {
workingDirectory: string;
model?: string;
thinkingLevel?: ThinkingLevel; // Thinking level for Claude models
reasoningEffort?: ReasoningEffort; // Reasoning effort for Codex models
sdkSessionId?: string; // Claude SDK session ID for conversation continuity
promptQueue: QueuedPrompt[]; // Queue of prompts to auto-run after current task
}
@@ -150,7 +144,6 @@ export class AgentService {
imagePaths,
model,
thinkingLevel,
reasoningEffort,
}: {
sessionId: string;
message: string;
@@ -158,7 +151,6 @@ export class AgentService {
imagePaths?: string[];
model?: string;
thinkingLevel?: ThinkingLevel;
reasoningEffort?: ReasoningEffort;
}) {
const session = this.sessions.get(sessionId);
if (!session) {
@@ -171,7 +163,7 @@ export class AgentService {
throw new Error('Agent is already processing a message');
}
// Update session model, thinking level, and reasoning effort if provided
// Update session model and thinking level if provided
if (model) {
session.model = model;
await this.updateSession(sessionId, { model });
@@ -179,21 +171,6 @@ export class AgentService {
if (thinkingLevel !== undefined) {
session.thinkingLevel = thinkingLevel;
}
if (reasoningEffort !== undefined) {
session.reasoningEffort = reasoningEffort;
}
// Validate vision support before processing images
const effectiveModel = model || session.model;
if (imagePaths && imagePaths.length > 0 && effectiveModel) {
const supportsVision = ProviderFactory.modelSupportsVision(effectiveModel);
if (!supportsVision) {
throw new Error(
`This model (${effectiveModel}) does not support image input. ` +
`Please switch to a model that supports vision, or remove the images and try again.`
);
}
}
// Read images and convert to base64
const images: Message['images'] = [];
@@ -255,34 +232,19 @@ export class AgentService {
'[AgentService]'
);
// Load enableSandboxMode setting (global setting only)
const enableSandboxMode = await getEnableSandboxModeSetting(
this.settingsService,
'[AgentService]'
);
// Load MCP servers from settings (global setting only)
const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AgentService]');
// Get Skills configuration from settings
const skillsConfig = this.settingsService
? await getSkillsConfiguration(this.settingsService)
: { enabled: false, sources: [] as Array<'user' | 'project'>, shouldIncludeInTools: false };
// Get Subagents configuration from settings
const subagentsConfig = this.settingsService
? await getSubagentsConfiguration(this.settingsService)
: { enabled: false, sources: [] as Array<'user' | 'project'>, shouldIncludeInTools: false };
// Get custom subagents from settings (merge global + project-level) only if enabled
const customSubagents =
this.settingsService && subagentsConfig.enabled
? await getCustomSubagents(this.settingsService, effectiveWorkDir)
: undefined;
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) and memory files
// Use the user's message as task context for smart memory selection
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.)
const contextResult = await loadContextFiles({
projectPath: effectiveWorkDir,
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
taskContext: {
title: message.substring(0, 200), // Use first 200 chars as title
description: message,
},
});
// When autoLoadClaudeMd is enabled, filter out CLAUDE.md to avoid duplication
@@ -296,9 +258,8 @@ export class AgentService {
: baseSystemPrompt;
// Build SDK options using centralized configuration
// Use thinking level and reasoning effort from request, or fall back to session's stored values
// Use thinking level from request, or fall back to session's stored thinking level
const effectiveThinkingLevel = thinkingLevel ?? session.thinkingLevel;
const effectiveReasoningEffort = reasoningEffort ?? session.reasoningEffort;
const sdkOptions = createChatOptions({
cwd: effectiveWorkDir,
model: model,
@@ -306,6 +267,7 @@ export class AgentService {
systemPrompt: combinedSystemPrompt,
abortController: session.abortController!,
autoLoadClaudeMd,
enableSandboxMode,
thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
});
@@ -313,71 +275,25 @@ export class AgentService {
// Extract model, maxTurns, and allowedTools from SDK options
const effectiveModel = sdkOptions.model!;
const maxTurns = sdkOptions.maxTurns;
let allowedTools = sdkOptions.allowedTools as string[] | undefined;
const allowedTools = sdkOptions.allowedTools as string[] | undefined;
// Build merged settingSources array using Set for automatic deduplication
const sdkSettingSources = (sdkOptions.settingSources ?? []).filter(
(source): source is 'user' | 'project' => source === 'user' || source === 'project'
);
const skillSettingSources = skillsConfig.enabled ? skillsConfig.sources : [];
const settingSources = [...new Set([...sdkSettingSources, ...skillSettingSources])];
// Enhance allowedTools with Skills and Subagents tools
// These tools are not in the provider's default set - they're added dynamically based on settings
const needsSkillTool = skillsConfig.shouldIncludeInTools;
const needsTaskTool =
subagentsConfig.shouldIncludeInTools &&
customSubagents &&
Object.keys(customSubagents).length > 0;
// Base tools that match the provider's default set
const baseTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'];
if (allowedTools) {
allowedTools = [...allowedTools]; // Create a copy to avoid mutating SDK options
// Add Skill tool if skills are enabled
if (needsSkillTool && !allowedTools.includes('Skill')) {
allowedTools.push('Skill');
}
// Add Task tool if custom subagents are configured
if (needsTaskTool && !allowedTools.includes('Task')) {
allowedTools.push('Task');
}
} else if (needsSkillTool || needsTaskTool) {
// If no allowedTools specified but we need to add Skill/Task tools,
// build the full list including base tools
allowedTools = [...baseTools];
if (needsSkillTool) {
allowedTools.push('Skill');
}
if (needsTaskTool) {
allowedTools.push('Task');
}
}
// Get provider for this model (with prefix)
// Get provider for this model
const provider = ProviderFactory.getProviderForModel(effectiveModel);
// Strip provider prefix - providers should receive bare model IDs
const bareModel = stripProviderPrefix(effectiveModel);
// Build options for provider
const options: ExecuteOptions = {
prompt: '', // Will be set below based on images
model: bareModel, // Bare model ID (e.g., "gpt-5.1-codex-max", "composer-1")
originalModel: effectiveModel, // Original with prefix for logging (e.g., "codex-gpt-5.1-codex-max")
model: effectiveModel,
cwd: effectiveWorkDir,
systemPrompt: sdkOptions.systemPrompt,
maxTurns: maxTurns,
allowedTools: allowedTools,
abortController: session.abortController!,
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
settingSources: settingSources.length > 0 ? settingSources : undefined,
settingSources: sdkOptions.settingSources,
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration
agents: customSubagents, // Pass custom subagents for task delegation
thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models
reasoningEffort: effectiveReasoningEffort, // Pass reasoning effort for Codex models
};
// Build prompt content with images
@@ -458,53 +374,6 @@ export class AgentService {
content: responseText,
toolUses,
});
} else if (msg.type === 'error') {
// Some providers (like Codex CLI/SaaS or Cursor CLI) surface failures as
// streamed error messages instead of throwing. Handle these here so the
// Agent Runner UX matches the Claude/Cursor behavior without changing
// their provider implementations.
const rawErrorText =
(typeof msg.error === 'string' && msg.error.trim()) ||
'Unexpected error from provider during agent execution.';
const errorInfo = classifyError(new Error(rawErrorText));
// Keep the provider-supplied text intact (Codex already includes helpful tips),
// only add a small rate-limit hint when we can detect it.
const enhancedText = errorInfo.isRateLimit
? `${rawErrorText}\n\nTip: It looks like you hit a rate limit. Try waiting a bit or reducing concurrent Agent Runner / Auto Mode tasks.`
: rawErrorText;
this.logger.error('Provider error during agent execution:', {
type: errorInfo.type,
message: errorInfo.message,
});
// Mark session as no longer running so the UI and queue stay in sync
session.isRunning = false;
session.abortController = null;
const errorMessage: Message = {
id: this.generateId(),
role: 'assistant',
content: `Error: ${enhancedText}`,
timestamp: new Date().toISOString(),
isError: true,
};
session.messages.push(errorMessage);
await this.saveSession(sessionId, session.messages);
this.emitAgentEvent(sessionId, {
type: 'error',
error: enhancedText,
message: errorMessage,
});
// Don't continue streaming after an error message
return {
success: false,
};
}
}

View File

@@ -14,17 +14,17 @@ import type {
ExecuteOptions,
Feature,
ModelProvider,
PipelineConfig,
PipelineStep,
ThinkingLevel,
PlanningMode,
} from '@automaker/types';
import { DEFAULT_PHASE_MODELS, stripProviderPrefix } from '@automaker/types';
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
import {
buildPromptWithImages,
isAbortError,
classifyError,
loadContextFiles,
appendLearning,
recordMemoryUsage,
createLogger,
} from '@automaker/utils';
@@ -47,6 +47,7 @@ import type { SettingsService } from './settings-service.js';
import { pipelineService, PipelineService } from './pipeline-service.js';
import {
getAutoLoadClaudeMdSetting,
getEnableSandboxModeSetting,
filterClaudeMdFromContext,
getMCPServersFromSettings,
getPromptCustomization,
@@ -322,8 +323,6 @@ export class AutoModeService {
projectPath,
});
// Note: Memory folder initialization is now handled by loadContextFiles
// Run the loop in the background
this.runAutoLoop().catch((error) => {
logger.error('Loop error:', error);
@@ -515,21 +514,15 @@ export class AutoModeService {
// Build the prompt - use continuation prompt if provided (for recovery after plan approval)
let prompt: string;
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) and memory files
// Context loader uses task context to select relevant memory files
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) - passed as system prompt
const contextResult = await loadContextFiles({
projectPath,
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
taskContext: {
title: feature.title ?? '',
description: feature.description ?? '',
},
});
// When autoLoadClaudeMd is enabled, filter out CLAUDE.md to avoid duplication
// (SDK handles CLAUDE.md via settingSources), but keep other context files like CODE_QUALITY.md
// Note: contextResult.formattedPrompt now includes both context AND memory
const combinedSystemPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd);
const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd);
if (options?.continuationPrompt) {
// Continuation prompt is used when recovering from a plan approval
@@ -582,7 +575,7 @@ export class AutoModeService {
projectPath,
planningMode: feature.planningMode,
requirePlanApproval: feature.requirePlanApproval,
systemPrompt: combinedSystemPrompt || undefined,
systemPrompt: contextFilesPrompt || undefined,
autoLoadClaudeMd,
thinkingLevel: feature.thinkingLevel,
}
@@ -614,36 +607,6 @@ export class AutoModeService {
// Record success to reset consecutive failure tracking
this.recordSuccess();
// Record learnings and memory usage after successful feature completion
try {
const featureDir = getFeatureDir(projectPath, featureId);
const outputPath = path.join(featureDir, 'agent-output.md');
let agentOutput = '';
try {
const outputContent = await secureFs.readFile(outputPath, 'utf-8');
agentOutput =
typeof outputContent === 'string' ? outputContent : outputContent.toString();
} catch {
// Agent output might not exist yet
}
// Record memory usage if we loaded any memory files
if (contextResult.memoryFiles.length > 0 && agentOutput) {
await recordMemoryUsage(
projectPath,
contextResult.memoryFiles,
agentOutput,
true, // success
secureFs as Parameters<typeof recordMemoryUsage>[4]
);
}
// Extract and record learnings from the agent output
await this.recordLearningsFromFeature(projectPath, feature, agentOutput);
} catch (learningError) {
console.warn('[AutoMode] Failed to record learnings:', learningError);
}
this.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
passes: true,
@@ -712,14 +675,10 @@ export class AutoModeService {
): Promise<void> {
logger.info(`Executing ${steps.length} pipeline step(s) for feature ${featureId}`);
// Load context files once with feature context for smart memory selection
// Load context files once
const contextResult = await loadContextFiles({
projectPath,
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
taskContext: {
title: feature.title ?? '',
description: feature.description ?? '',
},
});
const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd);
@@ -952,10 +911,6 @@ Complete the pipeline step instructions above. Review the previous work and appl
const contextResult = await loadContextFiles({
projectPath,
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
taskContext: {
title: feature?.title ?? prompt.substring(0, 200),
description: feature?.description ?? prompt,
},
});
// When autoLoadClaudeMd is enabled, filter out CLAUDE.md to avoid duplication
@@ -1359,6 +1314,7 @@ Format your response as a structured markdown document.`;
allowedTools: sdkOptions.allowedTools as string[],
abortController,
settingSources: sdkOptions.settingSources,
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
thinkingLevel: analysisThinkingLevel, // Pass thinking level
};
@@ -1828,13 +1784,9 @@ Format your response as a structured markdown document.`;
// Apply dependency-aware ordering
const { orderedFeatures } = resolveDependencies(pendingFeatures);
// Get skipVerificationInAutoMode setting
const settings = await this.settingsService?.getGlobalSettings();
const skipVerification = settings?.skipVerificationInAutoMode ?? false;
// Filter to only features with satisfied dependencies
const readyFeatures = orderedFeatures.filter((feature: Feature) =>
areDependenciesSatisfied(feature, allFeatures, { skipVerification })
areDependenciesSatisfied(feature, allFeatures)
);
return readyFeatures;
@@ -2037,18 +1989,6 @@ This helps parse your summary correctly in the output logs.`;
const planningMode = options?.planningMode || 'skip';
const previousContent = options?.previousContent;
// Validate vision support before processing images
const effectiveModel = model || 'claude-sonnet-4-20250514';
if (imagePaths && imagePaths.length > 0) {
const supportsVision = ProviderFactory.modelSupportsVision(effectiveModel);
if (!supportsVision) {
throw new Error(
`This model (${effectiveModel}) does not support image input. ` +
`Please switch to a model that supports vision (like Claude models), or remove the images and try again.`
);
}
}
// Check if this planning mode can generate a spec/plan that needs approval
// - spec and full always generate specs
// - lite only generates approval-ready content when requirePlanApproval is true
@@ -2122,6 +2062,9 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
? options.autoLoadClaudeMd
: await getAutoLoadClaudeMdSetting(finalProjectPath, this.settingsService, '[AutoMode]');
// Load enableSandboxMode setting (global setting only)
const enableSandboxMode = await getEnableSandboxModeSetting(this.settingsService, '[AutoMode]');
// Load MCP servers from settings (global setting only)
const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AutoMode]');
@@ -2133,6 +2076,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
model: model,
abortController,
autoLoadClaudeMd,
enableSandboxMode,
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
thinkingLevel: options?.thinkingLevel,
});
@@ -2149,12 +2093,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
// Get provider for this model
const provider = ProviderFactory.getProviderForModel(finalModel);
// Strip provider prefix - providers should receive bare model IDs
const bareModel = stripProviderPrefix(finalModel);
logger.info(
`Using provider "${provider.getName()}" for model "${finalModel}" (bare: ${bareModel})`
);
logger.info(`Using provider "${provider.getName()}" for model "${finalModel}"`);
// Build prompt content with images using utility
const { content: promptContent } = await buildPromptWithImages(
@@ -2173,13 +2112,14 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
const executeOptions: ExecuteOptions = {
prompt: promptContent,
model: bareModel,
model: finalModel,
maxTurns: maxTurns,
cwd: workDir,
allowedTools: allowedTools,
abortController,
systemPrompt: sdkOptions.systemPrompt,
settingSources: sdkOptions.settingSources,
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration
thinkingLevel: options?.thinkingLevel, // Pass thinking level for extended thinking
};
@@ -2262,23 +2202,9 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
}, WRITE_DEBOUNCE_MS);
};
// Heartbeat logging so "silent" model calls are visible.
// Some runs can take a while before the first streamed message arrives.
const streamStartTime = Date.now();
let receivedAnyStreamMessage = false;
const STREAM_HEARTBEAT_MS = 15_000;
const streamHeartbeat = setInterval(() => {
if (receivedAnyStreamMessage) return;
const elapsedSeconds = Math.round((Date.now() - streamStartTime) / 1000);
logger.info(
`Waiting for first model response for feature ${featureId} (${elapsedSeconds}s elapsed)...`
);
}, STREAM_HEARTBEAT_MS);
// Wrap stream processing in try/finally to ensure timeout cleanup on any error/abort
try {
streamLoop: for await (const msg of stream) {
receivedAnyStreamMessage = true;
// Log raw stream event for debugging
appendRawEvent(msg);
@@ -2478,7 +2404,7 @@ After generating the revised spec, output:
// Make revision call
const revisionStream = provider.executeQuery({
prompt: revisionPrompt,
model: bareModel,
model: finalModel,
maxTurns: maxTurns || 100,
cwd: workDir,
allowedTools: allowedTools,
@@ -2616,7 +2542,7 @@ After generating the revised spec, output:
// Execute task with dedicated agent
const taskStream = provider.executeQuery({
prompt: taskPrompt,
model: bareModel,
model: finalModel,
maxTurns: Math.min(maxTurns || 100, 50), // Limit turns per task
cwd: workDir,
allowedTools: allowedTools,
@@ -2704,7 +2630,7 @@ Implement all the changes described in the plan above.`;
const continuationStream = provider.executeQuery({
prompt: continuationPrompt,
model: bareModel,
model: finalModel,
maxTurns: maxTurns,
cwd: workDir,
allowedTools: allowedTools,
@@ -2795,7 +2721,6 @@ Implement all the changes described in the plan above.`;
}
}
} finally {
clearInterval(streamHeartbeat);
// ALWAYS clear pending timeouts to prevent memory leaks
// This runs on success, error, or abort
if (writeTimeout) {
@@ -2949,207 +2874,4 @@ Begin implementing task ${task.id} now.`;
}
});
}
/**
* Extract and record learnings from a completed feature
* Uses a quick Claude call to identify important decisions and patterns
*/
private async recordLearningsFromFeature(
projectPath: string,
feature: Feature,
agentOutput: string
): Promise<void> {
if (!agentOutput || agentOutput.length < 100) {
// Not enough output to extract learnings from
console.log(
`[AutoMode] Skipping learning extraction - output too short (${agentOutput?.length || 0} chars)`
);
return;
}
console.log(
`[AutoMode] Extracting learnings from feature "${feature.title}" (${agentOutput.length} chars)`
);
// Limit output to avoid token limits
const truncatedOutput = agentOutput.length > 10000 ? agentOutput.slice(-10000) : agentOutput;
const userPrompt = `You are an Architecture Decision Record (ADR) extractor. Analyze this implementation and return ONLY JSON with learnings. No explanations.
Feature: "${feature.title}"
Implementation log:
${truncatedOutput}
Extract MEANINGFUL learnings - not obvious things. For each, capture:
- DECISIONS: Why this approach vs alternatives? What would break if changed?
- GOTCHAS: What was unexpected? What's the root cause? How to avoid?
- PATTERNS: Why this pattern? What problem does it solve? Trade-offs?
JSON format ONLY (no markdown, no text):
{"learnings": [{
"category": "architecture|api|ui|database|auth|testing|performance|security|gotchas",
"type": "decision|gotcha|pattern",
"content": "What was done/learned",
"context": "Problem being solved or situation faced",
"why": "Reasoning - why this approach",
"rejected": "Alternative considered and why rejected",
"tradeoffs": "What became easier/harder",
"breaking": "What breaks if this is changed/removed"
}]}
IMPORTANT: Only include NON-OBVIOUS learnings with real reasoning. Skip trivial patterns.
If nothing notable: {"learnings": []}`;
try {
// Import query dynamically to avoid circular dependencies
const { query } = await import('@anthropic-ai/claude-agent-sdk');
// Get model from phase settings
const settings = await this.settingsService?.getGlobalSettings();
const phaseModelEntry =
settings?.phaseModels?.memoryExtractionModel || DEFAULT_PHASE_MODELS.memoryExtractionModel;
const { model } = resolvePhaseModel(phaseModelEntry);
const stream = query({
prompt: userPrompt,
options: {
model,
maxTurns: 1,
allowedTools: [],
permissionMode: 'acceptEdits',
systemPrompt:
'You are a JSON extraction assistant. You MUST respond with ONLY valid JSON, no explanations, no markdown, no other text. Extract learnings from the provided implementation context and return them as JSON.',
},
});
// Extract text from stream
let responseText = '';
for await (const msg of stream) {
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
}
}
} else if (msg.type === 'result' && msg.subtype === 'success') {
responseText = msg.result || responseText;
}
}
console.log(`[AutoMode] Learning extraction response: ${responseText.length} chars`);
console.log(`[AutoMode] Response preview: ${responseText.substring(0, 300)}`);
// Parse the response - handle JSON in markdown code blocks or raw
let jsonStr: string | null = null;
// First try to find JSON in markdown code blocks
const codeBlockMatch = responseText.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/);
if (codeBlockMatch) {
console.log('[AutoMode] Found JSON in code block');
jsonStr = codeBlockMatch[1];
} else {
// Fall back to finding balanced braces containing "learnings"
// Use a more precise approach: find the opening brace before "learnings"
const learningsIndex = responseText.indexOf('"learnings"');
if (learningsIndex !== -1) {
// Find the opening brace before "learnings"
let braceStart = responseText.lastIndexOf('{', learningsIndex);
if (braceStart !== -1) {
// Find matching closing brace
let braceCount = 0;
let braceEnd = -1;
for (let i = braceStart; i < responseText.length; i++) {
if (responseText[i] === '{') braceCount++;
if (responseText[i] === '}') braceCount--;
if (braceCount === 0) {
braceEnd = i;
break;
}
}
if (braceEnd !== -1) {
jsonStr = responseText.substring(braceStart, braceEnd + 1);
}
}
}
}
if (!jsonStr) {
console.log('[AutoMode] Could not extract JSON from response');
return;
}
console.log(`[AutoMode] Extracted JSON: ${jsonStr.substring(0, 200)}`);
let parsed: { learnings?: unknown[] };
try {
parsed = JSON.parse(jsonStr);
} catch {
console.warn('[AutoMode] Failed to parse learnings JSON:', jsonStr.substring(0, 200));
return;
}
if (!parsed.learnings || !Array.isArray(parsed.learnings)) {
console.log('[AutoMode] No learnings array in parsed response');
return;
}
console.log(`[AutoMode] Found ${parsed.learnings.length} potential learnings`);
// Valid learning types
const validTypes = new Set(['decision', 'learning', 'pattern', 'gotcha']);
// Record each learning
for (const item of parsed.learnings) {
// Validate required fields with proper type narrowing
if (!item || typeof item !== 'object') continue;
const learning = item as Record<string, unknown>;
if (
!learning.category ||
typeof learning.category !== 'string' ||
!learning.content ||
typeof learning.content !== 'string' ||
!learning.content.trim()
) {
continue;
}
// Validate and normalize type
const typeStr = typeof learning.type === 'string' ? learning.type : 'learning';
const learningType = validTypes.has(typeStr)
? (typeStr as 'decision' | 'learning' | 'pattern' | 'gotcha')
: 'learning';
console.log(
`[AutoMode] Appending learning: category=${learning.category}, type=${learningType}`
);
await appendLearning(
projectPath,
{
category: learning.category,
type: learningType,
content: learning.content.trim(),
context: typeof learning.context === 'string' ? learning.context : undefined,
why: typeof learning.why === 'string' ? learning.why : undefined,
rejected: typeof learning.rejected === 'string' ? learning.rejected : undefined,
tradeoffs: typeof learning.tradeoffs === 'string' ? learning.tradeoffs : undefined,
breaking: typeof learning.breaking === 'string' ? learning.breaking : undefined,
},
secureFs as Parameters<typeof appendLearning>[2]
);
}
const validLearnings = parsed.learnings.filter(
(l) => l && typeof l === 'object' && (l as Record<string, unknown>).content
);
if (validLearnings.length > 0) {
console.log(
`[AutoMode] Recorded ${parsed.learnings.length} learning(s) from feature ${feature.id}`
);
}
} catch (error) {
console.warn(`[AutoMode] Failed to extract learnings from feature ${feature.id}:`, error);
}
}
}

View File

@@ -1,404 +0,0 @@
import {
findCodexCliPath,
spawnProcess,
getCodexAuthPath,
systemPathExists,
systemPathReadFile,
} from '@automaker/platform';
import { createLogger } from '@automaker/utils';
const logger = createLogger('CodexUsage');
export interface CodexRateLimitWindow {
limit: number;
used: number;
remaining: number;
usedPercent: number;
windowDurationMins: number;
resetsAt: number;
}
export interface CodexCreditsSnapshot {
balance?: string;
unlimited?: boolean;
hasCredits?: boolean;
}
export type CodexPlanType = 'free' | 'plus' | 'pro' | 'team' | 'enterprise' | 'edu' | 'unknown';
export interface CodexUsageData {
rateLimits: {
primary?: CodexRateLimitWindow;
secondary?: CodexRateLimitWindow;
credits?: CodexCreditsSnapshot;
planType?: CodexPlanType;
} | null;
lastUpdated: string;
}
/**
* Codex Usage Service
*
* Attempts to fetch usage data from Codex CLI and OpenAI API.
* Codex CLI doesn't provide a direct usage command, but we can:
* 1. Parse usage info from error responses (rate limit errors contain plan info)
* 2. Check for OpenAI API usage if API key is available
*/
export class CodexUsageService {
private cachedCliPath: string | null = null;
/**
* Check if Codex CLI is available on the system
*/
async isAvailable(): Promise<boolean> {
this.cachedCliPath = await findCodexCliPath();
return Boolean(this.cachedCliPath);
}
/**
* Attempt to fetch usage data
*
* Tries multiple approaches:
* 1. Always try to get plan type from auth file first (authoritative source)
* 2. Check for OpenAI API key in environment for API usage
* 3. Make a test request to capture rate limit headers from CLI
* 4. Combine results from auth file and CLI
*/
async fetchUsageData(): Promise<CodexUsageData> {
const cliPath = this.cachedCliPath || (await findCodexCliPath());
if (!cliPath) {
throw new Error('Codex CLI not found. Please install it with: npm install -g @openai/codex');
}
// Always try to get plan type from auth file first - this is the authoritative source
const authPlanType = await this.getPlanTypeFromAuthFile();
// Check if user has an API key that we can use
const hasApiKey = !!process.env.OPENAI_API_KEY;
if (hasApiKey) {
// Try to get usage from OpenAI API
const openaiUsage = await this.fetchOpenAIUsage();
if (openaiUsage) {
// Merge with auth file plan type if available
if (authPlanType && openaiUsage.rateLimits) {
openaiUsage.rateLimits.planType = authPlanType;
}
return openaiUsage;
}
}
// Try to get usage from Codex CLI by making a simple request
const codexUsage = await this.fetchCodexUsage(cliPath, authPlanType);
if (codexUsage) {
return codexUsage;
}
// Fallback: try to parse full usage from auth file
const authUsage = await this.fetchFromAuthFile();
if (authUsage) {
return authUsage;
}
// If all else fails, return a message with helpful information
throw new Error(
'Codex usage statistics require additional configuration. ' +
'To enable usage tracking:\n\n' +
'1. Set your OpenAI API key in the environment:\n' +
' export OPENAI_API_KEY=sk-...\n\n' +
'2. Or check your usage at:\n' +
' https://platform.openai.com/usage\n\n' +
'Note: If using Codex CLI with ChatGPT OAuth authentication, ' +
'usage data must be queried through your OpenAI account.'
);
}
/**
* Extract plan type from auth file JWT token
* Returns the actual plan type or 'unknown' if not available
*/
private async getPlanTypeFromAuthFile(): Promise<CodexPlanType> {
try {
const authFilePath = getCodexAuthPath();
const exists = await systemPathExists(authFilePath);
if (!exists) {
return 'unknown';
}
const authContent = await systemPathReadFile(authFilePath);
const authData = JSON.parse(authContent);
if (!authData.tokens?.id_token) {
return 'unknown';
}
const claims = this.parseJwt(authData.tokens.id_token);
if (!claims) {
return 'unknown';
}
// Extract plan type from nested OpenAI auth object with type validation
const openaiAuthClaim = claims['https://api.openai.com/auth'];
let accountType: string | undefined;
let isSubscriptionExpired = false;
if (
openaiAuthClaim &&
typeof openaiAuthClaim === 'object' &&
!Array.isArray(openaiAuthClaim)
) {
const openaiAuth = openaiAuthClaim as Record<string, unknown>;
if (typeof openaiAuth.chatgpt_plan_type === 'string') {
accountType = openaiAuth.chatgpt_plan_type;
}
// Check if subscription has expired
if (typeof openaiAuth.chatgpt_subscription_active_until === 'string') {
const expiryDate = new Date(openaiAuth.chatgpt_subscription_active_until);
if (!isNaN(expiryDate.getTime())) {
isSubscriptionExpired = expiryDate < new Date();
}
}
} else {
// Fallback: try top-level claim names
const possibleClaimNames = [
'https://chatgpt.com/account_type',
'account_type',
'plan',
'plan_type',
];
for (const claimName of possibleClaimNames) {
const claimValue = claims[claimName];
if (claimValue && typeof claimValue === 'string') {
accountType = claimValue;
break;
}
}
}
// If subscription is expired, treat as free plan
if (isSubscriptionExpired && accountType && accountType !== 'free') {
logger.info(`Subscription expired, using "free" instead of "${accountType}"`);
accountType = 'free';
}
if (accountType) {
const normalizedType = accountType.toLowerCase();
if (['free', 'plus', 'pro', 'team', 'enterprise', 'edu'].includes(normalizedType)) {
return normalizedType as CodexPlanType;
}
}
} catch (error) {
logger.error('Failed to get plan type from auth file:', error);
}
return 'unknown';
}
/**
* Try to fetch usage from OpenAI API using the API key
*/
private async fetchOpenAIUsage(): Promise<CodexUsageData | null> {
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
return null;
}
try {
const endTime = Math.floor(Date.now() / 1000);
const startTime = endTime - 7 * 24 * 60 * 60; // Last 7 days
const response = await fetch(
`https://api.openai.com/v1/organization/usage/completions?start_time=${startTime}&end_time=${endTime}&limit=1`,
{
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
}
);
if (response.ok) {
const data = await response.json();
return this.parseOpenAIUsage(data);
}
} catch (error) {
logger.error('Failed to fetch from OpenAI API:', error);
}
return null;
}
/**
* Parse OpenAI usage API response
*/
private parseOpenAIUsage(data: any): CodexUsageData {
let totalInputTokens = 0;
let totalOutputTokens = 0;
if (data.data && Array.isArray(data.data)) {
for (const bucket of data.data) {
if (bucket.results && Array.isArray(bucket.results)) {
for (const result of bucket.results) {
totalInputTokens += result.input_tokens || 0;
totalOutputTokens += result.output_tokens || 0;
}
}
}
}
return {
rateLimits: {
planType: 'unknown',
credits: {
hasCredits: true,
},
},
lastUpdated: new Date().toISOString(),
};
}
/**
* Try to fetch usage by making a test request to Codex CLI
* and parsing rate limit information from the response
*/
private async fetchCodexUsage(
cliPath: string,
authPlanType: CodexPlanType
): Promise<CodexUsageData | null> {
try {
// Make a simple request to trigger rate limit info if at limit
const result = await spawnProcess({
command: cliPath,
args: ['exec', '--', 'echo', 'test'],
cwd: process.cwd(),
env: {
...process.env,
TERM: 'dumb',
},
timeout: 10000,
});
// Parse the output for rate limit information
const combinedOutput = (result.stdout + result.stderr).toLowerCase();
// Check if we got a rate limit error
const rateLimitMatch = combinedOutput.match(
/usage_limit_reached.*?"plan_type":"([^"]+)".*?"resets_at":(\d+).*?"resets_in_seconds":(\d+)/
);
if (rateLimitMatch) {
// Rate limit error contains the plan type - use that as it's the most authoritative
const planType = rateLimitMatch[1] as CodexPlanType;
const resetsAt = parseInt(rateLimitMatch[2], 10);
const resetsInSeconds = parseInt(rateLimitMatch[3], 10);
logger.info(
`Rate limit hit - plan: ${planType}, resets in ${Math.ceil(resetsInSeconds / 60)} mins`
);
return {
rateLimits: {
planType,
primary: {
limit: 0,
used: 0,
remaining: 0,
usedPercent: 100,
windowDurationMins: Math.ceil(resetsInSeconds / 60),
resetsAt,
},
},
lastUpdated: new Date().toISOString(),
};
}
// No rate limit error - use the plan type from auth file
const isFreePlan = authPlanType === 'free';
return {
rateLimits: {
planType: authPlanType,
credits: {
hasCredits: true,
unlimited: !isFreePlan && authPlanType !== 'unknown',
},
},
lastUpdated: new Date().toISOString(),
};
} catch (error) {
logger.error('Failed to fetch from Codex CLI:', error);
}
return null;
}
/**
* Try to extract usage info from the Codex auth file
* Reuses getPlanTypeFromAuthFile to avoid code duplication
*/
private async fetchFromAuthFile(): Promise<CodexUsageData | null> {
try {
const planType = await this.getPlanTypeFromAuthFile();
if (planType === 'unknown') {
return null;
}
const isFreePlan = planType === 'free';
return {
rateLimits: {
planType,
credits: {
hasCredits: true,
unlimited: !isFreePlan,
},
},
lastUpdated: new Date().toISOString(),
};
} catch (error) {
logger.error('Failed to parse auth file:', error);
}
return null;
}
/**
* Parse JWT token to extract claims
*/
private parseJwt(token: string): any {
try {
const parts = token.split('.');
if (parts.length !== 3) {
return null;
}
const base64Url = parts[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
// Use Buffer for Node.js environment instead of atob
let jsonPayload: string;
if (typeof Buffer !== 'undefined') {
jsonPayload = Buffer.from(base64, 'base64').toString('utf-8');
} else {
jsonPayload = decodeURIComponent(
atob(base64)
.split('')
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join('')
);
}
return JSON.parse(jsonPayload);
} catch {
return null;
}
}
}

View File

@@ -4,7 +4,7 @@
*/
import path from 'path';
import type { Feature, DescriptionHistoryEntry } from '@automaker/types';
import type { Feature } from '@automaker/types';
import { createLogger } from '@automaker/utils';
import * as secureFs from '../lib/secure-fs.js';
import {
@@ -274,16 +274,6 @@ export class FeatureLoader {
featureData.imagePaths
);
// Initialize description history with the initial description
const initialHistory: DescriptionHistoryEntry[] = [];
if (featureData.description && featureData.description.trim()) {
initialHistory.push({
description: featureData.description,
timestamp: new Date().toISOString(),
source: 'initial',
});
}
// Ensure feature has required fields
const feature: Feature = {
category: featureData.category || 'Uncategorized',
@@ -291,7 +281,6 @@ export class FeatureLoader {
...featureData,
id: featureId,
imagePaths: migratedImagePaths,
descriptionHistory: initialHistory,
};
// Write feature.json
@@ -303,18 +292,11 @@ export class FeatureLoader {
/**
* Update a feature (partial updates supported)
* @param projectPath - Path to the project
* @param featureId - ID of the feature to update
* @param updates - Partial feature updates
* @param descriptionHistorySource - Source of description change ('enhance' or 'edit')
* @param enhancementMode - Enhancement mode if source is 'enhance'
*/
async update(
projectPath: string,
featureId: string,
updates: Partial<Feature>,
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'
updates: Partial<Feature>
): Promise<Feature> {
const feature = await this.get(projectPath, featureId);
if (!feature) {
@@ -331,28 +313,11 @@ export class FeatureLoader {
updatedImagePaths = await this.migrateImages(projectPath, featureId, updates.imagePaths);
}
// Track description history if description changed
let updatedHistory = feature.descriptionHistory || [];
if (
updates.description !== undefined &&
updates.description !== feature.description &&
updates.description.trim()
) {
const historyEntry: DescriptionHistoryEntry = {
description: updates.description,
timestamp: new Date().toISOString(),
source: descriptionHistorySource || 'edit',
...(descriptionHistorySource === 'enhance' && enhancementMode ? { enhancementMode } : {}),
};
updatedHistory = [...updatedHistory, historyEntry];
}
// Merge updates
const updatedFeature: Feature = {
...feature,
...updates,
...(updatedImagePaths !== undefined ? { imagePaths: updatedImagePaths } : {}),
descriptionHistory: updatedHistory,
};
// Write back to file

View File

@@ -40,7 +40,6 @@ import type { SettingsService } from './settings-service.js';
import type { FeatureLoader } from './feature-loader.js';
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
import { resolveModelString } from '@automaker/model-resolver';
import { stripProviderPrefix } from '@automaker/types';
const logger = createLogger('IdeationService');
@@ -202,7 +201,7 @@ export class IdeationService {
existingWorkContext
);
// Resolve model alias to canonical identifier (with prefix)
// Resolve model alias to canonical identifier
const modelId = resolveModelString(options?.model ?? 'sonnet');
// Create SDK options
@@ -215,13 +214,9 @@ export class IdeationService {
const provider = ProviderFactory.getProviderForModel(modelId);
// Strip provider prefix - providers need bare model IDs
const bareModel = stripProviderPrefix(modelId);
const executeOptions: ExecuteOptions = {
prompt: message,
model: bareModel,
originalModel: modelId,
model: modelId,
cwd: projectPath,
systemPrompt: sdkOptions.systemPrompt,
maxTurns: 1, // Single turn for ideation
@@ -653,7 +648,7 @@ export class IdeationService {
existingWorkContext
);
// Resolve model alias to canonical identifier (with prefix)
// Resolve model alias to canonical identifier
const modelId = resolveModelString('sonnet');
// Create SDK options
@@ -666,13 +661,9 @@ export class IdeationService {
const provider = ProviderFactory.getProviderForModel(modelId);
// Strip provider prefix - providers need bare model IDs
const bareModel = stripProviderPrefix(modelId);
const executeOptions: ExecuteOptions = {
prompt: prompt.prompt,
model: bareModel,
originalModel: modelId,
model: modelId,
cwd: projectPath,
systemPrompt: sdkOptions.systemPrompt,
maxTurns: 1,

View File

@@ -0,0 +1,673 @@
/**
* Performance Monitor Service
*
* Collects and streams server-side performance metrics including:
* - Memory usage (heap, rss, external)
* - CPU usage (user, system, percentage)
* - Event loop lag detection
* - Memory leak trend analysis
*
* Emits debug events for real-time streaming to connected clients.
*/
import v8 from 'v8';
import { createLogger } from '@automaker/utils';
import type { EventEmitter } from '../lib/events.js';
import type {
ServerMemoryMetrics,
ServerCPUMetrics,
MemoryMetrics,
CPUMetrics,
MemoryTrend,
DebugMetricsConfig,
DebugMetricsSnapshot,
ProcessSummary,
TrackedProcess,
} from '@automaker/types';
import { DEFAULT_DEBUG_METRICS_CONFIG, formatBytes } from '@automaker/types';
const logger = createLogger('PerformanceMonitor');
/**
* Circular buffer for time-series data storage
* Uses index-based ring buffer for O(1) push operations instead of O(n) shift().
* Efficiently stores a fixed number of data points, automatically discarding old ones.
*/
class CircularBuffer<T> {
private buffer: (T | undefined)[];
private maxSize: number;
private head = 0; // Write position
private count = 0; // Number of items
constructor(maxSize: number) {
this.maxSize = maxSize;
this.buffer = new Array(maxSize);
}
/**
* Add item to buffer - O(1) operation
*/
push(item: T): void {
this.buffer[this.head] = item;
this.head = (this.head + 1) % this.maxSize;
if (this.count < this.maxSize) {
this.count++;
}
}
/**
* Get all items in chronological order - O(n) but only called when needed
*/
getAll(): T[] {
if (this.count === 0) return [];
const result: T[] = new Array(this.count);
const start = this.count < this.maxSize ? 0 : this.head;
for (let i = 0; i < this.count; i++) {
const idx = (start + i) % this.maxSize;
result[i] = this.buffer[idx] as T;
}
return result;
}
/**
* Get most recent item - O(1)
*/
getLast(): T | undefined {
if (this.count === 0) return undefined;
const idx = (this.head - 1 + this.maxSize) % this.maxSize;
return this.buffer[idx];
}
/**
* Get oldest item - O(1)
*/
getFirst(): T | undefined {
if (this.count === 0) return undefined;
const start = this.count < this.maxSize ? 0 : this.head;
return this.buffer[start];
}
/**
* Get current count - O(1)
*/
size(): number {
return this.count;
}
/**
* Clear all items - O(1)
*/
clear(): void {
this.head = 0;
this.count = 0;
// Don't reallocate array, just reset indices
}
/**
* Resize buffer, preserving existing data
*/
resize(newSize: number): void {
const oldData = this.getAll();
this.maxSize = newSize;
this.buffer = new Array(newSize);
this.head = 0;
this.count = 0;
// Copy over data (trim if necessary, keep most recent)
const startIdx = Math.max(0, oldData.length - newSize);
for (let i = startIdx; i < oldData.length; i++) {
this.push(oldData[i]);
}
}
}
/**
* Memory data point for trend analysis
*/
interface MemoryDataPoint {
timestamp: number;
heapUsed: number;
}
/**
* CPU data point for tracking
*/
interface CPUDataPoint {
timestamp: number;
user: number;
system: number;
}
/**
* PerformanceMonitorService - Collects server-side performance metrics
*
* This service runs in the Node.js server process and periodically collects:
* - Memory metrics from process.memoryUsage()
* - CPU metrics from process.cpuUsage()
* - Event loop lag using setTimeout deviation
*
* It streams metrics to connected clients via the event emitter and
* analyzes memory trends to detect potential leaks.
*/
export class PerformanceMonitorService {
private events: EventEmitter;
private config: DebugMetricsConfig;
private isRunning = false;
private collectionInterval: NodeJS.Timeout | null = null;
private eventLoopCheckInterval: NodeJS.Timeout | null = null;
// Data storage
private memoryHistory: CircularBuffer<MemoryDataPoint>;
private cpuHistory: CircularBuffer<CPUDataPoint>;
// CPU tracking state
private lastCpuUsage: NodeJS.CpuUsage | null = null;
private lastCpuTime: number = 0;
// Event loop lag tracking
private lastEventLoopLag = 0;
private eventLoopLagThreshold = 100; // ms - threshold for warning
// Memory warning thresholds (percentage of heap limit)
private memoryWarningThreshold = 70; // 70% of heap limit
private memoryCriticalThreshold = 90; // 90% of heap limit
private lastMemoryWarningTime = 0;
private memoryWarningCooldown = 30000; // 30 seconds between warnings
// Process tracking (will be populated by ProcessRegistryService)
private getProcesses: () => TrackedProcess[] = () => [];
constructor(events: EventEmitter, config?: Partial<DebugMetricsConfig>) {
this.events = events;
this.config = { ...DEFAULT_DEBUG_METRICS_CONFIG, ...config };
this.memoryHistory = new CircularBuffer(this.config.maxDataPoints);
this.cpuHistory = new CircularBuffer(this.config.maxDataPoints);
logger.info('PerformanceMonitorService initialized');
}
/**
* Set the process provider function (called by ProcessRegistryService)
*/
setProcessProvider(provider: () => TrackedProcess[]): void {
this.getProcesses = provider;
}
/**
* Start metrics collection
*/
start(): void {
if (this.isRunning) {
logger.warn('PerformanceMonitorService is already running');
return;
}
this.isRunning = true;
this.lastCpuUsage = process.cpuUsage();
this.lastCpuTime = Date.now();
// Start periodic metrics collection
this.collectionInterval = setInterval(() => {
this.collectAndEmitMetrics();
}, this.config.collectionInterval);
// Start event loop lag monitoring (more frequent for accurate detection)
this.startEventLoopMonitoring();
logger.info('PerformanceMonitorService started', {
interval: this.config.collectionInterval,
});
}
/**
* Stop metrics collection
*/
stop(): void {
if (!this.isRunning) {
return;
}
this.isRunning = false;
if (this.collectionInterval) {
clearInterval(this.collectionInterval);
this.collectionInterval = null;
}
if (this.eventLoopCheckInterval) {
clearInterval(this.eventLoopCheckInterval);
this.eventLoopCheckInterval = null;
}
logger.info('PerformanceMonitorService stopped');
}
/**
* Update configuration
*/
updateConfig(config: Partial<DebugMetricsConfig>): void {
const wasRunning = this.isRunning;
if (wasRunning) {
this.stop();
}
this.config = { ...this.config, ...config };
// Resize buffers if maxDataPoints changed
if (config.maxDataPoints) {
this.memoryHistory.resize(config.maxDataPoints);
this.cpuHistory.resize(config.maxDataPoints);
}
if (wasRunning) {
this.start();
}
logger.info('PerformanceMonitorService configuration updated', config);
}
/**
* Get current configuration
*/
getConfig(): DebugMetricsConfig {
return { ...this.config };
}
/**
* Get whether monitoring is active
*/
isActive(): boolean {
return this.isRunning;
}
/**
* Collect and emit current metrics
*/
private collectAndEmitMetrics(): void {
const timestamp = Date.now();
const memoryMetrics = this.collectMemoryMetrics(timestamp);
const cpuMetrics = this.collectCPUMetrics(timestamp);
// Store in history
if (this.config.memoryEnabled && memoryMetrics.server) {
this.memoryHistory.push({
timestamp,
heapUsed: memoryMetrics.server.heapUsed,
});
}
// Analyze memory trend
const memoryTrend = this.analyzeMemoryTrend();
// Get process information
const processes = this.getProcesses();
const processSummary = this.calculateProcessSummary(processes);
// Build snapshot
const snapshot: DebugMetricsSnapshot = {
timestamp,
memory: memoryMetrics,
cpu: cpuMetrics,
processes,
processSummary,
memoryTrend,
};
// Emit metrics event
this.events.emit('debug:metrics', {
type: 'debug:metrics',
timestamp,
metrics: snapshot,
});
// Check for memory warnings
this.checkMemoryThresholds(memoryMetrics);
// Check for memory leak
if (memoryTrend && memoryTrend.isLeaking) {
this.events.emit('debug:leak-detected', {
type: 'debug:leak-detected',
timestamp,
trend: memoryTrend,
message: `Potential memory leak detected: ${formatBytes(memoryTrend.growthRate)}/s sustained growth`,
});
}
// Check for high CPU
if (cpuMetrics.server && cpuMetrics.server.percentage > 80) {
this.events.emit('debug:high-cpu', {
type: 'debug:high-cpu',
timestamp,
cpu: cpuMetrics,
usagePercent: cpuMetrics.server.percentage,
threshold: 80,
message: `High CPU usage: ${cpuMetrics.server.percentage.toFixed(1)}%`,
});
}
}
/**
* Collect memory metrics from Node.js process
*/
private collectMemoryMetrics(timestamp: number): MemoryMetrics {
if (!this.config.memoryEnabled) {
return { timestamp };
}
const usage = process.memoryUsage();
const serverMetrics: ServerMemoryMetrics = {
heapTotal: usage.heapTotal,
heapUsed: usage.heapUsed,
external: usage.external,
rss: usage.rss,
arrayBuffers: usage.arrayBuffers,
};
return {
timestamp,
server: serverMetrics,
};
}
/**
* Collect CPU metrics from Node.js process
*/
private collectCPUMetrics(timestamp: number): CPUMetrics {
if (!this.config.cpuEnabled) {
return { timestamp };
}
const currentCpuUsage = process.cpuUsage();
const currentTime = Date.now();
let serverMetrics: ServerCPUMetrics | undefined;
if (this.lastCpuUsage) {
// Calculate CPU usage since last measurement
const userDiff = currentCpuUsage.user - this.lastCpuUsage.user;
const systemDiff = currentCpuUsage.system - this.lastCpuUsage.system;
const timeDiff = (currentTime - this.lastCpuTime) * 1000; // Convert to microseconds
// Calculate percentage (CPU usage is in microseconds)
// For multi-core systems, this can exceed 100%
const percentage = timeDiff > 0 ? ((userDiff + systemDiff) / timeDiff) * 100 : 0;
serverMetrics = {
percentage: Math.min(100, percentage), // Cap at 100% for single-core representation
user: userDiff,
system: systemDiff,
};
// Store in history
this.cpuHistory.push({
timestamp,
user: userDiff,
system: systemDiff,
});
}
this.lastCpuUsage = currentCpuUsage;
this.lastCpuTime = currentTime;
return {
timestamp,
server: serverMetrics,
eventLoopLag: this.lastEventLoopLag,
};
}
/**
* Start event loop lag monitoring
* Uses setTimeout deviation to detect when the event loop is blocked
*/
private startEventLoopMonitoring(): void {
const checkInterval = 100; // Check every 100ms
const measureLag = () => {
if (!this.isRunning) return;
const start = Date.now();
// setImmediate runs after I/O events, giving us event loop lag
setImmediate(() => {
const lag = Date.now() - start;
this.lastEventLoopLag = lag;
// Emit warning if lag exceeds threshold
if (lag > this.eventLoopLagThreshold) {
this.events.emit('debug:event-loop-blocked', {
type: 'debug:event-loop-blocked',
timestamp: Date.now(),
lag,
threshold: this.eventLoopLagThreshold,
message: `Event loop blocked for ${lag}ms`,
});
}
});
};
this.eventLoopCheckInterval = setInterval(measureLag, checkInterval);
}
/**
* Analyze memory trend for leak detection
*/
private analyzeMemoryTrend(): MemoryTrend | undefined {
const history = this.memoryHistory.getAll();
if (history.length < 10) {
return undefined; // Need at least 10 samples for meaningful analysis
}
const first = history[0];
const last = history[history.length - 1];
const windowDuration = last.timestamp - first.timestamp;
if (windowDuration === 0) {
return undefined;
}
// Calculate linear regression for growth rate
const n = history.length;
let sumX = 0;
let sumY = 0;
let sumXY = 0;
let sumXX = 0;
for (let i = 0; i < n; i++) {
const x = history[i].timestamp - first.timestamp;
const y = history[i].heapUsed;
sumX += x;
sumY += y;
sumXY += x * y;
sumXX += x * x;
}
// Slope of linear regression (bytes per millisecond)
const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX);
const growthRate = slope * 1000; // Convert to bytes per second
// Calculate R² for confidence
const meanY = sumY / n;
let ssRes = 0;
let ssTot = 0;
const intercept = (sumY - slope * sumX) / n;
for (let i = 0; i < n; i++) {
const x = history[i].timestamp - first.timestamp;
const y = history[i].heapUsed;
const yPred = slope * x + intercept;
ssRes += (y - yPred) ** 2;
ssTot += (y - meanY) ** 2;
}
const rSquared = ssTot > 0 ? 1 - ssRes / ssTot : 0;
const confidence = Math.max(0, Math.min(1, rSquared));
// Consider it a leak if:
// 1. Growth rate exceeds threshold
// 2. R² is high (indicating consistent growth, not just fluctuation)
const isLeaking =
growthRate > this.config.leakThreshold && confidence > 0.7 && windowDuration > 30000; // At least 30 seconds of data
return {
growthRate,
isLeaking,
confidence,
sampleCount: n,
windowDuration,
};
}
/**
* Check memory thresholds and emit warnings
*/
private checkMemoryThresholds(memory: MemoryMetrics): void {
if (!memory.server) return;
const now = Date.now();
if (now - this.lastMemoryWarningTime < this.memoryWarningCooldown) {
return; // Don't spam warnings
}
// Get V8 heap statistics for limit
const heapStats = v8.getHeapStatistics();
const heapLimit = heapStats.heap_size_limit;
const usagePercent = (memory.server.heapUsed / heapLimit) * 100;
if (usagePercent >= this.memoryCriticalThreshold) {
this.lastMemoryWarningTime = now;
this.events.emit('debug:memory-critical', {
type: 'debug:memory-critical',
timestamp: now,
memory,
usagePercent,
threshold: this.memoryCriticalThreshold,
message: `Critical memory usage: ${usagePercent.toFixed(1)}% of heap limit`,
});
} else if (usagePercent >= this.memoryWarningThreshold) {
this.lastMemoryWarningTime = now;
this.events.emit('debug:memory-warning', {
type: 'debug:memory-warning',
timestamp: now,
memory,
usagePercent,
threshold: this.memoryWarningThreshold,
message: `High memory usage: ${usagePercent.toFixed(1)}% of heap limit`,
});
}
}
/**
* Calculate process summary from tracked processes
*/
private calculateProcessSummary(processes: TrackedProcess[]): ProcessSummary {
const summary: ProcessSummary = {
total: processes.length,
running: 0,
idle: 0,
stopped: 0,
errored: 0,
byType: {
agent: 0,
cli: 0,
terminal: 0,
worker: 0,
},
};
for (const process of processes) {
// Count by status
switch (process.status) {
case 'running':
case 'starting':
summary.running++;
break;
case 'idle':
summary.idle++;
break;
case 'stopped':
case 'stopping':
summary.stopped++;
break;
case 'error':
summary.errored++;
break;
}
// Count by type
if (process.type in summary.byType) {
summary.byType[process.type]++;
}
}
return summary;
}
/**
* Get latest metrics snapshot
*/
getLatestSnapshot(): DebugMetricsSnapshot | null {
const timestamp = Date.now();
const lastMemory = this.memoryHistory.getLast();
if (!lastMemory) {
return null;
}
const memoryMetrics = this.collectMemoryMetrics(timestamp);
const cpuMetrics = this.collectCPUMetrics(timestamp);
const memoryTrend = this.analyzeMemoryTrend();
const processes = this.getProcesses();
const processSummary = this.calculateProcessSummary(processes);
return {
timestamp,
memory: memoryMetrics,
cpu: cpuMetrics,
processes,
processSummary,
memoryTrend,
};
}
/**
* Get memory history for charting
*/
getMemoryHistory(): MemoryDataPoint[] {
return this.memoryHistory.getAll();
}
/**
* Get CPU history for charting
*/
getCPUHistory(): CPUDataPoint[] {
return this.cpuHistory.getAll();
}
/**
* Force a garbage collection (if --expose-gc flag is used)
* Returns true if GC was triggered, false if not available
*/
forceGC(): boolean {
if (global.gc) {
global.gc();
logger.info('Forced garbage collection');
return true;
}
logger.warn('Garbage collection not available (start with --expose-gc flag)');
return false;
}
/**
* Clear collected history
*/
clearHistory(): void {
this.memoryHistory.clear();
this.cpuHistory.clear();
logger.info('Performance history cleared');
}
}

View File

@@ -0,0 +1,982 @@
/**
* Process Registry Service
*
* Tracks spawned agents, CLIs, and terminal processes for debugging and monitoring.
* Emits debug events for real-time updates to connected clients.
*
* This service provides:
* - Process registration and unregistration
* - Status updates for tracked processes
* - Integration with PerformanceMonitorService for metrics snapshots
* - Filtering and querying of tracked processes
* - Automatic cleanup of stopped processes after a retention period
*/
import { createLogger } from '@automaker/utils';
import type { EventEmitter } from '../lib/events.js';
import type {
TrackedProcess,
ProcessType,
ProcessStatus,
ProcessSummary,
AgentResourceMetrics,
FileIOOperation,
} from '@automaker/types';
import { createEmptyAgentResourceMetrics } from '@automaker/types';
const logger = createLogger('ProcessRegistry');
/**
* Options for recording a tool invocation
*/
export interface RecordToolUseOptions {
/** Tool name */
toolName: string;
/** Execution time in milliseconds */
executionTime?: number;
/** Whether the tool invocation failed */
failed?: boolean;
}
/**
* Options for recording a file operation
*/
export interface RecordFileOperationOptions {
/** Type of file operation */
operation: FileIOOperation;
/** File path accessed */
filePath: string;
/** Bytes read or written */
bytes?: number;
}
/**
* Options for recording a bash command
*/
export interface RecordBashCommandOptions {
/** Command executed */
command: string;
/** Execution time in milliseconds */
executionTime: number;
/** Exit code (null if still running or killed) */
exitCode: number | null;
}
/**
* Options for registering a new process
*/
export interface RegisterProcessOptions {
/** Unique identifier for the process */
id: string;
/** Process ID from the operating system (-1 if not applicable) */
pid: number;
/** Type of process */
type: ProcessType;
/** Human-readable name/label */
name: string;
/** Associated feature ID (for agent processes) */
featureId?: string;
/** Associated session ID (for agent/terminal processes) */
sessionId?: string;
/** Command that was executed */
command?: string;
/** Working directory */
cwd?: string;
}
/**
* Options for updating a process
*/
export interface UpdateProcessOptions {
/** New status */
status?: ProcessStatus;
/** Memory usage in bytes */
memoryUsage?: number;
/** CPU usage percentage */
cpuUsage?: number;
/** Exit code (when stopping) */
exitCode?: number;
/** Error message */
error?: string;
}
/**
* Options for querying processes
*/
export interface QueryProcessOptions {
/** Filter by process type */
type?: ProcessType;
/** Filter by status */
status?: ProcessStatus;
/** Include stopped processes (default: false) */
includeStopped?: boolean;
/** Filter by session ID */
sessionId?: string;
/** Filter by feature ID */
featureId?: string;
}
/**
* Configuration for the ProcessRegistryService
*/
export interface ProcessRegistryConfig {
/** How long to keep stopped processes in the registry (ms) */
stoppedProcessRetention: number;
/** Interval for cleanup of old stopped processes (ms) */
cleanupInterval: number;
/** Maximum number of stopped processes to retain */
maxStoppedProcesses: number;
}
const DEFAULT_CONFIG: ProcessRegistryConfig = {
stoppedProcessRetention: 5 * 60 * 1000, // 5 minutes
cleanupInterval: 60 * 1000, // 1 minute
maxStoppedProcesses: 100,
};
/**
* ProcessRegistryService - Tracks spawned processes for debugging
*
* This service maintains a registry of all tracked processes including:
* - Agent sessions (AI conversations)
* - CLI processes (one-off commands)
* - Terminal sessions (persistent PTY sessions)
* - Worker processes (background tasks)
*
* It emits events when processes are spawned, updated, or stopped,
* allowing real-time monitoring in the debug panel.
*/
export class ProcessRegistryService {
private events: EventEmitter;
private config: ProcessRegistryConfig;
private processes: Map<string, TrackedProcess> = new Map();
private cleanupIntervalId: NodeJS.Timeout | null = null;
constructor(events: EventEmitter, config?: Partial<ProcessRegistryConfig>) {
this.events = events;
this.config = { ...DEFAULT_CONFIG, ...config };
logger.info('ProcessRegistryService initialized');
}
/**
* Start the process registry service
* Begins periodic cleanup of old stopped processes
*/
start(): void {
if (this.cleanupIntervalId) {
logger.warn('ProcessRegistryService is already running');
return;
}
this.cleanupIntervalId = setInterval(() => {
this.cleanupStoppedProcesses();
}, this.config.cleanupInterval);
logger.info('ProcessRegistryService started');
}
/**
* Stop the process registry service
*/
stop(): void {
if (this.cleanupIntervalId) {
clearInterval(this.cleanupIntervalId);
this.cleanupIntervalId = null;
}
logger.info('ProcessRegistryService stopped');
}
/**
* Register a new process
*/
registerProcess(options: RegisterProcessOptions): TrackedProcess {
const now = Date.now();
const process: TrackedProcess = {
id: options.id,
pid: options.pid,
type: options.type,
name: options.name,
status: 'starting',
startedAt: now,
featureId: options.featureId,
sessionId: options.sessionId,
command: options.command,
cwd: options.cwd,
};
this.processes.set(options.id, process);
logger.info('Process registered', {
id: process.id,
type: process.type,
name: process.name,
pid: process.pid,
});
// Emit process spawned event
this.events.emit('debug:process-spawned', {
type: 'debug:process-spawned',
timestamp: now,
process,
message: `Process ${process.name} (${process.type}) started`,
});
return process;
}
/**
* Update an existing process
*/
updateProcess(id: string, updates: UpdateProcessOptions): TrackedProcess | null {
const process = this.processes.get(id);
if (!process) {
logger.warn('Attempted to update non-existent process', { id });
return null;
}
const now = Date.now();
// Apply updates
if (updates.status !== undefined) {
process.status = updates.status;
// Set stoppedAt timestamp when process stops
if (updates.status === 'stopped' || updates.status === 'error') {
process.stoppedAt = now;
}
}
if (updates.memoryUsage !== undefined) {
process.memoryUsage = updates.memoryUsage;
}
if (updates.cpuUsage !== undefined) {
process.cpuUsage = updates.cpuUsage;
}
if (updates.exitCode !== undefined) {
process.exitCode = updates.exitCode;
}
if (updates.error !== undefined) {
process.error = updates.error;
}
logger.debug('Process updated', {
id,
updates,
});
// Emit appropriate event based on status
if (updates.status === 'stopped') {
this.events.emit('debug:process-stopped', {
type: 'debug:process-stopped',
timestamp: now,
process,
message: `Process ${process.name} stopped${updates.exitCode !== undefined ? ` (exit code: ${updates.exitCode})` : ''}`,
});
} else if (updates.status === 'error') {
this.events.emit('debug:process-error', {
type: 'debug:process-error',
timestamp: now,
process,
message: `Process ${process.name} encountered an error: ${updates.error || 'Unknown error'}`,
});
} else {
this.events.emit('debug:process-updated', {
type: 'debug:process-updated',
timestamp: now,
process,
});
}
return process;
}
/**
* Mark a process as running
*/
markRunning(id: string): TrackedProcess | null {
return this.updateProcess(id, { status: 'running' });
}
/**
* Mark a process as idle
*/
markIdle(id: string): TrackedProcess | null {
return this.updateProcess(id, { status: 'idle' });
}
/**
* Mark a process as stopping
*/
markStopping(id: string): TrackedProcess | null {
return this.updateProcess(id, { status: 'stopping' });
}
/**
* Mark a process as stopped
*/
markStopped(id: string, exitCode?: number): TrackedProcess | null {
return this.updateProcess(id, { status: 'stopped', exitCode });
}
/**
* Mark a process as errored
*/
markError(id: string, error: string): TrackedProcess | null {
return this.updateProcess(id, { status: 'error', error });
}
/**
* Unregister a process (remove immediately without retention)
*/
unregisterProcess(id: string): boolean {
const process = this.processes.get(id);
if (!process) {
return false;
}
this.processes.delete(id);
logger.info('Process unregistered', {
id,
type: process.type,
name: process.name,
});
return true;
}
/**
* Get a process by ID
*/
getProcess(id: string): TrackedProcess | undefined {
return this.processes.get(id);
}
/**
* Get all tracked processes, optionally filtered
* Optimized single-pass filtering to avoid multiple array allocations
*/
getProcesses(options?: QueryProcessOptions): TrackedProcess[] {
// Pre-allocate array with estimated capacity
const result: TrackedProcess[] = [];
// Single-pass filtering
for (const process of this.processes.values()) {
// Filter by type
if (options?.type && process.type !== options.type) {
continue;
}
// Filter by status
if (options?.status && process.status !== options.status) {
continue;
}
// Filter out stopped processes by default
if (!options?.includeStopped) {
if (process.status === 'stopped' || process.status === 'error') {
continue;
}
}
// Filter by session ID
if (options?.sessionId && process.sessionId !== options.sessionId) {
continue;
}
// Filter by feature ID
if (options?.featureId && process.featureId !== options.featureId) {
continue;
}
result.push(process);
}
// Sort by start time (most recent first)
result.sort((a, b) => b.startedAt - a.startedAt);
return result;
}
/**
* Get all processes (for PerformanceMonitorService integration)
* This is used as the process provider function
*/
getAllProcesses(): TrackedProcess[] {
return Array.from(this.processes.values());
}
/**
* Get process provider function for PerformanceMonitorService
*/
getProcessProvider(): () => TrackedProcess[] {
return () => this.getAllProcesses();
}
/**
* Calculate summary statistics for tracked processes
*/
getProcessSummary(): ProcessSummary {
const processes = this.getAllProcesses();
const summary: ProcessSummary = {
total: processes.length,
running: 0,
idle: 0,
stopped: 0,
errored: 0,
byType: {
agent: 0,
cli: 0,
terminal: 0,
worker: 0,
},
};
for (const process of processes) {
// Count by status
switch (process.status) {
case 'running':
case 'starting':
summary.running++;
break;
case 'idle':
summary.idle++;
break;
case 'stopped':
case 'stopping':
summary.stopped++;
break;
case 'error':
summary.errored++;
break;
}
// Count by type
if (process.type in summary.byType) {
summary.byType[process.type]++;
}
}
return summary;
}
/**
* Get count of active (non-stopped) processes
*/
getActiveCount(): number {
let count = 0;
for (const process of this.processes.values()) {
if (process.status !== 'stopped' && process.status !== 'error') {
count++;
}
}
return count;
}
/**
* Get count of processes by type
*/
getCountByType(type: ProcessType): number {
let count = 0;
for (const process of this.processes.values()) {
if (process.type === type) {
count++;
}
}
return count;
}
/**
* Check if a process exists
*/
hasProcess(id: string): boolean {
return this.processes.has(id);
}
/**
* Update configuration
*/
updateConfig(config: Partial<ProcessRegistryConfig>): void {
this.config = { ...this.config, ...config };
logger.info('ProcessRegistryService configuration updated', config);
}
/**
* Get current configuration
*/
getConfig(): ProcessRegistryConfig {
return { ...this.config };
}
/**
* Clean up old stopped processes
*/
private cleanupStoppedProcesses(): void {
const now = Date.now();
const stoppedProcesses: Array<{ id: string; stoppedAt: number }> = [];
// Find all stopped processes
for (const [id, process] of this.processes.entries()) {
if ((process.status === 'stopped' || process.status === 'error') && process.stoppedAt) {
stoppedProcesses.push({ id, stoppedAt: process.stoppedAt });
}
}
// Sort by stoppedAt (oldest first)
stoppedProcesses.sort((a, b) => a.stoppedAt - b.stoppedAt);
let removedCount = 0;
// Remove processes that exceed retention time
for (const { id, stoppedAt } of stoppedProcesses) {
const age = now - stoppedAt;
if (age > this.config.stoppedProcessRetention) {
this.processes.delete(id);
removedCount++;
}
}
// If still over max, remove oldest stopped processes
const remainingStoppedCount = stoppedProcesses.length - removedCount;
if (remainingStoppedCount > this.config.maxStoppedProcesses) {
const toRemove = remainingStoppedCount - this.config.maxStoppedProcesses;
let removed = 0;
for (const { id } of stoppedProcesses) {
if (this.processes.has(id) && removed < toRemove) {
this.processes.delete(id);
removedCount++;
removed++;
}
}
}
if (removedCount > 0) {
logger.debug('Cleaned up stopped processes', { removedCount });
}
}
/**
* Clear all tracked processes
*/
clear(): void {
this.processes.clear();
logger.info('All tracked processes cleared');
}
// ============================================================================
// Agent Resource Metrics Tracking
// ============================================================================
/**
* Initialize resource metrics for an agent process
* Call this when an agent starts to begin tracking its resource usage
*/
initializeAgentMetrics(
processId: string,
options?: { sessionId?: string; featureId?: string }
): AgentResourceMetrics | null {
const process = this.processes.get(processId);
if (!process) {
logger.warn('Cannot initialize metrics for non-existent process', { processId });
return null;
}
if (process.type !== 'agent') {
logger.warn('Cannot initialize agent metrics for non-agent process', {
processId,
type: process.type,
});
return null;
}
const metrics = createEmptyAgentResourceMetrics(processId, {
sessionId: options?.sessionId || process.sessionId,
featureId: options?.featureId || process.featureId,
});
process.resourceMetrics = metrics;
logger.debug('Agent metrics initialized', { processId });
return metrics;
}
/**
* Get resource metrics for an agent process
*/
getAgentMetrics(processId: string): AgentResourceMetrics | undefined {
const process = this.processes.get(processId);
return process?.resourceMetrics;
}
/**
* Record a tool invocation for an agent
*/
recordToolUse(processId: string, options: RecordToolUseOptions): void {
const process = this.processes.get(processId);
if (!process?.resourceMetrics) {
return;
}
const metrics = process.resourceMetrics;
const now = Date.now();
// Update tool metrics
metrics.tools.totalInvocations++;
metrics.tools.byTool[options.toolName] = (metrics.tools.byTool[options.toolName] || 0) + 1;
if (options.executionTime !== undefined) {
metrics.tools.totalExecutionTime += options.executionTime;
metrics.tools.avgExecutionTime =
metrics.tools.totalExecutionTime / metrics.tools.totalInvocations;
}
if (options.failed) {
metrics.tools.failedInvocations++;
}
// Update memory snapshot
this.updateMemorySnapshot(processId);
metrics.lastUpdatedAt = now;
metrics.duration = now - metrics.startedAt;
logger.debug('Tool use recorded', {
processId,
tool: options.toolName,
totalInvocations: metrics.tools.totalInvocations,
});
}
/**
* Record a file operation for an agent
*/
recordFileOperation(processId: string, options: RecordFileOperationOptions): void {
const process = this.processes.get(processId);
if (!process?.resourceMetrics) {
return;
}
const metrics = process.resourceMetrics;
const now = Date.now();
// Update file I/O metrics based on operation type
switch (options.operation) {
case 'read':
metrics.fileIO.reads++;
if (options.bytes) {
metrics.fileIO.bytesRead += options.bytes;
}
break;
case 'write':
metrics.fileIO.writes++;
if (options.bytes) {
metrics.fileIO.bytesWritten += options.bytes;
}
break;
case 'edit':
metrics.fileIO.edits++;
if (options.bytes) {
metrics.fileIO.bytesWritten += options.bytes;
}
break;
case 'glob':
metrics.fileIO.globs++;
break;
case 'grep':
metrics.fileIO.greps++;
break;
}
// Track unique files accessed
if (!metrics.fileIO.filesAccessed.includes(options.filePath)) {
// Limit to 100 files to prevent memory bloat
if (metrics.fileIO.filesAccessed.length < 100) {
metrics.fileIO.filesAccessed.push(options.filePath);
}
}
metrics.lastUpdatedAt = now;
metrics.duration = now - metrics.startedAt;
logger.debug('File operation recorded', {
processId,
operation: options.operation,
filePath: options.filePath,
});
}
/**
* Record a bash command execution for an agent
*/
recordBashCommand(processId: string, options: RecordBashCommandOptions): void {
const process = this.processes.get(processId);
if (!process?.resourceMetrics) {
return;
}
const metrics = process.resourceMetrics;
const now = Date.now();
metrics.bash.commandCount++;
metrics.bash.totalExecutionTime += options.executionTime;
if (options.exitCode !== null && options.exitCode !== 0) {
metrics.bash.failedCommands++;
}
// Keep only last 20 commands to prevent memory bloat
if (metrics.bash.commands.length >= 20) {
metrics.bash.commands.shift();
}
metrics.bash.commands.push({
command: options.command.substring(0, 200), // Truncate long commands
exitCode: options.exitCode,
duration: options.executionTime,
timestamp: now,
});
// Update memory snapshot
this.updateMemorySnapshot(processId);
metrics.lastUpdatedAt = now;
metrics.duration = now - metrics.startedAt;
logger.debug('Bash command recorded', {
processId,
command: options.command.substring(0, 50),
exitCode: options.exitCode,
});
}
/**
* Record an API turn/iteration for an agent
*/
recordAPITurn(
processId: string,
options?: {
inputTokens?: number;
outputTokens?: number;
thinkingTokens?: number;
duration?: number;
error?: boolean;
}
): void {
const process = this.processes.get(processId);
if (!process?.resourceMetrics) {
return;
}
const metrics = process.resourceMetrics;
const now = Date.now();
metrics.api.turns++;
if (options?.inputTokens !== undefined) {
metrics.api.inputTokens = (metrics.api.inputTokens || 0) + options.inputTokens;
}
if (options?.outputTokens !== undefined) {
metrics.api.outputTokens = (metrics.api.outputTokens || 0) + options.outputTokens;
}
if (options?.thinkingTokens !== undefined) {
metrics.api.thinkingTokens = (metrics.api.thinkingTokens || 0) + options.thinkingTokens;
}
if (options?.duration !== undefined) {
metrics.api.totalDuration += options.duration;
}
if (options?.error) {
metrics.api.errors++;
}
// Update memory snapshot
this.updateMemorySnapshot(processId);
metrics.lastUpdatedAt = now;
metrics.duration = now - metrics.startedAt;
logger.debug('API turn recorded', {
processId,
turns: metrics.api.turns,
});
}
/**
* Update memory snapshot for an agent process
* Takes a memory sample and updates peak/delta values
*/
updateMemorySnapshot(processId: string): void {
const process = this.processes.get(processId);
if (!process?.resourceMetrics) {
return;
}
const metrics = process.resourceMetrics;
const now = Date.now();
const heapUsed = process.memoryUsage || 0;
// Update current heap
metrics.memory.currentHeapUsed = heapUsed;
// Update peak if higher
if (heapUsed > metrics.memory.peakHeapUsed) {
metrics.memory.peakHeapUsed = heapUsed;
}
// Calculate delta from start
metrics.memory.deltaHeapUsed = heapUsed - metrics.memory.startHeapUsed;
// Add sample (keep max 60 samples = 1 minute at 1 sample/second)
if (metrics.memory.samples.length >= 60) {
metrics.memory.samples.shift();
}
metrics.memory.samples.push({ timestamp: now, heapUsed });
metrics.lastUpdatedAt = now;
}
/**
* Mark agent metrics as completed (agent finished running)
*/
finalizeAgentMetrics(processId: string): void {
const process = this.processes.get(processId);
if (!process?.resourceMetrics) {
return;
}
const metrics = process.resourceMetrics;
const now = Date.now();
metrics.isRunning = false;
metrics.lastUpdatedAt = now;
metrics.duration = now - metrics.startedAt;
// Final memory snapshot
this.updateMemorySnapshot(processId);
logger.debug('Agent metrics finalized', {
processId,
duration: metrics.duration,
toolInvocations: metrics.tools.totalInvocations,
fileReads: metrics.fileIO.reads,
fileWrites: metrics.fileIO.writes,
bashCommands: metrics.bash.commandCount,
apiTurns: metrics.api.turns,
});
}
/**
* Get all agent processes with their resource metrics
*/
getAgentProcessesWithMetrics(): TrackedProcess[] {
const result: TrackedProcess[] = [];
for (const process of this.processes.values()) {
if (process.type === 'agent' && process.resourceMetrics) {
result.push(process);
}
}
return result.sort((a, b) => b.startedAt - a.startedAt);
}
/**
* Get summary of resource usage across all running agents
*/
getAgentResourceSummary(): {
totalAgents: number;
runningAgents: number;
totalFileReads: number;
totalFileWrites: number;
totalBytesRead: number;
totalBytesWritten: number;
totalToolInvocations: number;
totalBashCommands: number;
totalAPITurns: number;
peakMemoryUsage: number;
totalDuration: number;
} {
const summary = {
totalAgents: 0,
runningAgents: 0,
totalFileReads: 0,
totalFileWrites: 0,
totalBytesRead: 0,
totalBytesWritten: 0,
totalToolInvocations: 0,
totalBashCommands: 0,
totalAPITurns: 0,
peakMemoryUsage: 0,
totalDuration: 0,
};
for (const process of this.processes.values()) {
if (process.type !== 'agent' || !process.resourceMetrics) {
continue;
}
const metrics = process.resourceMetrics;
summary.totalAgents++;
if (metrics.isRunning) {
summary.runningAgents++;
}
summary.totalFileReads += metrics.fileIO.reads;
summary.totalFileWrites += metrics.fileIO.writes;
summary.totalBytesRead += metrics.fileIO.bytesRead;
summary.totalBytesWritten += metrics.fileIO.bytesWritten;
summary.totalToolInvocations += metrics.tools.totalInvocations;
summary.totalBashCommands += metrics.bash.commandCount;
summary.totalAPITurns += metrics.api.turns;
summary.totalDuration += metrics.duration;
if (metrics.memory.peakHeapUsed > summary.peakMemoryUsage) {
summary.peakMemoryUsage = metrics.memory.peakHeapUsed;
}
}
return summary;
}
}
// Singleton instance
let processRegistryService: ProcessRegistryService | null = null;
/**
* Get or create the ProcessRegistryService singleton
*/
export function getProcessRegistryService(
events?: EventEmitter,
config?: Partial<ProcessRegistryConfig>
): ProcessRegistryService {
if (!processRegistryService) {
if (!events) {
throw new Error('EventEmitter is required to initialize ProcessRegistryService');
}
processRegistryService = new ProcessRegistryService(events, config);
}
return processRegistryService;
}
/**
* Reset the singleton (for testing)
*/
export function resetProcessRegistryService(): void {
if (processRegistryService) {
processRegistryService.stop();
processRegistryService = null;
}
}

View File

@@ -153,6 +153,14 @@ export class SettingsService {
const storedVersion = settings.version || 1;
let needsSave = false;
// Migration v1 -> v2: Force enableSandboxMode to false for existing users
// Sandbox mode can cause issues on some systems, so we're disabling it by default
if (storedVersion < 2) {
logger.info('Migrating settings from v1 to v2: disabling sandbox mode');
result.enableSandboxMode = false;
needsSave = true;
}
// Migration v2 -> v3: Convert string phase models to PhaseModelEntry objects
// Note: migratePhaseModels() handles the actual conversion for both v1 and v2 formats
if (storedVersion < 3) {
@@ -162,16 +170,6 @@ export class SettingsService {
needsSave = true;
}
// Migration v3 -> v4: Add onboarding/setup wizard state fields
// Older settings files never stored setup state in settings.json (it lived in localStorage),
// so default to "setup complete" for existing installs to avoid forcing re-onboarding.
if (storedVersion < 4) {
if (settings.setupComplete === undefined) result.setupComplete = true;
if (settings.isFirstRun === undefined) result.isFirstRun = false;
if (settings.skipClaudeSetup === undefined) result.skipClaudeSetup = false;
needsSave = true;
}
// Update version if any migration occurred
if (needsSave) {
result.version = SETTINGS_VERSION;
@@ -266,79 +264,25 @@ export class SettingsService {
const settingsPath = getGlobalSettingsPath(this.dataDir);
const current = await this.getGlobalSettings();
// Guard against destructive "empty array/object" overwrites.
// During auth transitions, the UI can briefly have default/empty state and accidentally
// sync it, wiping persisted settings (especially `projects`).
const sanitizedUpdates: Partial<GlobalSettings> = { ...updates };
let attemptedProjectWipe = false;
const ignoreEmptyArrayOverwrite = <K extends keyof GlobalSettings>(key: K): void => {
const nextVal = sanitizedUpdates[key] as unknown;
const curVal = current[key] as unknown;
if (
Array.isArray(nextVal) &&
nextVal.length === 0 &&
Array.isArray(curVal) &&
curVal.length > 0
) {
delete sanitizedUpdates[key];
}
};
const currentProjectsLen = Array.isArray(current.projects) ? current.projects.length : 0;
if (
Array.isArray(sanitizedUpdates.projects) &&
sanitizedUpdates.projects.length === 0 &&
currentProjectsLen > 0
) {
attemptedProjectWipe = true;
delete sanitizedUpdates.projects;
}
ignoreEmptyArrayOverwrite('trashedProjects');
ignoreEmptyArrayOverwrite('projectHistory');
ignoreEmptyArrayOverwrite('recentFolders');
ignoreEmptyArrayOverwrite('aiProfiles');
ignoreEmptyArrayOverwrite('mcpServers');
ignoreEmptyArrayOverwrite('enabledCursorModels');
// Empty object overwrite guard
if (
sanitizedUpdates.lastSelectedSessionByProject &&
typeof sanitizedUpdates.lastSelectedSessionByProject === 'object' &&
!Array.isArray(sanitizedUpdates.lastSelectedSessionByProject) &&
Object.keys(sanitizedUpdates.lastSelectedSessionByProject).length === 0 &&
current.lastSelectedSessionByProject &&
Object.keys(current.lastSelectedSessionByProject).length > 0
) {
delete sanitizedUpdates.lastSelectedSessionByProject;
}
// If a request attempted to wipe projects, also ignore theme changes in that same request.
if (attemptedProjectWipe) {
delete sanitizedUpdates.theme;
}
const updated: GlobalSettings = {
...current,
...sanitizedUpdates,
...updates,
version: SETTINGS_VERSION,
};
// Deep merge keyboard shortcuts if provided
if (sanitizedUpdates.keyboardShortcuts) {
if (updates.keyboardShortcuts) {
updated.keyboardShortcuts = {
...current.keyboardShortcuts,
...sanitizedUpdates.keyboardShortcuts,
...updates.keyboardShortcuts,
};
}
// Deep merge phaseModels if provided
if (sanitizedUpdates.phaseModels) {
if (updates.phaseModels) {
updated.phaseModels = {
...current.phaseModels,
...sanitizedUpdates.phaseModels,
...updates.phaseModels,
};
}
@@ -579,26 +523,8 @@ export class SettingsService {
}
}
// Parse setup wizard state (previously stored in localStorage)
let setupState: Record<string, unknown> = {};
if (localStorageData['automaker-setup']) {
try {
const parsed = JSON.parse(localStorageData['automaker-setup']);
setupState = parsed.state || parsed;
} catch (e) {
errors.push(`Failed to parse automaker-setup: ${e}`);
}
}
// Extract global settings
const globalSettings: Partial<GlobalSettings> = {
setupComplete:
setupState.setupComplete !== undefined ? (setupState.setupComplete as boolean) : false,
isFirstRun: setupState.isFirstRun !== undefined ? (setupState.isFirstRun as boolean) : true,
skipClaudeSetup:
setupState.skipClaudeSetup !== undefined
? (setupState.skipClaudeSetup as boolean)
: false,
theme: (appState.theme as GlobalSettings['theme']) || 'dark',
sidebarOpen: appState.sidebarOpen !== undefined ? (appState.sidebarOpen as boolean) : true,
chatHistoryOpen: (appState.chatHistoryOpen as boolean) || false,
@@ -611,12 +537,7 @@ export class SettingsService {
appState.enableDependencyBlocking !== undefined
? (appState.enableDependencyBlocking as boolean)
: true,
skipVerificationInAutoMode:
appState.skipVerificationInAutoMode !== undefined
? (appState.skipVerificationInAutoMode as boolean)
: false,
useWorktrees:
appState.useWorktrees !== undefined ? (appState.useWorktrees as boolean) : true,
useWorktrees: (appState.useWorktrees as boolean) || false,
showProfilesOnly: (appState.showProfilesOnly as boolean) || false,
defaultPlanningMode:
(appState.defaultPlanningMode as GlobalSettings['defaultPlanningMode']) || 'skip',

View File

@@ -1,373 +0,0 @@
/**
* CLI Integration Tests
*
* Comprehensive tests for CLI detection, authentication, and operations
* across all providers (Claude, Codex, Cursor)
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
detectCli,
detectAllCLis,
findCommand,
getCliVersion,
getInstallInstructions,
validateCliInstallation,
} from '../lib/cli-detection.js';
import { classifyError, getUserFriendlyErrorMessage } from '../lib/error-handler.js';
describe('CLI Detection Framework', () => {
describe('findCommand', () => {
it('should find existing command', async () => {
// Test with a command that should exist
const result = await findCommand(['node']);
expect(result).toBeTruthy();
});
it('should return null for non-existent command', async () => {
const result = await findCommand(['nonexistent-command-12345']);
expect(result).toBeNull();
});
it('should find first available command from alternatives', async () => {
const result = await findCommand(['nonexistent-command-12345', 'node']);
expect(result).toBeTruthy();
expect(result).toContain('node');
});
});
describe('getCliVersion', () => {
it('should get version for existing command', async () => {
const version = await getCliVersion('node', ['--version'], 5000);
expect(version).toBeTruthy();
expect(typeof version).toBe('string');
});
it('should timeout for non-responsive command', async () => {
await expect(getCliVersion('sleep', ['10'], 1000)).rejects.toThrow();
}, 15000); // Give extra time for test timeout
it("should handle command that doesn't exist", async () => {
await expect(
getCliVersion('nonexistent-command-12345', ['--version'], 2000)
).rejects.toThrow();
});
});
describe('getInstallInstructions', () => {
it('should return instructions for supported platforms', () => {
const claudeInstructions = getInstallInstructions('claude', 'darwin');
expect(claudeInstructions).toContain('brew install');
const codexInstructions = getInstallInstructions('codex', 'linux');
expect(codexInstructions).toContain('npm install');
});
it('should handle unsupported platform', () => {
const instructions = getInstallInstructions('claude', 'unknown-platform' as any);
expect(instructions).toContain('No installation instructions available');
});
});
describe('validateCliInstallation', () => {
it('should validate properly installed CLI', () => {
const cliInfo = {
name: 'Test CLI',
command: 'node',
version: 'v18.0.0',
path: '/usr/bin/node',
installed: true,
authenticated: true,
authMethod: 'cli' as const,
};
const result = validateCliInstallation(cliInfo);
expect(result.valid).toBe(true);
expect(result.issues).toHaveLength(0);
});
it('should detect issues with installation', () => {
const cliInfo = {
name: 'Test CLI',
command: '',
version: '',
path: '',
installed: false,
authenticated: false,
authMethod: 'none' as const,
};
const result = validateCliInstallation(cliInfo);
expect(result.valid).toBe(false);
expect(result.issues.length).toBeGreaterThan(0);
expect(result.issues).toContain('CLI is not installed');
});
});
});
describe('Error Handling System', () => {
describe('classifyError', () => {
it('should classify authentication errors', () => {
const authError = new Error('invalid_api_key: Your API key is invalid');
const result = classifyError(authError, 'claude');
expect(result.type).toBe('authentication');
expect(result.severity).toBe('high');
expect(result.userMessage).toContain('Authentication failed');
expect(result.retryable).toBe(false);
expect(result.provider).toBe('claude');
});
it('should classify billing errors', () => {
const billingError = new Error('credit balance is too low');
const result = classifyError(billingError);
expect(result.type).toBe('billing');
expect(result.severity).toBe('high');
expect(result.userMessage).toContain('insufficient credits');
expect(result.retryable).toBe(false);
});
it('should classify rate limit errors', () => {
const rateLimitError = new Error('Rate limit reached. Try again later.');
const result = classifyError(rateLimitError);
expect(result.type).toBe('rate_limit');
expect(result.severity).toBe('medium');
expect(result.userMessage).toContain('Rate limit reached');
expect(result.retryable).toBe(true);
});
it('should classify network errors', () => {
const networkError = new Error('ECONNREFUSED: Connection refused');
const result = classifyError(networkError);
expect(result.type).toBe('network');
expect(result.severity).toBe('medium');
expect(result.userMessage).toContain('Network connection issue');
expect(result.retryable).toBe(true);
});
it('should handle unknown errors', () => {
const unknownError = new Error('Something completely unexpected happened');
const result = classifyError(unknownError);
expect(result.type).toBe('unknown');
expect(result.severity).toBe('medium');
expect(result.userMessage).toContain('unexpected error');
expect(result.retryable).toBe(true);
});
});
describe('getUserFriendlyErrorMessage', () => {
it('should include provider name in message', () => {
const error = new Error('invalid_api_key');
const message = getUserFriendlyErrorMessage(error, 'claude');
expect(message).toContain('[CLAUDE]');
});
it('should include suggested action when available', () => {
const error = new Error('invalid_api_key');
const message = getUserFriendlyErrorMessage(error);
expect(message).toContain('Verify your API key');
});
});
});
describe('Provider-Specific Tests', () => {
describe('Claude CLI Detection', () => {
it('should detect Claude CLI if installed', async () => {
const result = await detectCli('claude');
if (result.detected) {
expect(result.cli.name).toBe('Claude CLI');
expect(result.cli.installed).toBe(true);
expect(result.cli.command).toBeTruthy();
}
// If not installed, that's also a valid test result
});
it('should handle missing Claude CLI gracefully', async () => {
// This test will pass regardless of whether Claude is installed
const result = await detectCli('claude');
expect(typeof result.detected).toBe('boolean');
expect(Array.isArray(result.issues)).toBe(true);
});
});
describe('Codex CLI Detection', () => {
it('should detect Codex CLI if installed', async () => {
const result = await detectCli('codex');
if (result.detected) {
expect(result.cli.name).toBe('Codex CLI');
expect(result.cli.installed).toBe(true);
expect(result.cli.command).toBeTruthy();
}
});
});
describe('Cursor CLI Detection', () => {
it('should detect Cursor CLI if installed', async () => {
const result = await detectCli('cursor');
if (result.detected) {
expect(result.cli.name).toBe('Cursor CLI');
expect(result.cli.installed).toBe(true);
expect(result.cli.command).toBeTruthy();
}
});
});
});
describe('Integration Tests', () => {
describe('detectAllCLis', () => {
it('should detect all available CLIs', async () => {
const results = await detectAllCLis();
expect(results).toHaveProperty('claude');
expect(results).toHaveProperty('codex');
expect(results).toHaveProperty('cursor');
// Each should have the expected structure
Object.values(results).forEach((result) => {
expect(result).toHaveProperty('cli');
expect(result).toHaveProperty('detected');
expect(result).toHaveProperty('issues');
expect(result.cli).toHaveProperty('name');
expect(result.cli).toHaveProperty('installed');
expect(result.cli).toHaveProperty('authenticated');
});
}, 30000); // Longer timeout for CLI detection
it('should handle concurrent CLI detection', async () => {
// Run detection multiple times concurrently
const promises = [detectAllCLis(), detectAllCLis(), detectAllCLis()];
const results = await Promise.all(promises);
// All should return consistent results
expect(results).toHaveLength(3);
results.forEach((result) => {
expect(result).toHaveProperty('claude');
expect(result).toHaveProperty('codex');
expect(result).toHaveProperty('cursor');
});
}, 45000);
});
});
describe('Error Recovery Tests', () => {
it('should handle partial CLI detection failures', async () => {
// Mock a scenario where some CLIs fail to detect
const results = await detectAllCLis();
// Should still return results for all providers
expect(results).toHaveProperty('claude');
expect(results).toHaveProperty('codex');
expect(results).toHaveProperty('cursor');
// Should provide error information for failures
Object.entries(results).forEach(([provider, result]) => {
if (!result.detected && result.issues.length > 0) {
expect(result.issues.length).toBeGreaterThan(0);
expect(result.issues[0]).toBeTruthy();
}
});
});
it('should handle timeout during CLI detection', async () => {
// Test with very short timeout
const result = await detectCli('claude', { timeout: 1 });
// Should handle gracefully without throwing
expect(typeof result.detected).toBe('boolean');
expect(Array.isArray(result.issues)).toBe(true);
});
});
describe('Security Tests', () => {
it('should not expose sensitive information in error messages', () => {
const errorWithKey = new Error('invalid_api_key: sk-ant-abc123secret456');
const message = getUserFriendlyErrorMessage(errorWithKey);
// Should not expose the actual API key
expect(message).not.toContain('sk-ant-abc123secret456');
expect(message).toContain('Authentication failed');
});
it('should sanitize file paths in error messages', () => {
const errorWithPath = new Error('Permission denied: /home/user/.ssh/id_rsa');
const message = getUserFriendlyErrorMessage(errorWithPath);
// Should not expose sensitive file paths
expect(message).not.toContain('/home/user/.ssh/id_rsa');
});
});
// Performance Tests
describe('Performance Tests', () => {
it('should detect CLIs within reasonable time', async () => {
const startTime = Date.now();
const results = await detectAllCLis();
const endTime = Date.now();
const duration = endTime - startTime;
expect(duration).toBeLessThan(10000); // Should complete in under 10 seconds
expect(results).toHaveProperty('claude');
expect(results).toHaveProperty('codex');
expect(results).toHaveProperty('cursor');
}, 15000);
it('should handle rapid repeated calls', async () => {
// Make multiple rapid calls
const promises = Array.from({ length: 10 }, () => detectAllCLis());
const results = await Promise.all(promises);
// All should complete successfully
expect(results).toHaveLength(10);
results.forEach((result) => {
expect(result).toHaveProperty('claude');
expect(result).toHaveProperty('codex');
expect(result).toHaveProperty('cursor');
});
}, 60000);
});
// Edge Cases
describe('Edge Cases', () => {
it('should handle empty CLI names', async () => {
await expect(detectCli('' as any)).rejects.toThrow();
});
it('should handle null CLI names', async () => {
await expect(detectCli(null as any)).rejects.toThrow();
});
it('should handle undefined CLI names', async () => {
await expect(detectCli(undefined as any)).rejects.toThrow();
});
it('should handle malformed error objects', () => {
const testCases = [
null,
undefined,
'',
123,
[],
{ nested: { error: { message: 'test' } } },
{ error: 'simple string error' },
];
testCases.forEach((error) => {
expect(() => {
const result = classifyError(error);
expect(result).toHaveProperty('type');
expect(result).toHaveProperty('severity');
expect(result).toHaveProperty('userMessage');
}).not.toThrow();
});
});
});

View File

@@ -277,7 +277,7 @@ describe('auth.ts', () => {
const options = getSessionCookieOptions();
expect(options.httpOnly).toBe(true);
expect(options.sameSite).toBe('lax');
expect(options.sameSite).toBe('strict');
expect(options.path).toBe('/');
expect(options.maxAge).toBeGreaterThan(0);
});

View File

@@ -1,15 +1,161 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import os from 'os';
describe('sdk-options.ts', () => {
let originalEnv: NodeJS.ProcessEnv;
let homedirSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
originalEnv = { ...process.env };
vi.resetModules();
// Spy on os.homedir and set default return value
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue('/Users/test');
});
afterEach(() => {
process.env = originalEnv;
homedirSpy.mockRestore();
});
describe('isCloudStoragePath', () => {
it('should detect Dropbox paths on macOS', async () => {
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
expect(isCloudStoragePath('/Users/test/Library/CloudStorage/Dropbox-Personal/project')).toBe(
true
);
expect(isCloudStoragePath('/Users/test/Library/CloudStorage/Dropbox/project')).toBe(true);
});
it('should detect Google Drive paths on macOS', async () => {
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
expect(
isCloudStoragePath('/Users/test/Library/CloudStorage/GoogleDrive-user@gmail.com/project')
).toBe(true);
});
it('should detect OneDrive paths on macOS', async () => {
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
expect(isCloudStoragePath('/Users/test/Library/CloudStorage/OneDrive-Personal/project')).toBe(
true
);
});
it('should detect iCloud Drive paths on macOS', async () => {
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
expect(
isCloudStoragePath('/Users/test/Library/Mobile Documents/com~apple~CloudDocs/project')
).toBe(true);
});
it('should detect home-anchored Dropbox paths', async () => {
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
expect(isCloudStoragePath('/Users/test/Dropbox')).toBe(true);
expect(isCloudStoragePath('/Users/test/Dropbox/project')).toBe(true);
expect(isCloudStoragePath('/Users/test/Dropbox/nested/deep/project')).toBe(true);
});
it('should detect home-anchored Google Drive paths', async () => {
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
expect(isCloudStoragePath('/Users/test/Google Drive')).toBe(true);
expect(isCloudStoragePath('/Users/test/Google Drive/project')).toBe(true);
});
it('should detect home-anchored OneDrive paths', async () => {
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
expect(isCloudStoragePath('/Users/test/OneDrive')).toBe(true);
expect(isCloudStoragePath('/Users/test/OneDrive/project')).toBe(true);
});
it('should return false for local paths', async () => {
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
expect(isCloudStoragePath('/Users/test/projects/myapp')).toBe(false);
expect(isCloudStoragePath('/home/user/code/project')).toBe(false);
expect(isCloudStoragePath('/var/www/app')).toBe(false);
});
it('should return false for relative paths not in cloud storage', async () => {
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
expect(isCloudStoragePath('./project')).toBe(false);
expect(isCloudStoragePath('../other-project')).toBe(false);
});
// Tests for false positive prevention - paths that contain cloud storage names but aren't cloud storage
it('should NOT flag paths that merely contain "dropbox" in the name', async () => {
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
// Projects with dropbox-like names
expect(isCloudStoragePath('/home/user/my-project-about-dropbox')).toBe(false);
expect(isCloudStoragePath('/Users/test/projects/dropbox-clone')).toBe(false);
expect(isCloudStoragePath('/Users/test/projects/Dropbox-backup-tool')).toBe(false);
// Dropbox folder that's NOT in the home directory
expect(isCloudStoragePath('/var/shared/Dropbox/project')).toBe(false);
});
it('should NOT flag paths that merely contain "Google Drive" in the name', async () => {
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
expect(isCloudStoragePath('/Users/test/projects/google-drive-api-client')).toBe(false);
expect(isCloudStoragePath('/home/user/Google Drive API Tests')).toBe(false);
});
it('should NOT flag paths that merely contain "OneDrive" in the name', async () => {
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
expect(isCloudStoragePath('/Users/test/projects/onedrive-sync-tool')).toBe(false);
expect(isCloudStoragePath('/home/user/OneDrive-migration-scripts')).toBe(false);
});
it('should handle different home directories correctly', async () => {
// Change the mocked home directory
homedirSpy.mockReturnValue('/home/linuxuser');
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
// Should detect Dropbox under the Linux home directory
expect(isCloudStoragePath('/home/linuxuser/Dropbox/project')).toBe(true);
// Should NOT detect Dropbox under the old home directory (since home changed)
expect(isCloudStoragePath('/Users/test/Dropbox/project')).toBe(false);
});
});
describe('checkSandboxCompatibility', () => {
it('should return enabled=false when user disables sandbox', async () => {
const { checkSandboxCompatibility } = await import('@/lib/sdk-options.js');
const result = checkSandboxCompatibility('/Users/test/project', false);
expect(result.enabled).toBe(false);
expect(result.disabledReason).toBe('user_setting');
});
it('should return enabled=false for cloud storage paths even when sandbox enabled', async () => {
const { checkSandboxCompatibility } = await import('@/lib/sdk-options.js');
const result = checkSandboxCompatibility(
'/Users/test/Library/CloudStorage/Dropbox-Personal/project',
true
);
expect(result.enabled).toBe(false);
expect(result.disabledReason).toBe('cloud_storage');
expect(result.message).toContain('cloud storage');
});
it('should return enabled=true for local paths when sandbox enabled', async () => {
const { checkSandboxCompatibility } = await import('@/lib/sdk-options.js');
const result = checkSandboxCompatibility('/Users/test/projects/myapp', true);
expect(result.enabled).toBe(true);
expect(result.disabledReason).toBeUndefined();
});
it('should return enabled=true when enableSandboxMode is undefined for local paths', async () => {
const { checkSandboxCompatibility } = await import('@/lib/sdk-options.js');
const result = checkSandboxCompatibility('/Users/test/project', undefined);
expect(result.enabled).toBe(true);
expect(result.disabledReason).toBeUndefined();
});
it('should return enabled=false for cloud storage paths when enableSandboxMode is undefined', async () => {
const { checkSandboxCompatibility } = await import('@/lib/sdk-options.js');
const result = checkSandboxCompatibility(
'/Users/test/Library/CloudStorage/Dropbox-Personal/project',
undefined
);
expect(result.enabled).toBe(false);
expect(result.disabledReason).toBe('cloud_storage');
});
});
describe('TOOL_PRESETS', () => {
@@ -179,15 +325,19 @@ describe('sdk-options.ts', () => {
it('should create options with chat settings', async () => {
const { createChatOptions, TOOL_PRESETS, MAX_TURNS } = await import('@/lib/sdk-options.js');
const options = createChatOptions({ cwd: '/test/path' });
const options = createChatOptions({ cwd: '/test/path', enableSandboxMode: true });
expect(options.cwd).toBe('/test/path');
expect(options.maxTurns).toBe(MAX_TURNS.standard);
expect(options.allowedTools).toEqual([...TOOL_PRESETS.chat]);
expect(options.sandbox).toEqual({
enabled: true,
autoAllowBashIfSandboxed: true,
});
});
it('should prefer explicit model over session model', async () => {
const { createChatOptions } = await import('@/lib/sdk-options.js');
const { createChatOptions, getModelForUseCase } = await import('@/lib/sdk-options.js');
const options = createChatOptions({
cwd: '/test/path',
@@ -208,6 +358,41 @@ describe('sdk-options.ts', () => {
expect(options.model).toBe('claude-sonnet-4-20250514');
});
it('should not set sandbox when enableSandboxMode is false', async () => {
const { createChatOptions } = await import('@/lib/sdk-options.js');
const options = createChatOptions({
cwd: '/test/path',
enableSandboxMode: false,
});
expect(options.sandbox).toBeUndefined();
});
it('should enable sandbox by default when enableSandboxMode is not provided', async () => {
const { createChatOptions } = await import('@/lib/sdk-options.js');
const options = createChatOptions({
cwd: '/test/path',
});
expect(options.sandbox).toEqual({
enabled: true,
autoAllowBashIfSandboxed: true,
});
});
it('should auto-disable sandbox for cloud storage paths', async () => {
const { createChatOptions } = await import('@/lib/sdk-options.js');
const options = createChatOptions({
cwd: '/Users/test/Library/CloudStorage/Dropbox-Personal/project',
enableSandboxMode: true,
});
expect(options.sandbox).toBeUndefined();
});
});
describe('createAutoModeOptions', () => {
@@ -215,11 +400,15 @@ describe('sdk-options.ts', () => {
const { createAutoModeOptions, TOOL_PRESETS, MAX_TURNS } =
await import('@/lib/sdk-options.js');
const options = createAutoModeOptions({ cwd: '/test/path' });
const options = createAutoModeOptions({ cwd: '/test/path', enableSandboxMode: true });
expect(options.cwd).toBe('/test/path');
expect(options.maxTurns).toBe(MAX_TURNS.maximum);
expect(options.allowedTools).toEqual([...TOOL_PRESETS.fullAccess]);
expect(options.sandbox).toEqual({
enabled: true,
autoAllowBashIfSandboxed: true,
});
});
it('should include systemPrompt when provided', async () => {
@@ -244,6 +433,62 @@ describe('sdk-options.ts', () => {
expect(options.abortController).toBe(abortController);
});
it('should not set sandbox when enableSandboxMode is false', async () => {
const { createAutoModeOptions } = await import('@/lib/sdk-options.js');
const options = createAutoModeOptions({
cwd: '/test/path',
enableSandboxMode: false,
});
expect(options.sandbox).toBeUndefined();
});
it('should enable sandbox by default when enableSandboxMode is not provided', async () => {
const { createAutoModeOptions } = await import('@/lib/sdk-options.js');
const options = createAutoModeOptions({
cwd: '/test/path',
});
expect(options.sandbox).toEqual({
enabled: true,
autoAllowBashIfSandboxed: true,
});
});
it('should auto-disable sandbox for cloud storage paths', async () => {
const { createAutoModeOptions } = await import('@/lib/sdk-options.js');
const options = createAutoModeOptions({
cwd: '/Users/test/Library/CloudStorage/Dropbox-Personal/project',
enableSandboxMode: true,
});
expect(options.sandbox).toBeUndefined();
});
it('should auto-disable sandbox for cloud storage paths even when enableSandboxMode is not provided', async () => {
const { createAutoModeOptions } = await import('@/lib/sdk-options.js');
const options = createAutoModeOptions({
cwd: '/Users/test/Library/CloudStorage/Dropbox-Personal/project',
});
expect(options.sandbox).toBeUndefined();
});
it('should auto-disable sandbox for iCloud paths', async () => {
const { createAutoModeOptions } = await import('@/lib/sdk-options.js');
const options = createAutoModeOptions({
cwd: '/Users/test/Library/Mobile Documents/com~apple~CloudDocs/project',
enableSandboxMode: true,
});
expect(options.sandbox).toBeUndefined();
});
});
describe('createCustomOptions', () => {
@@ -254,11 +499,13 @@ describe('sdk-options.ts', () => {
cwd: '/test/path',
maxTurns: 10,
allowedTools: ['Read', 'Write'],
sandbox: { enabled: true },
});
expect(options.cwd).toBe('/test/path');
expect(options.maxTurns).toBe(10);
expect(options.allowedTools).toEqual(['Read', 'Write']);
expect(options.sandbox).toEqual({ enabled: true });
});
it('should use defaults when optional params not provided', async () => {
@@ -270,6 +517,20 @@ describe('sdk-options.ts', () => {
expect(options.allowedTools).toEqual([...TOOL_PRESETS.readOnly]);
});
it('should include sandbox when provided', async () => {
const { createCustomOptions } = await import('@/lib/sdk-options.js');
const options = createCustomOptions({
cwd: '/test/path',
sandbox: { enabled: true, autoAllowBashIfSandboxed: false },
});
expect(options.sandbox).toEqual({
enabled: true,
autoAllowBashIfSandboxed: false,
});
});
it('should include systemPrompt when provided', async () => {
const { createCustomOptions } = await import('@/lib/sdk-options.js');

View File

@@ -179,7 +179,8 @@ describe('validation-storage.ts', () => {
});
it('should return false for validation exactly at 24 hours', () => {
const exactDate = new Date(Date.now() - 24 * 60 * 60 * 1000 + 100);
const exactDate = new Date();
exactDate.setHours(exactDate.getHours() - 24);
const validation = createMockValidation({
validatedAt: exactDate.toISOString(),

View File

@@ -37,7 +37,6 @@ describe('claude-provider.ts', () => {
const generator = provider.executeQuery({
prompt: 'Hello',
model: 'claude-opus-4-5-20251101',
cwd: '/test',
});
@@ -80,7 +79,7 @@ describe('claude-provider.ts', () => {
});
});
it('should not include allowedTools when not specified (caller decides via sdk-options)', async () => {
it('should use default allowed tools when not specified', async () => {
vi.mocked(sdk.query).mockReturnValue(
(async function* () {
yield { type: 'text', text: 'test' };
@@ -89,7 +88,6 @@ describe('claude-provider.ts', () => {
const generator = provider.executeQuery({
prompt: 'Test',
model: 'claude-opus-4-5-20251101',
cwd: '/test',
});
@@ -97,8 +95,37 @@ describe('claude-provider.ts', () => {
expect(sdk.query).toHaveBeenCalledWith({
prompt: 'Test',
options: expect.not.objectContaining({
allowedTools: expect.anything(),
options: expect.objectContaining({
allowedTools: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'],
}),
});
});
it('should pass sandbox configuration when provided', async () => {
vi.mocked(sdk.query).mockReturnValue(
(async function* () {
yield { type: 'text', text: 'test' };
})()
);
const generator = provider.executeQuery({
prompt: 'Test',
cwd: '/test',
sandbox: {
enabled: true,
autoAllowBashIfSandboxed: true,
},
});
await collectAsyncGenerator(generator);
expect(sdk.query).toHaveBeenCalledWith({
prompt: 'Test',
options: expect.objectContaining({
sandbox: {
enabled: true,
autoAllowBashIfSandboxed: true,
},
}),
});
});
@@ -114,7 +141,6 @@ describe('claude-provider.ts', () => {
const generator = provider.executeQuery({
prompt: 'Test',
model: 'claude-opus-4-5-20251101',
cwd: '/test',
abortController,
});
@@ -143,7 +169,6 @@ describe('claude-provider.ts', () => {
const generator = provider.executeQuery({
prompt: 'Current message',
model: 'claude-opus-4-5-20251101',
cwd: '/test',
conversationHistory,
sdkSessionId: 'test-session-id',
@@ -174,7 +199,6 @@ describe('claude-provider.ts', () => {
const generator = provider.executeQuery({
prompt: arrayPrompt as any,
model: 'claude-opus-4-5-20251101',
cwd: '/test',
});
@@ -194,7 +218,6 @@ describe('claude-provider.ts', () => {
const generator = provider.executeQuery({
prompt: 'Test',
model: 'claude-opus-4-5-20251101',
cwd: '/test',
});
@@ -220,7 +243,6 @@ describe('claude-provider.ts', () => {
const generator = provider.executeQuery({
prompt: 'Test',
model: 'claude-opus-4-5-20251101',
cwd: '/test',
});

View File

@@ -1,293 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
import os from 'os';
import path from 'path';
import { CodexProvider } from '../../../src/providers/codex-provider.js';
import type { ProviderMessage } from '../../../src/providers/types.js';
import { collectAsyncGenerator } from '../../utils/helpers.js';
import {
spawnJSONLProcess,
findCodexCliPath,
secureFs,
getCodexConfigDir,
getCodexAuthIndicators,
} from '@automaker/platform';
const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY';
const originalOpenAIKey = process.env[OPENAI_API_KEY_ENV];
const codexRunMock = vi.fn();
vi.mock('@openai/codex-sdk', () => ({
Codex: class {
constructor(_opts: { apiKey: string }) {}
startThread() {
return {
id: 'thread-123',
run: codexRunMock,
};
}
resumeThread() {
return {
id: 'thread-123',
run: codexRunMock,
};
}
},
}));
const EXEC_SUBCOMMAND = 'exec';
vi.mock('@automaker/platform', () => ({
spawnJSONLProcess: vi.fn(),
spawnProcess: vi.fn(),
findCodexCliPath: vi.fn(),
getCodexAuthIndicators: vi.fn().mockResolvedValue({
hasAuthFile: false,
hasOAuthToken: false,
hasApiKey: false,
}),
getCodexConfigDir: vi.fn().mockReturnValue('/home/test/.codex'),
secureFs: {
readFile: vi.fn(),
mkdir: vi.fn(),
writeFile: vi.fn(),
},
getDataDirectory: vi.fn(),
}));
vi.mock('@/services/settings-service.js', () => ({
SettingsService: class {
async getGlobalSettings() {
return {
codexAutoLoadAgents: false,
codexSandboxMode: 'workspace-write',
codexApprovalPolicy: 'on-request',
};
}
},
}));
describe('codex-provider.ts', () => {
let provider: CodexProvider;
afterAll(() => {
if (originalOpenAIKey !== undefined) {
process.env[OPENAI_API_KEY_ENV] = originalOpenAIKey;
} else {
delete process.env[OPENAI_API_KEY_ENV];
}
});
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getCodexConfigDir).mockReturnValue('/home/test/.codex');
vi.mocked(findCodexCliPath).mockResolvedValue('/usr/bin/codex');
vi.mocked(getCodexAuthIndicators).mockResolvedValue({
hasAuthFile: true,
hasOAuthToken: true,
hasApiKey: false,
});
delete process.env[OPENAI_API_KEY_ENV];
provider = new CodexProvider();
});
describe('executeQuery', () => {
it('emits tool_use and tool_result with shared tool_use_id for command execution', async () => {
const mockEvents = [
{
type: 'item.started',
item: {
type: 'command_execution',
id: 'cmd-1',
command: 'ls',
},
},
{
type: 'item.completed',
item: {
type: 'command_execution',
id: 'cmd-1',
output: 'file1\nfile2',
},
},
];
vi.mocked(spawnJSONLProcess).mockReturnValue(
(async function* () {
for (const event of mockEvents) {
yield event;
}
})()
);
const results = await collectAsyncGenerator<ProviderMessage>(
provider.executeQuery({
prompt: 'List files',
model: 'gpt-5.2',
cwd: '/tmp',
})
);
expect(results).toHaveLength(2);
const toolUse = results[0];
const toolResult = results[1];
expect(toolUse.type).toBe('assistant');
expect(toolUse.message?.content[0].type).toBe('tool_use');
const toolUseId = toolUse.message?.content[0].tool_use_id;
expect(toolUseId).toBeDefined();
expect(toolResult.type).toBe('assistant');
expect(toolResult.message?.content[0].type).toBe('tool_result');
expect(toolResult.message?.content[0].tool_use_id).toBe(toolUseId);
expect(toolResult.message?.content[0].content).toBe('file1\nfile2');
});
it('adds output schema and max turn overrides when configured', async () => {
// Note: With full-permissions always on, these flags are no longer used
// This test now only verifies the basic CLI structure
// Using gpt-5.1-codex-max which should route to Codex (not Cursor)
vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());
await collectAsyncGenerator(
provider.executeQuery({
prompt: 'Test config',
model: 'gpt-5.1-codex-max',
cwd: '/tmp',
allowedTools: ['Read', 'Write'],
maxTurns: 5,
})
);
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
expect(call.args).toContain('exec'); // Should have exec subcommand
expect(call.args).toContain('--dangerously-bypass-approvals-and-sandbox'); // Should have YOLO flag
expect(call.args).toContain('--model');
expect(call.args).toContain('--json');
});
it('overrides approval policy when MCP auto-approval is enabled', async () => {
// Note: With full-permissions always on (--dangerously-bypass-approvals-and-sandbox),
// approval policy is bypassed, not configured via --config
vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());
await collectAsyncGenerator(
provider.executeQuery({
prompt: 'Test approvals',
model: 'gpt-5.1-codex-max',
cwd: '/tmp',
mcpServers: { mock: { type: 'stdio', command: 'node' } },
mcpAutoApproveTools: true,
codexSettings: { approvalPolicy: 'untrusted' },
})
);
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
const execIndex = call.args.indexOf(EXEC_SUBCOMMAND);
expect(call.args).toContain('--dangerously-bypass-approvals-and-sandbox'); // YOLO flag bypasses approval
expect(call.args).toContain('--model');
expect(call.args).toContain('--json');
});
it('injects user and project instructions when auto-load is enabled', async () => {
vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());
const userPath = path.join('/home/test/.codex', 'AGENTS.md');
const projectPath = path.join('/tmp/project', '.codex', 'AGENTS.md');
vi.mocked(secureFs.readFile).mockImplementation(async (filePath: string) => {
if (filePath === userPath) {
return 'User rules';
}
if (filePath === projectPath) {
return 'Project rules';
}
throw new Error('missing');
});
await collectAsyncGenerator(
provider.executeQuery({
prompt: 'Hello',
model: 'gpt-5.2',
cwd: '/tmp/project',
codexSettings: { autoLoadAgents: true },
})
);
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
const promptText = call.stdinData;
expect(promptText).toContain('User rules');
expect(promptText).toContain('Project rules');
});
it('disables sandbox mode when running in cloud storage paths', async () => {
// Note: With full-permissions always on (--dangerously-bypass-approvals-and-sandbox),
// sandbox mode is bypassed, not configured via --sandbox flag
vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());
const cloudPath = path.join(os.homedir(), 'Dropbox', 'project');
await collectAsyncGenerator(
provider.executeQuery({
prompt: 'Hello',
model: 'gpt-5.1-codex-max',
cwd: cloudPath,
codexSettings: { sandboxMode: 'workspace-write' },
})
);
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
// YOLO flag bypasses sandbox entirely
expect(call.args).toContain('--dangerously-bypass-approvals-and-sandbox');
expect(call.args).toContain('--model');
expect(call.args).toContain('--json');
});
it('uses the SDK when no tools are requested and an API key is present', async () => {
process.env[OPENAI_API_KEY_ENV] = 'sk-test';
codexRunMock.mockResolvedValue({ finalResponse: 'Hello from SDK' });
const results = await collectAsyncGenerator<ProviderMessage>(
provider.executeQuery({
prompt: 'Hello',
model: 'gpt-5.2',
cwd: '/tmp',
allowedTools: [],
})
);
expect(results[0].message?.content[0].text).toBe('Hello from SDK');
expect(results[1].result).toBe('Hello from SDK');
});
it('uses the CLI when tools are requested even if an API key is present', async () => {
process.env[OPENAI_API_KEY_ENV] = 'sk-test';
vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());
await collectAsyncGenerator(
provider.executeQuery({
prompt: 'Read files',
model: 'gpt-5.2',
cwd: '/tmp',
allowedTools: ['Read'],
})
);
expect(codexRunMock).not.toHaveBeenCalled();
expect(spawnJSONLProcess).toHaveBeenCalled();
});
it('falls back to CLI when no tools are requested and no API key is available', async () => {
vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());
await collectAsyncGenerator(
provider.executeQuery({
prompt: 'Hello',
model: 'gpt-5.2',
cwd: '/tmp',
allowedTools: [],
})
);
expect(codexRunMock).not.toHaveBeenCalled();
expect(spawnJSONLProcess).toHaveBeenCalled();
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -2,42 +2,18 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ProviderFactory } from '@/providers/provider-factory.js';
import { ClaudeProvider } from '@/providers/claude-provider.js';
import { CursorProvider } from '@/providers/cursor-provider.js';
import { CodexProvider } from '@/providers/codex-provider.js';
import { OpencodeProvider } from '@/providers/opencode-provider.js';
describe('provider-factory.ts', () => {
let consoleSpy: any;
let detectClaudeSpy: any;
let detectCursorSpy: any;
let detectCodexSpy: any;
let detectOpencodeSpy: any;
beforeEach(() => {
consoleSpy = {
warn: vi.spyOn(console, 'warn').mockImplementation(() => {}),
};
// Avoid hitting real CLI / filesystem checks during unit tests
detectClaudeSpy = vi
.spyOn(ClaudeProvider.prototype, 'detectInstallation')
.mockResolvedValue({ installed: true });
detectCursorSpy = vi
.spyOn(CursorProvider.prototype, 'detectInstallation')
.mockResolvedValue({ installed: true });
detectCodexSpy = vi
.spyOn(CodexProvider.prototype, 'detectInstallation')
.mockResolvedValue({ installed: true });
detectOpencodeSpy = vi
.spyOn(OpencodeProvider.prototype, 'detectInstallation')
.mockResolvedValue({ installed: true });
});
afterEach(() => {
consoleSpy.warn.mockRestore();
detectClaudeSpy.mockRestore();
detectCursorSpy.mockRestore();
detectCodexSpy.mockRestore();
detectOpencodeSpy.mockRestore();
});
describe('getProviderForModel', () => {
@@ -135,11 +111,10 @@ describe('provider-factory.ts', () => {
});
describe('Cursor models via model ID lookup', () => {
it('should return CodexProvider for gpt-5.2 (Codex model, not Cursor)', () => {
// gpt-5.2 is in both CURSOR_MODEL_MAP and CODEX_MODEL_CONFIG_MAP
// It should route to Codex since Codex models take priority
it('should return CursorProvider for gpt-5.2 (valid Cursor model)', () => {
// gpt-5.2 is in CURSOR_MODEL_MAP
const provider = ProviderFactory.getProviderForModel('gpt-5.2');
expect(provider).toBeInstanceOf(CodexProvider);
expect(provider).toBeInstanceOf(CursorProvider);
});
it('should return CursorProvider for grok (valid Cursor model)', () => {
@@ -166,9 +141,9 @@ describe('provider-factory.ts', () => {
expect(hasClaudeProvider).toBe(true);
});
it('should return exactly 4 providers', () => {
it('should return exactly 2 providers', () => {
const providers = ProviderFactory.getAllProviders();
expect(providers).toHaveLength(4);
expect(providers).toHaveLength(2);
});
it('should include CursorProvider', () => {
@@ -204,9 +179,7 @@ describe('provider-factory.ts', () => {
expect(keys).toContain('claude');
expect(keys).toContain('cursor');
expect(keys).toContain('codex');
expect(keys).toContain('opencode');
expect(keys).toHaveLength(4);
expect(keys).toHaveLength(2);
});
it('should include cursor status', async () => {

View File

@@ -0,0 +1,318 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Request, Response } from 'express';
import {
createGetMetricsHandler,
createStartMetricsHandler,
createStopMetricsHandler,
createForceGCHandler,
createClearHistoryHandler,
} from '@/routes/debug/routes/metrics.js';
import type { PerformanceMonitorService } from '@/services/performance-monitor-service.js';
import type { DebugMetricsConfig, DebugMetricsSnapshot } from '@automaker/types';
import { DEFAULT_DEBUG_METRICS_CONFIG } from '@automaker/types';
describe('Debug Metrics Routes', () => {
let mockPerformanceMonitor: Partial<PerformanceMonitorService>;
let mockReq: Partial<Request>;
let mockRes: Partial<Response>;
let jsonFn: ReturnType<typeof vi.fn>;
let statusFn: ReturnType<typeof vi.fn>;
const mockConfig: DebugMetricsConfig = { ...DEFAULT_DEBUG_METRICS_CONFIG };
const mockSnapshot: DebugMetricsSnapshot = {
timestamp: Date.now(),
memory: {
timestamp: Date.now(),
server: {
heapTotal: 100 * 1024 * 1024,
heapUsed: 50 * 1024 * 1024,
external: 5 * 1024 * 1024,
rss: 150 * 1024 * 1024,
arrayBuffers: 1 * 1024 * 1024,
},
},
cpu: {
timestamp: Date.now(),
server: {
percentage: 25.5,
user: 1000,
system: 500,
},
eventLoopLag: 5,
},
processes: [],
processSummary: {
total: 0,
running: 0,
idle: 0,
stopped: 0,
errored: 0,
byType: { agent: 0, cli: 0, terminal: 0, worker: 0 },
},
};
beforeEach(() => {
jsonFn = vi.fn();
statusFn = vi.fn(() => ({ json: jsonFn }));
mockPerformanceMonitor = {
getLatestSnapshot: vi.fn(() => mockSnapshot),
getConfig: vi.fn(() => mockConfig),
isActive: vi.fn(() => true),
start: vi.fn(),
stop: vi.fn(),
updateConfig: vi.fn(),
forceGC: vi.fn(() => true),
clearHistory: vi.fn(),
};
mockReq = {
body: {},
query: {},
params: {},
};
mockRes = {
json: jsonFn,
status: statusFn,
};
});
describe('GET /api/debug/metrics', () => {
it('should return current metrics snapshot', () => {
const handler = createGetMetricsHandler(mockPerformanceMonitor as PerformanceMonitorService);
handler(mockReq as Request, mockRes as Response);
expect(jsonFn).toHaveBeenCalledWith({
active: true,
config: mockConfig,
snapshot: mockSnapshot,
});
});
it('should return undefined snapshot when no data available', () => {
(mockPerformanceMonitor.getLatestSnapshot as ReturnType<typeof vi.fn>).mockReturnValue(null);
const handler = createGetMetricsHandler(mockPerformanceMonitor as PerformanceMonitorService);
handler(mockReq as Request, mockRes as Response);
expect(jsonFn).toHaveBeenCalledWith({
active: true,
config: mockConfig,
snapshot: undefined,
});
});
it('should return active status correctly', () => {
(mockPerformanceMonitor.isActive as ReturnType<typeof vi.fn>).mockReturnValue(false);
const handler = createGetMetricsHandler(mockPerformanceMonitor as PerformanceMonitorService);
handler(mockReq as Request, mockRes as Response);
expect(jsonFn).toHaveBeenCalledWith(
expect.objectContaining({
active: false,
})
);
});
});
describe('POST /api/debug/metrics/start', () => {
it('should start metrics collection', () => {
const handler = createStartMetricsHandler(
mockPerformanceMonitor as PerformanceMonitorService
);
handler(mockReq as Request, mockRes as Response);
expect(mockPerformanceMonitor.start).toHaveBeenCalled();
expect(jsonFn).toHaveBeenCalledWith({
active: true,
config: mockConfig,
});
});
it('should apply config overrides when provided', () => {
mockReq.body = {
config: {
collectionInterval: 5000,
maxDataPoints: 500,
},
};
const handler = createStartMetricsHandler(
mockPerformanceMonitor as PerformanceMonitorService
);
handler(mockReq as Request, mockRes as Response);
expect(mockPerformanceMonitor.updateConfig).toHaveBeenCalledWith({
collectionInterval: 5000,
maxDataPoints: 500,
});
});
it('should sanitize config values - clamp collectionInterval to min 100ms', () => {
mockReq.body = {
config: {
collectionInterval: 10, // Below minimum of 100ms
},
};
const handler = createStartMetricsHandler(
mockPerformanceMonitor as PerformanceMonitorService
);
handler(mockReq as Request, mockRes as Response);
expect(mockPerformanceMonitor.updateConfig).toHaveBeenCalledWith({
collectionInterval: 100,
});
});
it('should sanitize config values - clamp collectionInterval to max 60000ms', () => {
mockReq.body = {
config: {
collectionInterval: 100000, // Above maximum of 60000ms
},
};
const handler = createStartMetricsHandler(
mockPerformanceMonitor as PerformanceMonitorService
);
handler(mockReq as Request, mockRes as Response);
expect(mockPerformanceMonitor.updateConfig).toHaveBeenCalledWith({
collectionInterval: 60000,
});
});
it('should sanitize config values - clamp maxDataPoints to bounds', () => {
mockReq.body = {
config: {
maxDataPoints: 5, // Below minimum of 10
},
};
const handler = createStartMetricsHandler(
mockPerformanceMonitor as PerformanceMonitorService
);
handler(mockReq as Request, mockRes as Response);
expect(mockPerformanceMonitor.updateConfig).toHaveBeenCalledWith({
maxDataPoints: 10,
});
});
it('should sanitize config values - clamp maxDataPoints to max', () => {
mockReq.body = {
config: {
maxDataPoints: 50000, // Above maximum of 10000
},
};
const handler = createStartMetricsHandler(
mockPerformanceMonitor as PerformanceMonitorService
);
handler(mockReq as Request, mockRes as Response);
expect(mockPerformanceMonitor.updateConfig).toHaveBeenCalledWith({
maxDataPoints: 10000,
});
});
it('should ignore non-object config', () => {
mockReq.body = {
config: 'not-an-object',
};
const handler = createStartMetricsHandler(
mockPerformanceMonitor as PerformanceMonitorService
);
handler(mockReq as Request, mockRes as Response);
expect(mockPerformanceMonitor.updateConfig).not.toHaveBeenCalled();
});
it('should ignore empty config object', () => {
mockReq.body = {
config: {},
};
const handler = createStartMetricsHandler(
mockPerformanceMonitor as PerformanceMonitorService
);
handler(mockReq as Request, mockRes as Response);
expect(mockPerformanceMonitor.updateConfig).not.toHaveBeenCalled();
});
it('should only accept boolean flags as actual booleans', () => {
mockReq.body = {
config: {
memoryEnabled: 'true', // String, not boolean - should be ignored
cpuEnabled: true, // Boolean - should be accepted
},
};
const handler = createStartMetricsHandler(
mockPerformanceMonitor as PerformanceMonitorService
);
handler(mockReq as Request, mockRes as Response);
expect(mockPerformanceMonitor.updateConfig).toHaveBeenCalledWith({
cpuEnabled: true,
});
});
});
describe('POST /api/debug/metrics/stop', () => {
it('should stop metrics collection', () => {
const handler = createStopMetricsHandler(mockPerformanceMonitor as PerformanceMonitorService);
handler(mockReq as Request, mockRes as Response);
expect(mockPerformanceMonitor.stop).toHaveBeenCalled();
expect(jsonFn).toHaveBeenCalledWith({
active: false,
config: mockConfig,
});
});
});
describe('POST /api/debug/metrics/gc', () => {
it('should trigger garbage collection when available', () => {
const handler = createForceGCHandler(mockPerformanceMonitor as PerformanceMonitorService);
handler(mockReq as Request, mockRes as Response);
expect(mockPerformanceMonitor.forceGC).toHaveBeenCalled();
expect(jsonFn).toHaveBeenCalledWith({
success: true,
message: 'Garbage collection triggered',
});
});
it('should report when garbage collection is not available', () => {
(mockPerformanceMonitor.forceGC as ReturnType<typeof vi.fn>).mockReturnValue(false);
const handler = createForceGCHandler(mockPerformanceMonitor as PerformanceMonitorService);
handler(mockReq as Request, mockRes as Response);
expect(jsonFn).toHaveBeenCalledWith({
success: false,
message: 'Garbage collection not available (start Node.js with --expose-gc flag)',
});
});
});
describe('POST /api/debug/metrics/clear', () => {
it('should clear metrics history', () => {
const handler = createClearHistoryHandler(
mockPerformanceMonitor as PerformanceMonitorService
);
handler(mockReq as Request, mockRes as Response);
expect(mockPerformanceMonitor.clearHistory).toHaveBeenCalled();
expect(jsonFn).toHaveBeenCalledWith({
success: true,
message: 'Metrics history cleared',
});
});
});
});

View File

@@ -0,0 +1,293 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Request, Response } from 'express';
import {
createGetProcessesHandler,
createGetProcessHandler,
createGetSummaryHandler,
} from '@/routes/debug/routes/processes.js';
import type { ProcessRegistryService } from '@/services/process-registry-service.js';
import type { TrackedProcess, ProcessSummary } from '@automaker/types';
describe('Debug Processes Routes', () => {
let mockProcessRegistry: Partial<ProcessRegistryService>;
let mockReq: Partial<Request>;
let mockRes: Partial<Response>;
let jsonFn: ReturnType<typeof vi.fn>;
let statusFn: ReturnType<typeof vi.fn>;
const mockProcesses: TrackedProcess[] = [
{
id: 'process-1',
pid: 1234,
type: 'agent',
name: 'Agent 1',
status: 'running',
startedAt: Date.now() - 60000,
featureId: 'feature-1',
sessionId: 'session-1',
},
{
id: 'process-2',
pid: 5678,
type: 'terminal',
name: 'Terminal 1',
status: 'idle',
startedAt: Date.now() - 30000,
sessionId: 'session-1',
},
{
id: 'process-3',
pid: 9012,
type: 'cli',
name: 'CLI 1',
status: 'stopped',
startedAt: Date.now() - 120000,
stoppedAt: Date.now() - 60000,
exitCode: 0,
},
];
const mockSummary: ProcessSummary = {
total: 3,
running: 1,
idle: 1,
stopped: 1,
errored: 0,
byType: {
agent: 1,
cli: 1,
terminal: 1,
worker: 0,
},
};
beforeEach(() => {
jsonFn = vi.fn();
statusFn = vi.fn(() => ({ json: jsonFn }));
mockProcessRegistry = {
getProcesses: vi.fn(() => mockProcesses),
getProcess: vi.fn((id: string) => mockProcesses.find((p) => p.id === id)),
getProcessSummary: vi.fn(() => mockSummary),
};
mockReq = {
body: {},
query: {},
params: {},
};
mockRes = {
json: jsonFn,
status: statusFn,
};
});
describe('GET /api/debug/processes', () => {
it('should return list of processes with summary', () => {
const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService);
handler(mockReq as Request, mockRes as Response);
expect(mockProcessRegistry.getProcesses).toHaveBeenCalled();
expect(mockProcessRegistry.getProcessSummary).toHaveBeenCalled();
expect(jsonFn).toHaveBeenCalledWith({
processes: mockProcesses,
summary: mockSummary,
});
});
it('should pass type filter to service', () => {
mockReq.query = { type: 'agent' };
const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService);
handler(mockReq as Request, mockRes as Response);
expect(mockProcessRegistry.getProcesses).toHaveBeenCalledWith(
expect.objectContaining({
type: 'agent',
})
);
});
it('should pass status filter to service', () => {
mockReq.query = { status: 'running' };
const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService);
handler(mockReq as Request, mockRes as Response);
expect(mockProcessRegistry.getProcesses).toHaveBeenCalledWith(
expect.objectContaining({
status: 'running',
})
);
});
it('should pass includeStopped flag when set to "true"', () => {
mockReq.query = { includeStopped: 'true' };
const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService);
handler(mockReq as Request, mockRes as Response);
expect(mockProcessRegistry.getProcesses).toHaveBeenCalledWith(
expect.objectContaining({
includeStopped: true,
})
);
});
it('should not pass includeStopped when not "true"', () => {
mockReq.query = { includeStopped: 'false' };
const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService);
handler(mockReq as Request, mockRes as Response);
expect(mockProcessRegistry.getProcesses).toHaveBeenCalledWith(
expect.objectContaining({
includeStopped: undefined,
})
);
});
it('should pass sessionId filter to service', () => {
mockReq.query = { sessionId: 'session-1' };
const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService);
handler(mockReq as Request, mockRes as Response);
expect(mockProcessRegistry.getProcesses).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: 'session-1',
})
);
});
it('should pass featureId filter to service', () => {
mockReq.query = { featureId: 'feature-1' };
const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService);
handler(mockReq as Request, mockRes as Response);
expect(mockProcessRegistry.getProcesses).toHaveBeenCalledWith(
expect.objectContaining({
featureId: 'feature-1',
})
);
});
it('should handle multiple filters', () => {
mockReq.query = {
type: 'agent',
status: 'running',
sessionId: 'session-1',
includeStopped: 'true',
};
const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService);
handler(mockReq as Request, mockRes as Response);
expect(mockProcessRegistry.getProcesses).toHaveBeenCalledWith({
type: 'agent',
status: 'running',
sessionId: 'session-1',
includeStopped: true,
featureId: undefined,
});
});
});
describe('GET /api/debug/processes/:id', () => {
it('should return a specific process by ID', () => {
mockReq.params = { id: 'process-1' };
const handler = createGetProcessHandler(mockProcessRegistry as ProcessRegistryService);
handler(mockReq as Request, mockRes as Response);
expect(mockProcessRegistry.getProcess).toHaveBeenCalledWith('process-1');
expect(jsonFn).toHaveBeenCalledWith(mockProcesses[0]);
});
it('should return 404 for non-existent process', () => {
mockReq.params = { id: 'non-existent' };
(mockProcessRegistry.getProcess as ReturnType<typeof vi.fn>).mockReturnValue(undefined);
const handler = createGetProcessHandler(mockProcessRegistry as ProcessRegistryService);
handler(mockReq as Request, mockRes as Response);
expect(statusFn).toHaveBeenCalledWith(404);
expect(jsonFn).toHaveBeenCalledWith({
error: 'Process not found',
id: 'non-existent',
});
});
it('should return 400 for empty process ID', () => {
mockReq.params = { id: '' };
const handler = createGetProcessHandler(mockProcessRegistry as ProcessRegistryService);
handler(mockReq as Request, mockRes as Response);
expect(statusFn).toHaveBeenCalledWith(400);
expect(jsonFn).toHaveBeenCalledWith({
error: 'Invalid process ID format',
});
});
it('should return 400 for process ID exceeding max length', () => {
mockReq.params = { id: 'a'.repeat(257) };
const handler = createGetProcessHandler(mockProcessRegistry as ProcessRegistryService);
handler(mockReq as Request, mockRes as Response);
expect(statusFn).toHaveBeenCalledWith(400);
expect(jsonFn).toHaveBeenCalledWith({
error: 'Invalid process ID format',
});
});
it('should accept process ID at max length', () => {
mockReq.params = { id: 'a'.repeat(256) };
(mockProcessRegistry.getProcess as ReturnType<typeof vi.fn>).mockReturnValue(undefined);
const handler = createGetProcessHandler(mockProcessRegistry as ProcessRegistryService);
handler(mockReq as Request, mockRes as Response);
// Should pass validation but process not found
expect(statusFn).toHaveBeenCalledWith(404);
});
});
describe('GET /api/debug/processes/summary', () => {
it('should return process summary', () => {
const handler = createGetSummaryHandler(mockProcessRegistry as ProcessRegistryService);
handler(mockReq as Request, mockRes as Response);
expect(mockProcessRegistry.getProcessSummary).toHaveBeenCalled();
expect(jsonFn).toHaveBeenCalledWith(mockSummary);
});
it('should return correct counts', () => {
const customSummary: ProcessSummary = {
total: 10,
running: 5,
idle: 2,
stopped: 2,
errored: 1,
byType: {
agent: 4,
cli: 3,
terminal: 2,
worker: 1,
},
};
(mockProcessRegistry.getProcessSummary as ReturnType<typeof vi.fn>).mockReturnValue(
customSummary
);
const handler = createGetSummaryHandler(mockProcessRegistry as ProcessRegistryService);
handler(mockReq as Request, mockRes as Response);
expect(jsonFn).toHaveBeenCalledWith(customSummary);
});
});
});

View File

@@ -0,0 +1,418 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { PerformanceMonitorService } from '@/services/performance-monitor-service.js';
import { createEventEmitter } from '@/lib/events.js';
import type { EventEmitter } from '@/lib/events.js';
import type { TrackedProcess, DebugMetricsConfig } from '@automaker/types';
import { DEFAULT_DEBUG_METRICS_CONFIG } from '@automaker/types';
// Mock the logger to prevent console output during tests
vi.mock('@automaker/utils', () => ({
createLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
}));
describe('PerformanceMonitorService', () => {
let service: PerformanceMonitorService;
let events: EventEmitter;
beforeEach(() => {
vi.useFakeTimers();
events = createEventEmitter();
service = new PerformanceMonitorService(events);
});
afterEach(() => {
service.stop();
vi.useRealTimers();
vi.clearAllMocks();
});
describe('initialization', () => {
it('should initialize with default configuration', () => {
const config = service.getConfig();
expect(config.collectionInterval).toBe(DEFAULT_DEBUG_METRICS_CONFIG.collectionInterval);
expect(config.maxDataPoints).toBe(DEFAULT_DEBUG_METRICS_CONFIG.maxDataPoints);
expect(config.memoryEnabled).toBe(DEFAULT_DEBUG_METRICS_CONFIG.memoryEnabled);
expect(config.cpuEnabled).toBe(DEFAULT_DEBUG_METRICS_CONFIG.cpuEnabled);
});
it('should accept custom configuration on initialization', () => {
const customConfig: Partial<DebugMetricsConfig> = {
collectionInterval: 5000,
maxDataPoints: 500,
memoryEnabled: false,
};
const customService = new PerformanceMonitorService(events, customConfig);
const config = customService.getConfig();
expect(config.collectionInterval).toBe(5000);
expect(config.maxDataPoints).toBe(500);
expect(config.memoryEnabled).toBe(false);
expect(config.cpuEnabled).toBe(DEFAULT_DEBUG_METRICS_CONFIG.cpuEnabled);
customService.stop();
});
it('should not be running initially', () => {
expect(service.isActive()).toBe(false);
});
});
describe('start/stop', () => {
it('should start metrics collection', () => {
service.start();
expect(service.isActive()).toBe(true);
});
it('should stop metrics collection', () => {
service.start();
expect(service.isActive()).toBe(true);
service.stop();
expect(service.isActive()).toBe(false);
});
it('should not start again if already running', () => {
service.start();
const isActive1 = service.isActive();
service.start(); // Should log warning but not throw
const isActive2 = service.isActive();
expect(isActive1).toBe(true);
expect(isActive2).toBe(true);
});
it('should handle stop when not running', () => {
// Should not throw
expect(() => service.stop()).not.toThrow();
});
});
describe('configuration updates', () => {
it('should update configuration', () => {
service.updateConfig({ collectionInterval: 2000 });
expect(service.getConfig().collectionInterval).toBe(2000);
});
it('should restart collection if running when config is updated', () => {
service.start();
expect(service.isActive()).toBe(true);
service.updateConfig({ collectionInterval: 5000 });
// Should still be running after config update
expect(service.isActive()).toBe(true);
expect(service.getConfig().collectionInterval).toBe(5000);
});
it('should resize data buffers when maxDataPoints changes', () => {
// Start and collect some data
service.start();
// Collect multiple data points
for (let i = 0; i < 50; i++) {
vi.advanceTimersByTime(service.getConfig().collectionInterval);
}
// Reduce max data points
service.updateConfig({ maxDataPoints: 10 });
const history = service.getMemoryHistory();
expect(history.length).toBeLessThanOrEqual(10);
});
});
describe('metrics collection', () => {
it('should emit debug:metrics event on collection', () => {
const callback = vi.fn();
events.subscribe(callback);
service.start();
vi.advanceTimersByTime(service.getConfig().collectionInterval);
expect(callback).toHaveBeenCalled();
const [eventType, eventData] = callback.mock.calls[0];
expect(eventType).toBe('debug:metrics');
expect(eventData).toHaveProperty('timestamp');
expect(eventData).toHaveProperty('metrics');
});
it('should collect memory metrics when memoryEnabled is true', () => {
const callback = vi.fn();
events.subscribe(callback);
service.start();
vi.advanceTimersByTime(service.getConfig().collectionInterval);
const [, eventData] = callback.mock.calls[0];
expect(eventData.metrics.memory.server).toBeDefined();
expect(eventData.metrics.memory.server.heapUsed).toBeGreaterThan(0);
expect(eventData.metrics.memory.server.heapTotal).toBeGreaterThan(0);
});
it('should not collect memory metrics when memoryEnabled is false', () => {
const customService = new PerformanceMonitorService(events, { memoryEnabled: false });
const callback = vi.fn();
events.subscribe(callback);
customService.start();
vi.advanceTimersByTime(customService.getConfig().collectionInterval);
const [, eventData] = callback.mock.calls[0];
expect(eventData.metrics.memory.server).toBeUndefined();
customService.stop();
});
it('should collect CPU metrics when cpuEnabled is true', () => {
const callback = vi.fn();
events.subscribe(callback);
service.start();
vi.advanceTimersByTime(service.getConfig().collectionInterval);
vi.advanceTimersByTime(service.getConfig().collectionInterval);
// Need at least 2 collections for CPU diff
const lastCall = callback.mock.calls[callback.mock.calls.length - 1];
const [, eventData] = lastCall;
expect(eventData.metrics.cpu.server).toBeDefined();
});
it('should track event loop lag', () => {
const callback = vi.fn();
events.subscribe(callback);
service.start();
vi.advanceTimersByTime(service.getConfig().collectionInterval);
const [, eventData] = callback.mock.calls[0];
expect(eventData.metrics.cpu.eventLoopLag).toBeDefined();
});
});
describe('memory history', () => {
it('should return empty history initially', () => {
const history = service.getMemoryHistory();
expect(history).toEqual([]);
});
it('should accumulate memory history over time', () => {
service.start();
// Collect multiple data points
for (let i = 0; i < 5; i++) {
vi.advanceTimersByTime(service.getConfig().collectionInterval);
}
const history = service.getMemoryHistory();
expect(history.length).toBeGreaterThan(0);
});
it('should limit history to maxDataPoints', () => {
const maxPoints = 10;
const customService = new PerformanceMonitorService(events, { maxDataPoints: maxPoints });
customService.start();
// Collect more data points than max
for (let i = 0; i < maxPoints + 10; i++) {
vi.advanceTimersByTime(customService.getConfig().collectionInterval);
}
const history = customService.getMemoryHistory();
expect(history.length).toBeLessThanOrEqual(maxPoints);
customService.stop();
});
});
describe('CPU history', () => {
it('should return empty CPU history initially', () => {
const history = service.getCPUHistory();
expect(history).toEqual([]);
});
it('should accumulate CPU history over time', () => {
service.start();
// Collect multiple data points (need at least 2 for CPU diff)
for (let i = 0; i < 5; i++) {
vi.advanceTimersByTime(service.getConfig().collectionInterval);
}
const history = service.getCPUHistory();
expect(history.length).toBeGreaterThan(0);
});
});
describe('process provider', () => {
it('should use provided process provider', () => {
const mockProcesses: TrackedProcess[] = [
{
id: 'test-1',
type: 'agent',
name: 'TestAgent',
status: 'running',
startedAt: Date.now(),
},
{
id: 'test-2',
type: 'terminal',
name: 'TestTerminal',
status: 'idle',
startedAt: Date.now(),
},
];
const provider = vi.fn(() => mockProcesses);
service.setProcessProvider(provider);
const callback = vi.fn();
events.subscribe(callback);
service.start();
vi.advanceTimersByTime(service.getConfig().collectionInterval);
const [, eventData] = callback.mock.calls[0];
expect(eventData.metrics.processes).toEqual(mockProcesses);
expect(eventData.metrics.processSummary.total).toBe(2);
expect(eventData.metrics.processSummary.running).toBe(1);
expect(eventData.metrics.processSummary.idle).toBe(1);
expect(eventData.metrics.processSummary.byType.agent).toBe(1);
expect(eventData.metrics.processSummary.byType.terminal).toBe(1);
});
});
describe('getLatestSnapshot', () => {
it('should return null when no data collected', () => {
const snapshot = service.getLatestSnapshot();
expect(snapshot).toBeNull();
});
it('should return snapshot after data collection', () => {
service.start();
vi.advanceTimersByTime(service.getConfig().collectionInterval);
const snapshot = service.getLatestSnapshot();
expect(snapshot).not.toBeNull();
expect(snapshot).toHaveProperty('timestamp');
expect(snapshot).toHaveProperty('memory');
expect(snapshot).toHaveProperty('cpu');
expect(snapshot).toHaveProperty('processes');
expect(snapshot).toHaveProperty('processSummary');
});
});
describe('clearHistory', () => {
it('should clear all history', () => {
service.start();
// Collect some data
for (let i = 0; i < 5; i++) {
vi.advanceTimersByTime(service.getConfig().collectionInterval);
}
expect(service.getMemoryHistory().length).toBeGreaterThan(0);
service.clearHistory();
expect(service.getMemoryHistory().length).toBe(0);
expect(service.getCPUHistory().length).toBe(0);
});
});
describe('forceGC', () => {
it('should return false when gc is not available', () => {
const originalGc = global.gc;
global.gc = undefined;
const result = service.forceGC();
expect(result).toBe(false);
// Restore
global.gc = originalGc;
});
it('should return true and call gc when available', () => {
const mockGc = vi.fn();
global.gc = mockGc;
const result = service.forceGC();
expect(result).toBe(true);
expect(mockGc).toHaveBeenCalled();
// Cleanup
global.gc = undefined;
});
});
describe('memory trend analysis', () => {
it('should not calculate trend with insufficient data', () => {
service.start();
// Collect only a few data points
for (let i = 0; i < 5; i++) {
vi.advanceTimersByTime(service.getConfig().collectionInterval);
}
const snapshot = service.getLatestSnapshot();
// Trend requires at least 10 samples
expect(snapshot?.memoryTrend).toBeUndefined();
});
it('should calculate trend with sufficient data', () => {
service.start();
// Collect enough data points for trend analysis
for (let i = 0; i < 15; i++) {
vi.advanceTimersByTime(service.getConfig().collectionInterval);
}
const snapshot = service.getLatestSnapshot();
expect(snapshot?.memoryTrend).toBeDefined();
expect(snapshot?.memoryTrend).toHaveProperty('growthRate');
expect(snapshot?.memoryTrend).toHaveProperty('isLeaking');
expect(snapshot?.memoryTrend).toHaveProperty('confidence');
expect(snapshot?.memoryTrend).toHaveProperty('sampleCount');
});
});
describe('process summary calculation', () => {
it('should correctly categorize processes by status', () => {
const mockProcesses: TrackedProcess[] = [
{ id: '1', type: 'agent', name: 'A1', status: 'running', startedAt: Date.now() },
{ id: '2', type: 'agent', name: 'A2', status: 'starting', startedAt: Date.now() },
{ id: '3', type: 'terminal', name: 'T1', status: 'idle', startedAt: Date.now() },
{ id: '4', type: 'terminal', name: 'T2', status: 'stopped', startedAt: Date.now() },
{ id: '5', type: 'cli', name: 'C1', status: 'stopping', startedAt: Date.now() },
{ id: '6', type: 'worker', name: 'W1', status: 'error', startedAt: Date.now() },
];
service.setProcessProvider(() => mockProcesses);
const callback = vi.fn();
events.subscribe(callback);
service.start();
vi.advanceTimersByTime(service.getConfig().collectionInterval);
const [, eventData] = callback.mock.calls[0];
const summary = eventData.metrics.processSummary;
expect(summary.total).toBe(6);
expect(summary.running).toBe(2); // running + starting
expect(summary.idle).toBe(1);
expect(summary.stopped).toBe(2); // stopped + stopping
expect(summary.errored).toBe(1);
expect(summary.byType.agent).toBe(2);
expect(summary.byType.terminal).toBe(2);
expect(summary.byType.cli).toBe(1);
expect(summary.byType.worker).toBe(1);
});
});
});

View File

@@ -0,0 +1,538 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
ProcessRegistryService,
getProcessRegistryService,
resetProcessRegistryService,
} from '@/services/process-registry-service.js';
import { createEventEmitter } from '@/lib/events.js';
import type { EventEmitter } from '@/lib/events.js';
import type { TrackedProcess, ProcessType, ProcessStatus } from '@automaker/types';
// Mock the logger to prevent console output during tests
vi.mock('@automaker/utils', () => ({
createLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
}));
describe('ProcessRegistryService', () => {
let service: ProcessRegistryService;
let events: EventEmitter;
beforeEach(() => {
vi.useFakeTimers();
events = createEventEmitter();
service = new ProcessRegistryService(events);
resetProcessRegistryService();
});
afterEach(() => {
service.stop();
vi.useRealTimers();
vi.clearAllMocks();
});
describe('initialization', () => {
it('should initialize with default configuration', () => {
const config = service.getConfig();
expect(config.stoppedProcessRetention).toBe(5 * 60 * 1000);
expect(config.cleanupInterval).toBe(60 * 1000);
expect(config.maxStoppedProcesses).toBe(100);
});
it('should accept custom configuration', () => {
const customService = new ProcessRegistryService(events, {
stoppedProcessRetention: 10000,
maxStoppedProcesses: 50,
});
const config = customService.getConfig();
expect(config.stoppedProcessRetention).toBe(10000);
expect(config.maxStoppedProcesses).toBe(50);
expect(config.cleanupInterval).toBe(60 * 1000);
customService.stop();
});
});
describe('start/stop', () => {
it('should start the service', () => {
expect(() => service.start()).not.toThrow();
});
it('should stop the service', () => {
service.start();
expect(() => service.stop()).not.toThrow();
});
it('should not start again if already running', () => {
service.start();
// Should log warning but not throw
expect(() => service.start()).not.toThrow();
});
});
describe('process registration', () => {
it('should register a new process', () => {
const process = service.registerProcess({
id: 'test-1',
pid: 1234,
type: 'agent',
name: 'TestAgent',
});
expect(process.id).toBe('test-1');
expect(process.pid).toBe(1234);
expect(process.type).toBe('agent');
expect(process.name).toBe('TestAgent');
expect(process.status).toBe('starting');
expect(process.startedAt).toBeDefined();
});
it('should register a process with all optional fields', () => {
const process = service.registerProcess({
id: 'test-2',
pid: 5678,
type: 'terminal',
name: 'TestTerminal',
featureId: 'feature-123',
sessionId: 'session-456',
command: 'bash',
cwd: '/home/user',
});
expect(process.featureId).toBe('feature-123');
expect(process.sessionId).toBe('session-456');
expect(process.command).toBe('bash');
expect(process.cwd).toBe('/home/user');
});
it('should emit debug:process-spawned event on registration', () => {
const callback = vi.fn();
events.subscribe(callback);
service.registerProcess({
id: 'test-3',
pid: 111,
type: 'cli',
name: 'TestCLI',
});
expect(callback).toHaveBeenCalled();
const [eventType, eventData] = callback.mock.calls[0];
expect(eventType).toBe('debug:process-spawned');
expect(eventData.process.id).toBe('test-3');
});
});
describe('process retrieval', () => {
beforeEach(() => {
// Register test processes
service.registerProcess({
id: 'p1',
pid: 1,
type: 'agent',
name: 'Agent1',
featureId: 'f1',
sessionId: 's1',
});
service.registerProcess({
id: 'p2',
pid: 2,
type: 'terminal',
name: 'Terminal1',
sessionId: 's1',
});
service.registerProcess({ id: 'p3', pid: 3, type: 'cli', name: 'CLI1', featureId: 'f2' });
});
it('should get a process by ID', () => {
const process = service.getProcess('p1');
expect(process).toBeDefined();
expect(process?.name).toBe('Agent1');
});
it('should return undefined for non-existent process', () => {
const process = service.getProcess('non-existent');
expect(process).toBeUndefined();
});
it('should check if process exists', () => {
expect(service.hasProcess('p1')).toBe(true);
expect(service.hasProcess('non-existent')).toBe(false);
});
it('should get all processes without filters', () => {
const processes = service.getProcesses({ includeStopped: true });
expect(processes.length).toBe(3);
});
it('should filter by type', () => {
const agents = service.getProcesses({ type: 'agent', includeStopped: true });
expect(agents.length).toBe(1);
expect(agents[0].type).toBe('agent');
});
it('should filter by session ID', () => {
const sessionProcesses = service.getProcesses({ sessionId: 's1', includeStopped: true });
expect(sessionProcesses.length).toBe(2);
});
it('should filter by feature ID', () => {
const featureProcesses = service.getProcesses({ featureId: 'f1', includeStopped: true });
expect(featureProcesses.length).toBe(1);
expect(featureProcesses[0].id).toBe('p1');
});
it('should exclude stopped processes by default', () => {
service.markStopped('p1');
const processes = service.getProcesses();
expect(processes.length).toBe(2);
expect(processes.find((p) => p.id === 'p1')).toBeUndefined();
});
it('should include stopped processes when requested', () => {
service.markStopped('p1');
const processes = service.getProcesses({ includeStopped: true });
expect(processes.length).toBe(3);
});
it('should sort processes by start time (most recent first)', () => {
// Re-register processes with different timestamps
service.clear();
// Register p1 at time 0
service.registerProcess({ id: 'p1', pid: 1, type: 'agent', name: 'Agent1' });
// Advance time and register p2
vi.advanceTimersByTime(1000);
service.registerProcess({ id: 'p2', pid: 2, type: 'terminal', name: 'Terminal1' });
// Advance time and register p3
vi.advanceTimersByTime(1000);
service.registerProcess({ id: 'p3', pid: 3, type: 'cli', name: 'CLI1' });
const processes = service.getProcesses({ includeStopped: true });
// p3 was registered last (most recent), so it should be first
expect(processes[0].id).toBe('p3');
expect(processes[1].id).toBe('p2');
expect(processes[2].id).toBe('p1');
});
});
describe('process status updates', () => {
let process: TrackedProcess;
beforeEach(() => {
process = service.registerProcess({
id: 'test-proc',
pid: 100,
type: 'agent',
name: 'TestProcess',
});
});
it('should update process status', () => {
const updated = service.updateProcess('test-proc', { status: 'running' });
expect(updated?.status).toBe('running');
});
it('should update memory usage', () => {
const updated = service.updateProcess('test-proc', { memoryUsage: 1024 * 1024 });
expect(updated?.memoryUsage).toBe(1024 * 1024);
});
it('should update CPU usage', () => {
const updated = service.updateProcess('test-proc', { cpuUsage: 45.5 });
expect(updated?.cpuUsage).toBe(45.5);
});
it('should return null for non-existent process', () => {
const updated = service.updateProcess('non-existent', { status: 'running' });
expect(updated).toBeNull();
});
it('should set stoppedAt when status is stopped', () => {
const updated = service.markStopped('test-proc');
expect(updated?.stoppedAt).toBeDefined();
});
it('should set stoppedAt when status is error', () => {
const updated = service.markError('test-proc', 'Something went wrong');
expect(updated?.stoppedAt).toBeDefined();
expect(updated?.error).toBe('Something went wrong');
});
});
describe('status shortcut methods', () => {
beforeEach(() => {
service.registerProcess({
id: 'test-proc',
pid: 100,
type: 'agent',
name: 'TestProcess',
});
});
it('should mark process as running', () => {
const updated = service.markRunning('test-proc');
expect(updated?.status).toBe('running');
});
it('should mark process as idle', () => {
const updated = service.markIdle('test-proc');
expect(updated?.status).toBe('idle');
});
it('should mark process as stopping', () => {
const updated = service.markStopping('test-proc');
expect(updated?.status).toBe('stopping');
});
it('should mark process as stopped with exit code', () => {
const updated = service.markStopped('test-proc', 0);
expect(updated?.status).toBe('stopped');
expect(updated?.exitCode).toBe(0);
});
it('should mark process as error with message', () => {
const updated = service.markError('test-proc', 'Process crashed');
expect(updated?.status).toBe('error');
expect(updated?.error).toBe('Process crashed');
});
});
describe('event emissions', () => {
let callback: ReturnType<typeof vi.fn>;
beforeEach(() => {
callback = vi.fn();
events.subscribe(callback);
service.registerProcess({
id: 'test-proc',
pid: 100,
type: 'agent',
name: 'TestProcess',
});
callback.mockClear();
});
it('should emit debug:process-stopped when stopped', () => {
service.markStopped('test-proc', 0);
expect(callback).toHaveBeenCalled();
const [eventType] = callback.mock.calls[0];
expect(eventType).toBe('debug:process-stopped');
});
it('should emit debug:process-error when errored', () => {
service.markError('test-proc', 'Error message');
expect(callback).toHaveBeenCalled();
const [eventType, eventData] = callback.mock.calls[0];
expect(eventType).toBe('debug:process-error');
expect(eventData.message).toContain('Error message');
});
it('should emit debug:process-updated for other status changes', () => {
service.markRunning('test-proc');
expect(callback).toHaveBeenCalled();
const [eventType] = callback.mock.calls[0];
expect(eventType).toBe('debug:process-updated');
});
});
describe('process unregistration', () => {
it('should unregister an existing process', () => {
service.registerProcess({
id: 'test-proc',
pid: 100,
type: 'agent',
name: 'TestProcess',
});
const result = service.unregisterProcess('test-proc');
expect(result).toBe(true);
expect(service.getProcess('test-proc')).toBeUndefined();
});
it('should return false for non-existent process', () => {
const result = service.unregisterProcess('non-existent');
expect(result).toBe(false);
});
});
describe('process summary', () => {
beforeEach(() => {
service.registerProcess({ id: 'p1', pid: 1, type: 'agent', name: 'A1' });
service.registerProcess({ id: 'p2', pid: 2, type: 'agent', name: 'A2' });
service.registerProcess({ id: 'p3', pid: 3, type: 'terminal', name: 'T1' });
service.registerProcess({ id: 'p4', pid: 4, type: 'cli', name: 'C1' });
service.registerProcess({ id: 'p5', pid: 5, type: 'worker', name: 'W1' });
// Update statuses
service.markRunning('p1');
service.markIdle('p2');
service.markStopped('p3');
service.markError('p4', 'error');
service.markRunning('p5');
});
it('should calculate correct summary statistics', () => {
const summary = service.getProcessSummary();
expect(summary.total).toBe(5);
expect(summary.running).toBe(2); // p1 running, p5 running
expect(summary.idle).toBe(1); // p2 idle
expect(summary.stopped).toBe(1); // p3 stopped
expect(summary.errored).toBe(1); // p4 error
});
it('should count processes by type', () => {
const summary = service.getProcessSummary();
expect(summary.byType.agent).toBe(2);
expect(summary.byType.terminal).toBe(1);
expect(summary.byType.cli).toBe(1);
expect(summary.byType.worker).toBe(1);
});
});
describe('active count', () => {
beforeEach(() => {
service.registerProcess({ id: 'p1', pid: 1, type: 'agent', name: 'A1' });
service.registerProcess({ id: 'p2', pid: 2, type: 'agent', name: 'A2' });
service.registerProcess({ id: 'p3', pid: 3, type: 'terminal', name: 'T1' });
service.markRunning('p1');
service.markStopped('p2');
service.markIdle('p3');
});
it('should return count of active processes', () => {
expect(service.getActiveCount()).toBe(2); // p1 running, p3 idle
});
it('should return count by type', () => {
expect(service.getCountByType('agent')).toBe(2);
expect(service.getCountByType('terminal')).toBe(1);
expect(service.getCountByType('cli')).toBe(0);
});
});
describe('process provider', () => {
it('should return a process provider function', () => {
service.registerProcess({ id: 'p1', pid: 1, type: 'agent', name: 'A1' });
const provider = service.getProcessProvider();
expect(typeof provider).toBe('function');
const processes = provider();
expect(processes.length).toBe(1);
expect(processes[0].id).toBe('p1');
});
it('should return all processes including stopped', () => {
service.registerProcess({ id: 'p1', pid: 1, type: 'agent', name: 'A1' });
service.registerProcess({ id: 'p2', pid: 2, type: 'agent', name: 'A2' });
service.markStopped('p2');
const provider = service.getProcessProvider();
const processes = provider();
expect(processes.length).toBe(2);
});
});
describe('cleanup', () => {
it('should clean up old stopped processes', () => {
// Register and stop a process
service.registerProcess({ id: 'p1', pid: 1, type: 'agent', name: 'A1' });
service.markStopped('p1');
// Start service to enable cleanup
service.start();
// Advance time past retention period
vi.advanceTimersByTime(6 * 60 * 1000); // 6 minutes (past default 5 min retention)
// Process should be cleaned up
expect(service.getProcess('p1')).toBeUndefined();
});
it('should enforce max stopped processes limit', () => {
const customService = new ProcessRegistryService(events, {
maxStoppedProcesses: 3,
cleanupInterval: 1000,
});
// Register and stop more processes than max
for (let i = 0; i < 5; i++) {
customService.registerProcess({ id: `p${i}`, pid: i, type: 'agent', name: `A${i}` });
customService.markStopped(`p${i}`);
}
customService.start();
// Trigger cleanup
vi.advanceTimersByTime(1000);
// Should only have max stopped processes
const allProcesses = customService.getAllProcesses();
expect(allProcesses.length).toBeLessThanOrEqual(3);
customService.stop();
});
});
describe('configuration update', () => {
it('should update configuration', () => {
service.updateConfig({ maxStoppedProcesses: 200 });
expect(service.getConfig().maxStoppedProcesses).toBe(200);
});
});
describe('clear', () => {
it('should clear all tracked processes', () => {
service.registerProcess({ id: 'p1', pid: 1, type: 'agent', name: 'A1' });
service.registerProcess({ id: 'p2', pid: 2, type: 'terminal', name: 'T1' });
service.clear();
expect(service.getAllProcesses().length).toBe(0);
});
});
describe('singleton pattern', () => {
beforeEach(() => {
resetProcessRegistryService();
});
afterEach(() => {
resetProcessRegistryService();
});
it('should create singleton instance', () => {
const instance1 = getProcessRegistryService(events);
const instance2 = getProcessRegistryService();
expect(instance1).toBe(instance2);
});
it('should throw if no events provided on first call', () => {
expect(() => getProcessRegistryService()).toThrow();
});
it('should reset singleton', () => {
const instance1 = getProcessRegistryService(events);
resetProcessRegistryService();
const instance2 = getProcessRegistryService(events);
expect(instance1).not.toBe(instance2);
});
});
});

View File

@@ -144,33 +144,6 @@ describe('settings-service.ts', () => {
expect(updated.keyboardShortcuts.agent).toBe(DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts.agent);
});
it('should not overwrite non-empty projects with an empty array (data loss guard)', async () => {
const initial: GlobalSettings = {
...DEFAULT_GLOBAL_SETTINGS,
theme: 'solarized' as GlobalSettings['theme'],
projects: [
{
id: 'proj1',
name: 'Project 1',
path: '/tmp/project-1',
lastOpened: new Date().toISOString(),
},
] as any,
};
const settingsPath = path.join(testDataDir, 'settings.json');
await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2));
const updated = await settingsService.updateGlobalSettings({
projects: [],
theme: 'light',
} as any);
expect(updated.projects.length).toBe(1);
expect((updated.projects as any)[0]?.id).toBe('proj1');
// Theme should be preserved in the same request if it attempted to wipe projects
expect(updated.theme).toBe('solarized');
});
it('should create data directory if it does not exist', async () => {
const newDataDir = path.join(os.tmpdir(), `new-data-dir-${Date.now()}`);
const newService = new SettingsService(newDataDir);

View File

@@ -1,6 +1,6 @@
{
"name": "@automaker/ui",
"version": "0.9.0",
"version": "0.7.3",
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
"homepage": "https://github.com/AutoMaker-Org/automaker",
"repository": {

View File

@@ -3,7 +3,6 @@ import { defineConfig, devices } from '@playwright/test';
const port = process.env.TEST_PORT || 3007;
const serverPort = process.env.TEST_SERVER_PORT || 3008;
const reuseServer = process.env.TEST_REUSE_SERVER === 'true';
const useExternalBackend = !!process.env.VITE_SERVER_URL;
// Always use mock agent for tests (disables rate limiting, uses mock Claude responses)
const mockAgent = true;
@@ -34,38 +33,31 @@ export default defineConfig({
webServer: [
// Backend server - runs with mock agent enabled in CI
// Uses dev:test (no file watching) to avoid port conflicts from server restarts
...(useExternalBackend
? []
: [
{
command: `cd ../server && npm run dev:test`,
url: `http://localhost:${serverPort}/api/health`,
// Don't reuse existing server to ensure we use the test API key
reuseExistingServer: false,
timeout: 60000,
env: {
...process.env,
PORT: String(serverPort),
// Enable mock agent in CI to avoid real API calls
AUTOMAKER_MOCK_AGENT: mockAgent ? 'true' : 'false',
// Set a test API key for web mode authentication
AUTOMAKER_API_KEY:
process.env.AUTOMAKER_API_KEY || 'test-api-key-for-e2e-tests',
// Hide the API key banner to reduce log noise
AUTOMAKER_HIDE_API_KEY: 'true',
// Explicitly unset ALLOWED_ROOT_DIRECTORY to allow all paths for testing
// (prevents inheriting /projects from Docker or other environments)
ALLOWED_ROOT_DIRECTORY: '',
// Simulate containerized environment to skip sandbox confirmation dialogs
IS_CONTAINERIZED: 'true',
},
},
]),
{
command: `cd ../server && npm run dev:test`,
url: `http://localhost:${serverPort}/api/health`,
// Don't reuse existing server to ensure we use the test API key
reuseExistingServer: false,
timeout: 60000,
env: {
...process.env,
PORT: String(serverPort),
// Enable mock agent in CI to avoid real API calls
AUTOMAKER_MOCK_AGENT: mockAgent ? 'true' : 'false',
// Set a test API key for web mode authentication
AUTOMAKER_API_KEY: process.env.AUTOMAKER_API_KEY || 'test-api-key-for-e2e-tests',
// Hide the API key banner to reduce log noise
AUTOMAKER_HIDE_API_KEY: 'true',
// No ALLOWED_ROOT_DIRECTORY restriction - allow all paths for testing
// Simulate containerized environment to skip sandbox confirmation dialogs
IS_CONTAINERIZED: 'true',
},
},
// Frontend Vite dev server
{
command: `npm run dev`,
url: `http://localhost:${port}`,
reuseExistingServer: false,
reuseExistingServer: true,
timeout: 120000,
env: {
...process.env,

View File

@@ -10,42 +10,24 @@ const execAsync = promisify(exec);
const SERVER_PORT = process.env.TEST_SERVER_PORT || 3008;
const UI_PORT = process.env.TEST_PORT || 3007;
const USE_EXTERNAL_SERVER = !!process.env.VITE_SERVER_URL;
async function killProcessOnPort(port) {
try {
const hasLsof = await execAsync('command -v lsof').then(
() => true,
() => false
);
const { stdout } = await execAsync(`lsof -ti:${port}`);
const pids = stdout.trim().split('\n').filter(Boolean);
if (hasLsof) {
const { stdout } = await execAsync(`lsof -ti:${port}`);
const pids = stdout.trim().split('\n').filter(Boolean);
if (pids.length > 0) {
console.log(`[KillTestServers] Found process(es) on port ${port}: ${pids.join(', ')}`);
for (const pid of pids) {
try {
await execAsync(`kill -9 ${pid}`);
console.log(`[KillTestServers] Killed process ${pid}`);
} catch (error) {
// Process might have already exited
}
if (pids.length > 0) {
console.log(`[KillTestServers] Found process(es) on port ${port}: ${pids.join(', ')}`);
for (const pid of pids) {
try {
await execAsync(`kill -9 ${pid}`);
console.log(`[KillTestServers] Killed process ${pid}`);
} catch (error) {
// Process might have already exited
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
return;
}
const hasFuser = await execAsync('command -v fuser').then(
() => true,
() => false
);
if (hasFuser) {
await execAsync(`fuser -k -9 ${port}/tcp`).catch(() => undefined);
// Wait a moment for the port to be released
await new Promise((resolve) => setTimeout(resolve, 500));
return;
}
} catch (error) {
// No process on port, which is fine
@@ -54,9 +36,7 @@ async function killProcessOnPort(port) {
async function main() {
console.log('[KillTestServers] Checking for existing test servers...');
if (!USE_EXTERNAL_SERVER) {
await killProcessOnPort(Number(SERVER_PORT));
}
await killProcessOnPort(Number(SERVER_PORT));
await killProcessOnPort(Number(UI_PORT));
console.log('[KillTestServers] Done');
}

View File

@@ -3,11 +3,9 @@
/**
* Setup script for E2E test fixtures
* Creates the necessary test fixture directories and files before running Playwright tests
* Also resets the server's settings.json to a known state for test isolation
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { fileURLToPath } from 'url';
@@ -18,9 +16,6 @@ const __dirname = path.dirname(__filename);
const WORKSPACE_ROOT = path.resolve(__dirname, '../../..');
const FIXTURE_PATH = path.join(WORKSPACE_ROOT, 'test/fixtures/projectA');
const SPEC_FILE_PATH = path.join(FIXTURE_PATH, '.automaker/app_spec.txt');
const SERVER_SETTINGS_PATH = path.join(WORKSPACE_ROOT, 'apps/server/data/settings.json');
// Create a shared test workspace directory that will be used as default for project creation
const TEST_WORKSPACE_DIR = path.join(os.tmpdir(), 'automaker-e2e-workspace');
const SPEC_CONTENT = `<app_spec>
<name>Test Project A</name>
@@ -32,154 +27,10 @@ const SPEC_CONTENT = `<app_spec>
</app_spec>
`;
// Clean settings.json for E2E tests - no current project so localStorage can control state
const E2E_SETTINGS = {
version: 4,
setupComplete: true,
isFirstRun: false,
skipClaudeSetup: false,
theme: 'dark',
sidebarOpen: true,
chatHistoryOpen: false,
kanbanCardDetailLevel: 'standard',
maxConcurrency: 3,
defaultSkipTests: true,
enableDependencyBlocking: true,
skipVerificationInAutoMode: false,
useWorktrees: true,
showProfilesOnly: false,
defaultPlanningMode: 'skip',
defaultRequirePlanApproval: false,
defaultAIProfileId: null,
muteDoneSound: false,
phaseModels: {
enhancementModel: { model: 'sonnet' },
fileDescriptionModel: { model: 'haiku' },
imageDescriptionModel: { model: 'haiku' },
validationModel: { model: 'sonnet' },
specGenerationModel: { model: 'opus' },
featureGenerationModel: { model: 'sonnet' },
backlogPlanningModel: { model: 'sonnet' },
projectAnalysisModel: { model: 'sonnet' },
suggestionsModel: { model: 'sonnet' },
},
enhancementModel: 'sonnet',
validationModel: 'opus',
enabledCursorModels: ['auto', 'composer-1'],
cursorDefaultModel: 'auto',
keyboardShortcuts: {
board: 'K',
agent: 'A',
spec: 'D',
context: 'C',
settings: 'S',
profiles: 'M',
terminal: 'T',
toggleSidebar: '`',
addFeature: 'N',
addContextFile: 'N',
startNext: 'G',
newSession: 'N',
openProject: 'O',
projectPicker: 'P',
cyclePrevProject: 'Q',
cycleNextProject: 'E',
addProfile: 'N',
splitTerminalRight: 'Alt+D',
splitTerminalDown: 'Alt+S',
closeTerminal: 'Alt+W',
tools: 'T',
ideation: 'I',
githubIssues: 'G',
githubPrs: 'R',
newTerminalTab: 'Alt+T',
},
aiProfiles: [
{
id: 'profile-heavy-task',
name: 'Heavy Task',
description:
'Claude Opus with Ultrathink for complex architecture, migrations, or deep debugging.',
model: 'opus',
thinkingLevel: 'ultrathink',
provider: 'claude',
isBuiltIn: true,
icon: 'Brain',
},
{
id: 'profile-balanced',
name: 'Balanced',
description: 'Claude Sonnet with medium thinking for typical development tasks.',
model: 'sonnet',
thinkingLevel: 'medium',
provider: 'claude',
isBuiltIn: true,
icon: 'Scale',
},
{
id: 'profile-quick-edit',
name: 'Quick Edit',
description: 'Claude Haiku for fast, simple edits and minor fixes.',
model: 'haiku',
thinkingLevel: 'none',
provider: 'claude',
isBuiltIn: true,
icon: 'Zap',
},
{
id: 'profile-cursor-refactoring',
name: 'Cursor Refactoring',
description: 'Cursor Composer 1 for refactoring tasks.',
provider: 'cursor',
cursorModel: 'composer-1',
isBuiltIn: true,
icon: 'Sparkles',
},
],
// Default test project using the fixture path - tests can override via route mocking if needed
projects: [
{
id: 'e2e-default-project',
name: 'E2E Test Project',
path: FIXTURE_PATH,
lastOpened: new Date().toISOString(),
},
],
trashedProjects: [],
currentProjectId: 'e2e-default-project',
projectHistory: [],
projectHistoryIndex: 0,
lastProjectDir: TEST_WORKSPACE_DIR,
recentFolders: [],
worktreePanelCollapsed: false,
lastSelectedSessionByProject: {},
autoLoadClaudeMd: false,
skipSandboxWarning: true,
codexAutoLoadAgents: false,
codexSandboxMode: 'workspace-write',
codexApprovalPolicy: 'on-request',
codexEnableWebSearch: false,
codexEnableImages: true,
codexAdditionalDirs: [],
mcpServers: [],
enableSandboxMode: false,
mcpAutoApproveTools: true,
mcpUnrestrictedTools: true,
promptCustomization: {},
localStorageMigrated: true,
};
function setupFixtures() {
console.log('Setting up E2E test fixtures...');
console.log(`Workspace root: ${WORKSPACE_ROOT}`);
console.log(`Fixture path: ${FIXTURE_PATH}`);
console.log(`Test workspace dir: ${TEST_WORKSPACE_DIR}`);
// Create test workspace directory for project creation tests
if (!fs.existsSync(TEST_WORKSPACE_DIR)) {
fs.mkdirSync(TEST_WORKSPACE_DIR, { recursive: true });
console.log(`Created test workspace directory: ${TEST_WORKSPACE_DIR}`);
}
// Create fixture directory
const specDir = path.dirname(SPEC_FILE_PATH);
@@ -192,15 +43,6 @@ function setupFixtures() {
fs.writeFileSync(SPEC_FILE_PATH, SPEC_CONTENT);
console.log(`Created fixture file: ${SPEC_FILE_PATH}`);
// Reset server settings.json to a clean state for E2E tests
const settingsDir = path.dirname(SERVER_SETTINGS_PATH);
if (!fs.existsSync(settingsDir)) {
fs.mkdirSync(settingsDir, { recursive: true });
console.log(`Created directory: ${settingsDir}`);
}
fs.writeFileSync(SERVER_SETTINGS_PATH, JSON.stringify(E2E_SETTINGS, null, 2));
console.log(`Reset server settings: ${SERVER_SETTINGS_PATH}`);
console.log('E2E test fixtures setup complete!');
}

View File

@@ -3,7 +3,7 @@ import { RouterProvider } from '@tanstack/react-router';
import { createLogger } from '@automaker/utils/logger';
import { router } from './utils/router';
import { SplashScreen } from './components/splash-screen';
import { useSettingsSync } from './hooks/use-settings-sync';
import { useSettingsMigration } from './hooks/use-settings-migration';
import { useCursorStatusInit } from './hooks/use-cursor-status-init';
import './styles/global.css';
import './styles/theme-imports';
@@ -32,14 +32,10 @@ export default function App() {
}
}, []);
// Settings are now loaded in __root.tsx after successful session verification
// This ensures a unified flow: verify session → load settings → redirect
// We no longer block router rendering here - settings loading happens in __root.tsx
// Sync settings changes back to server (API-first persistence)
const settingsSyncState = useSettingsSync();
if (settingsSyncState.error) {
logger.error('Settings sync error:', settingsSyncState.error);
// Run settings migration on startup (localStorage -> file storage)
const migrationState = useSettingsMigration();
if (migrationState.migrated) {
logger.info('Settings migrated to file storage');
}
// Initialize Cursor CLI status at startup

View File

@@ -1,405 +0,0 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
// Error codes for distinguishing failure modes
const ERROR_CODES = {
API_BRIDGE_UNAVAILABLE: 'API_BRIDGE_UNAVAILABLE',
AUTH_ERROR: 'AUTH_ERROR',
NOT_AVAILABLE: 'NOT_AVAILABLE',
UNKNOWN: 'UNKNOWN',
} as const;
type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
type UsageError = {
code: ErrorCode;
message: string;
};
// Fixed refresh interval (45 seconds)
const REFRESH_INTERVAL_SECONDS = 45;
// Helper to format reset time
function formatResetTime(unixTimestamp: number): string {
const date = new Date(unixTimestamp * 1000);
const now = new Date();
const diff = date.getTime() - now.getTime();
// If less than 1 hour, show minutes
if (diff < 3600000) {
const mins = Math.ceil(diff / 60000);
return `Resets in ${mins}m`;
}
// If less than 24 hours, show hours and minutes
if (diff < 86400000) {
const hours = Math.floor(diff / 3600000);
const mins = Math.ceil((diff % 3600000) / 60000);
return `Resets in ${hours}h ${mins > 0 ? `${mins}m` : ''}`;
}
// Otherwise show date
return `Resets ${date.toLocaleDateString()} at ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
}
// Helper to format window duration
function getWindowLabel(durationMins: number): { title: string; subtitle: string } {
if (durationMins < 60) {
return { title: `${durationMins}min Window`, subtitle: 'Rate limit' };
}
if (durationMins < 1440) {
const hours = Math.round(durationMins / 60);
return { title: `${hours}h Window`, subtitle: 'Rate limit' };
}
const days = Math.round(durationMins / 1440);
return { title: `${days}d Window`, subtitle: 'Rate limit' };
}
export function CodexUsagePopover() {
const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore();
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<UsageError | null>(null);
// Check if Codex is authenticated
const isCodexAuthenticated = codexAuthStatus?.authenticated;
// Check if data is stale (older than 2 minutes)
const isStale = useMemo(() => {
return !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000;
}, [codexUsageLastUpdated]);
const fetchUsage = useCallback(
async (isAutoRefresh = false) => {
if (!isAutoRefresh) setLoading(true);
setError(null);
try {
const api = getElectronAPI();
if (!api.codex) {
setError({
code: ERROR_CODES.API_BRIDGE_UNAVAILABLE,
message: 'Codex API bridge not available',
});
return;
}
const data = await api.codex.getUsage();
if ('error' in data) {
// Check if it's the "not available" error
if (
data.message?.includes('not available') ||
data.message?.includes('does not provide')
) {
setError({
code: ERROR_CODES.NOT_AVAILABLE,
message: data.message || data.error,
});
} else {
setError({
code: ERROR_CODES.AUTH_ERROR,
message: data.message || data.error,
});
}
return;
}
setCodexUsage(data);
} catch (err) {
setError({
code: ERROR_CODES.UNKNOWN,
message: err instanceof Error ? err.message : 'Failed to fetch usage',
});
} finally {
if (!isAutoRefresh) setLoading(false);
}
},
[setCodexUsage]
);
// Auto-fetch on mount if data is stale (only if authenticated)
useEffect(() => {
if (isStale && isCodexAuthenticated) {
fetchUsage(true);
}
}, [isStale, isCodexAuthenticated, fetchUsage]);
useEffect(() => {
// Skip if not authenticated
if (!isCodexAuthenticated) return;
// Initial fetch when opened
if (open) {
if (!codexUsage || isStale) {
fetchUsage();
}
}
// Auto-refresh interval (only when open)
let intervalId: NodeJS.Timeout | null = null;
if (open) {
intervalId = setInterval(() => {
fetchUsage(true);
}, REFRESH_INTERVAL_SECONDS * 1000);
}
return () => {
if (intervalId) clearInterval(intervalId);
};
}, [open, codexUsage, isStale, isCodexAuthenticated, fetchUsage]);
// Derived status color/icon helper
const getStatusInfo = (percentage: number) => {
if (percentage >= 75) return { color: 'text-red-500', icon: XCircle, bg: 'bg-red-500' };
if (percentage >= 50)
return { color: 'text-orange-500', icon: AlertTriangle, bg: 'bg-orange-500' };
return { color: 'text-green-500', icon: CheckCircle, bg: 'bg-green-500' };
};
// Helper component for the progress bar
const ProgressBar = ({ percentage, colorClass }: { percentage: number; colorClass: string }) => (
<div className="h-2 w-full bg-secondary/50 rounded-full overflow-hidden">
<div
className={cn('h-full transition-all duration-500', colorClass)}
style={{ width: `${Math.min(percentage, 100)}%` }}
/>
</div>
);
const UsageCard = ({
title,
subtitle,
percentage,
resetText,
isPrimary = false,
stale = false,
}: {
title: string;
subtitle: string;
percentage: number;
resetText?: string;
isPrimary?: boolean;
stale?: boolean;
}) => {
const isValidPercentage =
typeof percentage === 'number' && !isNaN(percentage) && isFinite(percentage);
const safePercentage = isValidPercentage ? percentage : 0;
const status = getStatusInfo(safePercentage);
const StatusIcon = status.icon;
return (
<div
className={cn(
'rounded-xl border bg-card/50 p-4 transition-opacity',
isPrimary ? 'border-border/60 shadow-sm' : 'border-border/40',
(stale || !isValidPercentage) && 'opacity-50'
)}
>
<div className="flex items-start justify-between mb-3">
<div>
<h4 className={cn('font-semibold', isPrimary ? 'text-sm' : 'text-xs')}>{title}</h4>
<p className="text-[10px] text-muted-foreground">{subtitle}</p>
</div>
{isValidPercentage ? (
<div className="flex items-center gap-1.5">
<StatusIcon className={cn('w-3.5 h-3.5', status.color)} />
<span
className={cn(
'font-mono font-bold',
status.color,
isPrimary ? 'text-base' : 'text-sm'
)}
>
{Math.round(safePercentage)}%
</span>
</div>
) : (
<span className="text-xs text-muted-foreground">N/A</span>
)}
</div>
<ProgressBar
percentage={safePercentage}
colorClass={isValidPercentage ? status.bg : 'bg-muted-foreground/30'}
/>
{resetText && (
<div className="mt-2 flex justify-end">
<p className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="w-3 h-3" />
{resetText}
</p>
</div>
)}
</div>
);
};
// Header Button
const maxPercentage = codexUsage?.rateLimits
? Math.max(
codexUsage.rateLimits.primary?.usedPercent || 0,
codexUsage.rateLimits.secondary?.usedPercent || 0
)
: 0;
const getProgressBarColor = (percentage: number) => {
if (percentage >= 80) return 'bg-red-500';
if (percentage >= 50) return 'bg-yellow-500';
return 'bg-green-500';
};
const trigger = (
<Button variant="ghost" size="sm" className="h-9 gap-3 bg-secondary border border-border px-3">
<span className="text-sm font-medium">Codex</span>
{codexUsage && codexUsage.rateLimits && (
<div
className={cn(
'h-1.5 w-16 bg-muted-foreground/20 rounded-full overflow-hidden transition-opacity',
isStale && 'opacity-60'
)}
>
<div
className={cn('h-full transition-all duration-500', getProgressBarColor(maxPercentage))}
style={{ width: `${Math.min(maxPercentage, 100)}%` }}
/>
</div>
)}
</Button>
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent
className="w-80 p-0 overflow-hidden bg-background/95 backdrop-blur-xl border-border shadow-2xl"
align="end"
sideOffset={8}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border/50 bg-secondary/10">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold">Codex Usage</span>
</div>
{error && error.code !== ERROR_CODES.NOT_AVAILABLE && (
<Button
variant="ghost"
size="icon"
className={cn('h-6 w-6', loading && 'opacity-80')}
onClick={() => !loading && fetchUsage(false)}
>
<RefreshCw className="w-3.5 h-3.5" />
</Button>
)}
</div>
{/* Content */}
<div className="p-4 space-y-4">
{error ? (
<div className="flex flex-col items-center justify-center py-6 text-center space-y-3">
<AlertTriangle className="w-8 h-8 text-yellow-500/80" />
<div className="space-y-1 flex flex-col items-center">
<p className="text-sm font-medium">
{error.code === ERROR_CODES.NOT_AVAILABLE ? 'Usage not available' : error.message}
</p>
<p className="text-xs text-muted-foreground">
{error.code === ERROR_CODES.API_BRIDGE_UNAVAILABLE ? (
'Ensure the Electron bridge is running or restart the app'
) : error.code === ERROR_CODES.NOT_AVAILABLE ? (
<>
Codex CLI doesn't provide usage statistics. Check{' '}
<a
href="https://platform.openai.com/usage"
target="_blank"
rel="noreferrer"
className="underline hover:text-foreground"
>
OpenAI dashboard
</a>{' '}
for usage details.
</>
) : (
<>
Make sure Codex CLI is installed and authenticated via{' '}
<code className="font-mono bg-muted px-1 rounded">codex login</code>
</>
)}
</p>
</div>
</div>
) : !codexUsage ? (
// Loading state
<div className="flex flex-col items-center justify-center py-8 space-y-2">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground/50" />
<p className="text-xs text-muted-foreground">Loading usage data...</p>
</div>
) : codexUsage.rateLimits ? (
<>
{/* Primary Window Card */}
{codexUsage.rateLimits.primary && (
<UsageCard
title={getWindowLabel(codexUsage.rateLimits.primary.windowDurationMins).title}
subtitle={
getWindowLabel(codexUsage.rateLimits.primary.windowDurationMins).subtitle
}
percentage={codexUsage.rateLimits.primary.usedPercent}
resetText={formatResetTime(codexUsage.rateLimits.primary.resetsAt)}
isPrimary={true}
stale={isStale}
/>
)}
{/* Secondary Window Card */}
{codexUsage.rateLimits.secondary && (
<UsageCard
title={getWindowLabel(codexUsage.rateLimits.secondary.windowDurationMins).title}
subtitle={
getWindowLabel(codexUsage.rateLimits.secondary.windowDurationMins).subtitle
}
percentage={codexUsage.rateLimits.secondary.usedPercent}
resetText={formatResetTime(codexUsage.rateLimits.secondary.resetsAt)}
stale={isStale}
/>
)}
{/* Plan Type */}
{codexUsage.rateLimits.planType && (
<div className="rounded-xl border border-border/40 bg-secondary/20 p-3">
<p className="text-xs text-muted-foreground">
Plan:{' '}
<span className="text-foreground font-medium">
{codexUsage.rateLimits.planType.charAt(0).toUpperCase() +
codexUsage.rateLimits.planType.slice(1)}
</span>
</p>
</div>
)}
</>
) : (
<div className="flex flex-col items-center justify-center py-6 text-center">
<AlertTriangle className="w-8 h-8 text-yellow-500/80" />
<p className="text-sm font-medium mt-3">No usage data available</p>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-4 py-2 bg-secondary/10 border-t border-border/50">
<a
href="https://platform.openai.com/usage"
target="_blank"
rel="noreferrer"
className="text-[10px] text-muted-foreground hover:text-foreground flex items-center gap-1 transition-colors"
>
OpenAI Dashboard <ExternalLink className="w-2.5 h-2.5" />
</a>
<span className="text-[10px] text-muted-foreground">Updates every minute</span>
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,175 @@
/**
* CPU Monitor Component
*
* Displays CPU usage percentage with historical chart and event loop lag indicator.
*/
import { useMemo } from 'react';
import { Cpu, Activity, AlertTriangle } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { CPUDataPoint, ServerCPUMetrics } from '@automaker/types';
interface CPUMonitorProps {
history: CPUDataPoint[];
current: ServerCPUMetrics | null;
eventLoopLag?: number;
className?: string;
}
/**
* Simple sparkline chart for CPU data
*/
function CPUSparkline({ data, className }: { data: CPUDataPoint[]; className?: string }) {
const pathD = useMemo(() => {
if (data.length < 2) {
return '';
}
const w = 200;
const h = 40;
const padding = 2;
// CPU percentage is 0-100, but we'll use 0-100 as our range
const points = data.map((d, i) => {
const x = (i / (data.length - 1)) * (w - padding * 2) + padding;
const y = h - padding - (d.percentage / 100) * (h - padding * 2);
return `${x},${y}`;
});
return `M ${points.join(' L ')}`;
}, [data]);
if (data.length < 2) {
return (
<div
className={cn(
'h-10 flex items-center justify-center text-muted-foreground text-xs',
className
)}
>
Collecting data...
</div>
);
}
return (
<svg viewBox="0 0 200 40" className={cn('w-full', className)} preserveAspectRatio="none">
<path
d={pathD}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
className="text-green-500"
/>
</svg>
);
}
/**
* CPU usage gauge
*/
function CPUGauge({ percentage }: { percentage: number }) {
const isHigh = percentage > 60;
const isCritical = percentage > 80;
return (
<div className="relative w-16 h-16">
{/* Background circle */}
<svg className="w-full h-full -rotate-90" viewBox="0 0 36 36">
<circle cx="18" cy="18" r="16" fill="none" strokeWidth="3" className="stroke-muted" />
<circle
cx="18"
cy="18"
r="16"
fill="none"
strokeWidth="3"
strokeDasharray={`${percentage} 100`}
strokeLinecap="round"
className={cn(
'transition-all duration-300',
isCritical ? 'stroke-red-500' : isHigh ? 'stroke-yellow-500' : 'stroke-green-500'
)}
/>
</svg>
{/* Center text */}
<div className="absolute inset-0 flex items-center justify-center">
<span
className={cn(
'text-sm font-mono font-bold',
isCritical ? 'text-red-400' : isHigh ? 'text-yellow-400' : 'text-green-400'
)}
>
{percentage.toFixed(0)}%
</span>
</div>
</div>
);
}
/**
* Event loop lag indicator
*/
function EventLoopLag({ lag }: { lag?: number }) {
if (lag === undefined) {
return null;
}
const isBlocked = lag > 50;
const isSevere = lag > 100;
return (
<div
className={cn(
'flex items-center gap-1.5 text-xs px-2 py-1 rounded',
isSevere && 'bg-red-500/20 text-red-400',
isBlocked && !isSevere && 'bg-yellow-500/20 text-yellow-400',
!isBlocked && 'bg-muted text-muted-foreground'
)}
>
{isSevere ? <AlertTriangle className="w-3 h-3" /> : <Activity className="w-3 h-3" />}
<span>Event Loop: {lag.toFixed(0)}ms</span>
</div>
);
}
export function CPUMonitor({ history, current, eventLoopLag, className }: CPUMonitorProps) {
const percentage = current?.percentage ?? 0;
return (
<div className={cn('space-y-3', className)}>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Cpu className="w-4 h-4 text-green-500" />
<span className="text-sm font-medium">CPU</span>
</div>
<EventLoopLag lag={eventLoopLag} />
</div>
{/* Main content */}
<div className="flex items-center gap-4">
{/* Gauge */}
<CPUGauge percentage={percentage} />
{/* Sparkline */}
<div className="flex-1 h-10">
<CPUSparkline data={history} />
</div>
</div>
{/* Details */}
{current && (
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<span className="text-muted-foreground">User: </span>
<span>{(current.user / 1000).toFixed(1)}ms</span>
</div>
<div>
<span className="text-muted-foreground">System: </span>
<span>{(current.system / 1000).toFixed(1)}ms</span>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,229 @@
/**
* Debug Docked Panel Component
*
* Expandable panel that appears above the status bar when expanded.
* Contains the full debug interface with tabs.
*/
import { useRef, useCallback, useEffect } from 'react';
import { HardDrive, Cpu, Bot, RefreshCw, Trash2, Play, Pause, GripHorizontal } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
useDebugStore,
MIN_DOCKED_HEIGHT,
MAX_DOCKED_HEIGHT_RATIO,
type DebugTab,
} from '@/store/debug-store';
import { useDebugMetrics } from '@/hooks/use-debug-metrics';
import { useRenderTracking } from '@/hooks/use-render-tracking';
import { MemoryMonitor } from './memory-monitor';
import { CPUMonitor } from './cpu-monitor';
import { ProcessKanban } from './process-kanban';
import { RenderTracker } from './render-tracker';
import { LeakIndicator } from './leak-indicator';
import { useRenderTrackingContext } from './render-profiler';
const TAB_CONFIG: { id: DebugTab; label: string; icon: React.ReactNode }[] = [
{ id: 'memory', label: 'Memory', icon: <HardDrive className="w-3.5 h-3.5" /> },
{ id: 'cpu', label: 'CPU', icon: <Cpu className="w-3.5 h-3.5" /> },
{ id: 'processes', label: 'Processes', icon: <Bot className="w-3.5 h-3.5" /> },
{ id: 'renders', label: 'Renders', icon: <RefreshCw className="w-3.5 h-3.5" /> },
];
interface DebugDockedPanelProps {
className?: string;
}
export function DebugDockedPanel({ className }: DebugDockedPanelProps) {
const {
isOpen,
isDockedExpanded,
panelMode,
dockedHeight,
activeTab,
setActiveTab,
setDockedHeight,
isResizing,
setIsResizing,
} = useDebugStore();
const metrics = useDebugMetrics();
const renderTrackingFromContext = useRenderTrackingContext();
const localRenderTracking = useRenderTracking();
const renderTracking = renderTrackingFromContext ?? localRenderTracking;
// Ref for resize handling
const panelRef = useRef<HTMLDivElement>(null);
const resizeStartRef = useRef<{ y: number; height: number } | null>(null);
// Handle resize start (drag from top edge)
const handleResizeStart = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
setIsResizing(true);
resizeStartRef.current = {
y: e.clientY,
height: dockedHeight,
};
},
[setIsResizing, dockedHeight]
);
// Handle resize move
useEffect(() => {
if (!isResizing) return;
const handleMouseMove = (e: MouseEvent) => {
if (!resizeStartRef.current) return;
// Dragging up increases height, dragging down decreases
const deltaY = resizeStartRef.current.y - e.clientY;
const newHeight = resizeStartRef.current.height + deltaY;
// Clamp to min/max bounds
const maxHeight = window.innerHeight * MAX_DOCKED_HEIGHT_RATIO;
const clampedHeight = Math.max(MIN_DOCKED_HEIGHT, Math.min(maxHeight, newHeight));
setDockedHeight(clampedHeight);
};
const handleMouseUp = () => {
setIsResizing(false);
resizeStartRef.current = null;
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [isResizing, setIsResizing, setDockedHeight]);
// Only show in docked mode when expanded
if (panelMode !== 'docked' || !isDockedExpanded || !isOpen) {
return null;
}
return (
<div
ref={panelRef}
className={cn(
'flex flex-col bg-background border-t border-border',
isResizing && 'select-none',
className
)}
style={{ height: dockedHeight }}
>
{/* Resize handle - top edge */}
<div
className="h-1 cursor-ns-resize hover:bg-primary/20 transition-colors flex items-center justify-center group"
onMouseDown={handleResizeStart}
>
<GripHorizontal className="w-8 h-3 text-muted-foreground/30 group-hover:text-muted-foreground/60" />
</div>
{/* Tabs */}
<div className="flex items-center border-b bg-muted/30">
<div className="flex">
{TAB_CONFIG.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 text-xs border-b-2 -mb-px transition-colors',
activeTab === tab.id
? 'border-primary text-primary bg-background'
: 'border-transparent text-muted-foreground hover:text-foreground hover:bg-muted/50'
)}
>
{tab.icon}
<span>{tab.label}</span>
</button>
))}
</div>
{/* Right side controls */}
<div className="ml-auto flex items-center gap-1 px-2">
<button
onClick={() => (metrics.isActive ? metrics.stop() : metrics.start())}
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
title={metrics.isActive ? 'Stop collecting' : 'Start collecting'}
>
{metrics.isActive ? (
<Pause className="w-3.5 h-3.5" />
) : (
<Play className="w-3.5 h-3.5" />
)}
</button>
<button
onClick={metrics.clearHistory}
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
title="Clear history"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
<button
onClick={metrics.refresh}
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
title="Refresh now"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-3">
{activeTab === 'memory' && (
<div className="space-y-4">
<MemoryMonitor
history={metrics.memoryHistory}
current={metrics.latestSnapshot?.memory.server ?? null}
trend={metrics.memoryTrend}
/>
<LeakIndicator trend={metrics.memoryTrend} onForceGC={metrics.forceGC} />
</div>
)}
{activeTab === 'cpu' && (
<CPUMonitor
history={metrics.cpuHistory}
current={metrics.latestSnapshot?.cpu.server ?? null}
eventLoopLag={metrics.latestSnapshot?.cpu.eventLoopLag}
/>
)}
{activeTab === 'processes' && (
<ProcessKanban
processes={metrics.processes}
summary={metrics.processSummary}
panelWidth={window.innerWidth} // Full width in docked mode
/>
)}
{activeTab === 'renders' && (
<RenderTracker
summary={renderTracking.summary}
stats={renderTracking.getAllStats()}
onClear={renderTracking.clearRecords}
/>
)}
</div>
</div>
);
}
/**
* Debug Docked Panel Wrapper - Only renders in development mode
*/
export function DebugDockedPanelWrapper({ className }: DebugDockedPanelProps) {
const isDev = import.meta.env.DEV || import.meta.env.VITE_ENABLE_DEBUG_PANEL === 'true';
if (!isDev) {
return null;
}
return <DebugDockedPanel className={className} />;
}

Some files were not shown because too many files have changed in this diff Show More