mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
Compare commits
167 Commits
feat/debug
...
feat/inter
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4ce1f331b | ||
|
|
66b68ea4eb | ||
|
|
6e4b611662 | ||
|
|
7522e58fee | ||
|
|
317c21ffc0 | ||
|
|
9c5fe44617 | ||
|
|
7f79d9692c | ||
|
|
2d4ffc7514 | ||
|
|
5f3db1f25e | ||
|
|
7115460804 | ||
|
|
0db8808b2a | ||
|
|
cf3ed1dd8f | ||
|
|
da682e3993 | ||
|
|
a92457b871 | ||
|
|
89a960629a | ||
|
|
41144ff1fa | ||
|
|
360cddcb91 | ||
|
|
427832e72e | ||
|
|
27c60658f7 | ||
|
|
fa8ae149d3 | ||
|
|
0c19beb11c | ||
|
|
e34e4a59e9 | ||
|
|
7cc092cd59 | ||
|
|
51cd7156d2 | ||
|
|
1dc843d2d0 | ||
|
|
4040bef4b8 | ||
|
|
e64a850f57 | ||
|
|
555523df38 | ||
|
|
dd882139f3 | ||
|
|
a67b8c6109 | ||
|
|
134208dab6 | ||
|
|
887343d232 | ||
|
|
299b838400 | ||
|
|
c5d0a8be7d | ||
|
|
fe433a84c9 | ||
|
|
543aa7a27b | ||
|
|
36ddf0513b | ||
|
|
c99883e634 | ||
|
|
604f98b08f | ||
|
|
c5009a0333 | ||
|
|
99b05d35a2 | ||
|
|
a3ecc6fe02 | ||
|
|
fc20dd5ad4 | ||
|
|
3f2707404c | ||
|
|
fdd3a28be7 | ||
|
|
eb94e4de72 | ||
|
|
21d275c984 | ||
|
|
cadb19d7ed | ||
|
|
a01241fabe | ||
|
|
89a0877bcc | ||
|
|
950a97d72b | ||
|
|
639c1de2e8 | ||
|
|
254e4f630c | ||
|
|
5d0fb08651 | ||
|
|
7ea64b32f3 | ||
|
|
93807c22c1 | ||
|
|
b2cf17b53b | ||
|
|
7e768b6290 | ||
|
|
7bdf5e4261 | ||
|
|
f6fed612df | ||
|
|
3b3e282df7 | ||
|
|
7f69f652fb | ||
|
|
1452232409 | ||
|
|
4f0f56a7ba | ||
|
|
a695d0db7b | ||
|
|
87c3d766c9 | ||
|
|
89248001e4 | ||
|
|
41b4869068 | ||
|
|
be88a07329 | ||
|
|
f6738ff26c | ||
|
|
ae22f781d8 | ||
|
|
e649c4ced5 | ||
|
|
50da1b401c | ||
|
|
33d02d1df8 | ||
|
|
f3041190fa | ||
|
|
55c2530d5a | ||
|
|
5fbc7dd13e | ||
|
|
b2e5ff1460 | ||
|
|
0f9232ea33 | ||
|
|
a815be6a20 | ||
|
|
7b4667eba9 | ||
|
|
08ccf2632a | ||
|
|
7583598a05 | ||
|
|
7e68691e92 | ||
|
|
4b2034b834 | ||
|
|
4dcf54146c | ||
|
|
8a9715adef | ||
|
|
d253d494ba | ||
|
|
d1f7794afa | ||
|
|
4d80a93710 | ||
|
|
d70faf3b28 | ||
|
|
271749a5a4 | ||
|
|
d1bd131cab | ||
|
|
96fe90ca65 | ||
|
|
fd5f7b873a | ||
|
|
959467de90 | ||
|
|
69434fe356 | ||
|
|
dc264bd164 | ||
|
|
8992f667c7 | ||
|
|
eb627ef323 | ||
|
|
d8cdb0bf7a | ||
|
|
8c68c24716 | ||
|
|
9b302583c4 | ||
|
|
47c2d795e0 | ||
|
|
d608d8c2d4 | ||
|
|
f737b1f30a | ||
|
|
8b36fce7d7 | ||
|
|
30a2a1c921 | ||
|
|
763f9832c3 | ||
|
|
11b1bbc143 | ||
|
|
7176d3e513 | ||
|
|
9c3ba34b51 | ||
|
|
ff3af937da | ||
|
|
b9fcb916a6 | ||
|
|
cfa1f114fd | ||
|
|
761929ea8e | ||
|
|
4d36e66deb | ||
|
|
e58e389658 | ||
|
|
821827f850 | ||
|
|
9d8464cceb | ||
|
|
48a4fa5c6c | ||
|
|
70c04b5a3f | ||
|
|
24ea10e818 | ||
|
|
927451013c | ||
|
|
0d206fe75f | ||
|
|
11accac5ae | ||
|
|
2250367ddc | ||
|
|
fe305bbc81 | ||
|
|
92195340c6 | ||
|
|
1316ead8c8 | ||
|
|
03b33106e0 | ||
|
|
251f0fd88e | ||
|
|
96f154d440 | ||
|
|
27c6d5a3bb | ||
|
|
a57dcc170d | ||
|
|
5c601ff200 | ||
|
|
4d4025ca06 | ||
|
|
77f253c7bd | ||
|
|
fe13d47b24 | ||
|
|
33acf502ed | ||
|
|
66557b2093 | ||
|
|
c713cef484 | ||
|
|
5f7cbd3435 | ||
|
|
a4290b5863 | ||
|
|
f9b0a38642 | ||
|
|
46636cf385 | ||
|
|
e24a6894a5 | ||
|
|
cf9289e21a | ||
|
|
fe7bc954ba | ||
|
|
236989bf6e | ||
|
|
8a6a83bf52 | ||
|
|
84b582ffa7 | ||
|
|
bd5176165d | ||
|
|
49f32c4d59 | ||
|
|
0af5bc86f4 | ||
|
|
bc5a36c5f4 | ||
|
|
2934d73db2 | ||
|
|
a4968f7235 | ||
|
|
b8e0c18c53 | ||
|
|
d0b3e0d9bb | ||
|
|
2a0719e00c | ||
|
|
af394183e6 | ||
|
|
5d675561ba | ||
|
|
27fb3f2777 | ||
|
|
aca84fe16a | ||
|
|
1117afc37a | ||
|
|
06c02de1cb |
3
.claude/.gitignore
vendored
3
.claude/.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
hans/
|
hans/
|
||||||
|
skills/
|
||||||
@@ -1 +1,19 @@
|
|||||||
node_modules/
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
**/node_modules/
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
**/dist/
|
||||||
|
dist-electron/
|
||||||
|
**/dist-electron/
|
||||||
|
build/
|
||||||
|
**/build/
|
||||||
|
.next/
|
||||||
|
**/.next/
|
||||||
|
.nuxt/
|
||||||
|
**/.nuxt/
|
||||||
|
out/
|
||||||
|
**/out/
|
||||||
|
.cache/
|
||||||
|
**/.cache/
|
||||||
117
.github/workflows/e2e-tests.yml
vendored
117
.github/workflows/e2e-tests.yml
vendored
@@ -31,24 +31,99 @@ jobs:
|
|||||||
- name: Build server
|
- name: Build server
|
||||||
run: npm run build --workspace=apps/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
|
- name: Start backend server
|
||||||
run: npm run start --workspace=apps/server &
|
run: |
|
||||||
|
echo "Starting backend server..."
|
||||||
|
# Start server in background and save PID
|
||||||
|
npm run start --workspace=apps/server > backend.log 2>&1 &
|
||||||
|
SERVER_PID=$!
|
||||||
|
echo "Server started with PID: $SERVER_PID"
|
||||||
|
echo "SERVER_PID=$SERVER_PID" >> $GITHUB_ENV
|
||||||
|
|
||||||
env:
|
env:
|
||||||
PORT: 3008
|
PORT: 3008
|
||||||
NODE_ENV: test
|
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
|
- name: Wait for backend server
|
||||||
run: |
|
run: |
|
||||||
echo "Waiting for backend server to be ready..."
|
echo "Waiting for backend server to be ready..."
|
||||||
for i in {1..30}; do
|
|
||||||
if curl -s http://localhost:3008/api/health > /dev/null 2>&1; then
|
# Check if server process is running
|
||||||
|
if [ -z "$SERVER_PID" ]; then
|
||||||
|
echo "ERROR: Server PID not found in environment"
|
||||||
|
cat backend.log 2>/dev/null || echo "No backend log found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if process is actually running
|
||||||
|
if ! kill -0 $SERVER_PID 2>/dev/null; then
|
||||||
|
echo "ERROR: Server process $SERVER_PID is not running!"
|
||||||
|
echo "=== Backend logs ==="
|
||||||
|
cat backend.log
|
||||||
|
echo ""
|
||||||
|
echo "=== Recent system logs ==="
|
||||||
|
dmesg 2>/dev/null | tail -20 || echo "No dmesg available"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Wait for health endpoint
|
||||||
|
for i in {1..60}; do
|
||||||
|
if curl -s -f http://localhost:3008/api/health > /dev/null 2>&1; then
|
||||||
echo "Backend server is ready!"
|
echo "Backend server is ready!"
|
||||||
|
echo "=== Backend logs ==="
|
||||||
|
cat backend.log
|
||||||
|
echo ""
|
||||||
|
echo "Health check response:"
|
||||||
|
curl -s http://localhost:3008/api/health | jq . 2>/dev/null || echo "Health check: $(curl -s http://localhost:3008/api/health 2>/dev/null || echo 'No response')"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
echo "Waiting... ($i/30)"
|
|
||||||
|
# Check if server process is still running
|
||||||
|
if ! kill -0 $SERVER_PID 2>/dev/null; then
|
||||||
|
echo "ERROR: Server process died during wait!"
|
||||||
|
echo "=== Backend logs ==="
|
||||||
|
cat backend.log
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Waiting... ($i/60)"
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
echo "Backend server failed to start!"
|
|
||||||
|
echo "ERROR: Backend server failed to start within 60 seconds!"
|
||||||
|
echo "=== Backend logs ==="
|
||||||
|
cat backend.log
|
||||||
|
echo ""
|
||||||
|
echo "=== Process status ==="
|
||||||
|
ps aux | grep -E "(node|tsx)" | grep -v grep || echo "No node processes found"
|
||||||
|
echo ""
|
||||||
|
echo "=== Port status ==="
|
||||||
|
netstat -tlnp 2>/dev/null | grep :3008 || echo "Port 3008 not listening"
|
||||||
|
lsof -i :3008 2>/dev/null || echo "lsof not available or port not in use"
|
||||||
|
echo ""
|
||||||
|
echo "=== Health endpoint test ==="
|
||||||
|
curl -v http://localhost:3008/api/health 2>&1 || echo "Health endpoint failed"
|
||||||
|
|
||||||
|
# Kill the server process if it's still hanging
|
||||||
|
if kill -0 $SERVER_PID 2>/dev/null; then
|
||||||
|
echo ""
|
||||||
|
echo "Killing stuck server process..."
|
||||||
|
kill -9 $SERVER_PID 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
- name: Run E2E tests
|
- name: Run E2E tests
|
||||||
@@ -59,6 +134,20 @@ jobs:
|
|||||||
CI: true
|
CI: true
|
||||||
VITE_SERVER_URL: http://localhost:3008
|
VITE_SERVER_URL: http://localhost:3008
|
||||||
VITE_SKIP_SETUP: 'true'
|
VITE_SKIP_SETUP: 'true'
|
||||||
|
# Keep UI-side login/defaults consistent
|
||||||
|
AUTOMAKER_API_KEY: test-api-key-for-e2e-tests
|
||||||
|
|
||||||
|
- name: Print backend logs on failure
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
echo "=== E2E Tests Failed - Backend Logs ==="
|
||||||
|
cat backend.log 2>/dev/null || echo "No backend log found"
|
||||||
|
echo ""
|
||||||
|
echo "=== Process status at failure ==="
|
||||||
|
ps aux | grep -E "(node|tsx)" | grep -v grep || echo "No node processes found"
|
||||||
|
echo ""
|
||||||
|
echo "=== Port status ==="
|
||||||
|
netstat -tlnp 2>/dev/null | grep :3008 || echo "Port 3008 not listening"
|
||||||
|
|
||||||
- name: Upload Playwright report
|
- name: Upload Playwright report
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -68,10 +157,22 @@ jobs:
|
|||||||
path: apps/ui/playwright-report/
|
path: apps/ui/playwright-report/
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
- name: Upload test results
|
- name: Upload test results (screenshots, traces, videos)
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
if: failure()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: test-results
|
name: test-results
|
||||||
path: apps/ui/test-results/
|
path: |
|
||||||
|
apps/ui/test-results/
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
if-no-files-found: ignore
|
||||||
|
|
||||||
|
- name: Cleanup - Kill backend server
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [ -n "$SERVER_PID" ]; then
|
||||||
|
echo "Cleaning up backend server (PID: $SERVER_PID)..."
|
||||||
|
kill $SERVER_PID 2>/dev/null || true
|
||||||
|
kill -9 $SERVER_PID 2>/dev/null || true
|
||||||
|
echo "Backend server cleanup complete"
|
||||||
|
fi
|
||||||
|
|||||||
2
.github/workflows/security-audit.yml
vendored
2
.github/workflows/security-audit.yml
vendored
@@ -26,5 +26,5 @@ jobs:
|
|||||||
check-lockfile: 'true'
|
check-lockfile: 'true'
|
||||||
|
|
||||||
- name: Run npm audit
|
- name: Run npm audit
|
||||||
run: npm audit --audit-level=moderate
|
run: npm audit --audit-level=critical
|
||||||
continue-on-error: false
|
continue-on-error: false
|
||||||
|
|||||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -73,6 +73,9 @@ blob-report/
|
|||||||
!.env.example
|
!.env.example
|
||||||
!.env.local.example
|
!.env.local.example
|
||||||
|
|
||||||
|
# Codex config (contains API keys)
|
||||||
|
.codex/config.toml
|
||||||
|
|
||||||
# TypeScript
|
# TypeScript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
@@ -84,4 +87,11 @@ docker-compose.override.yml
|
|||||||
.claude/hans/
|
.claude/hans/
|
||||||
|
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
yarn.lock
|
yarn.lock
|
||||||
|
|
||||||
|
# Fork-specific workflow files (should never be committed)
|
||||||
|
DEVELOPMENT_WORKFLOW.md
|
||||||
|
check-sync.sh
|
||||||
|
# API key files
|
||||||
|
data/.api-key
|
||||||
|
data/credentials.json
|
||||||
|
|||||||
41
CLAUDE.md
41
CLAUDE.md
@@ -170,44 +170,3 @@ Use `resolveModelString()` from `@automaker/model-resolver` to convert model ali
|
|||||||
- `DATA_DIR` - Data storage directory (default: ./data)
|
- `DATA_DIR` - Data storage directory (default: ./data)
|
||||||
- `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory
|
- `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory
|
||||||
- `AUTOMAKER_MOCK_AGENT=true` - Enable mock agent mode for CI testing
|
- `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.
|
|
||||||
|
|||||||
89
Dockerfile
89
Dockerfile
@@ -8,10 +8,12 @@
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# BASE STAGE - Common setup for all builds (DRY: defined once, used by all)
|
# BASE STAGE - Common setup for all builds (DRY: defined once, used by all)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
FROM node:22-alpine AS base
|
FROM node:22-slim AS base
|
||||||
|
|
||||||
# Install build dependencies for native modules (node-pty)
|
# Install build dependencies for native modules (node-pty)
|
||||||
RUN apk add --no-cache python3 make g++
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
python3 make g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -51,30 +53,63 @@ RUN npm run build:packages && npm run build --workspace=apps/server
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# SERVER PRODUCTION STAGE
|
# SERVER PRODUCTION STAGE
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
FROM node:22-alpine AS server
|
FROM node:22-slim AS server
|
||||||
|
|
||||||
# Install git, curl, bash (for terminal), and GitHub CLI (pinned version, multi-arch)
|
# Build argument for tracking which commit this image was built from
|
||||||
RUN apk add --no-cache git curl bash && \
|
ARG GIT_COMMIT_SHA=unknown
|
||||||
GH_VERSION="2.63.2" && \
|
LABEL automaker.git.commit.sha="${GIT_COMMIT_SHA}"
|
||||||
ARCH=$(uname -m) && \
|
|
||||||
case "$ARCH" in \
|
# 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 \
|
||||||
x86_64) GH_ARCH="amd64" ;; \
|
x86_64) GH_ARCH="amd64" ;; \
|
||||||
aarch64|arm64) GH_ARCH="arm64" ;; \
|
aarch64|arm64) GH_ARCH="arm64" ;; \
|
||||||
*) echo "Unsupported architecture: $ARCH" && exit 1 ;; \
|
*) echo "Unsupported architecture: $ARCH" && exit 1 ;; \
|
||||||
esac && \
|
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 && \
|
&& 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 && \
|
&& tar -xzf gh.tar.gz \
|
||||||
mv gh_${GH_VERSION}_linux_${GH_ARCH}/bin/gh /usr/local/bin/gh && \
|
&& 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 gh.tar.gz gh_${GH_VERSION}_linux_${GH_ARCH} \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install Claude CLI globally
|
# Install Claude CLI globally (available to all users via npm global bin)
|
||||||
RUN npm install -g @anthropic-ai/claude-code
|
RUN npm install -g @anthropic-ai/claude-code
|
||||||
|
|
||||||
WORKDIR /app
|
# 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
|
||||||
|
|
||||||
# Create non-root user
|
# Install Cursor CLI as the automaker user
|
||||||
RUN addgroup -g 1001 -S automaker && \
|
# Set HOME explicitly and install to /home/automaker/.local/bin/
|
||||||
adduser -S automaker -u 1001
|
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
|
||||||
|
|
||||||
# Copy root package.json (needed for workspace resolution)
|
# Copy root package.json (needed for workspace resolution)
|
||||||
COPY --from=server-builder /app/package*.json ./
|
COPY --from=server-builder /app/package*.json ./
|
||||||
@@ -98,12 +133,19 @@ RUN git config --system --add safe.directory '*' && \
|
|||||||
# Use gh as credential helper (works with GH_TOKEN env var)
|
# Use gh as credential helper (works with GH_TOKEN env var)
|
||||||
git config --system credential.helper '!gh auth git-credential'
|
git config --system credential.helper '!gh auth git-credential'
|
||||||
|
|
||||||
# Switch to non-root user
|
# Copy entrypoint script for fixing permissions on mounted volumes
|
||||||
USER automaker
|
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
|
# Note: We stay as root here so entrypoint can fix permissions
|
||||||
|
# The entrypoint script will switch to automaker user before running the command
|
||||||
|
|
||||||
# Environment variables
|
# Environment variables
|
||||||
ENV PORT=3008
|
ENV PORT=3008
|
||||||
ENV DATA_DIR=/data
|
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 port
|
||||||
EXPOSE 3008
|
EXPOSE 3008
|
||||||
@@ -112,6 +154,9 @@ EXPOSE 3008
|
|||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
CMD curl -f http://localhost:3008/api/health || exit 1
|
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
|
# Start server
|
||||||
CMD ["node", "apps/server/dist/index.js"]
|
CMD ["node", "apps/server/dist/index.js"]
|
||||||
|
|
||||||
@@ -143,6 +188,10 @@ RUN npm run build:packages && npm run build --workspace=apps/ui
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
FROM nginx:alpine AS 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 built files
|
||||||
COPY --from=ui-builder /app/apps/ui/dist /usr/share/nginx/html
|
COPY --from=ui-builder /app/apps/ui/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
|||||||
80
Dockerfile.dev
Normal file
80
Dockerfile.dev
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# 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"]
|
||||||
21
README.md
21
README.md
@@ -117,24 +117,16 @@ cd automaker
|
|||||||
# 2. Install dependencies
|
# 2. Install dependencies
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# 3. Build shared packages (Now can be skipped npm install / run dev does it automaticly)
|
# 3. Build shared packages (can be skipped - npm run dev does it automatically)
|
||||||
npm run build:packages
|
npm run build:packages
|
||||||
|
|
||||||
# 4. Start Automaker (production mode)
|
# 4. Start Automaker
|
||||||
npm run start
|
npm run dev
|
||||||
# Choose between:
|
# Choose between:
|
||||||
# 1. Web Application (browser at localhost:3007)
|
# 1. Web Application (browser at localhost:3007)
|
||||||
# 2. Desktop Application (Electron - recommended)
|
# 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:
|
**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
|
- Use **Claude Code CLI** (recommended) - Automaker will detect your CLI credentials automatically
|
||||||
@@ -150,7 +142,7 @@ export ANTHROPIC_API_KEY="sk-ant-..."
|
|||||||
echo "ANTHROPIC_API_KEY=sk-ant-..." > .env
|
echo "ANTHROPIC_API_KEY=sk-ant-..." > .env
|
||||||
```
|
```
|
||||||
|
|
||||||
**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.
|
**For Development:** `npm run dev` starts the development server with Vite live reload and hot module replacement for fast refresh and instant updates as you make changes.
|
||||||
|
|
||||||
## How to Run
|
## How to Run
|
||||||
|
|
||||||
@@ -194,9 +186,6 @@ npm run dev:web
|
|||||||
```bash
|
```bash
|
||||||
# Build for web deployment (uses Vite)
|
# Build for web deployment (uses Vite)
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
# Run production build
|
|
||||||
npm run start
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Desktop Application
|
#### Desktop Application
|
||||||
@@ -374,7 +363,6 @@ npm run lint
|
|||||||
|
|
||||||
- `VITE_SKIP_ELECTRON` - Skip Electron in dev mode
|
- `VITE_SKIP_ELECTRON` - Skip Electron in dev mode
|
||||||
- `OPEN_DEVTOOLS` - Auto-open DevTools in Electron
|
- `OPEN_DEVTOOLS` - Auto-open DevTools in Electron
|
||||||
- `ENABLE_DEBUG_PANEL` - Enable the debug panel in non-development builds (for staging environments)
|
|
||||||
|
|
||||||
### Authentication Setup
|
### Authentication Setup
|
||||||
|
|
||||||
@@ -456,7 +444,6 @@ 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
|
- 🎨 **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)
|
- 🖥️ **Cross-Platform** - Desktop app for macOS (x64, arm64), Windows (x64), and Linux (x64)
|
||||||
- 🌐 **Web Mode** - Run in browser or as Electron desktop app
|
- 🌐 **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
|
### Advanced Features
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,20 @@
|
|||||||
# Your Anthropic API key for Claude models
|
# Your Anthropic API key for Claude models
|
||||||
ANTHROPIC_API_KEY=sk-ant-...
|
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
|
# OPTIONAL - Security
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@automaker/server",
|
"name": "@automaker/server",
|
||||||
"version": "0.7.3",
|
"version": "0.9.0",
|
||||||
"description": "Backend server for Automaker - provides API for both web and Electron modes",
|
"description": "Backend server for Automaker - provides API for both web and Electron modes",
|
||||||
"author": "AutoMaker Team",
|
"author": "AutoMaker Team",
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
@@ -32,7 +32,8 @@
|
|||||||
"@automaker/prompts": "1.0.0",
|
"@automaker/prompts": "1.0.0",
|
||||||
"@automaker/types": "1.0.0",
|
"@automaker/types": "1.0.0",
|
||||||
"@automaker/utils": "1.0.0",
|
"@automaker/utils": "1.0.0",
|
||||||
"@modelcontextprotocol/sdk": "1.25.1",
|
"@modelcontextprotocol/sdk": "1.25.2",
|
||||||
|
"@openai/codex-sdk": "^0.77.0",
|
||||||
"cookie-parser": "1.4.7",
|
"cookie-parser": "1.4.7",
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.5",
|
||||||
"dotenv": "17.2.3",
|
"dotenv": "17.2.3",
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ import { SettingsService } from './services/settings-service.js';
|
|||||||
import { createSpecRegenerationRoutes } from './routes/app-spec/index.js';
|
import { createSpecRegenerationRoutes } from './routes/app-spec/index.js';
|
||||||
import { createClaudeRoutes } from './routes/claude/index.js';
|
import { createClaudeRoutes } from './routes/claude/index.js';
|
||||||
import { ClaudeUsageService } from './services/claude-usage-service.js';
|
import { ClaudeUsageService } from './services/claude-usage-service.js';
|
||||||
|
import { createCodexRoutes } from './routes/codex/index.js';
|
||||||
|
import { CodexUsageService } from './services/codex-usage-service.js';
|
||||||
|
import { CodexAppServerService } from './services/codex-app-server-service.js';
|
||||||
|
import { CodexModelCacheService } from './services/codex-model-cache-service.js';
|
||||||
import { createGitHubRoutes } from './routes/github/index.js';
|
import { createGitHubRoutes } from './routes/github/index.js';
|
||||||
import { createContextRoutes } from './routes/context/index.js';
|
import { createContextRoutes } from './routes/context/index.js';
|
||||||
import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js';
|
import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js';
|
||||||
@@ -63,12 +67,6 @@ import { createPipelineRoutes } from './routes/pipeline/index.js';
|
|||||||
import { pipelineService } from './services/pipeline-service.js';
|
import { pipelineService } from './services/pipeline-service.js';
|
||||||
import { createIdeationRoutes } from './routes/ideation/index.js';
|
import { createIdeationRoutes } from './routes/ideation/index.js';
|
||||||
import { IdeationService } from './services/ideation-service.js';
|
import { IdeationService } from './services/ideation-service.js';
|
||||||
import {
|
|
||||||
createDebugRoutes,
|
|
||||||
createDebugServices,
|
|
||||||
stopDebugServices,
|
|
||||||
type DebugServices,
|
|
||||||
} from './routes/debug/index.js';
|
|
||||||
|
|
||||||
// Load environment variables
|
// Load environment variables
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
@@ -76,8 +74,6 @@ dotenv.config();
|
|||||||
const PORT = parseInt(process.env.PORT || '3008', 10);
|
const PORT = parseInt(process.env.PORT || '3008', 10);
|
||||||
const DATA_DIR = process.env.DATA_DIR || './data';
|
const DATA_DIR = process.env.DATA_DIR || './data';
|
||||||
const ENABLE_REQUEST_LOGGING = process.env.ENABLE_REQUEST_LOGGING !== 'false'; // Default to true
|
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
|
// Check for required environment variables
|
||||||
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
|
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
|
||||||
@@ -174,20 +170,21 @@ const agentService = new AgentService(DATA_DIR, events, settingsService);
|
|||||||
const featureLoader = new FeatureLoader();
|
const featureLoader = new FeatureLoader();
|
||||||
const autoModeService = new AutoModeService(events, settingsService);
|
const autoModeService = new AutoModeService(events, settingsService);
|
||||||
const claudeUsageService = new ClaudeUsageService();
|
const claudeUsageService = new ClaudeUsageService();
|
||||||
|
const codexAppServerService = new CodexAppServerService();
|
||||||
|
const codexModelCacheService = new CodexModelCacheService(DATA_DIR, codexAppServerService);
|
||||||
|
const codexUsageService = new CodexUsageService(codexAppServerService);
|
||||||
const mcpTestService = new MCPTestService(settingsService);
|
const mcpTestService = new MCPTestService(settingsService);
|
||||||
const ideationService = new IdeationService(events, settingsService, featureLoader);
|
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
|
// Initialize services
|
||||||
(async () => {
|
(async () => {
|
||||||
await agentService.initialize();
|
await agentService.initialize();
|
||||||
logger.info('Agent service initialized');
|
logger.info('Agent service initialized');
|
||||||
|
|
||||||
|
// Bootstrap Codex model cache in background (don't block server startup)
|
||||||
|
void codexModelCacheService.getModels().catch((err) => {
|
||||||
|
logger.error('Failed to bootstrap Codex model cache:', err);
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Run stale validation cleanup every hour to prevent memory leaks from crashed validations
|
// Run stale validation cleanup every hour to prevent memory leaks from crashed validations
|
||||||
@@ -203,9 +200,10 @@ setInterval(() => {
|
|||||||
// This helps prevent CSRF and content-type confusion attacks
|
// This helps prevent CSRF and content-type confusion attacks
|
||||||
app.use('/api', requireJsonContentType);
|
app.use('/api', requireJsonContentType);
|
||||||
|
|
||||||
// Mount API routes - health and auth are unauthenticated
|
// Mount API routes - health, auth, and setup are unauthenticated
|
||||||
app.use('/api/health', createHealthRoutes());
|
app.use('/api/health', createHealthRoutes());
|
||||||
app.use('/api/auth', createAuthRoutes());
|
app.use('/api/auth', createAuthRoutes());
|
||||||
|
app.use('/api/setup', createSetupRoutes());
|
||||||
|
|
||||||
// Apply authentication to all other routes
|
// Apply authentication to all other routes
|
||||||
app.use('/api', authMiddleware);
|
app.use('/api', authMiddleware);
|
||||||
@@ -221,7 +219,6 @@ app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
|
|||||||
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
|
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
|
||||||
app.use('/api/worktree', createWorktreeRoutes());
|
app.use('/api/worktree', createWorktreeRoutes());
|
||||||
app.use('/api/git', createGitRoutes());
|
app.use('/api/git', createGitRoutes());
|
||||||
app.use('/api/setup', createSetupRoutes());
|
|
||||||
app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService));
|
app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService));
|
||||||
app.use('/api/models', createModelsRoutes());
|
app.use('/api/models', createModelsRoutes());
|
||||||
app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events, settingsService));
|
app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events, settingsService));
|
||||||
@@ -231,6 +228,7 @@ app.use('/api/templates', createTemplatesRoutes());
|
|||||||
app.use('/api/terminal', createTerminalRoutes());
|
app.use('/api/terminal', createTerminalRoutes());
|
||||||
app.use('/api/settings', createSettingsRoutes(settingsService));
|
app.use('/api/settings', createSettingsRoutes(settingsService));
|
||||||
app.use('/api/claude', createClaudeRoutes(claudeUsageService));
|
app.use('/api/claude', createClaudeRoutes(claudeUsageService));
|
||||||
|
app.use('/api/codex', createCodexRoutes(codexUsageService, codexModelCacheService));
|
||||||
app.use('/api/github', createGitHubRoutes(events, settingsService));
|
app.use('/api/github', createGitHubRoutes(events, settingsService));
|
||||||
app.use('/api/context', createContextRoutes(settingsService));
|
app.use('/api/context', createContextRoutes(settingsService));
|
||||||
app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));
|
app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));
|
||||||
@@ -238,12 +236,6 @@ app.use('/api/mcp', createMCPRoutes(mcpTestService));
|
|||||||
app.use('/api/pipeline', createPipelineRoutes(pipelineService));
|
app.use('/api/pipeline', createPipelineRoutes(pipelineService));
|
||||||
app.use('/api/ideation', createIdeationRoutes(events, ideationService, featureLoader));
|
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
|
// Create HTTP server
|
||||||
const server = createServer(app);
|
const server = createServer(app);
|
||||||
|
|
||||||
@@ -605,13 +597,30 @@ const startServer = (port: number) => {
|
|||||||
|
|
||||||
startServer(PORT);
|
startServer(PORT);
|
||||||
|
|
||||||
|
// Global error handlers to prevent crashes from uncaught errors
|
||||||
|
process.on('unhandledRejection', (reason: unknown, _promise: Promise<unknown>) => {
|
||||||
|
logger.error('Unhandled Promise Rejection:', {
|
||||||
|
reason: reason instanceof Error ? reason.message : String(reason),
|
||||||
|
stack: reason instanceof Error ? reason.stack : undefined,
|
||||||
|
});
|
||||||
|
// Don't exit - log the error and continue running
|
||||||
|
// This prevents the server from crashing due to unhandled rejections
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('uncaughtException', (error: Error) => {
|
||||||
|
logger.error('Uncaught Exception:', {
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
});
|
||||||
|
// Exit on uncaught exceptions to prevent undefined behavior
|
||||||
|
// The process is in an unknown state after an uncaught exception
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
process.on('SIGTERM', () => {
|
process.on('SIGTERM', () => {
|
||||||
logger.info('SIGTERM received, shutting down...');
|
logger.info('SIGTERM received, shutting down...');
|
||||||
terminalService.cleanup();
|
terminalService.cleanup();
|
||||||
if (debugServices) {
|
|
||||||
stopDebugServices(debugServices);
|
|
||||||
}
|
|
||||||
server.close(() => {
|
server.close(() => {
|
||||||
logger.info('Server closed');
|
logger.info('Server closed');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
@@ -621,9 +630,6 @@ process.on('SIGTERM', () => {
|
|||||||
process.on('SIGINT', () => {
|
process.on('SIGINT', () => {
|
||||||
logger.info('SIGINT received, shutting down...');
|
logger.info('SIGINT received, shutting down...');
|
||||||
terminalService.cleanup();
|
terminalService.cleanup();
|
||||||
if (debugServices) {
|
|
||||||
stopDebugServices(debugServices);
|
|
||||||
}
|
|
||||||
server.close(() => {
|
server.close(() => {
|
||||||
logger.info('Server closed');
|
logger.info('Server closed');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
257
apps/server/src/lib/agent-discovery.ts
Normal file
257
apps/server/src/lib/agent-discovery.ts
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
/**
|
||||||
|
* Agent Discovery - Scans filesystem for AGENT.md files
|
||||||
|
*
|
||||||
|
* Discovers agents from:
|
||||||
|
* - ~/.claude/agents/ (user-level, global)
|
||||||
|
* - .claude/agents/ (project-level)
|
||||||
|
*
|
||||||
|
* Similar to Skills, but for custom subagents defined in AGENT.md files.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from 'path';
|
||||||
|
import os from 'os';
|
||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
|
import { secureFs, systemPaths } from '@automaker/platform';
|
||||||
|
import type { AgentDefinition } from '@automaker/types';
|
||||||
|
|
||||||
|
const logger = createLogger('AgentDiscovery');
|
||||||
|
|
||||||
|
export interface FilesystemAgent {
|
||||||
|
name: string; // Directory name (e.g., 'code-reviewer')
|
||||||
|
definition: AgentDefinition;
|
||||||
|
source: 'user' | 'project';
|
||||||
|
filePath: string; // Full path to AGENT.md
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse agent content string into AgentDefinition
|
||||||
|
* Format:
|
||||||
|
* ---
|
||||||
|
* name: agent-name # Optional
|
||||||
|
* description: When to use this agent
|
||||||
|
* tools: tool1, tool2, tool3 # Optional (comma or space separated list)
|
||||||
|
* model: sonnet # Optional: sonnet, opus, haiku
|
||||||
|
* ---
|
||||||
|
* System prompt content here...
|
||||||
|
*/
|
||||||
|
function parseAgentContent(content: string, filePath: string): AgentDefinition | null {
|
||||||
|
// Extract frontmatter
|
||||||
|
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
||||||
|
if (!frontmatterMatch) {
|
||||||
|
logger.warn(`Invalid agent file format (missing frontmatter): ${filePath}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, frontmatter, prompt] = frontmatterMatch;
|
||||||
|
|
||||||
|
// Parse description (required)
|
||||||
|
const description = frontmatter.match(/description:\s*(.+)/)?.[1]?.trim();
|
||||||
|
if (!description) {
|
||||||
|
logger.warn(`Missing description in agent file: ${filePath}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse tools (optional) - supports both comma-separated and space-separated
|
||||||
|
const toolsMatch = frontmatter.match(/tools:\s*(.+)/);
|
||||||
|
const tools = toolsMatch
|
||||||
|
? toolsMatch[1]
|
||||||
|
.split(/[,\s]+/) // Split by comma or whitespace
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter((t) => t && t !== '')
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Parse model (optional) - validate against allowed values
|
||||||
|
const modelMatch = frontmatter.match(/model:\s*(\w+)/);
|
||||||
|
const modelValue = modelMatch?.[1]?.trim();
|
||||||
|
const validModels = ['sonnet', 'opus', 'haiku', 'inherit'] as const;
|
||||||
|
const model =
|
||||||
|
modelValue && validModels.includes(modelValue as (typeof validModels)[number])
|
||||||
|
? (modelValue as 'sonnet' | 'opus' | 'haiku' | 'inherit')
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (modelValue && !model) {
|
||||||
|
logger.warn(
|
||||||
|
`Invalid model "${modelValue}" in agent file: ${filePath}. Expected one of: ${validModels.join(', ')}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
description,
|
||||||
|
prompt: prompt.trim(),
|
||||||
|
tools,
|
||||||
|
model,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Directory entry with type information
|
||||||
|
*/
|
||||||
|
interface DirEntry {
|
||||||
|
name: string;
|
||||||
|
isFile: boolean;
|
||||||
|
isDirectory: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filesystem adapter interface for abstracting systemPaths vs secureFs
|
||||||
|
*/
|
||||||
|
interface FsAdapter {
|
||||||
|
exists: (filePath: string) => Promise<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;
|
||||||
|
}
|
||||||
263
apps/server/src/lib/auth-utils.ts
Normal file
263
apps/server/src/lib/auth-utils.ts
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
/**
|
||||||
|
* Secure authentication utilities that avoid environment variable race conditions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
|
|
||||||
|
const logger = createLogger('AuthUtils');
|
||||||
|
|
||||||
|
export interface SecureAuthEnv {
|
||||||
|
[key: string]: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthValidationResult {
|
||||||
|
isValid: boolean;
|
||||||
|
error?: string;
|
||||||
|
normalizedKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates API key format without modifying process.env
|
||||||
|
*/
|
||||||
|
export function validateApiKey(
|
||||||
|
key: string,
|
||||||
|
provider: 'anthropic' | 'openai' | 'cursor'
|
||||||
|
): AuthValidationResult {
|
||||||
|
if (!key || typeof key !== 'string' || key.trim().length === 0) {
|
||||||
|
return { isValid: false, error: 'API key is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedKey = key.trim();
|
||||||
|
|
||||||
|
switch (provider) {
|
||||||
|
case 'anthropic':
|
||||||
|
if (!trimmedKey.startsWith('sk-ant-')) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
error: 'Invalid Anthropic API key format. Should start with "sk-ant-"',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (trimmedKey.length < 20) {
|
||||||
|
return { isValid: false, error: 'Anthropic API key too short' };
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'openai':
|
||||||
|
if (!trimmedKey.startsWith('sk-')) {
|
||||||
|
return { isValid: false, error: 'Invalid OpenAI API key format. Should start with "sk-"' };
|
||||||
|
}
|
||||||
|
if (trimmedKey.length < 20) {
|
||||||
|
return { isValid: false, error: 'OpenAI API key too short' };
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'cursor':
|
||||||
|
// Cursor API keys might have different format
|
||||||
|
if (trimmedKey.length < 10) {
|
||||||
|
return { isValid: false, error: 'Cursor API key too short' };
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isValid: true, normalizedKey: trimmedKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a secure environment object for authentication testing
|
||||||
|
* without modifying the global process.env
|
||||||
|
*/
|
||||||
|
export function createSecureAuthEnv(
|
||||||
|
authMethod: 'cli' | 'api_key',
|
||||||
|
apiKey?: string,
|
||||||
|
provider: 'anthropic' | 'openai' | 'cursor' = 'anthropic'
|
||||||
|
): SecureAuthEnv {
|
||||||
|
const env: SecureAuthEnv = { ...process.env };
|
||||||
|
|
||||||
|
if (authMethod === 'cli') {
|
||||||
|
// For CLI auth, remove the API key to force CLI authentication
|
||||||
|
const envKey = provider === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY';
|
||||||
|
delete env[envKey];
|
||||||
|
} else if (authMethod === 'api_key' && apiKey) {
|
||||||
|
// For API key auth, validate and set the provided key
|
||||||
|
const validation = validateApiKey(apiKey, provider);
|
||||||
|
if (!validation.isValid) {
|
||||||
|
throw new Error(validation.error);
|
||||||
|
}
|
||||||
|
const envKey = provider === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY';
|
||||||
|
env[envKey] = validation.normalizedKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a temporary environment override for the current process
|
||||||
|
* WARNING: This should only be used in isolated contexts and immediately cleaned up
|
||||||
|
*/
|
||||||
|
export function createTempEnvOverride(authEnv: SecureAuthEnv): () => void {
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
|
||||||
|
// Apply the auth environment
|
||||||
|
Object.assign(process.env, authEnv);
|
||||||
|
|
||||||
|
// Return cleanup function
|
||||||
|
return () => {
|
||||||
|
// Restore original environment
|
||||||
|
Object.keys(process.env).forEach((key) => {
|
||||||
|
if (!(key in originalEnv)) {
|
||||||
|
delete process.env[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Object.assign(process.env, originalEnv);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawns a process with secure environment isolation
|
||||||
|
*/
|
||||||
|
export function spawnSecureAuth(
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
authEnv: SecureAuthEnv,
|
||||||
|
options: {
|
||||||
|
cwd?: string;
|
||||||
|
timeout?: number;
|
||||||
|
} = {}
|
||||||
|
): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const { cwd = process.cwd(), timeout = 30000 } = options;
|
||||||
|
|
||||||
|
logger.debug(`Spawning secure auth process: ${command} ${args.join(' ')}`);
|
||||||
|
|
||||||
|
const child = spawn(command, args, {
|
||||||
|
cwd,
|
||||||
|
env: authEnv,
|
||||||
|
stdio: 'pipe',
|
||||||
|
shell: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
let isResolved = false;
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
if (!isResolved) {
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
isResolved = true;
|
||||||
|
reject(new Error('Authentication process timed out'));
|
||||||
|
}
|
||||||
|
}, timeout);
|
||||||
|
|
||||||
|
child.stdout?.on('data', (data) => {
|
||||||
|
stdout += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stderr?.on('data', (data) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('close', (code) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if (!isResolved) {
|
||||||
|
isResolved = true;
|
||||||
|
resolve({ stdout, stderr, exitCode: code });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('error', (error) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if (!isResolved) {
|
||||||
|
isResolved = true;
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely extracts environment variable without race conditions
|
||||||
|
*/
|
||||||
|
export function safeGetEnv(key: string): string | undefined {
|
||||||
|
return process.env[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an environment variable would be modified without actually modifying it
|
||||||
|
*/
|
||||||
|
export function wouldModifyEnv(key: string, newValue: string): boolean {
|
||||||
|
const currentValue = safeGetEnv(key);
|
||||||
|
return currentValue !== newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Secure auth session management
|
||||||
|
*/
|
||||||
|
export class AuthSessionManager {
|
||||||
|
private static activeSessions = new Map<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -262,7 +262,7 @@ export function getSessionCookieOptions(): {
|
|||||||
return {
|
return {
|
||||||
httpOnly: true, // JavaScript cannot access this cookie
|
httpOnly: true, // JavaScript cannot access this cookie
|
||||||
secure: process.env.NODE_ENV === 'production', // HTTPS only in production
|
secure: process.env.NODE_ENV === 'production', // HTTPS only in production
|
||||||
sameSite: 'strict', // Only sent for same-site requests (CSRF protection)
|
sameSite: 'lax', // Sent for same-site requests and top-level navigations, but not cross-origin fetch/XHR
|
||||||
maxAge: SESSION_MAX_AGE_MS,
|
maxAge: SESSION_MAX_AGE_MS,
|
||||||
path: '/',
|
path: '/',
|
||||||
};
|
};
|
||||||
|
|||||||
447
apps/server/src/lib/cli-detection.ts
Normal file
447
apps/server/src/lib/cli-detection.ts
Normal file
@@ -0,0 +1,447 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
68
apps/server/src/lib/codex-auth.ts
Normal file
68
apps/server/src/lib/codex-auth.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Shared utility for checking Codex CLI authentication status
|
||||||
|
*
|
||||||
|
* Uses 'codex login status' command to verify authentication.
|
||||||
|
* Never assumes authenticated - only returns true if CLI confirms.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawnProcess } from '@automaker/platform';
|
||||||
|
import { findCodexCliPath } from '@automaker/platform';
|
||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
|
|
||||||
|
const logger = createLogger('CodexAuth');
|
||||||
|
|
||||||
|
const CODEX_COMMAND = 'codex';
|
||||||
|
const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY';
|
||||||
|
|
||||||
|
export interface CodexAuthCheckResult {
|
||||||
|
authenticated: boolean;
|
||||||
|
method: 'api_key_env' | 'cli_authenticated' | 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check Codex authentication status using 'codex login status' command
|
||||||
|
*
|
||||||
|
* @param cliPath Optional CLI path. If not provided, will attempt to find it.
|
||||||
|
* @returns Authentication status and method
|
||||||
|
*/
|
||||||
|
export async function checkCodexAuthentication(
|
||||||
|
cliPath?: string | null
|
||||||
|
): Promise<CodexAuthCheckResult> {
|
||||||
|
const resolvedCliPath = cliPath || (await findCodexCliPath());
|
||||||
|
const hasApiKey = !!process.env[OPENAI_API_KEY_ENV];
|
||||||
|
|
||||||
|
// If CLI is not installed, cannot be authenticated
|
||||||
|
if (!resolvedCliPath) {
|
||||||
|
logger.info('CLI not found');
|
||||||
|
return { authenticated: false, method: 'none' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await spawnProcess({
|
||||||
|
command: resolvedCliPath || CODEX_COMMAND,
|
||||||
|
args: ['login', 'status'],
|
||||||
|
cwd: process.cwd(),
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
TERM: 'dumb', // Avoid interactive output
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check both stdout and stderr for "logged in" - Codex CLI outputs to stderr
|
||||||
|
const combinedOutput = (result.stdout + result.stderr).toLowerCase();
|
||||||
|
const isLoggedIn = combinedOutput.includes('logged in');
|
||||||
|
|
||||||
|
if (result.exitCode === 0 && isLoggedIn) {
|
||||||
|
// Determine auth method based on what we know
|
||||||
|
const method = hasApiKey ? 'api_key_env' : 'cli_authenticated';
|
||||||
|
logger.info(`✓ Authenticated (${method})`);
|
||||||
|
return { authenticated: true, method };
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Not authenticated');
|
||||||
|
return { authenticated: false, method: 'none' };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to check authentication:', error);
|
||||||
|
return { authenticated: false, method: 'none' };
|
||||||
|
}
|
||||||
|
}
|
||||||
414
apps/server/src/lib/error-handler.ts
Normal file
414
apps/server/src/lib/error-handler.ts
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
173
apps/server/src/lib/permission-enforcer.ts
Normal file
173
apps/server/src/lib/permission-enforcer.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Options } from '@anthropic-ai/claude-agent-sdk';
|
import type { Options } from '@anthropic-ai/claude-agent-sdk';
|
||||||
import os from 'os';
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { resolveModelString } from '@automaker/model-resolver';
|
import { resolveModelString } from '@automaker/model-resolver';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
@@ -31,6 +30,68 @@ import {
|
|||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
import { isPathAllowed, PathNotAllowedError, getAllowedRootDirectory } from '@automaker/platform';
|
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.
|
* Validate that a working directory is allowed by ALLOWED_ROOT_DIRECTORY.
|
||||||
* This is the centralized security check for ALL AI model invocations.
|
* This is the centralized security check for ALL AI model invocations.
|
||||||
@@ -57,139 +118,6 @@ 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
|
* Tool presets for different use cases
|
||||||
*/
|
*/
|
||||||
@@ -272,55 +200,31 @@ export function getModelForUseCase(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Base options that apply to all SDK calls
|
* Base options that apply to all SDK calls
|
||||||
|
* AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation
|
||||||
*/
|
*/
|
||||||
function getBaseOptions(): Partial<Options> {
|
function getBaseOptions(): Partial<Options> {
|
||||||
return {
|
return {
|
||||||
permissionMode: 'acceptEdits',
|
permissionMode: 'bypassPermissions',
|
||||||
|
allowDangerouslySkipPermissions: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MCP permission options result
|
* MCP options result
|
||||||
*/
|
*/
|
||||||
interface McpPermissionOptions {
|
interface McpOptions {
|
||||||
/** 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 */
|
/** Options to spread for MCP servers */
|
||||||
mcpServerOptions: Partial<Options>;
|
mcpServerOptions: Partial<Options>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build MCP-related options based on configuration.
|
* 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
|
* @param config - The SDK options config
|
||||||
* @returns Object with MCP permission settings to spread into final options
|
* @returns Object with MCP server settings to spread into final options
|
||||||
*/
|
*/
|
||||||
function buildMcpOptions(config: CreateSdkOptionsConfig): McpPermissionOptions {
|
function buildMcpOptions(config: CreateSdkOptionsConfig): McpOptions {
|
||||||
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 {
|
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
|
// Include MCP servers if configured
|
||||||
mcpServerOptions: config.mcpServers ? { mcpServers: config.mcpServers } : {},
|
mcpServerOptions: config.mcpServers ? { mcpServers: config.mcpServers } : {},
|
||||||
};
|
};
|
||||||
@@ -422,18 +326,9 @@ export interface CreateSdkOptionsConfig {
|
|||||||
/** Enable auto-loading of CLAUDE.md files via SDK's settingSources */
|
/** Enable auto-loading of CLAUDE.md files via SDK's settingSources */
|
||||||
autoLoadClaudeMd?: boolean;
|
autoLoadClaudeMd?: boolean;
|
||||||
|
|
||||||
/** Enable sandbox mode for bash command isolation */
|
|
||||||
enableSandboxMode?: boolean;
|
|
||||||
|
|
||||||
/** MCP servers to make available to the agent */
|
/** MCP servers to make available to the agent */
|
||||||
mcpServers?: Record<string, McpServerConfig>;
|
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 */
|
/** Extended thinking level for Claude models */
|
||||||
thinkingLevel?: ThinkingLevel;
|
thinkingLevel?: ThinkingLevel;
|
||||||
}
|
}
|
||||||
@@ -554,7 +449,6 @@ export function createSuggestionsOptions(config: CreateSdkOptionsConfig): Option
|
|||||||
* - Full tool access for code modification
|
* - Full tool access for code modification
|
||||||
* - Standard turns for interactive sessions
|
* - Standard turns for interactive sessions
|
||||||
* - Model priority: explicit model > session model > chat default
|
* - 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
|
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
|
||||||
*/
|
*/
|
||||||
export function createChatOptions(config: CreateSdkOptionsConfig): Options {
|
export function createChatOptions(config: CreateSdkOptionsConfig): Options {
|
||||||
@@ -573,24 +467,12 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
|
|||||||
// Build thinking options
|
// Build thinking options
|
||||||
const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
|
const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
|
||||||
|
|
||||||
// Check sandbox compatibility (auto-disables for cloud storage paths)
|
|
||||||
const sandboxCheck = checkSandboxCompatibility(config.cwd, config.enableSandboxMode);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...getBaseOptions(),
|
...getBaseOptions(),
|
||||||
model: getModelForUseCase('chat', effectiveModel),
|
model: getModelForUseCase('chat', effectiveModel),
|
||||||
maxTurns: MAX_TURNS.standard,
|
maxTurns: MAX_TURNS.standard,
|
||||||
cwd: config.cwd,
|
cwd: config.cwd,
|
||||||
// Only restrict tools if no MCP servers configured or unrestricted is disabled
|
allowedTools: [...TOOL_PRESETS.chat],
|
||||||
...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.chat] }),
|
|
||||||
// Apply MCP bypass options if configured
|
|
||||||
...mcpOptions.bypassOptions,
|
|
||||||
...(sandboxCheck.enabled && {
|
|
||||||
sandbox: {
|
|
||||||
enabled: true,
|
|
||||||
autoAllowBashIfSandboxed: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
...claudeMdOptions,
|
...claudeMdOptions,
|
||||||
...thinkingOptions,
|
...thinkingOptions,
|
||||||
...(config.abortController && { abortController: config.abortController }),
|
...(config.abortController && { abortController: config.abortController }),
|
||||||
@@ -605,7 +487,6 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
|
|||||||
* - Full tool access for code modification and implementation
|
* - Full tool access for code modification and implementation
|
||||||
* - Extended turns for thorough feature implementation
|
* - Extended turns for thorough feature implementation
|
||||||
* - Uses default model (can be overridden)
|
* - 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
|
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
|
||||||
*/
|
*/
|
||||||
export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
|
export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
|
||||||
@@ -621,24 +502,12 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
|
|||||||
// Build thinking options
|
// Build thinking options
|
||||||
const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
|
const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
|
||||||
|
|
||||||
// Check sandbox compatibility (auto-disables for cloud storage paths)
|
|
||||||
const sandboxCheck = checkSandboxCompatibility(config.cwd, config.enableSandboxMode);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...getBaseOptions(),
|
...getBaseOptions(),
|
||||||
model: getModelForUseCase('auto', config.model),
|
model: getModelForUseCase('auto', config.model),
|
||||||
maxTurns: MAX_TURNS.maximum,
|
maxTurns: MAX_TURNS.maximum,
|
||||||
cwd: config.cwd,
|
cwd: config.cwd,
|
||||||
// Only restrict tools if no MCP servers configured or unrestricted is disabled
|
allowedTools: [...TOOL_PRESETS.fullAccess],
|
||||||
...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.fullAccess] }),
|
|
||||||
// Apply MCP bypass options if configured
|
|
||||||
...mcpOptions.bypassOptions,
|
|
||||||
...(sandboxCheck.enabled && {
|
|
||||||
sandbox: {
|
|
||||||
enabled: true,
|
|
||||||
autoAllowBashIfSandboxed: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
...claudeMdOptions,
|
...claudeMdOptions,
|
||||||
...thinkingOptions,
|
...thinkingOptions,
|
||||||
...(config.abortController && { abortController: config.abortController }),
|
...(config.abortController && { abortController: config.abortController }),
|
||||||
@@ -656,7 +525,6 @@ export function createCustomOptions(
|
|||||||
config: CreateSdkOptionsConfig & {
|
config: CreateSdkOptionsConfig & {
|
||||||
maxTurns?: number;
|
maxTurns?: number;
|
||||||
allowedTools?: readonly string[];
|
allowedTools?: readonly string[];
|
||||||
sandbox?: { enabled: boolean; autoAllowBashIfSandboxed?: boolean };
|
|
||||||
}
|
}
|
||||||
): Options {
|
): Options {
|
||||||
// Validate working directory before creating options
|
// Validate working directory before creating options
|
||||||
@@ -671,22 +539,17 @@ export function createCustomOptions(
|
|||||||
// Build thinking options
|
// Build thinking options
|
||||||
const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
|
const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
|
||||||
|
|
||||||
// For custom options: use explicit allowedTools if provided, otherwise use preset based on MCP settings
|
// For custom options: use explicit allowedTools if provided, otherwise default to readOnly
|
||||||
const effectiveAllowedTools = config.allowedTools
|
const effectiveAllowedTools = config.allowedTools
|
||||||
? [...config.allowedTools]
|
? [...config.allowedTools]
|
||||||
: mcpOptions.shouldRestrictTools
|
: [...TOOL_PRESETS.readOnly];
|
||||||
? [...TOOL_PRESETS.readOnly]
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...getBaseOptions(),
|
...getBaseOptions(),
|
||||||
model: getModelForUseCase('default', config.model),
|
model: getModelForUseCase('default', config.model),
|
||||||
maxTurns: config.maxTurns ?? MAX_TURNS.maximum,
|
maxTurns: config.maxTurns ?? MAX_TURNS.maximum,
|
||||||
cwd: config.cwd,
|
cwd: config.cwd,
|
||||||
...(effectiveAllowedTools && { allowedTools: effectiveAllowedTools }),
|
allowedTools: effectiveAllowedTools,
|
||||||
...(config.sandbox && { sandbox: config.sandbox }),
|
|
||||||
// Apply MCP bypass options if configured
|
|
||||||
...mcpOptions.bypassOptions,
|
|
||||||
...claudeMdOptions,
|
...claudeMdOptions,
|
||||||
...thinkingOptions,
|
...thinkingOptions,
|
||||||
...(config.abortController && { abortController: config.abortController }),
|
...(config.abortController && { abortController: config.abortController }),
|
||||||
|
|||||||
@@ -55,34 +55,6 @@ 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
|
* Filters out CLAUDE.md from context files when autoLoadClaudeMd is enabled
|
||||||
* and rebuilds the formatted prompt without it.
|
* and rebuilds the formatted prompt without it.
|
||||||
@@ -269,3 +241,83 @@ export async function getPromptCustomization(
|
|||||||
enhancement: mergeEnhancementPrompts(customization.enhancement),
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { BaseProvider } from './base-provider.js';
|
|||||||
import { classifyError, getUserFriendlyErrorMessage, createLogger } from '@automaker/utils';
|
import { classifyError, getUserFriendlyErrorMessage, createLogger } from '@automaker/utils';
|
||||||
|
|
||||||
const logger = createLogger('ClaudeProvider');
|
const logger = createLogger('ClaudeProvider');
|
||||||
import { getThinkingTokenBudget } from '@automaker/types';
|
import { getThinkingTokenBudget, validateBareModelId } from '@automaker/types';
|
||||||
import type {
|
import type {
|
||||||
ExecuteOptions,
|
ExecuteOptions,
|
||||||
ProviderMessage,
|
ProviderMessage,
|
||||||
@@ -53,6 +53,10 @@ export class ClaudeProvider extends BaseProvider {
|
|||||||
* Execute a query using Claude Agent SDK
|
* Execute a query using Claude Agent SDK
|
||||||
*/
|
*/
|
||||||
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
|
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 {
|
const {
|
||||||
prompt,
|
prompt,
|
||||||
model,
|
model,
|
||||||
@@ -70,14 +74,6 @@ export class ClaudeProvider extends BaseProvider {
|
|||||||
const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel);
|
const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel);
|
||||||
|
|
||||||
// Build Claude SDK options
|
// 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 = {
|
const sdkOptions: Options = {
|
||||||
model,
|
model,
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
@@ -85,10 +81,9 @@ export class ClaudeProvider extends BaseProvider {
|
|||||||
cwd,
|
cwd,
|
||||||
// Pass only explicitly allowed environment variables to SDK
|
// Pass only explicitly allowed environment variables to SDK
|
||||||
env: buildEnv(),
|
env: buildEnv(),
|
||||||
// Only restrict tools if explicitly set OR (no MCP / unrestricted disabled)
|
// Pass through allowedTools if provided by caller (decided by sdk-options.ts)
|
||||||
...(allowedTools && shouldRestrictTools && { allowedTools }),
|
...(allowedTools && { allowedTools }),
|
||||||
...(!allowedTools && shouldRestrictTools && { allowedTools: defaultTools }),
|
// AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation
|
||||||
// AUTONOMOUS MODE: Always bypass permissions and allow dangerous operations
|
|
||||||
permissionMode: 'bypassPermissions',
|
permissionMode: 'bypassPermissions',
|
||||||
allowDangerouslySkipPermissions: true,
|
allowDangerouslySkipPermissions: true,
|
||||||
abortController,
|
abortController,
|
||||||
@@ -98,12 +93,12 @@ export class ClaudeProvider extends BaseProvider {
|
|||||||
: {}),
|
: {}),
|
||||||
// Forward settingSources for CLAUDE.md file loading
|
// Forward settingSources for CLAUDE.md file loading
|
||||||
...(options.settingSources && { settingSources: options.settingSources }),
|
...(options.settingSources && { settingSources: options.settingSources }),
|
||||||
// Forward sandbox configuration
|
|
||||||
...(options.sandbox && { sandbox: options.sandbox }),
|
|
||||||
// Forward MCP servers configuration
|
// Forward MCP servers configuration
|
||||||
...(options.mcpServers && { mcpServers: options.mcpServers }),
|
...(options.mcpServers && { mcpServers: options.mcpServers }),
|
||||||
// Extended thinking configuration
|
// Extended thinking configuration
|
||||||
...(maxThinkingTokens && { maxThinkingTokens }),
|
...(maxThinkingTokens && { maxThinkingTokens }),
|
||||||
|
// Subagents configuration for specialized task delegation
|
||||||
|
...(options.agents && { agents: options.agents }),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build prompt payload
|
// Build prompt payload
|
||||||
|
|||||||
85
apps/server/src/providers/codex-config-manager.ts
Normal file
85
apps/server/src/providers/codex-config-manager.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Codex Config Manager - Writes MCP server configuration for Codex CLI
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from 'path';
|
||||||
|
import type { McpServerConfig } from '@automaker/types';
|
||||||
|
import * as secureFs from '../lib/secure-fs.js';
|
||||||
|
|
||||||
|
const CODEX_CONFIG_DIR = '.codex';
|
||||||
|
const CODEX_CONFIG_FILENAME = 'config.toml';
|
||||||
|
const CODEX_MCP_SECTION = 'mcp_servers';
|
||||||
|
|
||||||
|
function formatTomlString(value: string): string {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTomlArray(values: string[]): string {
|
||||||
|
const formatted = values.map((value) => formatTomlString(value)).join(', ');
|
||||||
|
return `[${formatted}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTomlInlineTable(values: Record<string, 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
111
apps/server/src/providers/codex-models.ts
Normal file
111
apps/server/src/providers/codex-models.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
1082
apps/server/src/providers/codex-provider.ts
Normal file
1082
apps/server/src/providers/codex-provider.ts
Normal file
File diff suppressed because it is too large
Load Diff
173
apps/server/src/providers/codex-sdk-client.ts
Normal file
173
apps/server/src/providers/codex-sdk-client.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
436
apps/server/src/providers/codex-tool-mapping.ts
Normal file
436
apps/server/src/providers/codex-tool-mapping.ts
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -28,7 +28,9 @@ import type {
|
|||||||
ModelDefinition,
|
ModelDefinition,
|
||||||
ContentBlock,
|
ContentBlock,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { stripProviderPrefix } from '@automaker/types';
|
import { validateBareModelId } from '@automaker/types';
|
||||||
|
import { validateApiKey } from '../lib/auth-utils.js';
|
||||||
|
import { getEffectivePermissions } from '../services/cursor-config-service.js';
|
||||||
import {
|
import {
|
||||||
type CursorStreamEvent,
|
type CursorStreamEvent,
|
||||||
type CursorSystemEvent,
|
type CursorSystemEvent,
|
||||||
@@ -315,18 +317,25 @@ export class CursorProvider extends CliProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
buildCliArgs(options: ExecuteOptions): string[] {
|
buildCliArgs(options: ExecuteOptions): string[] {
|
||||||
// Extract model (strip 'cursor-' prefix if present)
|
// Model is already bare (no prefix) - validated by executeQuery
|
||||||
const model = stripProviderPrefix(options.model || 'auto');
|
const model = options.model || 'auto';
|
||||||
|
|
||||||
// Build CLI arguments for cursor-agent
|
// Build CLI arguments for cursor-agent
|
||||||
// NOTE: Prompt is NOT included here - it's passed via stdin to avoid
|
// NOTE: Prompt is NOT included here - it's passed via stdin to avoid
|
||||||
// shell escaping issues when content contains $(), backticks, etc.
|
// shell escaping issues when content contains $(), backticks, etc.
|
||||||
const cliArgs: string[] = [
|
const cliArgs: string[] = [];
|
||||||
|
|
||||||
|
// If using Cursor IDE (cliPath is 'cursor' not 'cursor-agent'), add 'agent' subcommand
|
||||||
|
if (this.cliPath && !this.cliPath.includes('cursor-agent')) {
|
||||||
|
cliArgs.push('agent');
|
||||||
|
}
|
||||||
|
|
||||||
|
cliArgs.push(
|
||||||
'-p', // Print mode (non-interactive)
|
'-p', // Print mode (non-interactive)
|
||||||
'--output-format',
|
'--output-format',
|
||||||
'stream-json',
|
'stream-json',
|
||||||
'--stream-partial-output', // Real-time streaming
|
'--stream-partial-output' // Real-time streaming
|
||||||
];
|
);
|
||||||
|
|
||||||
// Only add --force if NOT in read-only mode
|
// Only add --force if NOT in read-only mode
|
||||||
// Without --force, Cursor CLI suggests changes but doesn't apply them
|
// Without --force, Cursor CLI suggests changes but doesn't apply them
|
||||||
@@ -472,7 +481,9 @@ export class CursorProvider extends CliProvider {
|
|||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Override CLI detection to add Cursor-specific versions directory check
|
* Override CLI detection to add Cursor-specific checks:
|
||||||
|
* 1. Versions directory for cursor-agent installations
|
||||||
|
* 2. Cursor IDE with 'cursor agent' subcommand support
|
||||||
*/
|
*/
|
||||||
protected detectCli(): CliDetectionResult {
|
protected detectCli(): CliDetectionResult {
|
||||||
// First try standard detection (PATH, common paths, WSL)
|
// First try standard detection (PATH, common paths, WSL)
|
||||||
@@ -507,6 +518,39 @@ 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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -605,6 +649,10 @@ export class CursorProvider extends CliProvider {
|
|||||||
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
|
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
|
||||||
this.ensureCliDetected();
|
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) {
|
if (!this.cliPath) {
|
||||||
throw this.createError(
|
throw this.createError(
|
||||||
CursorErrorCode.NOT_INSTALLED,
|
CursorErrorCode.NOT_INSTALLED,
|
||||||
@@ -642,6 +690,9 @@ export class CursorProvider extends CliProvider {
|
|||||||
|
|
||||||
logger.debug(`CursorProvider.executeQuery called with model: "${options.model}"`);
|
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
|
// Debug: log raw events when AUTOMAKER_DEBUG_RAW_OUTPUT is enabled
|
||||||
const debugRawEvents =
|
const debugRawEvents =
|
||||||
process.env.AUTOMAKER_DEBUG_RAW_OUTPUT === 'true' ||
|
process.env.AUTOMAKER_DEBUG_RAW_OUTPUT === 'true' ||
|
||||||
@@ -838,9 +889,16 @@ export class CursorProvider extends CliProvider {
|
|||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
const result = execSync(`"${this.cliPath}" --version`, {
|
|
||||||
|
// If using Cursor IDE, use 'cursor agent --version'
|
||||||
|
const versionCmd = this.cliPath.includes('cursor-agent')
|
||||||
|
? `"${this.cliPath}" --version`
|
||||||
|
: `"${this.cliPath}" agent --version`;
|
||||||
|
|
||||||
|
const result = execSync(versionCmd, {
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
|
stdio: 'pipe',
|
||||||
}).trim();
|
}).trim();
|
||||||
return result;
|
return result;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -857,8 +915,13 @@ export class CursorProvider extends CliProvider {
|
|||||||
return { authenticated: false, method: 'none' };
|
return { authenticated: false, method: 'none' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for API key in environment
|
// Check for API key in environment with validation
|
||||||
if (process.env.CURSOR_API_KEY) {
|
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' };
|
return { authenticated: true, method: 'api_key' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,5 +25,8 @@ export { ClaudeProvider } from './claude-provider.js';
|
|||||||
export { CursorProvider, CursorErrorCode, CursorError } from './cursor-provider.js';
|
export { CursorProvider, CursorErrorCode, CursorError } from './cursor-provider.js';
|
||||||
export { CursorConfigManager } from './cursor-config-manager.js';
|
export { CursorConfigManager } from './cursor-config-manager.js';
|
||||||
|
|
||||||
|
// OpenCode provider
|
||||||
|
export { OpencodeProvider } from './opencode-provider.js';
|
||||||
|
|
||||||
// Provider factory
|
// Provider factory
|
||||||
export { ProviderFactory } from './provider-factory.js';
|
export { ProviderFactory } from './provider-factory.js';
|
||||||
|
|||||||
676
apps/server/src/providers/opencode-provider.ts
Normal file
676
apps/server/src/providers/opencode-provider.ts
Normal file
@@ -0,0 +1,676 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* OpenCode uses underscore format: step_start, step_finish, text
|
||||||
|
*/
|
||||||
|
interface OpenCodeBaseEvent {
|
||||||
|
/** Event type identifier */
|
||||||
|
type: string;
|
||||||
|
/** Timestamp of the event */
|
||||||
|
timestamp?: number;
|
||||||
|
/** Session ID */
|
||||||
|
sessionID?: string;
|
||||||
|
/** Part object containing the actual event data */
|
||||||
|
part?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text event - Text output from the model
|
||||||
|
* Format: {"type":"text","part":{"text":"content",...}}
|
||||||
|
*/
|
||||||
|
export interface OpenCodeTextEvent extends OpenCodeBaseEvent {
|
||||||
|
type: 'text';
|
||||||
|
part: {
|
||||||
|
type: 'text';
|
||||||
|
text: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool call event - Request to execute a tool
|
||||||
|
*/
|
||||||
|
export interface OpenCodeToolCallEvent extends OpenCodeBaseEvent {
|
||||||
|
type: 'tool_call';
|
||||||
|
part: {
|
||||||
|
type: 'tool-call';
|
||||||
|
name: string;
|
||||||
|
call_id?: string;
|
||||||
|
args: unknown;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool result event - Output from a tool execution
|
||||||
|
*/
|
||||||
|
export interface OpenCodeToolResultEvent extends OpenCodeBaseEvent {
|
||||||
|
type: 'tool_result';
|
||||||
|
part: {
|
||||||
|
type: 'tool-result';
|
||||||
|
call_id?: string;
|
||||||
|
output: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool error event - Tool execution failed
|
||||||
|
*/
|
||||||
|
export interface OpenCodeToolErrorEvent extends OpenCodeBaseEvent {
|
||||||
|
type: 'tool_error';
|
||||||
|
part: {
|
||||||
|
type: 'tool-error';
|
||||||
|
call_id?: string;
|
||||||
|
error: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start step event - Begins an agentic loop iteration
|
||||||
|
* Format: {"type":"step_start","part":{...}}
|
||||||
|
*/
|
||||||
|
export interface OpenCodeStartStepEvent extends OpenCodeBaseEvent {
|
||||||
|
type: 'step_start';
|
||||||
|
part?: {
|
||||||
|
type: 'step-start';
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finish step event - Completes an agentic loop iteration
|
||||||
|
* Format: {"type":"step_finish","part":{"reason":"stop",...}}
|
||||||
|
*/
|
||||||
|
export interface OpenCodeFinishStepEvent extends OpenCodeBaseEvent {
|
||||||
|
type: 'step_finish';
|
||||||
|
part?: {
|
||||||
|
type: 'step-finish';
|
||||||
|
reason?: string;
|
||||||
|
error?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union type of all OpenCode stream events
|
||||||
|
*/
|
||||||
|
export type OpenCodeStreamEvent =
|
||||||
|
| OpenCodeTextEvent
|
||||||
|
| 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', 'json' for JSON streaming output
|
||||||
|
* - '--model', '<model>' for model selection (if specified)
|
||||||
|
* - Message passed via stdin (no positional args needed)
|
||||||
|
*
|
||||||
|
* The prompt is passed via stdin to avoid shell escaping issues.
|
||||||
|
* OpenCode will read from stdin when no positional message arguments are provided.
|
||||||
|
*
|
||||||
|
* @param options - Execution options containing model, cwd, etc.
|
||||||
|
* @returns Array of CLI arguments for opencode run
|
||||||
|
*/
|
||||||
|
buildCliArgs(options: ExecuteOptions): string[] {
|
||||||
|
const args: string[] = ['run'];
|
||||||
|
|
||||||
|
// Add JSON output format for streaming
|
||||||
|
args.push('--format', 'json');
|
||||||
|
|
||||||
|
// Handle model selection
|
||||||
|
// Strip 'opencode-' prefix if present, OpenCode uses native format
|
||||||
|
if (options.model) {
|
||||||
|
const model = stripProviderPrefix(options.model);
|
||||||
|
args.push('--model', model);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: Working directory is set via subprocess cwd option, not CLI args
|
||||||
|
// Note: Message is passed via stdin, OpenCode reads from stdin automatically
|
||||||
|
|
||||||
|
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 -> type: 'assistant', content with type: 'text'
|
||||||
|
* - step_start -> null (informational, no message needed)
|
||||||
|
* - step_finish -> type: 'result', subtype: 'success' (or error if failed)
|
||||||
|
* - tool_call -> type: 'assistant', content with type: 'tool_use'
|
||||||
|
* - tool_result -> type: 'assistant', content with type: 'tool_result'
|
||||||
|
* - tool_error -> type: 'error'
|
||||||
|
*
|
||||||
|
* @param event - Raw event from OpenCode CLI JSONL output
|
||||||
|
* @returns Normalized ProviderMessage or null to skip the event
|
||||||
|
*/
|
||||||
|
normalizeEvent(event: unknown): ProviderMessage | null {
|
||||||
|
if (!event || typeof event !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCodeEvent = event as OpenCodeStreamEvent;
|
||||||
|
|
||||||
|
switch (openCodeEvent.type) {
|
||||||
|
case 'text': {
|
||||||
|
const textEvent = openCodeEvent as OpenCodeTextEvent;
|
||||||
|
|
||||||
|
// Skip if no text content
|
||||||
|
if (!textEvent.part?.text) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content: ContentBlock[] = [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: textEvent.part.text,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'assistant',
|
||||||
|
session_id: textEvent.sessionID,
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'step_start': {
|
||||||
|
// Start step is informational - no message needed
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'step_finish': {
|
||||||
|
const finishEvent = openCodeEvent as OpenCodeFinishStepEvent;
|
||||||
|
|
||||||
|
// Check if the step failed (either has error field or reason is 'error')
|
||||||
|
if (finishEvent.part?.error || finishEvent.part?.reason === 'error') {
|
||||||
|
return {
|
||||||
|
type: 'error',
|
||||||
|
session_id: finishEvent.sessionID,
|
||||||
|
error: finishEvent.part?.error || 'Step execution failed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Successful completion
|
||||||
|
const result: { type: 'result'; subtype: 'success'; session_id?: string; result?: string } =
|
||||||
|
{
|
||||||
|
type: 'result',
|
||||||
|
subtype: 'success',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (finishEvent.sessionID) {
|
||||||
|
result.session_id = finishEvent.sessionID;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safely handle arbitrary result payloads from CLI: ensure we assign a string.
|
||||||
|
const rawResult =
|
||||||
|
(finishEvent.part && (finishEvent.part as Record<string, unknown>).result) ?? undefined;
|
||||||
|
if (rawResult !== undefined) {
|
||||||
|
result.result = typeof rawResult === 'string' ? rawResult : JSON.stringify(rawResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'tool_call': {
|
||||||
|
const toolEvent = openCodeEvent as OpenCodeToolCallEvent;
|
||||||
|
|
||||||
|
if (!toolEvent.part) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a tool use ID if not provided
|
||||||
|
const toolUseId = toolEvent.part.call_id || generateToolUseId();
|
||||||
|
|
||||||
|
const content: ContentBlock[] = [
|
||||||
|
{
|
||||||
|
type: 'tool_use',
|
||||||
|
name: toolEvent.part.name,
|
||||||
|
tool_use_id: toolUseId,
|
||||||
|
input: toolEvent.part.args,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'assistant',
|
||||||
|
session_id: toolEvent.sessionID,
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'tool_result': {
|
||||||
|
const resultEvent = openCodeEvent as OpenCodeToolResultEvent;
|
||||||
|
|
||||||
|
if (!resultEvent.part) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content: ContentBlock[] = [
|
||||||
|
{
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_use_id: resultEvent.part.call_id,
|
||||||
|
content: resultEvent.part.output,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'assistant',
|
||||||
|
session_id: resultEvent.sessionID,
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'tool_error': {
|
||||||
|
const errorEvent = openCodeEvent as OpenCodeToolErrorEvent;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'error',
|
||||||
|
session_id: errorEvent.sessionID,
|
||||||
|
error: errorEvent.part?.error || 'Tool execution failed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,27 @@
|
|||||||
|
|
||||||
import { BaseProvider } from './base-provider.js';
|
import { BaseProvider } from './base-provider.js';
|
||||||
import type { InstallationStatus, ModelDefinition } from './types.js';
|
import type { InstallationStatus, ModelDefinition } from './types.js';
|
||||||
import { isCursorModel, type ModelProvider } from '@automaker/types';
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provider registration entry
|
* Provider registration entry
|
||||||
@@ -75,10 +95,26 @@ export class ProviderFactory {
|
|||||||
* Get the appropriate provider for a given model ID
|
* 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 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
|
* @returns Provider instance for the model
|
||||||
|
* @throws Error if provider is disconnected and throwOnDisconnected is true
|
||||||
*/
|
*/
|
||||||
static getProviderForModel(modelId: string): BaseProvider {
|
static getProviderForModel(
|
||||||
const providerName = this.getProviderNameForModel(modelId);
|
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.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const provider = this.getProviderByName(providerName);
|
const provider = this.getProviderByName(providerName);
|
||||||
|
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
@@ -93,6 +129,35 @@ export class ProviderFactory {
|
|||||||
return provider;
|
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
|
* Get all available providers
|
||||||
*/
|
*/
|
||||||
@@ -156,6 +221,41 @@ export class ProviderFactory {
|
|||||||
static getRegisteredProviderNames(): string[] {
|
static getRegisteredProviderNames(): string[] {
|
||||||
return Array.from(providerRegistry.keys());
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -165,6 +265,8 @@ export class ProviderFactory {
|
|||||||
// Import providers for registration side-effects
|
// Import providers for registration side-effects
|
||||||
import { ClaudeProvider } from './claude-provider.js';
|
import { ClaudeProvider } from './claude-provider.js';
|
||||||
import { CursorProvider } from './cursor-provider.js';
|
import { CursorProvider } from './cursor-provider.js';
|
||||||
|
import { CodexProvider } from './codex-provider.js';
|
||||||
|
import { OpencodeProvider } from './opencode-provider.js';
|
||||||
|
|
||||||
// Register Claude provider
|
// Register Claude provider
|
||||||
registerProvider('claude', {
|
registerProvider('claude', {
|
||||||
@@ -184,3 +286,18 @@ registerProvider('cursor', {
|
|||||||
canHandleModel: (model: string) => isCursorModel(model),
|
canHandleModel: (model: string) => isCursorModel(model),
|
||||||
priority: 10, // Higher priority - check Cursor models first
|
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)
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,26 +6,57 @@ import { createLogger } from '@automaker/utils';
|
|||||||
|
|
||||||
const logger = createLogger('SpecRegeneration');
|
const logger = createLogger('SpecRegeneration');
|
||||||
|
|
||||||
// Shared state for tracking generation status - private
|
// Shared state for tracking generation status - scoped by project path
|
||||||
let isRunning = false;
|
const runningProjects = new Map<string, boolean>();
|
||||||
let currentAbortController: AbortController | null = null;
|
const abortControllers = new Map<string, AbortController>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current running state
|
* Get the running state for a specific project
|
||||||
*/
|
*/
|
||||||
export function getSpecRegenerationStatus(): {
|
export function getSpecRegenerationStatus(projectPath?: string): {
|
||||||
isRunning: boolean;
|
isRunning: boolean;
|
||||||
currentAbortController: AbortController | null;
|
currentAbortController: AbortController | null;
|
||||||
|
projectPath?: string;
|
||||||
} {
|
} {
|
||||||
return { isRunning, currentAbortController };
|
if (projectPath) {
|
||||||
|
return {
|
||||||
|
isRunning: runningProjects.get(projectPath) || false,
|
||||||
|
currentAbortController: abortControllers.get(projectPath) || null,
|
||||||
|
projectPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Fallback: check if any project is running (for backward compatibility)
|
||||||
|
const isAnyRunning = Array.from(runningProjects.values()).some((running) => running);
|
||||||
|
return { isRunning: isAnyRunning, currentAbortController: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the running state and abort controller
|
* Get the project path that is currently running (if any)
|
||||||
*/
|
*/
|
||||||
export function setRunningState(running: boolean, controller: AbortController | null = null): void {
|
export function getRunningProjectPath(): string | null {
|
||||||
isRunning = running;
|
for (const [path, running] of runningProjects.entries()) {
|
||||||
currentAbortController = controller;
|
if (running) return path;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the running state and abort controller for a specific project
|
||||||
|
*/
|
||||||
|
export function setRunningState(
|
||||||
|
projectPath: string,
|
||||||
|
running: boolean,
|
||||||
|
controller: AbortController | null = null
|
||||||
|
): void {
|
||||||
|
if (running) {
|
||||||
|
runningProjects.set(projectPath, true);
|
||||||
|
if (controller) {
|
||||||
|
abortControllers.set(projectPath, controller);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
runningProjects.delete(projectPath);
|
||||||
|
abortControllers.delete(projectPath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { query } from '@anthropic-ai/claude-agent-sdk';
|
|||||||
import * as secureFs from '../../lib/secure-fs.js';
|
import * as secureFs from '../../lib/secure-fs.js';
|
||||||
import type { EventEmitter } from '../../lib/events.js';
|
import type { EventEmitter } from '../../lib/events.js';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
|
import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types';
|
||||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||||
import { createFeatureGenerationOptions } from '../../lib/sdk-options.js';
|
import { createFeatureGenerationOptions } from '../../lib/sdk-options.js';
|
||||||
import { ProviderFactory } from '../../providers/provider-factory.js';
|
import { ProviderFactory } from '../../providers/provider-factory.js';
|
||||||
@@ -124,6 +124,8 @@ IMPORTANT: Do not ask for clarification. The specification is provided above. Ge
|
|||||||
logger.info('[FeatureGeneration] Using Cursor provider');
|
logger.info('[FeatureGeneration] Using Cursor provider');
|
||||||
|
|
||||||
const provider = ProviderFactory.getProviderForModel(model);
|
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
|
// Add explicit instructions for Cursor to return JSON in response
|
||||||
const cursorPrompt = `${prompt}
|
const cursorPrompt = `${prompt}
|
||||||
@@ -135,7 +137,7 @@ CRITICAL INSTRUCTIONS:
|
|||||||
|
|
||||||
for await (const msg of provider.executeQuery({
|
for await (const msg of provider.executeQuery({
|
||||||
prompt: cursorPrompt,
|
prompt: cursorPrompt,
|
||||||
model,
|
model: bareModel,
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
maxTurns: 250,
|
maxTurns: 250,
|
||||||
allowedTools: ['Read', 'Glob', 'Grep'],
|
allowedTools: ['Read', 'Glob', 'Grep'],
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
type SpecOutput,
|
type SpecOutput,
|
||||||
} from '../../lib/app-spec-format.js';
|
} from '../../lib/app-spec-format.js';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
|
import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types';
|
||||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||||
import { createSpecGenerationOptions } from '../../lib/sdk-options.js';
|
import { createSpecGenerationOptions } from '../../lib/sdk-options.js';
|
||||||
import { extractJson } from '../../lib/json-extractor.js';
|
import { extractJson } from '../../lib/json-extractor.js';
|
||||||
@@ -118,6 +118,8 @@ ${getStructuredSpecPromptInstruction()}`;
|
|||||||
logger.info('[SpecGeneration] Using Cursor provider');
|
logger.info('[SpecGeneration] Using Cursor provider');
|
||||||
|
|
||||||
const provider = ProviderFactory.getProviderForModel(model);
|
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
|
// For Cursor, include the JSON schema in the prompt with clear instructions
|
||||||
// to return JSON in the response (not write to a file)
|
// to return JSON in the response (not write to a file)
|
||||||
@@ -134,7 +136,7 @@ Your entire response should be valid JSON starting with { and ending with }. No
|
|||||||
|
|
||||||
for await (const msg of provider.executeQuery({
|
for await (const msg of provider.executeQuery({
|
||||||
prompt: cursorPrompt,
|
prompt: cursorPrompt,
|
||||||
model,
|
model: bareModel,
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
maxTurns: 250,
|
maxTurns: 250,
|
||||||
allowedTools: ['Read', 'Glob', 'Grep'],
|
allowedTools: ['Read', 'Glob', 'Grep'],
|
||||||
|
|||||||
@@ -47,17 +47,17 @@ export function createCreateHandler(events: EventEmitter) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { isRunning } = getSpecRegenerationStatus();
|
const { isRunning } = getSpecRegenerationStatus(projectPath);
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
logger.warn('Generation already running, rejecting request');
|
logger.warn('Generation already running for project:', projectPath);
|
||||||
res.json({ success: false, error: 'Spec generation already running' });
|
res.json({ success: false, error: 'Spec generation already running for this project' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logAuthStatus('Before starting generation');
|
logAuthStatus('Before starting generation');
|
||||||
|
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
setRunningState(true, abortController);
|
setRunningState(projectPath, true, abortController);
|
||||||
logger.info('Starting background generation task...');
|
logger.info('Starting background generation task...');
|
||||||
|
|
||||||
// Start generation in background
|
// Start generation in background
|
||||||
@@ -80,7 +80,7 @@ export function createCreateHandler(events: EventEmitter) {
|
|||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
logger.info('Generation task finished (success or error)');
|
logger.info('Generation task finished (success or error)');
|
||||||
setRunningState(false, null);
|
setRunningState(projectPath, false, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info('Returning success response (generation running in background)');
|
logger.info('Returning success response (generation running in background)');
|
||||||
|
|||||||
@@ -40,17 +40,17 @@ export function createGenerateFeaturesHandler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { isRunning } = getSpecRegenerationStatus();
|
const { isRunning } = getSpecRegenerationStatus(projectPath);
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
logger.warn('Generation already running, rejecting request');
|
logger.warn('Generation already running for project:', projectPath);
|
||||||
res.json({ success: false, error: 'Generation already running' });
|
res.json({ success: false, error: 'Generation already running for this project' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logAuthStatus('Before starting feature generation');
|
logAuthStatus('Before starting feature generation');
|
||||||
|
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
setRunningState(true, abortController);
|
setRunningState(projectPath, true, abortController);
|
||||||
logger.info('Starting background feature generation task...');
|
logger.info('Starting background feature generation task...');
|
||||||
|
|
||||||
generateFeaturesFromSpec(projectPath, events, abortController, maxFeatures, settingsService)
|
generateFeaturesFromSpec(projectPath, events, abortController, maxFeatures, settingsService)
|
||||||
@@ -63,7 +63,7 @@ export function createGenerateFeaturesHandler(
|
|||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
logger.info('Feature generation task finished (success or error)');
|
logger.info('Feature generation task finished (success or error)');
|
||||||
setRunningState(false, null);
|
setRunningState(projectPath, false, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info('Returning success response (generation running in background)');
|
logger.info('Returning success response (generation running in background)');
|
||||||
|
|||||||
@@ -48,17 +48,17 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { isRunning } = getSpecRegenerationStatus();
|
const { isRunning } = getSpecRegenerationStatus(projectPath);
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
logger.warn('Generation already running, rejecting request');
|
logger.warn('Generation already running for project:', projectPath);
|
||||||
res.json({ success: false, error: 'Spec generation already running' });
|
res.json({ success: false, error: 'Spec generation already running for this project' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logAuthStatus('Before starting generation');
|
logAuthStatus('Before starting generation');
|
||||||
|
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
setRunningState(true, abortController);
|
setRunningState(projectPath, true, abortController);
|
||||||
logger.info('Starting background generation task...');
|
logger.info('Starting background generation task...');
|
||||||
|
|
||||||
generateSpec(
|
generateSpec(
|
||||||
@@ -81,7 +81,7 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se
|
|||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
logger.info('Generation task finished (success or error)');
|
logger.info('Generation task finished (success or error)');
|
||||||
setRunningState(false, null);
|
setRunningState(projectPath, false, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info('Returning success response (generation running in background)');
|
logger.info('Returning success response (generation running in background)');
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import type { Request, Response } from 'express';
|
|||||||
import { getSpecRegenerationStatus, getErrorMessage } from '../common.js';
|
import { getSpecRegenerationStatus, getErrorMessage } from '../common.js';
|
||||||
|
|
||||||
export function createStatusHandler() {
|
export function createStatusHandler() {
|
||||||
return async (_req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { isRunning } = getSpecRegenerationStatus();
|
const projectPath = req.query.projectPath as string | undefined;
|
||||||
res.json({ success: true, isRunning });
|
const { isRunning } = getSpecRegenerationStatus(projectPath);
|
||||||
|
res.json({ success: true, isRunning, projectPath });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,16 @@ import type { Request, Response } from 'express';
|
|||||||
import { getSpecRegenerationStatus, setRunningState, getErrorMessage } from '../common.js';
|
import { getSpecRegenerationStatus, setRunningState, getErrorMessage } from '../common.js';
|
||||||
|
|
||||||
export function createStopHandler() {
|
export function createStopHandler() {
|
||||||
return async (_req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { currentAbortController } = getSpecRegenerationStatus();
|
const { projectPath } = req.body as { projectPath?: string };
|
||||||
|
const { currentAbortController } = getSpecRegenerationStatus(projectPath);
|
||||||
if (currentAbortController) {
|
if (currentAbortController) {
|
||||||
currentAbortController.abort();
|
currentAbortController.abort();
|
||||||
}
|
}
|
||||||
setRunningState(false, null);
|
if (projectPath) {
|
||||||
|
setRunningState(projectPath, false, null);
|
||||||
|
}
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
|||||||
@@ -229,12 +229,13 @@ export function createAuthRoutes(): Router {
|
|||||||
await invalidateSession(sessionToken);
|
await invalidateSession(sessionToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear the cookie
|
// Clear the cookie by setting it to empty with immediate expiration
|
||||||
res.clearCookie(cookieName, {
|
// Using res.cookie() with maxAge: 0 is more reliable than clearCookie()
|
||||||
httpOnly: true,
|
// in cross-origin development environments
|
||||||
secure: process.env.NODE_ENV === 'production',
|
res.cookie(cookieName, '', {
|
||||||
sameSite: 'strict',
|
...getSessionCookieOptions(),
|
||||||
path: '/',
|
maxAge: 0,
|
||||||
|
expires: new Date(0),
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { createAnalyzeProjectHandler } from './routes/analyze-project.js';
|
|||||||
import { createFollowUpFeatureHandler } from './routes/follow-up-feature.js';
|
import { createFollowUpFeatureHandler } from './routes/follow-up-feature.js';
|
||||||
import { createCommitFeatureHandler } from './routes/commit-feature.js';
|
import { createCommitFeatureHandler } from './routes/commit-feature.js';
|
||||||
import { createApprovePlanHandler } from './routes/approve-plan.js';
|
import { createApprovePlanHandler } from './routes/approve-plan.js';
|
||||||
|
import { createResumeInterruptedHandler } from './routes/resume-interrupted.js';
|
||||||
|
|
||||||
export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
|
export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -63,6 +64,11 @@ export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
|
|||||||
validatePathParams('projectPath'),
|
validatePathParams('projectPath'),
|
||||||
createApprovePlanHandler(autoModeService)
|
createApprovePlanHandler(autoModeService)
|
||||||
);
|
);
|
||||||
|
router.post(
|
||||||
|
'/resume-interrupted',
|
||||||
|
validatePathParams('projectPath'),
|
||||||
|
createResumeInterruptedHandler(autoModeService)
|
||||||
|
);
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ export function createFollowUpFeatureHandler(autoModeService: AutoModeService) {
|
|||||||
// Start follow-up in background
|
// Start follow-up in background
|
||||||
// followUpFeature derives workDir from feature.branchName
|
// followUpFeature derives workDir from feature.branchName
|
||||||
autoModeService
|
autoModeService
|
||||||
.followUpFeature(projectPath, featureId, prompt, imagePaths, useWorktrees ?? true)
|
// 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)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
logger.error(`[AutoMode] Follow up feature ${featureId} error:`, error);
|
logger.error(`[AutoMode] Follow up feature ${featureId} error:`, error);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Resume Interrupted Features Handler
|
||||||
|
*
|
||||||
|
* Checks for features that were interrupted (in pipeline steps or in_progress)
|
||||||
|
* when the server was restarted and resumes them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
|
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||||
|
|
||||||
|
const logger = createLogger('ResumeInterrupted');
|
||||||
|
|
||||||
|
interface ResumeInterruptedRequest {
|
||||||
|
projectPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createResumeInterruptedHandler(autoModeService: AutoModeService) {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
const { projectPath } = req.body as ResumeInterruptedRequest;
|
||||||
|
|
||||||
|
if (!projectPath) {
|
||||||
|
res.status(400).json({ error: 'Project path is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Checking for interrupted features in ${projectPath}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await autoModeService.resumeInterruptedFeatures(projectPath);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Resume check completed',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error resuming interrupted features:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,7 +7,12 @@
|
|||||||
|
|
||||||
import type { EventEmitter } from '../../lib/events.js';
|
import type { EventEmitter } from '../../lib/events.js';
|
||||||
import type { Feature, BacklogPlanResult, BacklogChange, DependencyUpdate } from '@automaker/types';
|
import type { Feature, BacklogPlanResult, BacklogChange, DependencyUpdate } from '@automaker/types';
|
||||||
import { DEFAULT_PHASE_MODELS, isCursorModel, type ThinkingLevel } from '@automaker/types';
|
import {
|
||||||
|
DEFAULT_PHASE_MODELS,
|
||||||
|
isCursorModel,
|
||||||
|
stripProviderPrefix,
|
||||||
|
type ThinkingLevel,
|
||||||
|
} from '@automaker/types';
|
||||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||||
import { FeatureLoader } from '../../services/feature-loader.js';
|
import { FeatureLoader } from '../../services/feature-loader.js';
|
||||||
import { ProviderFactory } from '../../providers/provider-factory.js';
|
import { ProviderFactory } from '../../providers/provider-factory.js';
|
||||||
@@ -120,6 +125,8 @@ export async function generateBacklogPlan(
|
|||||||
logger.info('[BacklogPlan] Using model:', effectiveModel);
|
logger.info('[BacklogPlan] Using model:', effectiveModel);
|
||||||
|
|
||||||
const provider = ProviderFactory.getProviderForModel(effectiveModel);
|
const provider = ProviderFactory.getProviderForModel(effectiveModel);
|
||||||
|
// Strip provider prefix - providers expect bare model IDs
|
||||||
|
const bareModel = stripProviderPrefix(effectiveModel);
|
||||||
|
|
||||||
// Get autoLoadClaudeMd setting
|
// Get autoLoadClaudeMd setting
|
||||||
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
|
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
|
||||||
@@ -151,7 +158,7 @@ ${userPrompt}`;
|
|||||||
// Execute the query
|
// Execute the query
|
||||||
const stream = provider.executeQuery({
|
const stream = provider.executeQuery({
|
||||||
prompt: finalPrompt,
|
prompt: finalPrompt,
|
||||||
model: effectiveModel,
|
model: bareModel,
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
systemPrompt: finalSystemPrompt,
|
systemPrompt: finalSystemPrompt,
|
||||||
maxTurns: 1,
|
maxTurns: 1,
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ export function createClaudeRoutes(service: ClaudeUsageService): Router {
|
|||||||
// Check if Claude CLI is available first
|
// Check if Claude CLI is available first
|
||||||
const isAvailable = await service.isAvailable();
|
const isAvailable = await service.isAvailable();
|
||||||
if (!isAvailable) {
|
if (!isAvailable) {
|
||||||
res.status(503).json({
|
// IMPORTANT: This endpoint is behind Automaker session auth already.
|
||||||
|
// Use a 200 + error payload for Claude CLI issues so the UI doesn't
|
||||||
|
// interpret it as an invalid Automaker session (401/403 triggers logout).
|
||||||
|
res.status(200).json({
|
||||||
error: 'Claude CLI not found',
|
error: 'Claude CLI not found',
|
||||||
message: "Please install Claude Code CLI and run 'claude login' to authenticate",
|
message: "Please install Claude Code CLI and run 'claude login' to authenticate",
|
||||||
});
|
});
|
||||||
@@ -26,12 +29,13 @@ export function createClaudeRoutes(service: ClaudeUsageService): Router {
|
|||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
|
||||||
if (message.includes('Authentication required') || message.includes('token_expired')) {
|
if (message.includes('Authentication required') || message.includes('token_expired')) {
|
||||||
res.status(401).json({
|
// Do NOT use 401/403 here: that status code is reserved for Automaker session auth.
|
||||||
|
res.status(200).json({
|
||||||
error: 'Authentication required',
|
error: 'Authentication required',
|
||||||
message: "Please run 'claude login' to authenticate",
|
message: "Please run 'claude login' to authenticate",
|
||||||
});
|
});
|
||||||
} else if (message.includes('timed out')) {
|
} else if (message.includes('timed out')) {
|
||||||
res.status(504).json({
|
res.status(200).json({
|
||||||
error: 'Command timed out',
|
error: 'Command timed out',
|
||||||
message: 'The Claude CLI took too long to respond',
|
message: 'The Claude CLI took too long to respond',
|
||||||
});
|
});
|
||||||
|
|||||||
90
apps/server/src/routes/codex/index.ts
Normal file
90
apps/server/src/routes/codex/index.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { CodexUsageService } from '../../services/codex-usage-service.js';
|
||||||
|
import { CodexModelCacheService } from '../../services/codex-model-cache-service.js';
|
||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
|
|
||||||
|
const logger = createLogger('Codex');
|
||||||
|
|
||||||
|
export function createCodexRoutes(
|
||||||
|
usageService: CodexUsageService,
|
||||||
|
modelCacheService: CodexModelCacheService
|
||||||
|
): Router {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Get current usage (attempts to fetch from Codex CLI)
|
||||||
|
router.get('/usage', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
// Check if Codex CLI is available first
|
||||||
|
const isAvailable = await usageService.isAvailable();
|
||||||
|
if (!isAvailable) {
|
||||||
|
// IMPORTANT: This endpoint is behind Automaker session auth already.
|
||||||
|
// Use a 200 + error payload for Codex CLI issues so the UI doesn't
|
||||||
|
// interpret it as an invalid Automaker session (401/403 triggers logout).
|
||||||
|
res.status(200).json({
|
||||||
|
error: 'Codex CLI not found',
|
||||||
|
message: "Please install Codex CLI and run 'codex login' to authenticate",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const usage = await usageService.fetchUsageData();
|
||||||
|
res.json(usage);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
|
||||||
|
if (message.includes('not authenticated') || message.includes('login')) {
|
||||||
|
// Do NOT use 401/403 here: that status code is reserved for Automaker session auth.
|
||||||
|
res.status(200).json({
|
||||||
|
error: 'Authentication required',
|
||||||
|
message: "Please run 'codex login' to authenticate",
|
||||||
|
});
|
||||||
|
} else if (message.includes('not available') || message.includes('does not provide')) {
|
||||||
|
// This is the expected case - Codex doesn't provide usage stats
|
||||||
|
res.status(200).json({
|
||||||
|
error: 'Usage statistics not available',
|
||||||
|
message: message,
|
||||||
|
});
|
||||||
|
} else if (message.includes('timed out')) {
|
||||||
|
res.status(200).json({
|
||||||
|
error: 'Command timed out',
|
||||||
|
message: 'The Codex CLI took too long to respond',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.error('Error fetching usage:', error);
|
||||||
|
res.status(500).json({ error: message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get available Codex models (cached)
|
||||||
|
router.get('/models', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const forceRefresh = req.query.refresh === 'true';
|
||||||
|
const { models, cachedAt } = await modelCacheService.getModelsWithMetadata(forceRefresh);
|
||||||
|
|
||||||
|
if (models.length === 0) {
|
||||||
|
res.status(503).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Codex CLI not available or not authenticated',
|
||||||
|
message: "Please install Codex CLI and run 'codex login' to authenticate",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
models,
|
||||||
|
cachedAt,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching models:', error);
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
|
import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types';
|
||||||
import { PathNotAllowedError } from '@automaker/platform';
|
import { PathNotAllowedError } from '@automaker/platform';
|
||||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||||
import { createCustomOptions } from '../../../lib/sdk-options.js';
|
import { createCustomOptions } from '../../../lib/sdk-options.js';
|
||||||
@@ -198,6 +198,8 @@ File: ${fileName}${truncated ? ' (truncated)' : ''}`;
|
|||||||
logger.info(`Using Cursor provider for model: ${model}`);
|
logger.info(`Using Cursor provider for model: ${model}`);
|
||||||
|
|
||||||
const provider = ProviderFactory.getProviderForModel(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)
|
// Build a simple text prompt for Cursor (no multi-part content blocks)
|
||||||
const cursorPrompt = `${instructionText}\n\n--- FILE CONTENT ---\n${contentToAnalyze}`;
|
const cursorPrompt = `${instructionText}\n\n--- FILE CONTENT ---\n${contentToAnalyze}`;
|
||||||
@@ -205,7 +207,7 @@ File: ${fileName}${truncated ? ' (truncated)' : ''}`;
|
|||||||
let responseText = '';
|
let responseText = '';
|
||||||
for await (const msg of provider.executeQuery({
|
for await (const msg of provider.executeQuery({
|
||||||
prompt: cursorPrompt,
|
prompt: cursorPrompt,
|
||||||
model,
|
model: bareModel,
|
||||||
cwd,
|
cwd,
|
||||||
maxTurns: 1,
|
maxTurns: 1,
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
@@ -232,7 +234,6 @@ File: ${fileName}${truncated ? ' (truncated)' : ''}`;
|
|||||||
maxTurns: 1,
|
maxTurns: 1,
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
autoLoadClaudeMd,
|
autoLoadClaudeMd,
|
||||||
sandbox: { enabled: true, autoAllowBashIfSandboxed: true },
|
|
||||||
thinkingLevel, // Pass thinking level for extended thinking
|
thinkingLevel, // Pass thinking level for extended thinking
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||||
import { createLogger, readImageAsBase64 } from '@automaker/utils';
|
import { createLogger, readImageAsBase64 } from '@automaker/utils';
|
||||||
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
|
import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types';
|
||||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||||
import { createCustomOptions } from '../../../lib/sdk-options.js';
|
import { createCustomOptions } from '../../../lib/sdk-options.js';
|
||||||
import { ProviderFactory } from '../../../providers/provider-factory.js';
|
import { ProviderFactory } from '../../../providers/provider-factory.js';
|
||||||
@@ -357,6 +357,8 @@ export function createDescribeImageHandler(
|
|||||||
logger.info(`[${requestId}] Using Cursor provider for model: ${model}`);
|
logger.info(`[${requestId}] Using Cursor provider for model: ${model}`);
|
||||||
|
|
||||||
const provider = ProviderFactory.getProviderForModel(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
|
// Build prompt with image reference for Cursor
|
||||||
// Note: Cursor CLI may not support base64 image blocks directly,
|
// Note: Cursor CLI may not support base64 image blocks directly,
|
||||||
@@ -367,7 +369,7 @@ export function createDescribeImageHandler(
|
|||||||
const queryStart = Date.now();
|
const queryStart = Date.now();
|
||||||
for await (const msg of provider.executeQuery({
|
for await (const msg of provider.executeQuery({
|
||||||
prompt: cursorPrompt,
|
prompt: cursorPrompt,
|
||||||
model,
|
model: bareModel,
|
||||||
cwd,
|
cwd,
|
||||||
maxTurns: 1,
|
maxTurns: 1,
|
||||||
allowedTools: ['Read'], // Allow Read tool so Cursor can read the image if needed
|
allowedTools: ['Read'], // Allow Read tool so Cursor can read the image if needed
|
||||||
@@ -394,14 +396,13 @@ export function createDescribeImageHandler(
|
|||||||
maxTurns: 1,
|
maxTurns: 1,
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
autoLoadClaudeMd,
|
autoLoadClaudeMd,
|
||||||
sandbox: { enabled: true, autoAllowBashIfSandboxed: true },
|
|
||||||
thinkingLevel, // Pass thinking level for extended thinking
|
thinkingLevel, // Pass thinking level for extended thinking
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`[${requestId}] SDK options model=${sdkOptions.model} maxTurns=${sdkOptions.maxTurns} allowedTools=${JSON.stringify(
|
`[${requestId}] SDK options model=${sdkOptions.model} maxTurns=${sdkOptions.maxTurns} allowedTools=${JSON.stringify(
|
||||||
sdkOptions.allowedTools
|
sdkOptions.allowedTools
|
||||||
)} sandbox=${JSON.stringify(sdkOptions.sandbox)}`
|
)}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const promptGenerator = (async function* () {
|
const promptGenerator = (async function* () {
|
||||||
|
|||||||
@@ -1,332 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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';
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -12,6 +12,7 @@ import { resolveModelString } from '@automaker/model-resolver';
|
|||||||
import {
|
import {
|
||||||
CLAUDE_MODEL_MAP,
|
CLAUDE_MODEL_MAP,
|
||||||
isCursorModel,
|
isCursorModel,
|
||||||
|
stripProviderPrefix,
|
||||||
ThinkingLevel,
|
ThinkingLevel,
|
||||||
getThinkingTokenBudget,
|
getThinkingTokenBudget,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
@@ -98,12 +99,14 @@ async function extractTextFromStream(
|
|||||||
*/
|
*/
|
||||||
async function executeWithCursor(prompt: string, model: string): Promise<string> {
|
async function executeWithCursor(prompt: string, model: string): Promise<string> {
|
||||||
const provider = ProviderFactory.getProviderForModel(model);
|
const provider = ProviderFactory.getProviderForModel(model);
|
||||||
|
// Strip provider prefix - providers expect bare model IDs
|
||||||
|
const bareModel = stripProviderPrefix(model);
|
||||||
|
|
||||||
let responseText = '';
|
let responseText = '';
|
||||||
|
|
||||||
for await (const msg of provider.executeQuery({
|
for await (const msg of provider.executeQuery({
|
||||||
prompt,
|
prompt,
|
||||||
model,
|
model: bareModel,
|
||||||
cwd: process.cwd(), // Enhancement doesn't need a specific working directory
|
cwd: process.cwd(), // Enhancement doesn't need a specific working directory
|
||||||
readOnly: true, // Prompt enhancement only generates text, doesn't write files
|
readOnly: true, // Prompt enhancement only generates text, doesn't write files
|
||||||
})) {
|
})) {
|
||||||
@@ -185,6 +188,7 @@ export function createEnhanceHandler(
|
|||||||
technical: prompts.enhancement.technicalSystemPrompt,
|
technical: prompts.enhancement.technicalSystemPrompt,
|
||||||
simplify: prompts.enhancement.simplifySystemPrompt,
|
simplify: prompts.enhancement.simplifySystemPrompt,
|
||||||
acceptance: prompts.enhancement.acceptanceSystemPrompt,
|
acceptance: prompts.enhancement.acceptanceSystemPrompt,
|
||||||
|
'ux-reviewer': prompts.enhancement.uxReviewerSystemPrompt,
|
||||||
};
|
};
|
||||||
const systemPrompt = systemPromptMap[validMode];
|
const systemPrompt = systemPromptMap[validMode];
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { createListHandler } from './routes/list.js';
|
|||||||
import { createGetHandler } from './routes/get.js';
|
import { createGetHandler } from './routes/get.js';
|
||||||
import { createCreateHandler } from './routes/create.js';
|
import { createCreateHandler } from './routes/create.js';
|
||||||
import { createUpdateHandler } from './routes/update.js';
|
import { createUpdateHandler } from './routes/update.js';
|
||||||
|
import { createBulkUpdateHandler } from './routes/bulk-update.js';
|
||||||
|
import { createBulkDeleteHandler } from './routes/bulk-delete.js';
|
||||||
import { createDeleteHandler } from './routes/delete.js';
|
import { createDeleteHandler } from './routes/delete.js';
|
||||||
import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent-output.js';
|
import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent-output.js';
|
||||||
import { createGenerateTitleHandler } from './routes/generate-title.js';
|
import { createGenerateTitleHandler } from './routes/generate-title.js';
|
||||||
@@ -20,6 +22,16 @@ export function createFeaturesRoutes(featureLoader: FeatureLoader): Router {
|
|||||||
router.post('/get', validatePathParams('projectPath'), createGetHandler(featureLoader));
|
router.post('/get', validatePathParams('projectPath'), createGetHandler(featureLoader));
|
||||||
router.post('/create', validatePathParams('projectPath'), createCreateHandler(featureLoader));
|
router.post('/create', validatePathParams('projectPath'), createCreateHandler(featureLoader));
|
||||||
router.post('/update', validatePathParams('projectPath'), createUpdateHandler(featureLoader));
|
router.post('/update', validatePathParams('projectPath'), createUpdateHandler(featureLoader));
|
||||||
|
router.post(
|
||||||
|
'/bulk-update',
|
||||||
|
validatePathParams('projectPath'),
|
||||||
|
createBulkUpdateHandler(featureLoader)
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
'/bulk-delete',
|
||||||
|
validatePathParams('projectPath'),
|
||||||
|
createBulkDeleteHandler(featureLoader)
|
||||||
|
);
|
||||||
router.post('/delete', validatePathParams('projectPath'), createDeleteHandler(featureLoader));
|
router.post('/delete', validatePathParams('projectPath'), createDeleteHandler(featureLoader));
|
||||||
router.post('/agent-output', createAgentOutputHandler(featureLoader));
|
router.post('/agent-output', createAgentOutputHandler(featureLoader));
|
||||||
router.post('/raw-output', createRawOutputHandler(featureLoader));
|
router.post('/raw-output', createRawOutputHandler(featureLoader));
|
||||||
|
|||||||
61
apps/server/src/routes/features/routes/bulk-delete.ts
Normal file
61
apps/server/src/routes/features/routes/bulk-delete.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* POST /bulk-delete endpoint - Delete multiple features at once
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||||
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
|
interface BulkDeleteRequest {
|
||||||
|
projectPath: string;
|
||||||
|
featureIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BulkDeleteResult {
|
||||||
|
featureId: string;
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBulkDeleteHandler(featureLoader: FeatureLoader) {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { projectPath, featureIds } = req.body as BulkDeleteRequest;
|
||||||
|
|
||||||
|
if (!projectPath || !featureIds || !Array.isArray(featureIds) || featureIds.length === 0) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'projectPath and featureIds (non-empty array) are required',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
featureIds.map(async (featureId) => {
|
||||||
|
const success = await featureLoader.delete(projectPath, featureId);
|
||||||
|
if (success) {
|
||||||
|
return { featureId, success: true };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
featureId,
|
||||||
|
success: false,
|
||||||
|
error: 'Deletion failed. Check server logs for details.',
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const successCount = results.reduce((count, r) => count + (r.success ? 1 : 0), 0);
|
||||||
|
const failureCount = results.length - successCount;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: failureCount === 0,
|
||||||
|
deletedCount: successCount,
|
||||||
|
failedCount: failureCount,
|
||||||
|
results,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Bulk delete features failed');
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
75
apps/server/src/routes/features/routes/bulk-update.ts
Normal file
75
apps/server/src/routes/features/routes/bulk-update.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* 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) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,11 +10,14 @@ import { getErrorMessage, logError } from '../common.js';
|
|||||||
export function createUpdateHandler(featureLoader: FeatureLoader) {
|
export function createUpdateHandler(featureLoader: FeatureLoader) {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { projectPath, featureId, updates } = req.body as {
|
const { projectPath, featureId, updates, descriptionHistorySource, enhancementMode } =
|
||||||
projectPath: string;
|
req.body as {
|
||||||
featureId: string;
|
projectPath: string;
|
||||||
updates: Partial<Feature>;
|
featureId: string;
|
||||||
};
|
updates: Partial<Feature>;
|
||||||
|
descriptionHistorySource?: 'enhance' | 'edit';
|
||||||
|
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer';
|
||||||
|
};
|
||||||
|
|
||||||
if (!projectPath || !featureId || !updates) {
|
if (!projectPath || !featureId || !updates) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
@@ -24,7 +27,13 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await featureLoader.update(projectPath, featureId, updates);
|
const updated = await featureLoader.update(
|
||||||
|
projectPath,
|
||||||
|
featureId,
|
||||||
|
updates,
|
||||||
|
descriptionHistorySource,
|
||||||
|
enhancementMode
|
||||||
|
);
|
||||||
res.json({ success: true, feature: updated });
|
res.json({ success: true, feature: updated });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error, 'Update feature failed');
|
logError(error, 'Update feature failed');
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import type {
|
|||||||
LinkedPRInfo,
|
LinkedPRInfo,
|
||||||
ThinkingLevel,
|
ThinkingLevel,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
import { isCursorModel, DEFAULT_PHASE_MODELS } from '@automaker/types';
|
import { isCursorModel, DEFAULT_PHASE_MODELS, stripProviderPrefix } from '@automaker/types';
|
||||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||||
import { createSuggestionsOptions } from '../../../lib/sdk-options.js';
|
import { createSuggestionsOptions } from '../../../lib/sdk-options.js';
|
||||||
import { extractJson } from '../../../lib/json-extractor.js';
|
import { extractJson } from '../../../lib/json-extractor.js';
|
||||||
@@ -120,6 +120,8 @@ async function runValidation(
|
|||||||
logger.info(`Using Cursor provider for validation with model: ${model}`);
|
logger.info(`Using Cursor provider for validation with model: ${model}`);
|
||||||
|
|
||||||
const provider = ProviderFactory.getProviderForModel(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
|
// For Cursor, include the system prompt and schema in the user prompt
|
||||||
const cursorPrompt = `${ISSUE_VALIDATION_SYSTEM_PROMPT}
|
const cursorPrompt = `${ISSUE_VALIDATION_SYSTEM_PROMPT}
|
||||||
@@ -137,7 +139,7 @@ ${prompt}`;
|
|||||||
|
|
||||||
for await (const msg of provider.executeQuery({
|
for await (const msg of provider.executeQuery({
|
||||||
prompt: cursorPrompt,
|
prompt: cursorPrompt,
|
||||||
model,
|
model: bareModel,
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
readOnly: true, // Issue validation only reads code, doesn't write
|
readOnly: true, // Issue validation only reads code, doesn't write
|
||||||
})) {
|
})) {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { createGetProjectHandler } from './routes/get-project.js';
|
|||||||
import { createUpdateProjectHandler } from './routes/update-project.js';
|
import { createUpdateProjectHandler } from './routes/update-project.js';
|
||||||
import { createMigrateHandler } from './routes/migrate.js';
|
import { createMigrateHandler } from './routes/migrate.js';
|
||||||
import { createStatusHandler } from './routes/status.js';
|
import { createStatusHandler } from './routes/status.js';
|
||||||
|
import { createDiscoverAgentsHandler } from './routes/discover-agents.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create settings router with all endpoints
|
* Create settings router with all endpoints
|
||||||
@@ -39,6 +40,7 @@ import { createStatusHandler } from './routes/status.js';
|
|||||||
* - POST /project - Get project settings (requires projectPath in body)
|
* - POST /project - Get project settings (requires projectPath in body)
|
||||||
* - PUT /project - Update project settings
|
* - PUT /project - Update project settings
|
||||||
* - POST /migrate - Migrate settings from localStorage
|
* - 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
|
* @param settingsService - Instance of SettingsService for file I/O
|
||||||
* @returns Express Router configured with all settings endpoints
|
* @returns Express Router configured with all settings endpoints
|
||||||
@@ -72,5 +74,8 @@ export function createSettingsRoutes(settingsService: SettingsService): Router {
|
|||||||
// Migration from localStorage
|
// Migration from localStorage
|
||||||
router.post('/migrate', createMigrateHandler(settingsService));
|
router.post('/migrate', createMigrateHandler(settingsService));
|
||||||
|
|
||||||
|
// Filesystem agents discovery (read-only)
|
||||||
|
router.post('/agents/discover', createDiscoverAgentsHandler());
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
61
apps/server/src/routes/settings/routes/discover-agents.ts
Normal file
61
apps/server/src/routes/settings/routes/discover-agents.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import type { SettingsService } from '../../../services/settings-service.js';
|
import type { SettingsService } from '../../../services/settings-service.js';
|
||||||
import type { GlobalSettings } from '../../../types/settings.js';
|
import type { GlobalSettings } from '../../../types/settings.js';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError, logger } from '../common.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create handler factory for PUT /api/settings/global
|
* Create handler factory for PUT /api/settings/global
|
||||||
@@ -32,6 +32,18 @@ export function createUpdateGlobalHandler(settingsService: SettingsService) {
|
|||||||
return;
|
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);
|
const settings = await settingsService.updateGlobalSettings(updates);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
@@ -6,9 +6,24 @@ import { exec } from 'child_process';
|
|||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import { getClaudeCliPaths, getClaudeAuthIndicators, systemPathAccess } from '@automaker/platform';
|
import { getClaudeCliPaths, getClaudeAuthIndicators, systemPathAccess } from '@automaker/platform';
|
||||||
import { getApiKey } from './common.js';
|
import { getApiKey } from './common.js';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
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() {
|
export async function getClaudeStatus() {
|
||||||
let installed = false;
|
let installed = false;
|
||||||
let version = '';
|
let version = '';
|
||||||
@@ -60,6 +75,30 @@ 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
|
// Check authentication - detect all possible auth methods
|
||||||
// Note: apiKeys.anthropic_oauth_token stores OAuth tokens from subscription auth
|
// Note: apiKeys.anthropic_oauth_token stores OAuth tokens from subscription auth
|
||||||
// apiKeys.anthropic stores direct API keys for pay-per-use
|
// apiKeys.anthropic stores direct API keys for pay-per-use
|
||||||
|
|||||||
@@ -11,8 +11,19 @@ import { createDeleteApiKeyHandler } from './routes/delete-api-key.js';
|
|||||||
import { createApiKeysHandler } from './routes/api-keys.js';
|
import { createApiKeysHandler } from './routes/api-keys.js';
|
||||||
import { createPlatformHandler } from './routes/platform.js';
|
import { createPlatformHandler } from './routes/platform.js';
|
||||||
import { createVerifyClaudeAuthHandler } from './routes/verify-claude-auth.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 { createGhStatusHandler } from './routes/gh-status.js';
|
||||||
import { createCursorStatusHandler } from './routes/cursor-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 {
|
import {
|
||||||
createGetCursorConfigHandler,
|
createGetCursorConfigHandler,
|
||||||
createSetCursorDefaultModelHandler,
|
createSetCursorDefaultModelHandler,
|
||||||
@@ -30,15 +41,30 @@ export function createSetupRoutes(): Router {
|
|||||||
router.get('/claude-status', createClaudeStatusHandler());
|
router.get('/claude-status', createClaudeStatusHandler());
|
||||||
router.post('/install-claude', createInstallClaudeHandler());
|
router.post('/install-claude', createInstallClaudeHandler());
|
||||||
router.post('/auth-claude', createAuthClaudeHandler());
|
router.post('/auth-claude', createAuthClaudeHandler());
|
||||||
|
router.post('/deauth-claude', createDeauthClaudeHandler());
|
||||||
router.post('/store-api-key', createStoreApiKeyHandler());
|
router.post('/store-api-key', createStoreApiKeyHandler());
|
||||||
router.post('/delete-api-key', createDeleteApiKeyHandler());
|
router.post('/delete-api-key', createDeleteApiKeyHandler());
|
||||||
router.get('/api-keys', createApiKeysHandler());
|
router.get('/api-keys', createApiKeysHandler());
|
||||||
router.get('/platform', createPlatformHandler());
|
router.get('/platform', createPlatformHandler());
|
||||||
router.post('/verify-claude-auth', createVerifyClaudeAuthHandler());
|
router.post('/verify-claude-auth', createVerifyClaudeAuthHandler());
|
||||||
|
router.post('/verify-codex-auth', createVerifyCodexAuthHandler());
|
||||||
router.get('/gh-status', createGhStatusHandler());
|
router.get('/gh-status', createGhStatusHandler());
|
||||||
|
|
||||||
// Cursor CLI routes
|
// Cursor CLI routes
|
||||||
router.get('/cursor-status', createCursorStatusHandler());
|
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.get('/cursor-config', createGetCursorConfigHandler());
|
||||||
router.post('/cursor-config/default-model', createSetCursorDefaultModelHandler());
|
router.post('/cursor-config/default-model', createSetCursorDefaultModelHandler());
|
||||||
router.post('/cursor-config/models', createSetCursorModelsHandler());
|
router.post('/cursor-config/models', createSetCursorModelsHandler());
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export function createApiKeysHandler() {
|
|||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
hasAnthropicKey: !!getApiKey('anthropic') || !!process.env.ANTHROPIC_API_KEY,
|
hasAnthropicKey: !!getApiKey('anthropic') || !!process.env.ANTHROPIC_API_KEY,
|
||||||
|
hasOpenaiKey: !!getApiKey('openai') || !!process.env.OPENAI_API_KEY,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error, 'Get API keys failed');
|
logError(error, 'Get API keys failed');
|
||||||
|
|||||||
@@ -4,19 +4,54 @@
|
|||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
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() {
|
export function createAuthClaudeHandler() {
|
||||||
return async (_req: Request, res: Response): Promise<void> => {
|
return async (_req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
res.json({
|
// Remove the disconnected marker file to reconnect the app to the CLI
|
||||||
success: true,
|
const markerPath = path.join(process.cwd(), '.automaker', '.claude-disconnected');
|
||||||
requiresManualAuth: true,
|
if (fs.existsSync(markerPath)) {
|
||||||
command: 'claude login',
|
fs.unlinkSync(markerPath);
|
||||||
message: "Please run 'claude login' in your terminal to authenticate",
|
}
|
||||||
});
|
|
||||||
|
// 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error, 'Auth Claude failed');
|
logError(error, 'Auth Claude failed');
|
||||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
message: 'Failed to link Claude CLI with the app',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
50
apps/server/src/routes/setup/routes/auth-codex.ts
Normal file
50
apps/server/src/routes/setup/routes/auth-codex.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
73
apps/server/src/routes/setup/routes/auth-cursor.ts
Normal file
73
apps/server/src/routes/setup/routes/auth-cursor.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
51
apps/server/src/routes/setup/routes/auth-opencode.ts
Normal file
51
apps/server/src/routes/setup/routes/auth-opencode.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
81
apps/server/src/routes/setup/routes/codex-status.ts
Normal file
81
apps/server/src/routes/setup/routes/codex-status.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* 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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,6 +5,20 @@
|
|||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { CursorProvider } from '../../../providers/cursor-provider.js';
|
import { CursorProvider } from '../../../providers/cursor-provider.js';
|
||||||
import { getErrorMessage, logError } from '../common.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
|
* Creates handler for GET /api/setup/cursor-status
|
||||||
@@ -16,6 +30,30 @@ export function createCursorStatusHandler() {
|
|||||||
|
|
||||||
return async (_req: Request, res: Response): Promise<void> => {
|
return async (_req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
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 provider = new CursorProvider();
|
||||||
|
|
||||||
const [installed, version, auth] = await Promise.all([
|
const [installed, version, auth] = await Promise.all([
|
||||||
|
|||||||
44
apps/server/src/routes/setup/routes/deauth-claude.ts
Normal file
44
apps/server/src/routes/setup/routes/deauth-claude.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
44
apps/server/src/routes/setup/routes/deauth-codex.ts
Normal file
44
apps/server/src/routes/setup/routes/deauth-codex.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
44
apps/server/src/routes/setup/routes/deauth-cursor.ts
Normal file
44
apps/server/src/routes/setup/routes/deauth-cursor.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
40
apps/server/src/routes/setup/routes/deauth-opencode.ts
Normal file
40
apps/server/src/routes/setup/routes/deauth-opencode.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -46,13 +46,14 @@ export function createDeleteApiKeyHandler() {
|
|||||||
// Map provider to env key name
|
// Map provider to env key name
|
||||||
const envKeyMap: Record<string, string> = {
|
const envKeyMap: Record<string, string> = {
|
||||||
anthropic: 'ANTHROPIC_API_KEY',
|
anthropic: 'ANTHROPIC_API_KEY',
|
||||||
|
openai: 'OPENAI_API_KEY',
|
||||||
};
|
};
|
||||||
|
|
||||||
const envKey = envKeyMap[provider];
|
const envKey = envKeyMap[provider];
|
||||||
if (!envKey) {
|
if (!envKey) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: `Unknown provider: ${provider}. Only anthropic is supported.`,
|
error: `Unknown provider: ${provider}. Only anthropic and openai are supported.`,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
33
apps/server/src/routes/setup/routes/install-codex.ts
Normal file
33
apps/server/src/routes/setup/routes/install-codex.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* 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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
59
apps/server/src/routes/setup/routes/opencode-status.ts
Normal file
59
apps/server/src/routes/setup/routes/opencode-status.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* 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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,8 +7,16 @@ import type { Request, Response } from 'express';
|
|||||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import { getApiKey } from '../common.js';
|
import { getApiKey } from '../common.js';
|
||||||
|
import {
|
||||||
|
createSecureAuthEnv,
|
||||||
|
AuthSessionManager,
|
||||||
|
AuthRateLimiter,
|
||||||
|
validateApiKey,
|
||||||
|
createTempEnvOverride,
|
||||||
|
} from '../../../lib/auth-utils.js';
|
||||||
|
|
||||||
const logger = createLogger('Setup');
|
const logger = createLogger('Setup');
|
||||||
|
const rateLimiter = new AuthRateLimiter();
|
||||||
|
|
||||||
// Known error patterns that indicate auth failure
|
// Known error patterns that indicate auth failure
|
||||||
const AUTH_ERROR_PATTERNS = [
|
const AUTH_ERROR_PATTERNS = [
|
||||||
@@ -77,6 +85,19 @@ export function createVerifyClaudeAuthHandler() {
|
|||||||
apiKey?: string;
|
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(
|
logger.info(
|
||||||
`[Setup] Verifying Claude authentication using method: ${authMethod || 'auto'}${apiKey ? ' (with provided key)' : ''}`
|
`[Setup] Verifying Claude authentication using method: ${authMethod || 'auto'}${apiKey ? ' (with provided key)' : ''}`
|
||||||
);
|
);
|
||||||
@@ -89,37 +110,48 @@ export function createVerifyClaudeAuthHandler() {
|
|||||||
let errorMessage = '';
|
let errorMessage = '';
|
||||||
let receivedAnyContent = false;
|
let receivedAnyContent = false;
|
||||||
|
|
||||||
// Save original env values
|
// Create secure auth session
|
||||||
const originalAnthropicKey = process.env.ANTHROPIC_API_KEY;
|
const sessionId = `claude-auth-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Configure environment based on auth method
|
// For API key verification, validate the key first
|
||||||
if (authMethod === 'cli') {
|
if (authMethod === 'api_key' && apiKey) {
|
||||||
// For CLI verification, remove any API key so it uses CLI credentials only
|
const validation = validateApiKey(apiKey, 'anthropic');
|
||||||
delete process.env.ANTHROPIC_API_KEY;
|
if (!validation.isValid) {
|
||||||
logger.info('[Setup] Cleared API key environment for CLI verification');
|
res.json({
|
||||||
} else if (authMethod === 'api_key') {
|
success: true,
|
||||||
// For API key verification, use provided key, stored key, or env var (in order of priority)
|
authenticated: false,
|
||||||
if (apiKey) {
|
error: validation.error,
|
||||||
// Use the provided API key (allows testing unsaved keys)
|
});
|
||||||
process.env.ANTHROPIC_API_KEY = apiKey;
|
return;
|
||||||
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
|
// Run a minimal query to verify authentication
|
||||||
const stream = query({
|
const stream = query({
|
||||||
prompt: "Reply with only the word 'ok'",
|
prompt: "Reply with only the word 'ok'",
|
||||||
@@ -278,13 +310,8 @@ export function createVerifyClaudeAuthHandler() {
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
// Restore original environment
|
// Clean up the auth session
|
||||||
if (originalAnthropicKey !== undefined) {
|
AuthSessionManager.destroySession(sessionId);
|
||||||
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:', {
|
logger.info('[Setup] Verification result:', {
|
||||||
|
|||||||
282
apps/server/src/routes/setup/routes/verify-codex-auth.ts
Normal file
282
apps/server/src/routes/setup/routes/verify-codex-auth.ts
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,7 +8,12 @@
|
|||||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||||
import type { EventEmitter } from '../../lib/events.js';
|
import type { EventEmitter } from '../../lib/events.js';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import { DEFAULT_PHASE_MODELS, isCursorModel, type ThinkingLevel } from '@automaker/types';
|
import {
|
||||||
|
DEFAULT_PHASE_MODELS,
|
||||||
|
isCursorModel,
|
||||||
|
stripProviderPrefix,
|
||||||
|
type ThinkingLevel,
|
||||||
|
} from '@automaker/types';
|
||||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||||
import { createSuggestionsOptions } from '../../lib/sdk-options.js';
|
import { createSuggestionsOptions } from '../../lib/sdk-options.js';
|
||||||
import { extractJsonWithArray } from '../../lib/json-extractor.js';
|
import { extractJsonWithArray } from '../../lib/json-extractor.js';
|
||||||
@@ -207,6 +212,8 @@ The response will be automatically formatted as structured JSON.`;
|
|||||||
logger.info('[Suggestions] Using Cursor provider');
|
logger.info('[Suggestions] Using Cursor provider');
|
||||||
|
|
||||||
const provider = ProviderFactory.getProviderForModel(model);
|
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
|
// For Cursor, include the JSON schema in the prompt with clear instructions
|
||||||
const cursorPrompt = `${prompt}
|
const cursorPrompt = `${prompt}
|
||||||
@@ -222,7 +229,7 @@ Your entire response should be valid JSON starting with { and ending with }. No
|
|||||||
|
|
||||||
for await (const msg of provider.executeQuery({
|
for await (const msg of provider.executeQuery({
|
||||||
prompt: cursorPrompt,
|
prompt: cursorPrompt,
|
||||||
model,
|
model: bareModel,
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
maxTurns: 250,
|
maxTurns: 250,
|
||||||
allowedTools: ['Read', 'Glob', 'Grep'],
|
allowedTools: ['Read', 'Glob', 'Grep'],
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ import { getGitRepositoryDiffs } from '../../common.js';
|
|||||||
export function createDiffsHandler() {
|
export function createDiffsHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { projectPath, featureId } = req.body as {
|
const { projectPath, featureId, useWorktrees } = req.body as {
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
featureId: string;
|
featureId: string;
|
||||||
|
useWorktrees?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!projectPath || !featureId) {
|
if (!projectPath || !featureId) {
|
||||||
@@ -24,6 +25,19 @@ export function createDiffsHandler() {
|
|||||||
return;
|
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
|
// Git worktrees are stored in project directory
|
||||||
const worktreePath = path.join(projectPath, '.worktrees', featureId);
|
const worktreePath = path.join(projectPath, '.worktrees', featureId);
|
||||||
|
|
||||||
@@ -41,7 +55,11 @@ export function createDiffsHandler() {
|
|||||||
});
|
});
|
||||||
} catch (innerError) {
|
} catch (innerError) {
|
||||||
// Worktree doesn't exist - fallback to main project path
|
// Worktree doesn't exist - fallback to main project path
|
||||||
logError(innerError, 'Worktree access failed, falling back to main project');
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await getGitRepositoryDiffs(projectPath);
|
const result = await getGitRepositoryDiffs(projectPath);
|
||||||
|
|||||||
@@ -15,10 +15,11 @@ const execAsync = promisify(exec);
|
|||||||
export function createFileDiffHandler() {
|
export function createFileDiffHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { projectPath, featureId, filePath } = req.body as {
|
const { projectPath, featureId, filePath, useWorktrees } = req.body as {
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
featureId: string;
|
featureId: string;
|
||||||
filePath: string;
|
filePath: string;
|
||||||
|
useWorktrees?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!projectPath || !featureId || !filePath) {
|
if (!projectPath || !featureId || !filePath) {
|
||||||
@@ -29,6 +30,12 @@ export function createFileDiffHandler() {
|
|||||||
return;
|
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
|
// Git worktrees are stored in project directory
|
||||||
const worktreePath = path.join(projectPath, '.worktrees', featureId);
|
const worktreePath = path.join(projectPath, '.worktrees', featureId);
|
||||||
|
|
||||||
@@ -57,7 +64,11 @@ export function createFileDiffHandler() {
|
|||||||
|
|
||||||
res.json({ success: true, diff, filePath });
|
res.json({ success: true, diff, filePath });
|
||||||
} catch (innerError) {
|
} catch (innerError) {
|
||||||
logError(innerError, 'Worktree file diff failed');
|
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');
|
||||||
|
}
|
||||||
res.json({ success: true, diff: '', filePath });
|
res.json({ success: true, diff: '', filePath });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -2,18 +2,23 @@
|
|||||||
* POST /list endpoint - List all git worktrees
|
* POST /list endpoint - List all git worktrees
|
||||||
*
|
*
|
||||||
* Returns actual git worktrees from `git worktree list`.
|
* Returns actual git worktrees from `git worktree list`.
|
||||||
|
* Also scans .worktrees/ directory to discover worktrees that may have been
|
||||||
|
* created externally or whose git state was corrupted.
|
||||||
* Does NOT include tracked branches - only real worktrees with separate directories.
|
* Does NOT include tracked branches - only real worktrees with separate directories.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { exec } from 'child_process';
|
import { exec } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
|
import path from 'path';
|
||||||
import * as secureFs from '../../../lib/secure-fs.js';
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
import { isGitRepo } from '@automaker/git-utils';
|
import { isGitRepo } from '@automaker/git-utils';
|
||||||
import { getErrorMessage, logError, normalizePath } from '../common.js';
|
import { getErrorMessage, logError, normalizePath } from '../common.js';
|
||||||
import { readAllWorktreeMetadata, type WorktreePRInfo } from '../../../lib/worktree-metadata.js';
|
import { readAllWorktreeMetadata, type WorktreePRInfo } from '../../../lib/worktree-metadata.js';
|
||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
const logger = createLogger('Worktree');
|
||||||
|
|
||||||
interface WorktreeInfo {
|
interface WorktreeInfo {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -35,6 +40,87 @@ async function getCurrentBranch(cwd: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan the .worktrees directory to discover worktrees that may exist on disk
|
||||||
|
* but are not registered with git (e.g., created externally or corrupted state).
|
||||||
|
*/
|
||||||
|
async function scanWorktreesDirectory(
|
||||||
|
projectPath: string,
|
||||||
|
knownWorktreePaths: Set<string>
|
||||||
|
): Promise<Array<{ path: string; branch: string }>> {
|
||||||
|
const discovered: Array<{ path: string; branch: string }> = [];
|
||||||
|
const worktreesDir = path.join(projectPath, '.worktrees');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if .worktrees directory exists
|
||||||
|
await secureFs.access(worktreesDir);
|
||||||
|
} catch {
|
||||||
|
// .worktrees directory doesn't exist
|
||||||
|
return discovered;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = await secureFs.readdir(worktreesDir, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
|
||||||
|
const worktreePath = path.join(worktreesDir, entry.name);
|
||||||
|
const normalizedPath = normalizePath(worktreePath);
|
||||||
|
|
||||||
|
// Skip if already known from git worktree list
|
||||||
|
if (knownWorktreePaths.has(normalizedPath)) continue;
|
||||||
|
|
||||||
|
// Check if this is a valid git repository
|
||||||
|
const gitPath = path.join(worktreePath, '.git');
|
||||||
|
try {
|
||||||
|
const gitStat = await secureFs.stat(gitPath);
|
||||||
|
|
||||||
|
// Git worktrees have a .git FILE (not directory) that points to the parent repo
|
||||||
|
// Regular repos have a .git DIRECTORY
|
||||||
|
if (gitStat.isFile() || gitStat.isDirectory()) {
|
||||||
|
// Try to get the branch name
|
||||||
|
const branch = await getCurrentBranch(worktreePath);
|
||||||
|
if (branch) {
|
||||||
|
logger.info(
|
||||||
|
`Discovered worktree in .worktrees/ not in git worktree list: ${entry.name} (branch: ${branch})`
|
||||||
|
);
|
||||||
|
discovered.push({
|
||||||
|
path: normalizedPath,
|
||||||
|
branch,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Try to get branch from HEAD if branch --show-current fails (detached HEAD)
|
||||||
|
try {
|
||||||
|
const { stdout: headRef } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||||
|
cwd: worktreePath,
|
||||||
|
});
|
||||||
|
const headBranch = headRef.trim();
|
||||||
|
if (headBranch && headBranch !== 'HEAD') {
|
||||||
|
logger.info(
|
||||||
|
`Discovered worktree in .worktrees/ not in git worktree list: ${entry.name} (branch: ${headBranch})`
|
||||||
|
);
|
||||||
|
discovered.push({
|
||||||
|
path: normalizedPath,
|
||||||
|
branch: headBranch,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Can't determine branch, skip this directory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not a git repo, skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Failed to scan .worktrees directory: ${getErrorMessage(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return discovered;
|
||||||
|
}
|
||||||
|
|
||||||
export function createListHandler() {
|
export function createListHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
@@ -116,6 +202,22 @@ export function createListHandler() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scan .worktrees directory to discover worktrees that exist on disk
|
||||||
|
// but are not registered with git (e.g., created externally)
|
||||||
|
const knownPaths = new Set(worktrees.map((w) => w.path));
|
||||||
|
const discoveredWorktrees = await scanWorktreesDirectory(projectPath, knownPaths);
|
||||||
|
|
||||||
|
// Add discovered worktrees to the list
|
||||||
|
for (const discovered of discoveredWorktrees) {
|
||||||
|
worktrees.push({
|
||||||
|
path: discovered.path,
|
||||||
|
branch: discovered.branch,
|
||||||
|
isMain: false,
|
||||||
|
isCurrent: discovered.branch === currentBranch,
|
||||||
|
hasWorktree: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Read all worktree metadata to get PR info
|
// Read all worktree metadata to get PR info
|
||||||
const allMetadata = await readAllWorktreeMetadata(projectPath);
|
const allMetadata = await readAllWorktreeMetadata(projectPath);
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,16 @@
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import * as secureFs from '../lib/secure-fs.js';
|
import * as secureFs from '../lib/secure-fs.js';
|
||||||
import type { EventEmitter } from '../lib/events.js';
|
import type { EventEmitter } from '../lib/events.js';
|
||||||
import type { ExecuteOptions, ThinkingLevel } from '@automaker/types';
|
import type { ExecuteOptions, ThinkingLevel, ReasoningEffort } from '@automaker/types';
|
||||||
|
import { stripProviderPrefix } from '@automaker/types';
|
||||||
import {
|
import {
|
||||||
readImageAsBase64,
|
readImageAsBase64,
|
||||||
buildPromptWithImages,
|
buildPromptWithImages,
|
||||||
isAbortError,
|
isAbortError,
|
||||||
loadContextFiles,
|
loadContextFiles,
|
||||||
createLogger,
|
createLogger,
|
||||||
|
classifyError,
|
||||||
|
getUserFriendlyErrorMessage,
|
||||||
} from '@automaker/utils';
|
} from '@automaker/utils';
|
||||||
import { ProviderFactory } from '../providers/provider-factory.js';
|
import { ProviderFactory } from '../providers/provider-factory.js';
|
||||||
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
|
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
|
||||||
@@ -20,10 +23,12 @@ import { PathNotAllowedError } from '@automaker/platform';
|
|||||||
import type { SettingsService } from './settings-service.js';
|
import type { SettingsService } from './settings-service.js';
|
||||||
import {
|
import {
|
||||||
getAutoLoadClaudeMdSetting,
|
getAutoLoadClaudeMdSetting,
|
||||||
getEnableSandboxModeSetting,
|
|
||||||
filterClaudeMdFromContext,
|
filterClaudeMdFromContext,
|
||||||
getMCPServersFromSettings,
|
getMCPServersFromSettings,
|
||||||
getPromptCustomization,
|
getPromptCustomization,
|
||||||
|
getSkillsConfiguration,
|
||||||
|
getSubagentsConfiguration,
|
||||||
|
getCustomSubagents,
|
||||||
} from '../lib/settings-helpers.js';
|
} from '../lib/settings-helpers.js';
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
@@ -55,6 +60,7 @@ interface Session {
|
|||||||
workingDirectory: string;
|
workingDirectory: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
thinkingLevel?: ThinkingLevel; // Thinking level for Claude models
|
thinkingLevel?: ThinkingLevel; // Thinking level for Claude models
|
||||||
|
reasoningEffort?: ReasoningEffort; // Reasoning effort for Codex models
|
||||||
sdkSessionId?: string; // Claude SDK session ID for conversation continuity
|
sdkSessionId?: string; // Claude SDK session ID for conversation continuity
|
||||||
promptQueue: QueuedPrompt[]; // Queue of prompts to auto-run after current task
|
promptQueue: QueuedPrompt[]; // Queue of prompts to auto-run after current task
|
||||||
}
|
}
|
||||||
@@ -144,6 +150,7 @@ export class AgentService {
|
|||||||
imagePaths,
|
imagePaths,
|
||||||
model,
|
model,
|
||||||
thinkingLevel,
|
thinkingLevel,
|
||||||
|
reasoningEffort,
|
||||||
}: {
|
}: {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -151,6 +158,7 @@ export class AgentService {
|
|||||||
imagePaths?: string[];
|
imagePaths?: string[];
|
||||||
model?: string;
|
model?: string;
|
||||||
thinkingLevel?: ThinkingLevel;
|
thinkingLevel?: ThinkingLevel;
|
||||||
|
reasoningEffort?: ReasoningEffort;
|
||||||
}) {
|
}) {
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -163,7 +171,7 @@ export class AgentService {
|
|||||||
throw new Error('Agent is already processing a message');
|
throw new Error('Agent is already processing a message');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update session model and thinking level if provided
|
// Update session model, thinking level, and reasoning effort if provided
|
||||||
if (model) {
|
if (model) {
|
||||||
session.model = model;
|
session.model = model;
|
||||||
await this.updateSession(sessionId, { model });
|
await this.updateSession(sessionId, { model });
|
||||||
@@ -171,6 +179,21 @@ export class AgentService {
|
|||||||
if (thinkingLevel !== undefined) {
|
if (thinkingLevel !== undefined) {
|
||||||
session.thinkingLevel = thinkingLevel;
|
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
|
// Read images and convert to base64
|
||||||
const images: Message['images'] = [];
|
const images: Message['images'] = [];
|
||||||
@@ -232,19 +255,34 @@ export class AgentService {
|
|||||||
'[AgentService]'
|
'[AgentService]'
|
||||||
);
|
);
|
||||||
|
|
||||||
// Load enableSandboxMode setting (global setting only)
|
|
||||||
const enableSandboxMode = await getEnableSandboxModeSetting(
|
|
||||||
this.settingsService,
|
|
||||||
'[AgentService]'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Load MCP servers from settings (global setting only)
|
// Load MCP servers from settings (global setting only)
|
||||||
const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AgentService]');
|
const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AgentService]');
|
||||||
|
|
||||||
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.)
|
// 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
|
||||||
const contextResult = await loadContextFiles({
|
const contextResult = await loadContextFiles({
|
||||||
projectPath: effectiveWorkDir,
|
projectPath: effectiveWorkDir,
|
||||||
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
|
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
|
// When autoLoadClaudeMd is enabled, filter out CLAUDE.md to avoid duplication
|
||||||
@@ -258,8 +296,9 @@ export class AgentService {
|
|||||||
: baseSystemPrompt;
|
: baseSystemPrompt;
|
||||||
|
|
||||||
// Build SDK options using centralized configuration
|
// Build SDK options using centralized configuration
|
||||||
// Use thinking level from request, or fall back to session's stored thinking level
|
// Use thinking level and reasoning effort from request, or fall back to session's stored values
|
||||||
const effectiveThinkingLevel = thinkingLevel ?? session.thinkingLevel;
|
const effectiveThinkingLevel = thinkingLevel ?? session.thinkingLevel;
|
||||||
|
const effectiveReasoningEffort = reasoningEffort ?? session.reasoningEffort;
|
||||||
const sdkOptions = createChatOptions({
|
const sdkOptions = createChatOptions({
|
||||||
cwd: effectiveWorkDir,
|
cwd: effectiveWorkDir,
|
||||||
model: model,
|
model: model,
|
||||||
@@ -267,7 +306,6 @@ export class AgentService {
|
|||||||
systemPrompt: combinedSystemPrompt,
|
systemPrompt: combinedSystemPrompt,
|
||||||
abortController: session.abortController!,
|
abortController: session.abortController!,
|
||||||
autoLoadClaudeMd,
|
autoLoadClaudeMd,
|
||||||
enableSandboxMode,
|
|
||||||
thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models
|
thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models
|
||||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
||||||
});
|
});
|
||||||
@@ -275,25 +313,71 @@ export class AgentService {
|
|||||||
// Extract model, maxTurns, and allowedTools from SDK options
|
// Extract model, maxTurns, and allowedTools from SDK options
|
||||||
const effectiveModel = sdkOptions.model!;
|
const effectiveModel = sdkOptions.model!;
|
||||||
const maxTurns = sdkOptions.maxTurns;
|
const maxTurns = sdkOptions.maxTurns;
|
||||||
const allowedTools = sdkOptions.allowedTools as string[] | undefined;
|
let allowedTools = sdkOptions.allowedTools as string[] | undefined;
|
||||||
|
|
||||||
// Get provider for this model
|
// 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)
|
||||||
const provider = ProviderFactory.getProviderForModel(effectiveModel);
|
const provider = ProviderFactory.getProviderForModel(effectiveModel);
|
||||||
|
|
||||||
|
// Strip provider prefix - providers should receive bare model IDs
|
||||||
|
const bareModel = stripProviderPrefix(effectiveModel);
|
||||||
|
|
||||||
// Build options for provider
|
// Build options for provider
|
||||||
const options: ExecuteOptions = {
|
const options: ExecuteOptions = {
|
||||||
prompt: '', // Will be set below based on images
|
prompt: '', // Will be set below based on images
|
||||||
model: effectiveModel,
|
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")
|
||||||
cwd: effectiveWorkDir,
|
cwd: effectiveWorkDir,
|
||||||
systemPrompt: sdkOptions.systemPrompt,
|
systemPrompt: sdkOptions.systemPrompt,
|
||||||
maxTurns: maxTurns,
|
maxTurns: maxTurns,
|
||||||
allowedTools: allowedTools,
|
allowedTools: allowedTools,
|
||||||
abortController: session.abortController!,
|
abortController: session.abortController!,
|
||||||
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
|
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
|
||||||
settingSources: sdkOptions.settingSources,
|
settingSources: settingSources.length > 0 ? settingSources : undefined,
|
||||||
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
|
|
||||||
sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming
|
sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming
|
||||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration
|
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
|
// Build prompt content with images
|
||||||
@@ -374,6 +458,53 @@ export class AgentService {
|
|||||||
content: responseText,
|
content: responseText,
|
||||||
toolUses,
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,24 +14,30 @@ import type {
|
|||||||
ExecuteOptions,
|
ExecuteOptions,
|
||||||
Feature,
|
Feature,
|
||||||
ModelProvider,
|
ModelProvider,
|
||||||
PipelineConfig,
|
|
||||||
PipelineStep,
|
PipelineStep,
|
||||||
ThinkingLevel,
|
ThinkingLevel,
|
||||||
PlanningMode,
|
PlanningMode,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
|
import { DEFAULT_PHASE_MODELS, stripProviderPrefix } from '@automaker/types';
|
||||||
import {
|
import {
|
||||||
buildPromptWithImages,
|
buildPromptWithImages,
|
||||||
isAbortError,
|
|
||||||
classifyError,
|
classifyError,
|
||||||
loadContextFiles,
|
loadContextFiles,
|
||||||
|
appendLearning,
|
||||||
|
recordMemoryUsage,
|
||||||
createLogger,
|
createLogger,
|
||||||
} from '@automaker/utils';
|
} from '@automaker/utils';
|
||||||
|
|
||||||
const logger = createLogger('AutoMode');
|
const logger = createLogger('AutoMode');
|
||||||
import { resolveModelString, resolvePhaseModel, DEFAULT_MODELS } from '@automaker/model-resolver';
|
import { resolveModelString, resolvePhaseModel, DEFAULT_MODELS } from '@automaker/model-resolver';
|
||||||
import { resolveDependencies, areDependenciesSatisfied } from '@automaker/dependency-resolver';
|
import { resolveDependencies, areDependenciesSatisfied } from '@automaker/dependency-resolver';
|
||||||
import { getFeatureDir, getAutomakerDir, getFeaturesDir } from '@automaker/platform';
|
import {
|
||||||
|
getFeatureDir,
|
||||||
|
getAutomakerDir,
|
||||||
|
getFeaturesDir,
|
||||||
|
getExecutionStatePath,
|
||||||
|
ensureAutomakerDir,
|
||||||
|
} from '@automaker/platform';
|
||||||
import { exec } from 'child_process';
|
import { exec } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@@ -47,7 +53,6 @@ import type { SettingsService } from './settings-service.js';
|
|||||||
import { pipelineService, PipelineService } from './pipeline-service.js';
|
import { pipelineService, PipelineService } from './pipeline-service.js';
|
||||||
import {
|
import {
|
||||||
getAutoLoadClaudeMdSetting,
|
getAutoLoadClaudeMdSetting,
|
||||||
getEnableSandboxModeSetting,
|
|
||||||
filterClaudeMdFromContext,
|
filterClaudeMdFromContext,
|
||||||
getMCPServersFromSettings,
|
getMCPServersFromSettings,
|
||||||
getPromptCustomization,
|
getPromptCustomization,
|
||||||
@@ -202,6 +207,29 @@ interface AutoModeConfig {
|
|||||||
projectPath: string;
|
projectPath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execution state for recovery after server restart
|
||||||
|
* Tracks which features were running and auto-loop configuration
|
||||||
|
*/
|
||||||
|
interface ExecutionState {
|
||||||
|
version: 1;
|
||||||
|
autoLoopWasRunning: boolean;
|
||||||
|
maxConcurrency: number;
|
||||||
|
projectPath: string;
|
||||||
|
runningFeatureIds: string[];
|
||||||
|
savedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default empty execution state
|
||||||
|
const DEFAULT_EXECUTION_STATE: ExecutionState = {
|
||||||
|
version: 1,
|
||||||
|
autoLoopWasRunning: false,
|
||||||
|
maxConcurrency: 3,
|
||||||
|
projectPath: '',
|
||||||
|
runningFeatureIds: [],
|
||||||
|
savedAt: '',
|
||||||
|
};
|
||||||
|
|
||||||
// Constants for consecutive failure tracking
|
// Constants for consecutive failure tracking
|
||||||
const CONSECUTIVE_FAILURE_THRESHOLD = 3; // Pause after 3 consecutive failures
|
const CONSECUTIVE_FAILURE_THRESHOLD = 3; // Pause after 3 consecutive failures
|
||||||
const FAILURE_WINDOW_MS = 60000; // Failures within 1 minute count as consecutive
|
const FAILURE_WINDOW_MS = 60000; // Failures within 1 minute count as consecutive
|
||||||
@@ -323,6 +351,11 @@ export class AutoModeService {
|
|||||||
projectPath,
|
projectPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Save execution state for recovery after restart
|
||||||
|
await this.saveExecutionState(projectPath);
|
||||||
|
|
||||||
|
// Note: Memory folder initialization is now handled by loadContextFiles
|
||||||
|
|
||||||
// Run the loop in the background
|
// Run the loop in the background
|
||||||
this.runAutoLoop().catch((error) => {
|
this.runAutoLoop().catch((error) => {
|
||||||
logger.error('Loop error:', error);
|
logger.error('Loop error:', error);
|
||||||
@@ -389,17 +422,23 @@ export class AutoModeService {
|
|||||||
*/
|
*/
|
||||||
async stopAutoLoop(): Promise<number> {
|
async stopAutoLoop(): Promise<number> {
|
||||||
const wasRunning = this.autoLoopRunning;
|
const wasRunning = this.autoLoopRunning;
|
||||||
|
const projectPath = this.config?.projectPath;
|
||||||
this.autoLoopRunning = false;
|
this.autoLoopRunning = false;
|
||||||
if (this.autoLoopAbortController) {
|
if (this.autoLoopAbortController) {
|
||||||
this.autoLoopAbortController.abort();
|
this.autoLoopAbortController.abort();
|
||||||
this.autoLoopAbortController = null;
|
this.autoLoopAbortController = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear execution state when auto-loop is explicitly stopped
|
||||||
|
if (projectPath) {
|
||||||
|
await this.clearExecutionState(projectPath);
|
||||||
|
}
|
||||||
|
|
||||||
// Emit stop event immediately when user explicitly stops
|
// Emit stop event immediately when user explicitly stops
|
||||||
if (wasRunning) {
|
if (wasRunning) {
|
||||||
this.emitAutoModeEvent('auto_mode_stopped', {
|
this.emitAutoModeEvent('auto_mode_stopped', {
|
||||||
message: 'Auto mode stopped',
|
message: 'Auto mode stopped',
|
||||||
projectPath: this.config?.projectPath,
|
projectPath,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,6 +479,11 @@ export class AutoModeService {
|
|||||||
};
|
};
|
||||||
this.runningFeatures.set(featureId, tempRunningFeature);
|
this.runningFeatures.set(featureId, tempRunningFeature);
|
||||||
|
|
||||||
|
// Save execution state when feature starts
|
||||||
|
if (isAutoMode) {
|
||||||
|
await this.saveExecutionState(projectPath);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Validate that project path is allowed using centralized validation
|
// Validate that project path is allowed using centralized validation
|
||||||
validateWorkingDirectory(projectPath);
|
validateWorkingDirectory(projectPath);
|
||||||
@@ -514,15 +558,21 @@ export class AutoModeService {
|
|||||||
|
|
||||||
// Build the prompt - use continuation prompt if provided (for recovery after plan approval)
|
// Build the prompt - use continuation prompt if provided (for recovery after plan approval)
|
||||||
let prompt: string;
|
let prompt: string;
|
||||||
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) - passed as system prompt
|
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) and memory files
|
||||||
|
// Context loader uses task context to select relevant memory files
|
||||||
const contextResult = await loadContextFiles({
|
const contextResult = await loadContextFiles({
|
||||||
projectPath,
|
projectPath,
|
||||||
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
|
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
|
// 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
|
// (SDK handles CLAUDE.md via settingSources), but keep other context files like CODE_QUALITY.md
|
||||||
const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd);
|
// Note: contextResult.formattedPrompt now includes both context AND memory
|
||||||
|
const combinedSystemPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd);
|
||||||
|
|
||||||
if (options?.continuationPrompt) {
|
if (options?.continuationPrompt) {
|
||||||
// Continuation prompt is used when recovering from a plan approval
|
// Continuation prompt is used when recovering from a plan approval
|
||||||
@@ -575,7 +625,7 @@ export class AutoModeService {
|
|||||||
projectPath,
|
projectPath,
|
||||||
planningMode: feature.planningMode,
|
planningMode: feature.planningMode,
|
||||||
requirePlanApproval: feature.requirePlanApproval,
|
requirePlanApproval: feature.requirePlanApproval,
|
||||||
systemPrompt: contextFilesPrompt || undefined,
|
systemPrompt: combinedSystemPrompt || undefined,
|
||||||
autoLoadClaudeMd,
|
autoLoadClaudeMd,
|
||||||
thinkingLevel: feature.thinkingLevel,
|
thinkingLevel: feature.thinkingLevel,
|
||||||
}
|
}
|
||||||
@@ -607,6 +657,36 @@ export class AutoModeService {
|
|||||||
// Record success to reset consecutive failure tracking
|
// Record success to reset consecutive failure tracking
|
||||||
this.recordSuccess();
|
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', {
|
this.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||||
featureId,
|
featureId,
|
||||||
passes: true,
|
passes: true,
|
||||||
@@ -658,6 +738,11 @@ export class AutoModeService {
|
|||||||
`Pending approvals at cleanup: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`
|
`Pending approvals at cleanup: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`
|
||||||
);
|
);
|
||||||
this.runningFeatures.delete(featureId);
|
this.runningFeatures.delete(featureId);
|
||||||
|
|
||||||
|
// Update execution state after feature completes
|
||||||
|
if (this.autoLoopRunning && projectPath) {
|
||||||
|
await this.saveExecutionState(projectPath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -675,10 +760,14 @@ export class AutoModeService {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
logger.info(`Executing ${steps.length} pipeline step(s) for feature ${featureId}`);
|
logger.info(`Executing ${steps.length} pipeline step(s) for feature ${featureId}`);
|
||||||
|
|
||||||
// Load context files once
|
// Load context files once with feature context for smart memory selection
|
||||||
const contextResult = await loadContextFiles({
|
const contextResult = await loadContextFiles({
|
||||||
projectPath,
|
projectPath,
|
||||||
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
|
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
|
||||||
|
taskContext: {
|
||||||
|
title: feature.title ?? '',
|
||||||
|
description: feature.description ?? '',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd);
|
const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd);
|
||||||
|
|
||||||
@@ -911,6 +1000,10 @@ Complete the pipeline step instructions above. Review the previous work and appl
|
|||||||
const contextResult = await loadContextFiles({
|
const contextResult = await loadContextFiles({
|
||||||
projectPath,
|
projectPath,
|
||||||
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
|
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
|
// When autoLoadClaudeMd is enabled, filter out CLAUDE.md to avoid duplication
|
||||||
@@ -1314,7 +1407,6 @@ Format your response as a structured markdown document.`;
|
|||||||
allowedTools: sdkOptions.allowedTools as string[],
|
allowedTools: sdkOptions.allowedTools as string[],
|
||||||
abortController,
|
abortController,
|
||||||
settingSources: sdkOptions.settingSources,
|
settingSources: sdkOptions.settingSources,
|
||||||
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
|
|
||||||
thinkingLevel: analysisThinkingLevel, // Pass thinking level
|
thinkingLevel: analysisThinkingLevel, // Pass thinking level
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1784,9 +1876,13 @@ Format your response as a structured markdown document.`;
|
|||||||
// Apply dependency-aware ordering
|
// Apply dependency-aware ordering
|
||||||
const { orderedFeatures } = resolveDependencies(pendingFeatures);
|
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
|
// Filter to only features with satisfied dependencies
|
||||||
const readyFeatures = orderedFeatures.filter((feature: Feature) =>
|
const readyFeatures = orderedFeatures.filter((feature: Feature) =>
|
||||||
areDependenciesSatisfied(feature, allFeatures)
|
areDependenciesSatisfied(feature, allFeatures, { skipVerification })
|
||||||
);
|
);
|
||||||
|
|
||||||
return readyFeatures;
|
return readyFeatures;
|
||||||
@@ -1989,6 +2085,18 @@ This helps parse your summary correctly in the output logs.`;
|
|||||||
const planningMode = options?.planningMode || 'skip';
|
const planningMode = options?.planningMode || 'skip';
|
||||||
const previousContent = options?.previousContent;
|
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
|
// Check if this planning mode can generate a spec/plan that needs approval
|
||||||
// - spec and full always generate specs
|
// - spec and full always generate specs
|
||||||
// - lite only generates approval-ready content when requirePlanApproval is true
|
// - lite only generates approval-ready content when requirePlanApproval is true
|
||||||
@@ -2062,9 +2170,6 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
|||||||
? options.autoLoadClaudeMd
|
? options.autoLoadClaudeMd
|
||||||
: await getAutoLoadClaudeMdSetting(finalProjectPath, this.settingsService, '[AutoMode]');
|
: 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)
|
// Load MCP servers from settings (global setting only)
|
||||||
const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AutoMode]');
|
const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AutoMode]');
|
||||||
|
|
||||||
@@ -2076,7 +2181,6 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
|||||||
model: model,
|
model: model,
|
||||||
abortController,
|
abortController,
|
||||||
autoLoadClaudeMd,
|
autoLoadClaudeMd,
|
||||||
enableSandboxMode,
|
|
||||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
||||||
thinkingLevel: options?.thinkingLevel,
|
thinkingLevel: options?.thinkingLevel,
|
||||||
});
|
});
|
||||||
@@ -2093,7 +2197,12 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
|||||||
// Get provider for this model
|
// Get provider for this model
|
||||||
const provider = ProviderFactory.getProviderForModel(finalModel);
|
const provider = ProviderFactory.getProviderForModel(finalModel);
|
||||||
|
|
||||||
logger.info(`Using provider "${provider.getName()}" for model "${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})`
|
||||||
|
);
|
||||||
|
|
||||||
// Build prompt content with images using utility
|
// Build prompt content with images using utility
|
||||||
const { content: promptContent } = await buildPromptWithImages(
|
const { content: promptContent } = await buildPromptWithImages(
|
||||||
@@ -2112,14 +2221,13 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
|||||||
|
|
||||||
const executeOptions: ExecuteOptions = {
|
const executeOptions: ExecuteOptions = {
|
||||||
prompt: promptContent,
|
prompt: promptContent,
|
||||||
model: finalModel,
|
model: bareModel,
|
||||||
maxTurns: maxTurns,
|
maxTurns: maxTurns,
|
||||||
cwd: workDir,
|
cwd: workDir,
|
||||||
allowedTools: allowedTools,
|
allowedTools: allowedTools,
|
||||||
abortController,
|
abortController,
|
||||||
systemPrompt: sdkOptions.systemPrompt,
|
systemPrompt: sdkOptions.systemPrompt,
|
||||||
settingSources: sdkOptions.settingSources,
|
settingSources: sdkOptions.settingSources,
|
||||||
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
|
|
||||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration
|
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration
|
||||||
thinkingLevel: options?.thinkingLevel, // Pass thinking level for extended thinking
|
thinkingLevel: options?.thinkingLevel, // Pass thinking level for extended thinking
|
||||||
};
|
};
|
||||||
@@ -2202,9 +2310,23 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
|||||||
}, WRITE_DEBOUNCE_MS);
|
}, 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
|
// Wrap stream processing in try/finally to ensure timeout cleanup on any error/abort
|
||||||
try {
|
try {
|
||||||
streamLoop: for await (const msg of stream) {
|
streamLoop: for await (const msg of stream) {
|
||||||
|
receivedAnyStreamMessage = true;
|
||||||
// Log raw stream event for debugging
|
// Log raw stream event for debugging
|
||||||
appendRawEvent(msg);
|
appendRawEvent(msg);
|
||||||
|
|
||||||
@@ -2404,7 +2526,7 @@ After generating the revised spec, output:
|
|||||||
// Make revision call
|
// Make revision call
|
||||||
const revisionStream = provider.executeQuery({
|
const revisionStream = provider.executeQuery({
|
||||||
prompt: revisionPrompt,
|
prompt: revisionPrompt,
|
||||||
model: finalModel,
|
model: bareModel,
|
||||||
maxTurns: maxTurns || 100,
|
maxTurns: maxTurns || 100,
|
||||||
cwd: workDir,
|
cwd: workDir,
|
||||||
allowedTools: allowedTools,
|
allowedTools: allowedTools,
|
||||||
@@ -2542,7 +2664,7 @@ After generating the revised spec, output:
|
|||||||
// Execute task with dedicated agent
|
// Execute task with dedicated agent
|
||||||
const taskStream = provider.executeQuery({
|
const taskStream = provider.executeQuery({
|
||||||
prompt: taskPrompt,
|
prompt: taskPrompt,
|
||||||
model: finalModel,
|
model: bareModel,
|
||||||
maxTurns: Math.min(maxTurns || 100, 50), // Limit turns per task
|
maxTurns: Math.min(maxTurns || 100, 50), // Limit turns per task
|
||||||
cwd: workDir,
|
cwd: workDir,
|
||||||
allowedTools: allowedTools,
|
allowedTools: allowedTools,
|
||||||
@@ -2630,7 +2752,7 @@ Implement all the changes described in the plan above.`;
|
|||||||
|
|
||||||
const continuationStream = provider.executeQuery({
|
const continuationStream = provider.executeQuery({
|
||||||
prompt: continuationPrompt,
|
prompt: continuationPrompt,
|
||||||
model: finalModel,
|
model: bareModel,
|
||||||
maxTurns: maxTurns,
|
maxTurns: maxTurns,
|
||||||
cwd: workDir,
|
cwd: workDir,
|
||||||
allowedTools: allowedTools,
|
allowedTools: allowedTools,
|
||||||
@@ -2721,6 +2843,7 @@ Implement all the changes described in the plan above.`;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
clearInterval(streamHeartbeat);
|
||||||
// ALWAYS clear pending timeouts to prevent memory leaks
|
// ALWAYS clear pending timeouts to prevent memory leaks
|
||||||
// This runs on success, error, or abort
|
// This runs on success, error, or abort
|
||||||
if (writeTimeout) {
|
if (writeTimeout) {
|
||||||
@@ -2874,4 +2997,350 @@ Begin implementing task ${task.id} now.`;
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Execution State Persistence - For recovery after server restart
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save execution state to disk for recovery after server restart
|
||||||
|
*/
|
||||||
|
private async saveExecutionState(projectPath: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ensureAutomakerDir(projectPath);
|
||||||
|
const statePath = getExecutionStatePath(projectPath);
|
||||||
|
const state: ExecutionState = {
|
||||||
|
version: 1,
|
||||||
|
autoLoopWasRunning: this.autoLoopRunning,
|
||||||
|
maxConcurrency: this.config?.maxConcurrency ?? 3,
|
||||||
|
projectPath,
|
||||||
|
runningFeatureIds: Array.from(this.runningFeatures.keys()),
|
||||||
|
savedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await secureFs.writeFile(statePath, JSON.stringify(state, null, 2), 'utf-8');
|
||||||
|
logger.info(`Saved execution state: ${state.runningFeatureIds.length} running features`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to save execution state:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load execution state from disk
|
||||||
|
*/
|
||||||
|
private async loadExecutionState(projectPath: string): Promise<ExecutionState> {
|
||||||
|
try {
|
||||||
|
const statePath = getExecutionStatePath(projectPath);
|
||||||
|
const content = (await secureFs.readFile(statePath, 'utf-8')) as string;
|
||||||
|
const state = JSON.parse(content) as ExecutionState;
|
||||||
|
return state;
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||||
|
logger.error('Failed to load execution state:', error);
|
||||||
|
}
|
||||||
|
return DEFAULT_EXECUTION_STATE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear execution state (called on successful shutdown or when auto-loop stops)
|
||||||
|
*/
|
||||||
|
private async clearExecutionState(projectPath: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const statePath = getExecutionStatePath(projectPath);
|
||||||
|
await secureFs.unlink(statePath);
|
||||||
|
logger.info('Cleared execution state');
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||||
|
logger.error('Failed to clear execution state:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for and resume interrupted features after server restart
|
||||||
|
* This should be called during server initialization
|
||||||
|
*/
|
||||||
|
async resumeInterruptedFeatures(projectPath: string): Promise<void> {
|
||||||
|
logger.info('Checking for interrupted features to resume...');
|
||||||
|
|
||||||
|
// Load all features and find those that were interrupted
|
||||||
|
const featuresDir = getFeaturesDir(projectPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = await secureFs.readdir(featuresDir, { withFileTypes: true });
|
||||||
|
const interruptedFeatures: Feature[] = [];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
const featurePath = path.join(featuresDir, entry.name, 'feature.json');
|
||||||
|
try {
|
||||||
|
const data = (await secureFs.readFile(featurePath, 'utf-8')) as string;
|
||||||
|
const feature = JSON.parse(data) as Feature;
|
||||||
|
|
||||||
|
// Check if feature was interrupted (in_progress or pipeline_*)
|
||||||
|
if (
|
||||||
|
feature.status === 'in_progress' ||
|
||||||
|
(feature.status && feature.status.startsWith('pipeline_'))
|
||||||
|
) {
|
||||||
|
// Verify it has existing context (agent-output.md)
|
||||||
|
const featureDir = getFeatureDir(projectPath, feature.id);
|
||||||
|
const contextPath = path.join(featureDir, 'agent-output.md');
|
||||||
|
try {
|
||||||
|
await secureFs.access(contextPath);
|
||||||
|
interruptedFeatures.push(feature);
|
||||||
|
logger.info(
|
||||||
|
`Found interrupted feature: ${feature.id} (${feature.title}) - status: ${feature.status}`
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// No context file, skip this feature - it will be restarted fresh
|
||||||
|
logger.info(`Interrupted feature ${feature.id} has no context, will restart fresh`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip invalid features
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interruptedFeatures.length === 0) {
|
||||||
|
logger.info('No interrupted features found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Found ${interruptedFeatures.length} interrupted feature(s) to resume`);
|
||||||
|
|
||||||
|
// Emit event to notify UI
|
||||||
|
this.emitAutoModeEvent('auto_mode_resuming_features', {
|
||||||
|
message: `Resuming ${interruptedFeatures.length} interrupted feature(s) after server restart`,
|
||||||
|
projectPath,
|
||||||
|
featureIds: interruptedFeatures.map((f) => f.id),
|
||||||
|
features: interruptedFeatures.map((f) => ({
|
||||||
|
id: f.id,
|
||||||
|
title: f.title,
|
||||||
|
status: f.status,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resume each interrupted feature
|
||||||
|
for (const feature of interruptedFeatures) {
|
||||||
|
try {
|
||||||
|
logger.info(`Resuming feature: ${feature.id} (${feature.title})`);
|
||||||
|
// Use resumeFeature which will detect the existing context and continue
|
||||||
|
await this.resumeFeature(projectPath, feature.id, true);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to resume feature ${feature.id}:`, error);
|
||||||
|
// Continue with other features
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
logger.info('No features directory found, nothing to resume');
|
||||||
|
} else {
|
||||||
|
logger.error('Error checking for interrupted features:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { spawn } from 'child_process';
|
|||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as pty from 'node-pty';
|
import * as pty from 'node-pty';
|
||||||
import { ClaudeUsage } from '../routes/claude/types.js';
|
import { ClaudeUsage } from '../routes/claude/types.js';
|
||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Claude Usage Service
|
* Claude Usage Service
|
||||||
@@ -14,6 +15,8 @@ import { ClaudeUsage } from '../routes/claude/types.js';
|
|||||||
* - macOS: Uses 'expect' command for PTY
|
* - macOS: Uses 'expect' command for PTY
|
||||||
* - Windows/Linux: Uses node-pty for PTY
|
* - Windows/Linux: Uses node-pty for PTY
|
||||||
*/
|
*/
|
||||||
|
const logger = createLogger('ClaudeUsage');
|
||||||
|
|
||||||
export class ClaudeUsageService {
|
export class ClaudeUsageService {
|
||||||
private claudeBinary = 'claude';
|
private claudeBinary = 'claude';
|
||||||
private timeout = 30000; // 30 second timeout
|
private timeout = 30000; // 30 second timeout
|
||||||
@@ -164,21 +167,40 @@ export class ClaudeUsageService {
|
|||||||
const shell = this.isWindows ? 'cmd.exe' : '/bin/sh';
|
const shell = this.isWindows ? 'cmd.exe' : '/bin/sh';
|
||||||
const args = this.isWindows ? ['/c', 'claude', '/usage'] : ['-c', 'claude /usage'];
|
const args = this.isWindows ? ['/c', 'claude', '/usage'] : ['-c', 'claude /usage'];
|
||||||
|
|
||||||
const ptyProcess = pty.spawn(shell, args, {
|
let ptyProcess: any = null;
|
||||||
name: 'xterm-256color',
|
|
||||||
cols: 120,
|
try {
|
||||||
rows: 30,
|
ptyProcess = pty.spawn(shell, args, {
|
||||||
cwd: workingDirectory,
|
name: 'xterm-256color',
|
||||||
env: {
|
cols: 120,
|
||||||
...process.env,
|
rows: 30,
|
||||||
TERM: 'xterm-256color',
|
cwd: workingDirectory,
|
||||||
} as Record<string, string>,
|
env: {
|
||||||
});
|
...process.env,
|
||||||
|
TERM: 'xterm-256color',
|
||||||
|
} as Record<string, string>,
|
||||||
|
});
|
||||||
|
} catch (spawnError) {
|
||||||
|
// pty.spawn() can throw synchronously if the native module fails to load
|
||||||
|
// or if PTY is not available in the current environment (e.g., containers without /dev/pts)
|
||||||
|
const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError);
|
||||||
|
logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage);
|
||||||
|
|
||||||
|
// Return a user-friendly error instead of crashing
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
`Unable to access terminal: ${errorMessage}. Claude CLI may not be available or PTY support is limited in this environment.`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
if (!settled) {
|
if (!settled) {
|
||||||
settled = true;
|
settled = true;
|
||||||
ptyProcess.kill();
|
if (ptyProcess && !ptyProcess.killed) {
|
||||||
|
ptyProcess.kill();
|
||||||
|
}
|
||||||
// Don't fail if we have data - return it instead
|
// Don't fail if we have data - return it instead
|
||||||
if (output.includes('Current session')) {
|
if (output.includes('Current session')) {
|
||||||
resolve(output);
|
resolve(output);
|
||||||
@@ -188,7 +210,7 @@ export class ClaudeUsageService {
|
|||||||
}
|
}
|
||||||
}, this.timeout);
|
}, this.timeout);
|
||||||
|
|
||||||
ptyProcess.onData((data) => {
|
ptyProcess.onData((data: string) => {
|
||||||
output += data;
|
output += data;
|
||||||
|
|
||||||
// Check if we've seen the usage data (look for "Current session")
|
// Check if we've seen the usage data (look for "Current session")
|
||||||
@@ -196,12 +218,12 @@ export class ClaudeUsageService {
|
|||||||
hasSeenUsageData = true;
|
hasSeenUsageData = true;
|
||||||
// Wait for full output, then send escape to exit
|
// Wait for full output, then send escape to exit
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!settled) {
|
if (!settled && ptyProcess && !ptyProcess.killed) {
|
||||||
ptyProcess.write('\x1b'); // Send escape key
|
ptyProcess.write('\x1b'); // Send escape key
|
||||||
|
|
||||||
// Fallback: if ESC doesn't exit (Linux), use SIGTERM after 2s
|
// Fallback: if ESC doesn't exit (Linux), use SIGTERM after 2s
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!settled) {
|
if (!settled && ptyProcess && !ptyProcess.killed) {
|
||||||
ptyProcess.kill('SIGTERM');
|
ptyProcess.kill('SIGTERM');
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
@@ -212,14 +234,14 @@ export class ClaudeUsageService {
|
|||||||
// Fallback: if we see "Esc to cancel" but haven't seen usage data yet
|
// Fallback: if we see "Esc to cancel" but haven't seen usage data yet
|
||||||
if (!hasSeenUsageData && output.includes('Esc to cancel')) {
|
if (!hasSeenUsageData && output.includes('Esc to cancel')) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!settled) {
|
if (!settled && ptyProcess && !ptyProcess.killed) {
|
||||||
ptyProcess.write('\x1b'); // Send escape key
|
ptyProcess.write('\x1b'); // Send escape key
|
||||||
}
|
}
|
||||||
}, 3000);
|
}, 3000);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ptyProcess.onExit(({ exitCode }) => {
|
ptyProcess.onExit(({ exitCode }: { exitCode: number }) => {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
|
|||||||
212
apps/server/src/services/codex-app-server-service.ts
Normal file
212
apps/server/src/services/codex-app-server-service.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import { spawn, type ChildProcess } from 'child_process';
|
||||||
|
import readline from 'readline';
|
||||||
|
import { findCodexCliPath } from '@automaker/platform';
|
||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
|
import type {
|
||||||
|
AppServerModelResponse,
|
||||||
|
AppServerAccountResponse,
|
||||||
|
AppServerRateLimitsResponse,
|
||||||
|
JsonRpcRequest,
|
||||||
|
} from '@automaker/types';
|
||||||
|
|
||||||
|
const logger = createLogger('CodexAppServer');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CodexAppServerService
|
||||||
|
*
|
||||||
|
* Centralized service for communicating with Codex CLI's app-server via JSON-RPC protocol.
|
||||||
|
* Handles process spawning, JSON-RPC messaging, and cleanup.
|
||||||
|
*
|
||||||
|
* Connection strategy: Spawn on-demand (new process for each method call)
|
||||||
|
*/
|
||||||
|
export class CodexAppServerService {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch available models from app-server
|
||||||
|
*/
|
||||||
|
async getModels(): Promise<AppServerModelResponse | null> {
|
||||||
|
const result = await this.executeJsonRpc<AppServerModelResponse>((sendRequest) => {
|
||||||
|
return sendRequest('model/list', {});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
logger.info(`[getModels] ✓ Fetched ${result.data.length} models`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch account information from app-server
|
||||||
|
*/
|
||||||
|
async getAccount(): Promise<AppServerAccountResponse | null> {
|
||||||
|
return this.executeJsonRpc<AppServerAccountResponse>((sendRequest) => {
|
||||||
|
return sendRequest('account/read', { refreshToken: false });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch rate limits from app-server
|
||||||
|
*/
|
||||||
|
async getRateLimits(): Promise<AppServerRateLimitsResponse | null> {
|
||||||
|
return this.executeJsonRpc<AppServerRateLimitsResponse>((sendRequest) => {
|
||||||
|
return sendRequest('account/rateLimits/read', {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute JSON-RPC requests via Codex app-server
|
||||||
|
*
|
||||||
|
* This method:
|
||||||
|
* 1. Spawns a new `codex app-server` process
|
||||||
|
* 2. Handles JSON-RPC initialization handshake
|
||||||
|
* 3. Executes user-provided requests
|
||||||
|
* 4. Cleans up the process
|
||||||
|
*
|
||||||
|
* @param requestFn - Function that receives sendRequest helper and returns a promise
|
||||||
|
* @returns Result of the JSON-RPC request or null on failure
|
||||||
|
*/
|
||||||
|
private async executeJsonRpc<T>(
|
||||||
|
requestFn: (sendRequest: <R>(method: string, params?: unknown) => Promise<R>) => Promise<T>
|
||||||
|
): Promise<T | null> {
|
||||||
|
let childProcess: ChildProcess | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cliPath = this.cachedCliPath || (await findCodexCliPath());
|
||||||
|
|
||||||
|
if (!cliPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// On Windows, .cmd files must be run through shell
|
||||||
|
const needsShell = process.platform === 'win32' && cliPath.toLowerCase().endsWith('.cmd');
|
||||||
|
|
||||||
|
childProcess = spawn(cliPath, ['app-server'], {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
TERM: 'dumb',
|
||||||
|
},
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
shell: needsShell,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!childProcess.stdin || !childProcess.stdout) {
|
||||||
|
throw new Error('Failed to create stdio pipes');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup readline for reading JSONL responses
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: childProcess.stdout,
|
||||||
|
crlfDelay: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Message ID counter for JSON-RPC
|
||||||
|
let messageId = 0;
|
||||||
|
const pendingRequests = new Map<
|
||||||
|
number,
|
||||||
|
{
|
||||||
|
resolve: (value: unknown) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
timeout: NodeJS.Timeout;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
// Process incoming messages
|
||||||
|
rl.on('line', (line) => {
|
||||||
|
if (!line.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(line);
|
||||||
|
|
||||||
|
// Handle response to our request
|
||||||
|
if ('id' in message && message.id !== undefined) {
|
||||||
|
const pending = pendingRequests.get(message.id);
|
||||||
|
if (pending) {
|
||||||
|
clearTimeout(pending.timeout);
|
||||||
|
pendingRequests.delete(message.id);
|
||||||
|
if (message.error) {
|
||||||
|
pending.reject(new Error(message.error.message || 'Unknown error'));
|
||||||
|
} else {
|
||||||
|
pending.resolve(message.result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ignore notifications (no id field)
|
||||||
|
} catch {
|
||||||
|
// Ignore parse errors for non-JSON lines
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper to send JSON-RPC request and wait for response
|
||||||
|
const sendRequest = <R>(method: string, params?: unknown): Promise<R> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const id = ++messageId;
|
||||||
|
const request: JsonRpcRequest = {
|
||||||
|
method,
|
||||||
|
id,
|
||||||
|
params: params ?? {},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set timeout for request (10 seconds)
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
pendingRequests.delete(id);
|
||||||
|
reject(new Error(`Request timeout: ${method}`));
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
pendingRequests.set(id, {
|
||||||
|
resolve: resolve as (value: unknown) => void,
|
||||||
|
reject,
|
||||||
|
timeout,
|
||||||
|
});
|
||||||
|
|
||||||
|
childProcess!.stdin!.write(JSON.stringify(request) + '\n');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to send notification (no response expected)
|
||||||
|
const sendNotification = (method: string, params?: unknown): void => {
|
||||||
|
const notification = params ? { method, params } : { method };
|
||||||
|
childProcess!.stdin!.write(JSON.stringify(notification) + '\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Initialize the app-server
|
||||||
|
await sendRequest('initialize', {
|
||||||
|
clientInfo: {
|
||||||
|
name: 'automaker',
|
||||||
|
title: 'AutoMaker',
|
||||||
|
version: '1.0.0',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Send initialized notification
|
||||||
|
sendNotification('initialized');
|
||||||
|
|
||||||
|
// 3. Execute user-provided requests
|
||||||
|
const result = await requestFn(sendRequest);
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
rl.close();
|
||||||
|
childProcess.kill('SIGTERM');
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[executeJsonRpc] Failed:', error);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
// Ensure process is killed
|
||||||
|
if (childProcess && !childProcess.killed) {
|
||||||
|
childProcess.kill('SIGTERM');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
258
apps/server/src/services/codex-model-cache-service.ts
Normal file
258
apps/server/src/services/codex-model-cache-service.ts
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
import path from 'path';
|
||||||
|
import { secureFs } from '@automaker/platform';
|
||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
|
import type { AppServerModel } from '@automaker/types';
|
||||||
|
import type { CodexAppServerService } from './codex-app-server-service.js';
|
||||||
|
|
||||||
|
const logger = createLogger('CodexModelCache');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Codex model with UI-compatible format
|
||||||
|
*/
|
||||||
|
export interface CodexModel {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
hasThinking: boolean;
|
||||||
|
supportsVision: boolean;
|
||||||
|
tier: 'premium' | 'standard' | 'basic';
|
||||||
|
isDefault: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache structure stored on disk
|
||||||
|
*/
|
||||||
|
interface CodexModelCache {
|
||||||
|
models: CodexModel[];
|
||||||
|
cachedAt: number;
|
||||||
|
ttl: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CodexModelCacheService
|
||||||
|
*
|
||||||
|
* Caches Codex models fetched from app-server with TTL-based invalidation and disk persistence.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - 1-hour TTL (configurable)
|
||||||
|
* - Atomic file writes (temp file + rename)
|
||||||
|
* - Thread-safe (deduplicates concurrent refresh requests)
|
||||||
|
* - Auto-bootstrap on service creation
|
||||||
|
* - Graceful fallback (returns empty array on errors)
|
||||||
|
*/
|
||||||
|
export class CodexModelCacheService {
|
||||||
|
private cacheFilePath: string;
|
||||||
|
private ttl: number;
|
||||||
|
private appServerService: CodexAppServerService;
|
||||||
|
private inFlightRefresh: Promise<CodexModel[]> | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
dataDir: string,
|
||||||
|
appServerService: CodexAppServerService,
|
||||||
|
ttl: number = 3600000 // 1 hour default
|
||||||
|
) {
|
||||||
|
this.cacheFilePath = path.join(dataDir, 'codex-models-cache.json');
|
||||||
|
this.ttl = ttl;
|
||||||
|
this.appServerService = appServerService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get models from cache or fetch if stale
|
||||||
|
*
|
||||||
|
* @param forceRefresh - If true, bypass cache and fetch fresh data
|
||||||
|
* @returns Array of Codex models (empty array if unavailable)
|
||||||
|
*/
|
||||||
|
async getModels(forceRefresh = false): Promise<CodexModel[]> {
|
||||||
|
// If force refresh, skip cache
|
||||||
|
if (forceRefresh) {
|
||||||
|
return this.refreshModels();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to load from cache
|
||||||
|
const cached = await this.loadFromCache();
|
||||||
|
if (cached) {
|
||||||
|
const age = Date.now() - cached.cachedAt;
|
||||||
|
const isStale = age > cached.ttl;
|
||||||
|
|
||||||
|
if (!isStale) {
|
||||||
|
logger.info(
|
||||||
|
`[getModels] ✓ Using cached models (${cached.models.length} models, age: ${Math.round(age / 60000)}min)`
|
||||||
|
);
|
||||||
|
return cached.models;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache is stale or missing, refresh
|
||||||
|
return this.refreshModels();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get models with cache metadata
|
||||||
|
*
|
||||||
|
* @param forceRefresh - If true, bypass cache and fetch fresh data
|
||||||
|
* @returns Object containing models and cache timestamp
|
||||||
|
*/
|
||||||
|
async getModelsWithMetadata(
|
||||||
|
forceRefresh = false
|
||||||
|
): Promise<{ models: CodexModel[]; cachedAt: number }> {
|
||||||
|
const models = await this.getModels(forceRefresh);
|
||||||
|
|
||||||
|
// Try to get the actual cache timestamp
|
||||||
|
const cached = await this.loadFromCache();
|
||||||
|
const cachedAt = cached?.cachedAt ?? Date.now();
|
||||||
|
|
||||||
|
return { models, cachedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh models from app-server and update cache
|
||||||
|
*
|
||||||
|
* Thread-safe: Deduplicates concurrent refresh requests
|
||||||
|
*/
|
||||||
|
async refreshModels(): Promise<CodexModel[]> {
|
||||||
|
// Deduplicate concurrent refresh requests
|
||||||
|
if (this.inFlightRefresh) {
|
||||||
|
return this.inFlightRefresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start new refresh
|
||||||
|
this.inFlightRefresh = this.doRefresh();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const models = await this.inFlightRefresh;
|
||||||
|
return models;
|
||||||
|
} finally {
|
||||||
|
this.inFlightRefresh = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the cache file
|
||||||
|
*/
|
||||||
|
async clearCache(): Promise<void> {
|
||||||
|
logger.info('[clearCache] Clearing cache...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await secureFs.unlink(this.cacheFilePath);
|
||||||
|
logger.info('[clearCache] Cache cleared');
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||||
|
logger.error('[clearCache] Failed to clear cache:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal method to perform the actual refresh
|
||||||
|
*/
|
||||||
|
private async doRefresh(): Promise<CodexModel[]> {
|
||||||
|
try {
|
||||||
|
// Check if app-server is available
|
||||||
|
const isAvailable = await this.appServerService.isAvailable();
|
||||||
|
if (!isAvailable) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch models from app-server
|
||||||
|
const response = await this.appServerService.getModels();
|
||||||
|
if (!response || !response.data) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform models to UI format
|
||||||
|
const models = response.data.map((model) => this.transformModel(model));
|
||||||
|
|
||||||
|
// Save to cache
|
||||||
|
await this.saveToCache(models);
|
||||||
|
|
||||||
|
logger.info(`[refreshModels] ✓ Fetched fresh models (${models.length} models)`);
|
||||||
|
|
||||||
|
return models;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[doRefresh] Refresh failed:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform app-server model to UI-compatible format
|
||||||
|
*/
|
||||||
|
private transformModel(appServerModel: AppServerModel): CodexModel {
|
||||||
|
return {
|
||||||
|
id: `codex-${appServerModel.id}`, // Add 'codex-' prefix for compatibility
|
||||||
|
label: appServerModel.displayName,
|
||||||
|
description: appServerModel.description,
|
||||||
|
hasThinking: appServerModel.supportedReasoningEfforts.length > 0,
|
||||||
|
supportsVision: true, // All Codex models support vision
|
||||||
|
tier: this.inferTier(appServerModel.id),
|
||||||
|
isDefault: appServerModel.isDefault,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infer tier from model ID
|
||||||
|
*/
|
||||||
|
private inferTier(modelId: string): 'premium' | 'standard' | 'basic' {
|
||||||
|
if (modelId.includes('max') || modelId.includes('gpt-5.2-codex')) {
|
||||||
|
return 'premium';
|
||||||
|
}
|
||||||
|
if (modelId.includes('mini')) {
|
||||||
|
return 'basic';
|
||||||
|
}
|
||||||
|
return 'standard';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load cache from disk
|
||||||
|
*/
|
||||||
|
private async loadFromCache(): Promise<CodexModelCache | null> {
|
||||||
|
try {
|
||||||
|
const content = await secureFs.readFile(this.cacheFilePath, 'utf-8');
|
||||||
|
const cache = JSON.parse(content.toString()) as CodexModelCache;
|
||||||
|
|
||||||
|
// Validate cache structure
|
||||||
|
if (!Array.isArray(cache.models) || typeof cache.cachedAt !== 'number') {
|
||||||
|
logger.warn('[loadFromCache] Invalid cache structure, ignoring');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cache;
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||||
|
logger.warn('[loadFromCache] Failed to read cache:', error);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save cache to disk (atomic write)
|
||||||
|
*/
|
||||||
|
private async saveToCache(models: CodexModel[]): Promise<void> {
|
||||||
|
const cache: CodexModelCache = {
|
||||||
|
models,
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
ttl: this.ttl,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tempPath = `${this.cacheFilePath}.tmp.${Date.now()}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Write to temp file
|
||||||
|
const content = JSON.stringify(cache, null, 2);
|
||||||
|
await secureFs.writeFile(tempPath, content, 'utf-8');
|
||||||
|
|
||||||
|
// Atomic rename
|
||||||
|
await secureFs.rename(tempPath, this.cacheFilePath);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[saveToCache] Failed to save cache:', error);
|
||||||
|
|
||||||
|
// Clean up temp file
|
||||||
|
try {
|
||||||
|
await secureFs.unlink(tempPath);
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
348
apps/server/src/services/codex-usage-service.ts
Normal file
348
apps/server/src/services/codex-usage-service.ts
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
import {
|
||||||
|
findCodexCliPath,
|
||||||
|
getCodexAuthPath,
|
||||||
|
systemPathExists,
|
||||||
|
systemPathReadFile,
|
||||||
|
} from '@automaker/platform';
|
||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
|
import type { CodexAppServerService } from './codex-app-server-service.js';
|
||||||
|
|
||||||
|
const logger = createLogger('CodexUsage');
|
||||||
|
|
||||||
|
export interface CodexRateLimitWindow {
|
||||||
|
limit: number;
|
||||||
|
used: number;
|
||||||
|
remaining: number;
|
||||||
|
usedPercent: number;
|
||||||
|
windowDurationMins: number;
|
||||||
|
resetsAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CodexPlanType = 'free' | 'plus' | 'pro' | 'team' | 'enterprise' | 'edu' | 'unknown';
|
||||||
|
|
||||||
|
export interface CodexUsageData {
|
||||||
|
rateLimits: {
|
||||||
|
primary?: CodexRateLimitWindow;
|
||||||
|
secondary?: CodexRateLimitWindow;
|
||||||
|
planType?: CodexPlanType;
|
||||||
|
} | null;
|
||||||
|
lastUpdated: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Codex Usage Service
|
||||||
|
*
|
||||||
|
* Fetches usage data from Codex CLI using the app-server JSON-RPC API.
|
||||||
|
* Falls back to auth file parsing if app-server is unavailable.
|
||||||
|
*/
|
||||||
|
export class CodexUsageService {
|
||||||
|
private cachedCliPath: string | null = null;
|
||||||
|
private appServerService: CodexAppServerService | null = null;
|
||||||
|
private accountPlanTypeArray: CodexPlanType[] = [
|
||||||
|
'free',
|
||||||
|
'plus',
|
||||||
|
'pro',
|
||||||
|
'team',
|
||||||
|
'enterprise',
|
||||||
|
'edu',
|
||||||
|
];
|
||||||
|
|
||||||
|
constructor(appServerService?: CodexAppServerService) {
|
||||||
|
this.appServerService = appServerService || 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
|
||||||
|
*
|
||||||
|
* Priority order:
|
||||||
|
* 1. Codex app-server JSON-RPC API (most reliable, provides real-time data)
|
||||||
|
* 2. Auth file JWT parsing (fallback for plan type)
|
||||||
|
*/
|
||||||
|
async fetchUsageData(): Promise<CodexUsageData> {
|
||||||
|
logger.info('[fetchUsageData] Starting...');
|
||||||
|
const cliPath = this.cachedCliPath || (await findCodexCliPath());
|
||||||
|
|
||||||
|
if (!cliPath) {
|
||||||
|
logger.error('[fetchUsageData] Codex CLI not found');
|
||||||
|
throw new Error('Codex CLI not found. Please install it with: npm install -g @openai/codex');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`[fetchUsageData] Using CLI path: ${cliPath}`);
|
||||||
|
|
||||||
|
// Try to get usage from Codex app-server (most reliable method)
|
||||||
|
const appServerUsage = await this.fetchFromAppServer();
|
||||||
|
if (appServerUsage) {
|
||||||
|
logger.info('[fetchUsageData] ✓ Fetched usage from app-server');
|
||||||
|
return appServerUsage;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('[fetchUsageData] App-server failed, trying auth file fallback...');
|
||||||
|
|
||||||
|
// Fallback: try to parse usage from auth file
|
||||||
|
const authUsage = await this.fetchFromAuthFile();
|
||||||
|
if (authUsage) {
|
||||||
|
logger.info('[fetchUsageData] ✓ Fetched usage from auth file');
|
||||||
|
return authUsage;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('[fetchUsageData] All methods failed, returning unknown');
|
||||||
|
|
||||||
|
// If all else fails, return unknown
|
||||||
|
return {
|
||||||
|
rateLimits: {
|
||||||
|
planType: 'unknown',
|
||||||
|
},
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch usage data from Codex app-server using JSON-RPC API
|
||||||
|
* This is the most reliable method as it gets real-time data from OpenAI
|
||||||
|
*/
|
||||||
|
private async fetchFromAppServer(): Promise<CodexUsageData | null> {
|
||||||
|
try {
|
||||||
|
// Use CodexAppServerService if available
|
||||||
|
if (!this.appServerService) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch account and rate limits in parallel
|
||||||
|
const [accountResult, rateLimitsResult] = await Promise.all([
|
||||||
|
this.appServerService.getAccount(),
|
||||||
|
this.appServerService.getRateLimits(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!accountResult) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build response
|
||||||
|
// Prefer planType from rateLimits (more accurate/current) over account (can be stale)
|
||||||
|
let planType: CodexPlanType = 'unknown';
|
||||||
|
|
||||||
|
// First try rate limits planType (most accurate)
|
||||||
|
const rateLimitsPlanType = rateLimitsResult?.rateLimits?.planType;
|
||||||
|
if (rateLimitsPlanType) {
|
||||||
|
const normalizedType = rateLimitsPlanType.toLowerCase() as CodexPlanType;
|
||||||
|
if (this.accountPlanTypeArray.includes(normalizedType)) {
|
||||||
|
planType = normalizedType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to account planType if rate limits didn't have it
|
||||||
|
if (planType === 'unknown' && accountResult.account?.planType) {
|
||||||
|
const normalizedType = accountResult.account.planType.toLowerCase() as CodexPlanType;
|
||||||
|
if (this.accountPlanTypeArray.includes(normalizedType)) {
|
||||||
|
planType = normalizedType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: CodexUsageData = {
|
||||||
|
rateLimits: {
|
||||||
|
planType,
|
||||||
|
},
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add rate limit info if available
|
||||||
|
if (rateLimitsResult?.rateLimits?.primary) {
|
||||||
|
const primary = rateLimitsResult.rateLimits.primary;
|
||||||
|
result.rateLimits!.primary = {
|
||||||
|
limit: -1, // Not provided by API
|
||||||
|
used: -1, // Not provided by API
|
||||||
|
remaining: -1, // Not provided by API
|
||||||
|
usedPercent: primary.usedPercent,
|
||||||
|
windowDurationMins: primary.windowDurationMins,
|
||||||
|
resetsAt: primary.resetsAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add secondary rate limit if available
|
||||||
|
if (rateLimitsResult?.rateLimits?.secondary) {
|
||||||
|
const secondary = rateLimitsResult.rateLimits.secondary;
|
||||||
|
result.rateLimits!.secondary = {
|
||||||
|
limit: -1, // Not provided by API
|
||||||
|
used: -1, // Not provided by API
|
||||||
|
remaining: -1, // Not provided by API
|
||||||
|
usedPercent: secondary.usedPercent,
|
||||||
|
windowDurationMins: secondary.windowDurationMins,
|
||||||
|
resetsAt: secondary.resetsAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`[fetchFromAppServer] ✓ Plan: ${planType}, Primary: ${result.rateLimits?.primary?.usedPercent || 'N/A'}%, Secondary: ${result.rateLimits?.secondary?.usedPercent || 'N/A'}%`
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[fetchFromAppServer] Failed:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
logger.info(`[getPlanTypeFromAuthFile] Auth file path: ${authFilePath}`);
|
||||||
|
const exists = systemPathExists(authFilePath);
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
logger.warn('[getPlanTypeFromAuthFile] Auth file does not exist');
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
const authContent = await systemPathReadFile(authFilePath);
|
||||||
|
const authData = JSON.parse(authContent);
|
||||||
|
|
||||||
|
if (!authData.tokens?.id_token) {
|
||||||
|
logger.info('[getPlanTypeFromAuthFile] No id_token in auth file');
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
const claims = this.parseJwt(authData.tokens.id_token);
|
||||||
|
if (!claims) {
|
||||||
|
logger.info('[getPlanTypeFromAuthFile] Failed to parse JWT');
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('[getPlanTypeFromAuthFile] JWT claims keys:', Object.keys(claims));
|
||||||
|
|
||||||
|
// Extract plan type from nested OpenAI auth object with type validation
|
||||||
|
const openaiAuthClaim = claims['https://api.openai.com/auth'];
|
||||||
|
logger.info(
|
||||||
|
'[getPlanTypeFromAuthFile] OpenAI auth claim:',
|
||||||
|
JSON.stringify(openaiAuthClaim, null, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
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() as CodexPlanType;
|
||||||
|
logger.info(
|
||||||
|
`[getPlanTypeFromAuthFile] Account type: "${accountType}", normalized: "${normalizedType}"`
|
||||||
|
);
|
||||||
|
if (this.accountPlanTypeArray.includes(normalizedType)) {
|
||||||
|
logger.info(`[getPlanTypeFromAuthFile] Returning plan type: ${normalizedType}`);
|
||||||
|
return normalizedType;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.info('[getPlanTypeFromAuthFile] No account type found in claims');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[getPlanTypeFromAuthFile] Failed to get plan type from auth file:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('[getPlanTypeFromAuthFile] Returning unknown');
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to extract usage info from the Codex auth file
|
||||||
|
* Reuses getPlanTypeFromAuthFile to avoid code duplication
|
||||||
|
*/
|
||||||
|
private async fetchFromAuthFile(): Promise<CodexUsageData | null> {
|
||||||
|
logger.info('[fetchFromAuthFile] Starting...');
|
||||||
|
try {
|
||||||
|
const planType = await this.getPlanTypeFromAuthFile();
|
||||||
|
logger.info(`[fetchFromAuthFile] Got plan type: ${planType}`);
|
||||||
|
|
||||||
|
if (planType === 'unknown') {
|
||||||
|
logger.info('[fetchFromAuthFile] Plan type unknown, returning null');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: CodexUsageData = {
|
||||||
|
rateLimits: {
|
||||||
|
planType,
|
||||||
|
},
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info('[fetchFromAuthFile] Returning result:', JSON.stringify(result, null, 2));
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[fetchFromAuthFile] Failed to parse auth file:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse JWT token to extract claims
|
||||||
|
*/
|
||||||
|
private parseJwt(token: string): Record<string, unknown> | null {
|
||||||
|
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
|
||||||
|
const jsonPayload = Buffer.from(base64, 'base64').toString('utf-8');
|
||||||
|
|
||||||
|
return JSON.parse(jsonPayload);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import type { Feature } from '@automaker/types';
|
import type { Feature, DescriptionHistoryEntry } from '@automaker/types';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import * as secureFs from '../lib/secure-fs.js';
|
import * as secureFs from '../lib/secure-fs.js';
|
||||||
import {
|
import {
|
||||||
@@ -274,6 +274,16 @@ export class FeatureLoader {
|
|||||||
featureData.imagePaths
|
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
|
// Ensure feature has required fields
|
||||||
const feature: Feature = {
|
const feature: Feature = {
|
||||||
category: featureData.category || 'Uncategorized',
|
category: featureData.category || 'Uncategorized',
|
||||||
@@ -281,6 +291,7 @@ export class FeatureLoader {
|
|||||||
...featureData,
|
...featureData,
|
||||||
id: featureId,
|
id: featureId,
|
||||||
imagePaths: migratedImagePaths,
|
imagePaths: migratedImagePaths,
|
||||||
|
descriptionHistory: initialHistory,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Write feature.json
|
// Write feature.json
|
||||||
@@ -292,11 +303,18 @@ export class FeatureLoader {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a feature (partial updates supported)
|
* 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(
|
async update(
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
featureId: string,
|
featureId: string,
|
||||||
updates: Partial<Feature>
|
updates: Partial<Feature>,
|
||||||
|
descriptionHistorySource?: 'enhance' | 'edit',
|
||||||
|
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'
|
||||||
): Promise<Feature> {
|
): Promise<Feature> {
|
||||||
const feature = await this.get(projectPath, featureId);
|
const feature = await this.get(projectPath, featureId);
|
||||||
if (!feature) {
|
if (!feature) {
|
||||||
@@ -313,11 +331,28 @@ export class FeatureLoader {
|
|||||||
updatedImagePaths = await this.migrateImages(projectPath, featureId, updates.imagePaths);
|
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
|
// Merge updates
|
||||||
const updatedFeature: Feature = {
|
const updatedFeature: Feature = {
|
||||||
...feature,
|
...feature,
|
||||||
...updates,
|
...updates,
|
||||||
...(updatedImagePaths !== undefined ? { imagePaths: updatedImagePaths } : {}),
|
...(updatedImagePaths !== undefined ? { imagePaths: updatedImagePaths } : {}),
|
||||||
|
descriptionHistory: updatedHistory,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Write back to file
|
// Write back to file
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import type { SettingsService } from './settings-service.js';
|
|||||||
import type { FeatureLoader } from './feature-loader.js';
|
import type { FeatureLoader } from './feature-loader.js';
|
||||||
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
|
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
|
||||||
import { resolveModelString } from '@automaker/model-resolver';
|
import { resolveModelString } from '@automaker/model-resolver';
|
||||||
|
import { stripProviderPrefix } from '@automaker/types';
|
||||||
|
|
||||||
const logger = createLogger('IdeationService');
|
const logger = createLogger('IdeationService');
|
||||||
|
|
||||||
@@ -201,7 +202,7 @@ export class IdeationService {
|
|||||||
existingWorkContext
|
existingWorkContext
|
||||||
);
|
);
|
||||||
|
|
||||||
// Resolve model alias to canonical identifier
|
// Resolve model alias to canonical identifier (with prefix)
|
||||||
const modelId = resolveModelString(options?.model ?? 'sonnet');
|
const modelId = resolveModelString(options?.model ?? 'sonnet');
|
||||||
|
|
||||||
// Create SDK options
|
// Create SDK options
|
||||||
@@ -214,9 +215,13 @@ export class IdeationService {
|
|||||||
|
|
||||||
const provider = ProviderFactory.getProviderForModel(modelId);
|
const provider = ProviderFactory.getProviderForModel(modelId);
|
||||||
|
|
||||||
|
// Strip provider prefix - providers need bare model IDs
|
||||||
|
const bareModel = stripProviderPrefix(modelId);
|
||||||
|
|
||||||
const executeOptions: ExecuteOptions = {
|
const executeOptions: ExecuteOptions = {
|
||||||
prompt: message,
|
prompt: message,
|
||||||
model: modelId,
|
model: bareModel,
|
||||||
|
originalModel: modelId,
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
systemPrompt: sdkOptions.systemPrompt,
|
systemPrompt: sdkOptions.systemPrompt,
|
||||||
maxTurns: 1, // Single turn for ideation
|
maxTurns: 1, // Single turn for ideation
|
||||||
@@ -648,7 +653,7 @@ export class IdeationService {
|
|||||||
existingWorkContext
|
existingWorkContext
|
||||||
);
|
);
|
||||||
|
|
||||||
// Resolve model alias to canonical identifier
|
// Resolve model alias to canonical identifier (with prefix)
|
||||||
const modelId = resolveModelString('sonnet');
|
const modelId = resolveModelString('sonnet');
|
||||||
|
|
||||||
// Create SDK options
|
// Create SDK options
|
||||||
@@ -661,9 +666,13 @@ export class IdeationService {
|
|||||||
|
|
||||||
const provider = ProviderFactory.getProviderForModel(modelId);
|
const provider = ProviderFactory.getProviderForModel(modelId);
|
||||||
|
|
||||||
|
// Strip provider prefix - providers need bare model IDs
|
||||||
|
const bareModel = stripProviderPrefix(modelId);
|
||||||
|
|
||||||
const executeOptions: ExecuteOptions = {
|
const executeOptions: ExecuteOptions = {
|
||||||
prompt: prompt.prompt,
|
prompt: prompt.prompt,
|
||||||
model: modelId,
|
model: bareModel,
|
||||||
|
originalModel: modelId,
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
systemPrompt: sdkOptions.systemPrompt,
|
systemPrompt: sdkOptions.systemPrompt,
|
||||||
maxTurns: 1,
|
maxTurns: 1,
|
||||||
|
|||||||
@@ -1,673 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,982 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -22,7 +22,6 @@ import type {
|
|||||||
Credentials,
|
Credentials,
|
||||||
ProjectSettings,
|
ProjectSettings,
|
||||||
KeyboardShortcuts,
|
KeyboardShortcuts,
|
||||||
AIProfile,
|
|
||||||
ProjectRef,
|
ProjectRef,
|
||||||
TrashedProjectRef,
|
TrashedProjectRef,
|
||||||
BoardBackgroundSettings,
|
BoardBackgroundSettings,
|
||||||
@@ -153,14 +152,6 @@ export class SettingsService {
|
|||||||
const storedVersion = settings.version || 1;
|
const storedVersion = settings.version || 1;
|
||||||
let needsSave = false;
|
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
|
// Migration v2 -> v3: Convert string phase models to PhaseModelEntry objects
|
||||||
// Note: migratePhaseModels() handles the actual conversion for both v1 and v2 formats
|
// Note: migratePhaseModels() handles the actual conversion for both v1 and v2 formats
|
||||||
if (storedVersion < 3) {
|
if (storedVersion < 3) {
|
||||||
@@ -170,6 +161,16 @@ export class SettingsService {
|
|||||||
needsSave = true;
|
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
|
// Update version if any migration occurred
|
||||||
if (needsSave) {
|
if (needsSave) {
|
||||||
result.version = SETTINGS_VERSION;
|
result.version = SETTINGS_VERSION;
|
||||||
@@ -264,25 +265,78 @@ export class SettingsService {
|
|||||||
const settingsPath = getGlobalSettingsPath(this.dataDir);
|
const settingsPath = getGlobalSettingsPath(this.dataDir);
|
||||||
|
|
||||||
const current = await this.getGlobalSettings();
|
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('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 = {
|
const updated: GlobalSettings = {
|
||||||
...current,
|
...current,
|
||||||
...updates,
|
...sanitizedUpdates,
|
||||||
version: SETTINGS_VERSION,
|
version: SETTINGS_VERSION,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Deep merge keyboard shortcuts if provided
|
// Deep merge keyboard shortcuts if provided
|
||||||
if (updates.keyboardShortcuts) {
|
if (sanitizedUpdates.keyboardShortcuts) {
|
||||||
updated.keyboardShortcuts = {
|
updated.keyboardShortcuts = {
|
||||||
...current.keyboardShortcuts,
|
...current.keyboardShortcuts,
|
||||||
...updates.keyboardShortcuts,
|
...sanitizedUpdates.keyboardShortcuts,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deep merge phaseModels if provided
|
// Deep merge phaseModels if provided
|
||||||
if (updates.phaseModels) {
|
if (sanitizedUpdates.phaseModels) {
|
||||||
updated.phaseModels = {
|
updated.phaseModels = {
|
||||||
...current.phaseModels,
|
...current.phaseModels,
|
||||||
...updates.phaseModels,
|
...sanitizedUpdates.phaseModels,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,13 +577,29 @@ 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
|
// Extract global settings
|
||||||
const globalSettings: Partial<GlobalSettings> = {
|
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',
|
theme: (appState.theme as GlobalSettings['theme']) || 'dark',
|
||||||
sidebarOpen: appState.sidebarOpen !== undefined ? (appState.sidebarOpen as boolean) : true,
|
sidebarOpen: appState.sidebarOpen !== undefined ? (appState.sidebarOpen as boolean) : true,
|
||||||
chatHistoryOpen: (appState.chatHistoryOpen as boolean) || false,
|
chatHistoryOpen: (appState.chatHistoryOpen as boolean) || false,
|
||||||
kanbanCardDetailLevel:
|
|
||||||
(appState.kanbanCardDetailLevel as GlobalSettings['kanbanCardDetailLevel']) || 'standard',
|
|
||||||
maxConcurrency: (appState.maxConcurrency as number) || 3,
|
maxConcurrency: (appState.maxConcurrency as number) || 3,
|
||||||
defaultSkipTests:
|
defaultSkipTests:
|
||||||
appState.defaultSkipTests !== undefined ? (appState.defaultSkipTests as boolean) : true,
|
appState.defaultSkipTests !== undefined ? (appState.defaultSkipTests as boolean) : true,
|
||||||
@@ -537,19 +607,21 @@ export class SettingsService {
|
|||||||
appState.enableDependencyBlocking !== undefined
|
appState.enableDependencyBlocking !== undefined
|
||||||
? (appState.enableDependencyBlocking as boolean)
|
? (appState.enableDependencyBlocking as boolean)
|
||||||
: true,
|
: true,
|
||||||
useWorktrees: (appState.useWorktrees as boolean) || false,
|
skipVerificationInAutoMode:
|
||||||
showProfilesOnly: (appState.showProfilesOnly as boolean) || false,
|
appState.skipVerificationInAutoMode !== undefined
|
||||||
|
? (appState.skipVerificationInAutoMode as boolean)
|
||||||
|
: false,
|
||||||
|
useWorktrees:
|
||||||
|
appState.useWorktrees !== undefined ? (appState.useWorktrees as boolean) : true,
|
||||||
defaultPlanningMode:
|
defaultPlanningMode:
|
||||||
(appState.defaultPlanningMode as GlobalSettings['defaultPlanningMode']) || 'skip',
|
(appState.defaultPlanningMode as GlobalSettings['defaultPlanningMode']) || 'skip',
|
||||||
defaultRequirePlanApproval: (appState.defaultRequirePlanApproval as boolean) || false,
|
defaultRequirePlanApproval: (appState.defaultRequirePlanApproval as boolean) || false,
|
||||||
defaultAIProfileId: (appState.defaultAIProfileId as string | null) || null,
|
|
||||||
muteDoneSound: (appState.muteDoneSound as boolean) || false,
|
muteDoneSound: (appState.muteDoneSound as boolean) || false,
|
||||||
enhancementModel:
|
enhancementModel:
|
||||||
(appState.enhancementModel as GlobalSettings['enhancementModel']) || 'sonnet',
|
(appState.enhancementModel as GlobalSettings['enhancementModel']) || 'sonnet',
|
||||||
keyboardShortcuts:
|
keyboardShortcuts:
|
||||||
(appState.keyboardShortcuts as KeyboardShortcuts) ||
|
(appState.keyboardShortcuts as KeyboardShortcuts) ||
|
||||||
DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts,
|
DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts,
|
||||||
aiProfiles: (appState.aiProfiles as AIProfile[]) || [],
|
|
||||||
projects: (appState.projects as ProjectRef[]) || [],
|
projects: (appState.projects as ProjectRef[]) || [],
|
||||||
trashedProjects: (appState.trashedProjects as TrashedProjectRef[]) || [],
|
trashedProjects: (appState.trashedProjects as TrashedProjectRef[]) || [],
|
||||||
projectHistory: (appState.projectHistory as string[]) || [],
|
projectHistory: (appState.projectHistory as string[]) || [],
|
||||||
|
|||||||
373
apps/server/src/tests/cli-integration.test.ts
Normal file
373
apps/server/src/tests/cli-integration.test.ts
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,13 +7,11 @@
|
|||||||
|
|
||||||
export type {
|
export type {
|
||||||
ThemeMode,
|
ThemeMode,
|
||||||
KanbanCardDetailLevel,
|
|
||||||
ModelAlias,
|
ModelAlias,
|
||||||
PlanningMode,
|
PlanningMode,
|
||||||
ThinkingLevel,
|
ThinkingLevel,
|
||||||
ModelProvider,
|
ModelProvider,
|
||||||
KeyboardShortcuts,
|
KeyboardShortcuts,
|
||||||
AIProfile,
|
|
||||||
ProjectRef,
|
ProjectRef,
|
||||||
TrashedProjectRef,
|
TrashedProjectRef,
|
||||||
ChatSessionRef,
|
ChatSessionRef,
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ describe('auth.ts', () => {
|
|||||||
const options = getSessionCookieOptions();
|
const options = getSessionCookieOptions();
|
||||||
|
|
||||||
expect(options.httpOnly).toBe(true);
|
expect(options.httpOnly).toBe(true);
|
||||||
expect(options.sameSite).toBe('strict');
|
expect(options.sameSite).toBe('lax');
|
||||||
expect(options.path).toBe('/');
|
expect(options.path).toBe('/');
|
||||||
expect(options.maxAge).toBeGreaterThan(0);
|
expect(options.maxAge).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,161 +1,15 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import os from 'os';
|
|
||||||
|
|
||||||
describe('sdk-options.ts', () => {
|
describe('sdk-options.ts', () => {
|
||||||
let originalEnv: NodeJS.ProcessEnv;
|
let originalEnv: NodeJS.ProcessEnv;
|
||||||
let homedirSpy: ReturnType<typeof vi.spyOn>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
originalEnv = { ...process.env };
|
originalEnv = { ...process.env };
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
// Spy on os.homedir and set default return value
|
|
||||||
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue('/Users/test');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
process.env = originalEnv;
|
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', () => {
|
describe('TOOL_PRESETS', () => {
|
||||||
@@ -325,19 +179,15 @@ describe('sdk-options.ts', () => {
|
|||||||
it('should create options with chat settings', async () => {
|
it('should create options with chat settings', async () => {
|
||||||
const { createChatOptions, TOOL_PRESETS, MAX_TURNS } = await import('@/lib/sdk-options.js');
|
const { createChatOptions, TOOL_PRESETS, MAX_TURNS } = await import('@/lib/sdk-options.js');
|
||||||
|
|
||||||
const options = createChatOptions({ cwd: '/test/path', enableSandboxMode: true });
|
const options = createChatOptions({ cwd: '/test/path' });
|
||||||
|
|
||||||
expect(options.cwd).toBe('/test/path');
|
expect(options.cwd).toBe('/test/path');
|
||||||
expect(options.maxTurns).toBe(MAX_TURNS.standard);
|
expect(options.maxTurns).toBe(MAX_TURNS.standard);
|
||||||
expect(options.allowedTools).toEqual([...TOOL_PRESETS.chat]);
|
expect(options.allowedTools).toEqual([...TOOL_PRESETS.chat]);
|
||||||
expect(options.sandbox).toEqual({
|
|
||||||
enabled: true,
|
|
||||||
autoAllowBashIfSandboxed: true,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should prefer explicit model over session model', async () => {
|
it('should prefer explicit model over session model', async () => {
|
||||||
const { createChatOptions, getModelForUseCase } = await import('@/lib/sdk-options.js');
|
const { createChatOptions } = await import('@/lib/sdk-options.js');
|
||||||
|
|
||||||
const options = createChatOptions({
|
const options = createChatOptions({
|
||||||
cwd: '/test/path',
|
cwd: '/test/path',
|
||||||
@@ -358,41 +208,6 @@ describe('sdk-options.ts', () => {
|
|||||||
|
|
||||||
expect(options.model).toBe('claude-sonnet-4-20250514');
|
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', () => {
|
describe('createAutoModeOptions', () => {
|
||||||
@@ -400,15 +215,11 @@ describe('sdk-options.ts', () => {
|
|||||||
const { createAutoModeOptions, TOOL_PRESETS, MAX_TURNS } =
|
const { createAutoModeOptions, TOOL_PRESETS, MAX_TURNS } =
|
||||||
await import('@/lib/sdk-options.js');
|
await import('@/lib/sdk-options.js');
|
||||||
|
|
||||||
const options = createAutoModeOptions({ cwd: '/test/path', enableSandboxMode: true });
|
const options = createAutoModeOptions({ cwd: '/test/path' });
|
||||||
|
|
||||||
expect(options.cwd).toBe('/test/path');
|
expect(options.cwd).toBe('/test/path');
|
||||||
expect(options.maxTurns).toBe(MAX_TURNS.maximum);
|
expect(options.maxTurns).toBe(MAX_TURNS.maximum);
|
||||||
expect(options.allowedTools).toEqual([...TOOL_PRESETS.fullAccess]);
|
expect(options.allowedTools).toEqual([...TOOL_PRESETS.fullAccess]);
|
||||||
expect(options.sandbox).toEqual({
|
|
||||||
enabled: true,
|
|
||||||
autoAllowBashIfSandboxed: true,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should include systemPrompt when provided', async () => {
|
it('should include systemPrompt when provided', async () => {
|
||||||
@@ -433,62 +244,6 @@ describe('sdk-options.ts', () => {
|
|||||||
|
|
||||||
expect(options.abortController).toBe(abortController);
|
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', () => {
|
describe('createCustomOptions', () => {
|
||||||
@@ -499,13 +254,11 @@ describe('sdk-options.ts', () => {
|
|||||||
cwd: '/test/path',
|
cwd: '/test/path',
|
||||||
maxTurns: 10,
|
maxTurns: 10,
|
||||||
allowedTools: ['Read', 'Write'],
|
allowedTools: ['Read', 'Write'],
|
||||||
sandbox: { enabled: true },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(options.cwd).toBe('/test/path');
|
expect(options.cwd).toBe('/test/path');
|
||||||
expect(options.maxTurns).toBe(10);
|
expect(options.maxTurns).toBe(10);
|
||||||
expect(options.allowedTools).toEqual(['Read', 'Write']);
|
expect(options.allowedTools).toEqual(['Read', 'Write']);
|
||||||
expect(options.sandbox).toEqual({ enabled: true });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use defaults when optional params not provided', async () => {
|
it('should use defaults when optional params not provided', async () => {
|
||||||
@@ -517,20 +270,6 @@ describe('sdk-options.ts', () => {
|
|||||||
expect(options.allowedTools).toEqual([...TOOL_PRESETS.readOnly]);
|
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 () => {
|
it('should include systemPrompt when provided', async () => {
|
||||||
const { createCustomOptions } = await import('@/lib/sdk-options.js');
|
const { createCustomOptions } = await import('@/lib/sdk-options.js');
|
||||||
|
|
||||||
|
|||||||
@@ -179,8 +179,7 @@ describe('validation-storage.ts', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return false for validation exactly at 24 hours', () => {
|
it('should return false for validation exactly at 24 hours', () => {
|
||||||
const exactDate = new Date();
|
const exactDate = new Date(Date.now() - 24 * 60 * 60 * 1000 + 100);
|
||||||
exactDate.setHours(exactDate.getHours() - 24);
|
|
||||||
|
|
||||||
const validation = createMockValidation({
|
const validation = createMockValidation({
|
||||||
validatedAt: exactDate.toISOString(),
|
validatedAt: exactDate.toISOString(),
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ describe('claude-provider.ts', () => {
|
|||||||
|
|
||||||
const generator = provider.executeQuery({
|
const generator = provider.executeQuery({
|
||||||
prompt: 'Hello',
|
prompt: 'Hello',
|
||||||
|
model: 'claude-opus-4-5-20251101',
|
||||||
cwd: '/test',
|
cwd: '/test',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -79,7 +80,7 @@ describe('claude-provider.ts', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use default allowed tools when not specified', async () => {
|
it('should not include allowedTools when not specified (caller decides via sdk-options)', async () => {
|
||||||
vi.mocked(sdk.query).mockReturnValue(
|
vi.mocked(sdk.query).mockReturnValue(
|
||||||
(async function* () {
|
(async function* () {
|
||||||
yield { type: 'text', text: 'test' };
|
yield { type: 'text', text: 'test' };
|
||||||
@@ -88,6 +89,7 @@ describe('claude-provider.ts', () => {
|
|||||||
|
|
||||||
const generator = provider.executeQuery({
|
const generator = provider.executeQuery({
|
||||||
prompt: 'Test',
|
prompt: 'Test',
|
||||||
|
model: 'claude-opus-4-5-20251101',
|
||||||
cwd: '/test',
|
cwd: '/test',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -95,37 +97,8 @@ describe('claude-provider.ts', () => {
|
|||||||
|
|
||||||
expect(sdk.query).toHaveBeenCalledWith({
|
expect(sdk.query).toHaveBeenCalledWith({
|
||||||
prompt: 'Test',
|
prompt: 'Test',
|
||||||
options: expect.objectContaining({
|
options: expect.not.objectContaining({
|
||||||
allowedTools: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'],
|
allowedTools: expect.anything(),
|
||||||
}),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -141,6 +114,7 @@ describe('claude-provider.ts', () => {
|
|||||||
|
|
||||||
const generator = provider.executeQuery({
|
const generator = provider.executeQuery({
|
||||||
prompt: 'Test',
|
prompt: 'Test',
|
||||||
|
model: 'claude-opus-4-5-20251101',
|
||||||
cwd: '/test',
|
cwd: '/test',
|
||||||
abortController,
|
abortController,
|
||||||
});
|
});
|
||||||
@@ -169,6 +143,7 @@ describe('claude-provider.ts', () => {
|
|||||||
|
|
||||||
const generator = provider.executeQuery({
|
const generator = provider.executeQuery({
|
||||||
prompt: 'Current message',
|
prompt: 'Current message',
|
||||||
|
model: 'claude-opus-4-5-20251101',
|
||||||
cwd: '/test',
|
cwd: '/test',
|
||||||
conversationHistory,
|
conversationHistory,
|
||||||
sdkSessionId: 'test-session-id',
|
sdkSessionId: 'test-session-id',
|
||||||
@@ -199,6 +174,7 @@ describe('claude-provider.ts', () => {
|
|||||||
|
|
||||||
const generator = provider.executeQuery({
|
const generator = provider.executeQuery({
|
||||||
prompt: arrayPrompt as any,
|
prompt: arrayPrompt as any,
|
||||||
|
model: 'claude-opus-4-5-20251101',
|
||||||
cwd: '/test',
|
cwd: '/test',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -218,6 +194,7 @@ describe('claude-provider.ts', () => {
|
|||||||
|
|
||||||
const generator = provider.executeQuery({
|
const generator = provider.executeQuery({
|
||||||
prompt: 'Test',
|
prompt: 'Test',
|
||||||
|
model: 'claude-opus-4-5-20251101',
|
||||||
cwd: '/test',
|
cwd: '/test',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -243,6 +220,7 @@ describe('claude-provider.ts', () => {
|
|||||||
|
|
||||||
const generator = provider.executeQuery({
|
const generator = provider.executeQuery({
|
||||||
prompt: 'Test',
|
prompt: 'Test',
|
||||||
|
model: 'claude-opus-4-5-20251101',
|
||||||
cwd: '/test',
|
cwd: '/test',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
293
apps/server/tests/unit/providers/codex-provider.test.ts
Normal file
293
apps/server/tests/unit/providers/codex-provider.test.ts
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user